merge: resolve conflicts with main
This commit is contained in:
commit
d461a231a5
151 changed files with 4288 additions and 1889 deletions
7
.github/workflows/build.yml
vendored
7
.github/workflows/build.yml
vendored
|
|
@ -2,7 +2,7 @@ name: build
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: ["main"]
|
branches: [ "main" ]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
|
|
@ -16,10 +16,5 @@ jobs:
|
||||||
with:
|
with:
|
||||||
go-version-file: go.mod
|
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
|
- name: Build
|
||||||
run: make build-all
|
run: make build-all
|
||||||
|
|
|
||||||
39
.github/workflows/pr.yml
vendored
39
.github/workflows/pr.yml
vendored
|
|
@ -24,48 +24,9 @@ jobs:
|
||||||
with:
|
with:
|
||||||
version: v2.10.1
|
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:
|
test:
|
||||||
name: Tests
|
name: Tests
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: fmt-check
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v6
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,6 @@ linters:
|
||||||
- godox
|
- godox
|
||||||
- goprintffuncname
|
- goprintffuncname
|
||||||
- gosec
|
- gosec
|
||||||
- govet
|
|
||||||
- ineffassign
|
- ineffassign
|
||||||
- lll
|
- lll
|
||||||
- maintidx
|
- maintidx
|
||||||
|
|
@ -160,12 +159,11 @@ issues:
|
||||||
|
|
||||||
formatters:
|
formatters:
|
||||||
enable:
|
enable:
|
||||||
|
- gci
|
||||||
|
- gofmt
|
||||||
|
- gofumpt
|
||||||
- goimports
|
- goimports
|
||||||
# TODO: Disabled, because they are failing at the moment, we should fix them and enable (step by step)
|
- golines
|
||||||
# - gci
|
|
||||||
# - gofmt
|
|
||||||
# - gofumpt
|
|
||||||
# - golines
|
|
||||||
settings:
|
settings:
|
||||||
gci:
|
gci:
|
||||||
sections:
|
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 辅助开发能产生优秀的成果。我们同样相信,人类必须对自己提交的内容负责。这两点并不矛盾。
|
||||||
|
|
||||||
|
感谢你的贡献!
|
||||||
18
Makefile
18
Makefile
|
|
@ -17,10 +17,14 @@ LDFLAGS=-ldflags "-X main.version=$(VERSION) -X main.gitCommit=$(GIT_COMMIT) -X
|
||||||
GO?=go
|
GO?=go
|
||||||
GOFLAGS?=-v -tags stdjson
|
GOFLAGS?=-v -tags stdjson
|
||||||
|
|
||||||
|
# Golangci-lint
|
||||||
|
GOLANGCI_LINT?=golangci-lint
|
||||||
|
|
||||||
# Installation
|
# Installation
|
||||||
INSTALL_PREFIX?=$(HOME)/.local
|
INSTALL_PREFIX?=$(HOME)/.local
|
||||||
INSTALL_BIN_DIR=$(INSTALL_PREFIX)/bin
|
INSTALL_BIN_DIR=$(INSTALL_PREFIX)/bin
|
||||||
INSTALL_MAN_DIR=$(INSTALL_PREFIX)/share/man/man1
|
INSTALL_MAN_DIR=$(INSTALL_PREFIX)/share/man/man1
|
||||||
|
INSTALL_TMP_SUFFIX=.new
|
||||||
|
|
||||||
# Workspace and Skills
|
# Workspace and Skills
|
||||||
PICOCLAW_HOME?=$(HOME)/.picoclaw
|
PICOCLAW_HOME?=$(HOME)/.picoclaw
|
||||||
|
|
@ -96,8 +100,10 @@ build-all: generate
|
||||||
install: build
|
install: build
|
||||||
@echo "Installing $(BINARY_NAME)..."
|
@echo "Installing $(BINARY_NAME)..."
|
||||||
@mkdir -p $(INSTALL_BIN_DIR)
|
@mkdir -p $(INSTALL_BIN_DIR)
|
||||||
@cp $(BUILD_DIR)/$(BINARY_NAME) $(INSTALL_BIN_DIR)/$(BINARY_NAME)
|
# Copy binary with temporary suffix to ensure atomic update
|
||||||
@chmod +x $(INSTALL_BIN_DIR)/$(BINARY_NAME)
|
@cp $(BUILD_DIR)/$(BINARY_NAME) $(INSTALL_BIN_DIR)/$(BINARY_NAME)$(INSTALL_TMP_SUFFIX)
|
||||||
|
@chmod +x $(INSTALL_BIN_DIR)/$(BINARY_NAME)$(INSTALL_TMP_SUFFIX)
|
||||||
|
@mv -f $(INSTALL_BIN_DIR)/$(BINARY_NAME)$(INSTALL_TMP_SUFFIX) $(INSTALL_BIN_DIR)/$(BINARY_NAME)
|
||||||
@echo "Installed binary to $(INSTALL_BIN_DIR)/$(BINARY_NAME)"
|
@echo "Installed binary to $(INSTALL_BIN_DIR)/$(BINARY_NAME)"
|
||||||
@echo "Installation complete!"
|
@echo "Installation complete!"
|
||||||
|
|
||||||
|
|
@ -126,13 +132,17 @@ clean:
|
||||||
vet:
|
vet:
|
||||||
@$(GO) vet ./...
|
@$(GO) vet ./...
|
||||||
|
|
||||||
## fmt: Format Go code
|
## test: Test Go code
|
||||||
test:
|
test:
|
||||||
@$(GO) test ./...
|
@$(GO) test ./...
|
||||||
|
|
||||||
## fmt: Format Go code
|
## fmt: Format Go code
|
||||||
fmt:
|
fmt:
|
||||||
@$(GO) fmt ./...
|
@$(GOLANGCI_LINT) fmt
|
||||||
|
|
||||||
|
## lint: Run linters
|
||||||
|
lint:
|
||||||
|
@$(GOLANGCI_LINT) run
|
||||||
|
|
||||||
## deps: Download dependencies
|
## deps: Download dependencies
|
||||||
deps:
|
deps:
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,7 @@
|
||||||
|
|
||||||
## 📢 Actualités
|
## 📢 Actualités
|
||||||
|
|
||||||
2026-02-16 🎉 PicoClaw a atteint 12K étoiles en une semaine ! Merci à tous pour votre soutien ! PicoClaw grandit plus vite que nous ne l'avions jamais imaginé. Vu le volume élevé de PR, nous avons un besoin urgent de mainteneurs communautaires. Nos rôles de bénévoles et notre feuille de route sont officiellement publiés [ici](docs/picoclaw_community_roadmap_260216.md) — nous avons hâte de vous accueillir !
|
2026-02-16 🎉 PicoClaw a atteint 12K étoiles en une semaine ! Merci à tous pour votre soutien ! PicoClaw grandit plus vite que nous ne l'avions jamais imaginé. Vu le volume élevé de PR, nous avons un besoin urgent de mainteneurs communautaires. Nos rôles de bénévoles et notre feuille de route sont officiellement publiés [ici](docs/ROADMAP.md) — nous avons hâte de vous accueillir !
|
||||||
|
|
||||||
2026-02-13 🎉 PicoClaw a atteint 5000 étoiles en 4 jours ! Merci à la communauté ! Nous finalisons la **Feuille de Route du Projet** et mettons en place le **Groupe de Développeurs** pour accélérer le développement de PicoClaw.
|
2026-02-13 🎉 PicoClaw a atteint 5000 étoiles en 4 jours ! Merci à la communauté ! Nous finalisons la **Feuille de Route du Projet** et mettons en place le **Groupe de Développeurs** pour accélérer le développement de PicoClaw.
|
||||||
🚀 **Appel à l'action :** Soumettez vos demandes de fonctionnalités dans les GitHub Discussions. Nous les examinerons et les prioriserons lors de notre prochaine réunion hebdomadaire.
|
🚀 **Appel à l'action :** Soumettez vos demandes de fonctionnalités dans les GitHub Discussions. Nous les examinerons et les prioriserons lors de notre prochaine réunion hebdomadaire.
|
||||||
|
|
|
||||||
29
README.ja.md
29
README.ja.md
|
|
@ -162,7 +162,7 @@ docker compose --profile gateway up -d
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
> `~/.picoclaw/config.json` に API キーを設定してください。
|
> `~/.picoclaw/config.json` に API キーを設定してください。
|
||||||
> API キーの取得先: [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM)
|
> API キーの取得先: [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM)
|
||||||
> Web 検索は **任意** です - 無料の [Brave Search API](https://brave.com/search/api) (月 2000 クエリ無料)
|
> Web 検索は **任意** です - 無料の [Tavily API](https://tavily.com) (月 1000 クエリ無料) または [Brave Search API](https://brave.com/search/api) (月 2000 クエリ無料)
|
||||||
|
|
||||||
**1. 初期化**
|
**1. 初期化**
|
||||||
|
|
||||||
|
|
@ -193,14 +193,34 @@ picoclaw onboard
|
||||||
"token": "YOUR_TELEGRAM_BOT_TOKEN",
|
"token": "YOUR_TELEGRAM_BOT_TOKEN",
|
||||||
"allow_from": []
|
"allow_from": []
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"tools": {
|
||||||
|
"web": {
|
||||||
|
"search": {
|
||||||
|
"api_key": "YOUR_BRAVE_API_KEY",
|
||||||
|
"max_results": 5
|
||||||
|
},
|
||||||
|
"tavily": {
|
||||||
|
"enabled": false,
|
||||||
|
"api_key": "YOUR_TAVILY_API_KEY",
|
||||||
|
"max_results": 5
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cron": {
|
||||||
|
"exec_timeout_minutes": 5
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"heartbeat": {
|
||||||
|
"enabled": true,
|
||||||
|
"interval": 30
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**3. API キーの取得**
|
**3. API キーの取得**
|
||||||
|
|
||||||
- **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) · [Qwen](https://dashscope.console.aliyun.com)
|
- **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)
|
||||||
- **Web 検索**(任意): [Brave Search](https://brave.com/search/api) - 無料枠あり(月 2000 リクエスト)
|
- **Web 検索**(任意): [Tavily](https://tavily.com) - AI エージェント向けに最適化 (月 1000 リクエスト) · [Brave Search](https://brave.com/search/api) - 無料枠あり(月 2000 リクエスト)
|
||||||
|
|
||||||
> **注意**: 完全な設定テンプレートは `config.example.json` を参照してください。
|
> **注意**: 完全な設定テンプレートは `config.example.json` を参照してください。
|
||||||
|
|
||||||
|
|
@ -985,7 +1005,7 @@ Discord: https://discord.gg/V4sAZ9XWpN
|
||||||
検索 API キーをまだ設定していない場合、これは正常です。PicoClaw は手動検索用の便利なリンクを提供します。
|
検索 API キーをまだ設定していない場合、これは正常です。PicoClaw は手動検索用の便利なリンクを提供します。
|
||||||
|
|
||||||
Web 検索を有効にするには:
|
Web 検索を有効にするには:
|
||||||
1. [https://brave.com/search/api](https://brave.com/search/api) で無料の API キーを取得(月 2000 クエリ無料)
|
1. [https://tavily.com](https://tavily.com) (月 1000 クエリ無料) または [https://brave.com/search/api](https://brave.com/search/api) で無料の API キーを取得(月 2000 クエリ無料)
|
||||||
2. `~/.picoclaw/config.json` に追加:
|
2. `~/.picoclaw/config.json` に追加:
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
|
|
@ -1023,5 +1043,6 @@ Web 検索を有効にするには:
|
||||||
| **Zhipu** | 月 200K トークン | 中国ユーザー向け最適 |
|
| **Zhipu** | 月 200K トークン | 中国ユーザー向け最適 |
|
||||||
| **Qwen** | 無料枠あり | 通義千問 (Qwen) |
|
| **Qwen** | 無料枠あり | 通義千問 (Qwen) |
|
||||||
| **Brave Search** | 月 2000 クエリ | Web 検索機能 |
|
| **Brave Search** | 月 2000 クエリ | Web 検索機能 |
|
||||||
|
| **Tavily** | 月 1000 クエリ | AI エージェント検索最適化 |
|
||||||
| **Groq** | 無料枠あり | 高速推論(Llama, Mixtral) |
|
| **Groq** | 無料枠あり | 高速推論(Llama, Mixtral) |
|
||||||
| **Cerebras** | 無料枠あり | 高速推論(Llama, Qwen など) |
|
| **Cerebras** | 無料枠あり | 高速推論(Llama, Qwen など) |
|
||||||
|
|
|
||||||
59
README.md
59
README.md
|
|
@ -14,7 +14,8 @@
|
||||||
<a href="https://x.com/SipeedIO"><img src="https://img.shields.io/badge/X_(Twitter)-SipeedIO-black?style=flat&logo=x&logoColor=white" alt="Twitter"></a>
|
<a href="https://x.com/SipeedIO"><img src="https://img.shields.io/badge/X_(Twitter)-SipeedIO-black?style=flat&logo=x&logoColor=white" alt="Twitter"></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | **English**
|
[中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | **English**
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -42,16 +43,17 @@
|
||||||
> **🚨 SECURITY & OFFICIAL CHANNELS / 安全声明**
|
> **🚨 SECURITY & OFFICIAL CHANNELS / 安全声明**
|
||||||
>
|
>
|
||||||
> * **NO CRYPTO:** PicoClaw has **NO** official token/coin. All claims on `pump.fun` or other trading platforms are **SCAMS**.
|
> * **NO CRYPTO:** PicoClaw has **NO** official token/coin. All claims on `pump.fun` or other trading platforms are **SCAMS**.
|
||||||
|
>
|
||||||
> * **OFFICIAL DOMAIN:** The **ONLY** official website is **[picoclaw.io](https://picoclaw.io)**, and company website is **[sipeed.com](https://sipeed.com)**
|
> * **OFFICIAL DOMAIN:** The **ONLY** official website is **[picoclaw.io](https://picoclaw.io)**, and company website is **[sipeed.com](https://sipeed.com)**
|
||||||
> * **Warning:** Many `.ai/.org/.com/.net/...` domains are registered by third parties.
|
> * **Warning:** Many `.ai/.org/.com/.net/...` domains are registered by third parties.
|
||||||
> * **Warning:** picoclaw is in early development now and may have unresolved network security issues. Do not deploy to production environments before the v1.0 release.
|
> * **Warning:** picoclaw is in early development now and may have unresolved network security issues. Do not deploy to production environments before the v1.0 release.
|
||||||
> * **Note:** picoclaw has recently merged a lot of PRs, which may result in a larger memory footprint (10–20MB) in the latest versions. We plan to prioritize resource optimization as soon as the current feature set reaches a stable state.
|
> * **Note:** picoclaw has recently merged a lot of PRs, which may result in a larger memory footprint (10–20MB) in the latest versions. We plan to prioritize resource optimization as soon as the current feature set reaches a stable state.
|
||||||
|
|
||||||
|
|
||||||
## 📢 News
|
## 📢 News
|
||||||
2026-02-16 🎉 PicoClaw hit 12K stars in one week! Thank you all for your support! PicoClaw is growing faster than we ever imagined. Given the high volume of PRs, we urgently need community maintainers. Our volunteer roles and roadmap are officially posted [here](docs/picoclaw_community_roadmap_260216.md) —we can’t wait to have you on board!
|
|
||||||
|
|
||||||
2026-02-13 🎉 PicoClaw hit 5000 stars in 4days! Thank you for the community! There are so many PRs&issues come in (during Chinese New Year holidays), we are finalizing the Project Roadmap and setting up the Developer Group to accelerate PicoClaw's development.
|
2026-02-16 🎉 PicoClaw hit 12K stars in one week! Thank you all for your support! PicoClaw is growing faster than we ever imagined. Given the high volume of PRs, we urgently need community maintainers. Our volunteer roles and roadmap are officially posted [here](docs/ROADMAP.md) —we can’t wait to have you on board!
|
||||||
|
|
||||||
|
2026-02-13 🎉 PicoClaw hit 5000 stars in 4days! Thank you for the community! There are so many PRs & issues coming in (during Chinese New Year holidays), we are finalizing the Project Roadmap and setting up the Developer Group to accelerate PicoClaw's development.
|
||||||
🚀 Call to Action: Please submit your feature requests in GitHub Discussions. We will review and prioritize them during our upcoming weekly meeting.
|
🚀 Call to Action: Please submit your feature requests in GitHub Discussions. We will review and prioritize them during our upcoming weekly meeting.
|
||||||
|
|
||||||
2026-02-09 🎉 PicoClaw Launched! Built in 1 day to bring AI Agents to $10 hardware with <10MB RAM. 🦐 PicoClaw,Let's Go!
|
2026-02-09 🎉 PicoClaw Launched! Built in 1 day to bring AI Agents to $10 hardware with <10MB RAM. 🦐 PicoClaw,Let's Go!
|
||||||
|
|
@ -100,9 +102,12 @@
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
### 📱 Run on old Android Phones
|
### 📱 Run on old Android Phones
|
||||||
|
|
||||||
Give your decade-old phone a second life! Turn it into a smart AI Assistant with PicoClaw. Quick Start:
|
Give your decade-old phone a second life! Turn it into a smart AI Assistant with PicoClaw. Quick Start:
|
||||||
|
|
||||||
1. **Install Termux** (Available on F-Droid or Google Play).
|
1. **Install Termux** (Available on F-Droid or Google Play).
|
||||||
2. **Execute cmds**
|
2. **Execute cmds**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Note: Replace v0.1.1 with the latest version from the Releases page
|
# Note: Replace v0.1.1 with the latest version from the Releases page
|
||||||
wget https://github.com/sipeed/picoclaw/releases/download/v0.1.1/picoclaw-linux-arm64
|
wget https://github.com/sipeed/picoclaw/releases/download/v0.1.1/picoclaw-linux-arm64
|
||||||
|
|
@ -110,6 +115,7 @@ chmod +x picoclaw-linux-arm64
|
||||||
pkg install proot
|
pkg install proot
|
||||||
termux-chroot ./picoclaw-linux-arm64 onboard
|
termux-chroot ./picoclaw-linux-arm64 onboard
|
||||||
```
|
```
|
||||||
|
|
||||||
And then follow the instructions in the "Quick Start" section to complete the configuration!
|
And then follow the instructions in the "Quick Start" section to complete the configuration!
|
||||||
<img src="assets/termux.jpg" alt="PicoClaw" width="512">
|
<img src="assets/termux.jpg" alt="PicoClaw" width="512">
|
||||||
|
|
||||||
|
|
@ -194,7 +200,7 @@ docker compose --profile gateway up -d
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
> Set your API key in `~/.picoclaw/config.json`.
|
> Set your API key in `~/.picoclaw/config.json`.
|
||||||
> Get API keys: [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM)
|
> Get API keys: [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM)
|
||||||
> Web search is **optional** - get free [Brave Search API](https://brave.com/search/api) (2000 free queries/month) or use built-in auto fallback.
|
> Web Search is **optional** - get free [Tavily API](https://tavily.com) (1000 free queries/month) or [Brave Search API](https://brave.com/search/api) (2000 free queries/month) or use built-in auto fallback.
|
||||||
|
|
||||||
**1. Initialize**
|
**1. Initialize**
|
||||||
|
|
||||||
|
|
@ -234,6 +240,11 @@ picoclaw onboard
|
||||||
"api_key": "YOUR_BRAVE_API_KEY",
|
"api_key": "YOUR_BRAVE_API_KEY",
|
||||||
"max_results": 5
|
"max_results": 5
|
||||||
},
|
},
|
||||||
|
"tavily": {
|
||||||
|
"enabled": false,
|
||||||
|
"api_key": "YOUR_TAVILY_API_KEY",
|
||||||
|
"max_results": 5
|
||||||
|
},
|
||||||
"duckduckgo": {
|
"duckduckgo": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"max_results": 5
|
"max_results": 5
|
||||||
|
|
@ -243,12 +254,12 @@ 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**
|
**3. Get API Keys**
|
||||||
|
|
||||||
* **LLM Provider**: [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)
|
* **LLM Provider**: [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)
|
||||||
* **Web Search** (optional): [Brave Search](https://brave.com/search/api) - Free tier available (2000 requests/month)
|
* **Web Search** (optional): [Tavily](https://tavily.com) - Optimized for AI Agents (1000 requests/month) · [Brave Search](https://brave.com/search/api) - Free tier available (2000 requests/month)
|
||||||
|
|
||||||
> **Note**: See `config.example.json` for a complete configuration template.
|
> **Note**: See `config.example.json` for a complete configuration template.
|
||||||
|
|
||||||
|
|
@ -323,7 +334,6 @@ picoclaw gateway
|
||||||
* (Optional) Enable **SERVER MEMBERS INTENT** if you plan to use allow lists based on member data
|
* (Optional) Enable **SERVER MEMBERS INTENT** if you plan to use allow lists based on member data
|
||||||
|
|
||||||
**3. Get your User ID**
|
**3. Get your User ID**
|
||||||
|
|
||||||
* Discord Settings → Advanced → enable **Developer Mode**
|
* Discord Settings → Advanced → enable **Developer Mode**
|
||||||
* Right-click your avatar → **Copy User ID**
|
* Right-click your avatar → **Copy User ID**
|
||||||
|
|
||||||
|
|
@ -425,7 +435,6 @@ picoclaw gateway
|
||||||
```bash
|
```bash
|
||||||
picoclaw gateway
|
picoclaw gateway
|
||||||
```
|
```
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
|
|
@ -521,7 +530,6 @@ See [WeCom App Configuration Guide](docs/wecom-app-configuration.md) for detaile
|
||||||
* Go to WeCom Admin Console → App Management → Create App
|
* Go to WeCom Admin Console → App Management → Create App
|
||||||
* Copy **AgentId** and **Secret**
|
* Copy **AgentId** and **Secret**
|
||||||
* Go to "My Company" page, copy **CorpID**
|
* Go to "My Company" page, copy **CorpID**
|
||||||
|
|
||||||
**2. Configure receive message**
|
**2. Configure receive message**
|
||||||
|
|
||||||
* In App details, click "Receive Message" → "Set API"
|
* In App details, click "Receive Message" → "Set API"
|
||||||
|
|
@ -606,7 +614,7 @@ PicoClaw runs in a sandboxed environment by default. The agent can only access f
|
||||||
```
|
```
|
||||||
|
|
||||||
| Option | Default | Description |
|
| Option | Default | Description |
|
||||||
|--------|---------|-------------|
|
| ----------------------- | ----------------------- | ----------------------------------------- |
|
||||||
| `workspace` | `~/.picoclaw/workspace` | Working directory for the agent |
|
| `workspace` | `~/.picoclaw/workspace` | Working directory for the agent |
|
||||||
| `restrict_to_workspace` | `true` | Restrict file/command access to workspace |
|
| `restrict_to_workspace` | `true` | Restrict file/command access to workspace |
|
||||||
|
|
||||||
|
|
@ -615,7 +623,7 @@ PicoClaw runs in a sandboxed environment by default. The agent can only access f
|
||||||
When `restrict_to_workspace: true`, the following tools are sandboxed:
|
When `restrict_to_workspace: true`, the following tools are sandboxed:
|
||||||
|
|
||||||
| Tool | Function | Restriction |
|
| Tool | Function | Restriction |
|
||||||
|------|----------|-------------|
|
| ------------- | ---------------- | -------------------------------------- |
|
||||||
| `read_file` | Read files | Only files within workspace |
|
| `read_file` | Read files | Only files within workspace |
|
||||||
| `write_file` | Write files | Only files within workspace |
|
| `write_file` | Write files | Only files within workspace |
|
||||||
| `list_dir` | List directories | Only directories within workspace |
|
| `list_dir` | List directories | Only directories within workspace |
|
||||||
|
|
@ -675,7 +683,7 @@ export PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE=false
|
||||||
The `restrict_to_workspace` setting applies consistently across all execution paths:
|
The `restrict_to_workspace` setting applies consistently across all execution paths:
|
||||||
|
|
||||||
| Execution Path | Security Boundary |
|
| Execution Path | Security Boundary |
|
||||||
|----------------|-------------------|
|
| ---------------- | ---------------------------- |
|
||||||
| Main Agent | `restrict_to_workspace` ✅ |
|
| Main Agent | `restrict_to_workspace` ✅ |
|
||||||
| Subagent / Spawn | Inherits same restriction ✅ |
|
| Subagent / Spawn | Inherits same restriction ✅ |
|
||||||
| Heartbeat tasks | Inherits same restriction ✅ |
|
| Heartbeat tasks | Inherits same restriction ✅ |
|
||||||
|
|
@ -704,9 +712,11 @@ For long-running tasks (web search, API calls), use the `spawn` tool to create a
|
||||||
# Periodic Tasks
|
# Periodic Tasks
|
||||||
|
|
||||||
## Quick Tasks (respond directly)
|
## Quick Tasks (respond directly)
|
||||||
|
|
||||||
- Report current time
|
- Report current time
|
||||||
|
|
||||||
## Long Tasks (use spawn for async)
|
## Long Tasks (use spawn for async)
|
||||||
|
|
||||||
- Search the web for AI news and summarize
|
- Search the web for AI news and summarize
|
||||||
- Check email and report important messages
|
- Check email and report important messages
|
||||||
```
|
```
|
||||||
|
|
@ -714,7 +724,7 @@ For long-running tasks (web search, API calls), use the `spawn` tool to create a
|
||||||
**Key behaviors:**
|
**Key behaviors:**
|
||||||
|
|
||||||
| Feature | Description |
|
| Feature | Description |
|
||||||
|---------|-------------|
|
| ----------------------- | --------------------------------------------------------- |
|
||||||
| **spawn** | Creates async subagent, doesn't block heartbeat |
|
| **spawn** | Creates async subagent, doesn't block heartbeat |
|
||||||
| **Independent context** | Subagent has its own context, no session history |
|
| **Independent context** | Subagent has its own context, no session history |
|
||||||
| **message tool** | Subagent communicates with user directly via message tool |
|
| **message tool** | Subagent communicates with user directly via message tool |
|
||||||
|
|
@ -750,7 +760,7 @@ The subagent has access to tools (message, web_search, etc.) and can communicate
|
||||||
```
|
```
|
||||||
|
|
||||||
| Option | Default | Description |
|
| Option | Default | Description |
|
||||||
|--------|---------|-------------|
|
| ---------- | ------- | ---------------------------------- |
|
||||||
| `enabled` | `true` | Enable/disable heartbeat |
|
| `enabled` | `true` | Enable/disable heartbeat |
|
||||||
| `interval` | `30` | Check interval in minutes (min: 5) |
|
| `interval` | `30` | Check interval in minutes (min: 5) |
|
||||||
|
|
||||||
|
|
@ -765,9 +775,9 @@ The subagent has access to tools (message, web_search, etc.) and can communicate
|
||||||
> Groq provides free voice transcription via Whisper. If configured, Telegram voice messages will be automatically transcribed.
|
> Groq provides free voice transcription via Whisper. If configured, Telegram voice messages will be automatically transcribed.
|
||||||
|
|
||||||
| Provider | Purpose | Get API Key |
|
| Provider | Purpose | Get API Key |
|
||||||
| -------------------------- | --------------------------------------- | ------------------------------------------------------ |
|
| -------------------------- | --------------------------------------- | -------------------------------------------------------------------- |
|
||||||
| `gemini` | LLM (Gemini direct) | [aistudio.google.com](https://aistudio.google.com) |
|
| `gemini` | LLM (Gemini direct) | [aistudio.google.com](https://aistudio.google.com) |
|
||||||
| `zhipu` | LLM (Zhipu direct) | [bigmodel.cn](bigmodel.cn) |
|
| `zhipu` | LLM (Zhipu direct) | [bigmodel.cn](https://bigmodel.cn) |
|
||||||
| `openrouter(To be tested)` | LLM (recommended, access to all models) | [openrouter.ai](https://openrouter.ai) |
|
| `openrouter(To be tested)` | LLM (recommended, access to all models) | [openrouter.ai](https://openrouter.ai) |
|
||||||
| `anthropic(To be tested)` | LLM (Claude direct) | [console.anthropic.com](https://console.anthropic.com) |
|
| `anthropic(To be tested)` | LLM (Claude direct) | [console.anthropic.com](https://console.anthropic.com) |
|
||||||
| `openai(To be tested)` | LLM (GPT direct) | [platform.openai.com](https://platform.openai.com) |
|
| `openai(To be tested)` | LLM (GPT direct) | [platform.openai.com](https://platform.openai.com) |
|
||||||
|
|
@ -790,7 +800,7 @@ This design also enables **multi-agent support** with flexible provider selectio
|
||||||
#### 📋 All Supported Vendors
|
#### 📋 All Supported Vendors
|
||||||
|
|
||||||
| Vendor | `model` Prefix | Default API Base | Protocol | API Key |
|
| Vendor | `model` Prefix | Default API Base | Protocol | API Key |
|
||||||
|--------|----------------|------------------|----------|---------|
|
| ------------------- | ----------------- | --------------------------------------------------- | --------- | ---------------------------------------------------------------- |
|
||||||
| **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [Get Key](https://platform.openai.com) |
|
| **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [Get Key](https://platform.openai.com) |
|
||||||
| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [Get Key](https://console.anthropic.com) |
|
| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [Get Key](https://console.anthropic.com) |
|
||||||
| **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Get Key](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) |
|
| **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Get Key](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) |
|
||||||
|
|
@ -841,6 +851,7 @@ This design also enables **multi-agent support** with flexible provider selectio
|
||||||
#### Vendor-Specific Examples
|
#### Vendor-Specific Examples
|
||||||
|
|
||||||
**OpenAI**
|
**OpenAI**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.2",
|
"model_name": "gpt-5.2",
|
||||||
|
|
@ -850,6 +861,7 @@ This design also enables **multi-agent support** with flexible provider selectio
|
||||||
```
|
```
|
||||||
|
|
||||||
**智谱 AI (GLM)**
|
**智谱 AI (GLM)**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"model_name": "glm-4.7",
|
"model_name": "glm-4.7",
|
||||||
|
|
@ -859,6 +871,7 @@ This design also enables **multi-agent support** with flexible provider selectio
|
||||||
```
|
```
|
||||||
|
|
||||||
**DeepSeek**
|
**DeepSeek**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"model_name": "deepseek-chat",
|
"model_name": "deepseek-chat",
|
||||||
|
|
@ -868,6 +881,7 @@ This design also enables **multi-agent support** with flexible provider selectio
|
||||||
```
|
```
|
||||||
|
|
||||||
**Anthropic (with API key)**
|
**Anthropic (with API key)**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"model_name": "claude-sonnet-4.6",
|
"model_name": "claude-sonnet-4.6",
|
||||||
|
|
@ -875,9 +889,11 @@ This design also enables **multi-agent support** with flexible provider selectio
|
||||||
"api_key": "sk-ant-your-key"
|
"api_key": "sk-ant-your-key"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
> Run `picoclaw auth login --provider anthropic` to paste your API token.
|
> Run `picoclaw auth login --provider anthropic` to paste your API token.
|
||||||
|
|
||||||
**Ollama (local)**
|
**Ollama (local)**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"model_name": "llama3",
|
"model_name": "llama3",
|
||||||
|
|
@ -886,6 +902,7 @@ This design also enables **multi-agent support** with flexible provider selectio
|
||||||
```
|
```
|
||||||
|
|
||||||
**Custom Proxy/API**
|
**Custom Proxy/API**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"model_name": "my-custom-model",
|
"model_name": "my-custom-model",
|
||||||
|
|
@ -923,6 +940,7 @@ Configure multiple endpoints for the same model name—PicoClaw will automatical
|
||||||
The old `providers` configuration is **deprecated** but still supported for backward compatibility.
|
The old `providers` configuration is **deprecated** but still supported for backward compatibility.
|
||||||
|
|
||||||
**Old Config (deprecated):**
|
**Old Config (deprecated):**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"providers": {
|
"providers": {
|
||||||
|
|
@ -941,6 +959,7 @@ The old `providers` configuration is **deprecated** but still supported for back
|
||||||
```
|
```
|
||||||
|
|
||||||
**New Config (recommended):**
|
**New Config (recommended):**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"model_list": [
|
"model_list": [
|
||||||
|
|
@ -1105,9 +1124,9 @@ Jobs are stored in `~/.picoclaw/workspace/cron/` and processed automatically.
|
||||||
|
|
||||||
PRs welcome! The codebase is intentionally small and readable. 🤗
|
PRs welcome! The codebase is intentionally small and readable. 🤗
|
||||||
|
|
||||||
Roadmap coming soon...
|
See our full [Community Roadmap](https://github.com/sipeed/picoclaw/blob/main/ROADMAP.md).
|
||||||
|
|
||||||
Developer group building, Entry Requirement: At least 1 Merged PR.
|
Developer group building, join after your first merged PR!
|
||||||
|
|
||||||
User Groups:
|
User Groups:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,7 @@
|
||||||
|
|
||||||
## 📢 Novidades
|
## 📢 Novidades
|
||||||
|
|
||||||
2026-02-16 🎉 PicoClaw atingiu 12K stars em uma semana! Obrigado a todos pelo apoio! O PicoClaw está crescendo mais rápido do que jamais imaginamos. Dado o alto volume de PRs, precisamos urgentemente de maintainers da comunidade. Nossos papéis de voluntários e roadmap foram publicados oficialmente [aqui](docs/picoclaw_community_roadmap_260216.md) — estamos ansiosos para ter você a bordo!
|
2026-02-16 🎉 PicoClaw atingiu 12K stars em uma semana! Obrigado a todos pelo apoio! O PicoClaw está crescendo mais rápido do que jamais imaginamos. Dado o alto volume de PRs, precisamos urgentemente de maintainers da comunidade. Nossos papéis de voluntários e roadmap foram publicados oficialmente [aqui](docs/ROADMAP.md) — estamos ansiosos para ter você a bordo!
|
||||||
|
|
||||||
2026-02-13 🎉 PicoClaw atingiu 5000 stars em 4 dias! Obrigado à comunidade! Estamos finalizando o **Roadmap do Projeto** e configurando o **Grupo de Desenvolvedores** para acelerar o desenvolvimento do PicoClaw.
|
2026-02-13 🎉 PicoClaw atingiu 5000 stars em 4 dias! Obrigado à comunidade! Estamos finalizando o **Roadmap do Projeto** e configurando o **Grupo de Desenvolvedores** para acelerar o desenvolvimento do PicoClaw.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,7 @@
|
||||||
|
|
||||||
## 📢 Tin tức
|
## 📢 Tin tức
|
||||||
|
|
||||||
2026-02-16 🎉 PicoClaw đạt 12K stars chỉ trong một tuần! Cảm ơn tất cả mọi người! PicoClaw đang phát triển nhanh hơn chúng tôi tưởng tượng. Do số lượng PR tăng cao, chúng tôi cấp thiết cần maintainer từ cộng đồng. Các vai trò tình nguyện viên và roadmap đã được công bố [tại đây](docs/picoclaw_community_roadmap_260216.md) — rất mong đón nhận sự tham gia của bạn!
|
2026-02-16 🎉 PicoClaw đạt 12K stars chỉ trong một tuần! Cảm ơn tất cả mọi người! PicoClaw đang phát triển nhanh hơn chúng tôi tưởng tượng. Do số lượng PR tăng cao, chúng tôi cấp thiết cần maintainer từ cộng đồng. Các vai trò tình nguyện viên và roadmap đã được công bố [tại đây](docs/ROADMAP.md) — rất mong đón nhận sự tham gia của bạn!
|
||||||
|
|
||||||
2026-02-13 🎉 PicoClaw đạt 5000 stars trong 4 ngày! Cảm ơn cộng đồng! Chúng tôi đang hoàn thiện **Lộ trình dự án (Roadmap)** và thiết lập **Nhóm phát triển** để đẩy nhanh tốc độ phát triển PicoClaw.
|
2026-02-13 🎉 PicoClaw đạt 5000 stars trong 4 ngày! Cảm ơn cộng đồng! Chúng tôi đang hoàn thiện **Lộ trình dự án (Roadmap)** và thiết lập **Nhóm phát triển** để đẩy nhanh tốc độ phát triển PicoClaw.
|
||||||
🚀 **Kêu gọi hành động:** Vui lòng gửi yêu cầu tính năng tại GitHub Discussions. Chúng tôi sẽ xem xét và ưu tiên trong cuộc họp hàng tuần.
|
🚀 **Kêu gọi hành động:** Vui lòng gửi yêu cầu tính năng tại GitHub Discussions. Chúng tôi sẽ xem xét và ưu tiên trong cuộc họp hàng tuần.
|
||||||
|
|
|
||||||
357
README.zh.md
357
README.zh.md
|
|
@ -14,7 +14,8 @@
|
||||||
<a href="https://x.com/SipeedIO"><img src="https://img.shields.io/badge/X_(Twitter)-SipeedIO-black?style=flat&logo=x&logoColor=white" alt="Twitter"></a>
|
<a href="https://x.com/SipeedIO"><img src="https://img.shields.io/badge/X_(Twitter)-SipeedIO-black?style=flat&logo=x&logoColor=white" alt="Twitter"></a>
|
||||||
</p>
|
</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)
|
**中文** | [日本語](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>
|
</div>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -42,15 +43,16 @@
|
||||||
|
|
||||||
> [!CAUTION]
|
> [!CAUTION]
|
||||||
> **🚨 SECURITY & OFFICIAL CHANNELS / 安全声明**
|
> **🚨 SECURITY & OFFICIAL CHANNELS / 安全声明**
|
||||||
> * **无加密货币 (NO CRYPTO):** PicoClaw **没有** 发行任何官方代币、Token 或虚拟货币。所有在 `pump.fun` 或其他交易平台上的相关声称均为 **诈骗**。
|
>
|
||||||
> * **官方域名:** 唯一的官方网站是 **[picoclaw.io](https://picoclaw.io)**,公司官网是 **[sipeed.com](https://sipeed.com)**。
|
> - **无加密货币 (NO CRYPTO):** PicoClaw **没有** 发行任何官方代币、Token 或虚拟货币。所有在 `pump.fun` 或其他交易平台上的相关声称均为 **诈骗**。
|
||||||
> * **警惕:** 许多 `.ai/.org/.com/.net/...` 后缀的域名被第三方抢注,请勿轻信。
|
> - **官方域名:** 唯一的官方网站是 **[picoclaw.io](https://picoclaw.io)**,公司官网是 **[sipeed.com](https://sipeed.com)**。
|
||||||
> * **注意:** picoclaw正在初期的快速功能开发阶段,可能有尚未修复的网络安全问题,在1.0正式版发布前,请不要将其部署到生产环境中
|
> - **警惕:** 许多 `.ai/.org/.com/.net/...` 后缀的域名被第三方抢注,请勿轻信。
|
||||||
> * **注意:** picoclaw最近合并了大量PRs,近期版本可能内存占用较大(10~20MB),我们将在功能较为收敛后进行资源占用优化.
|
> - **注意:** picoclaw正在初期的快速功能开发阶段,可能有尚未修复的网络安全问题,在1.0正式版发布前,请不要将其部署到生产环境中
|
||||||
|
> - **注意:** picoclaw最近合并了大量PRs,近期版本可能内存占用较大(10~20MB),我们将在功能较为收敛后进行资源占用优化.
|
||||||
|
|
||||||
## 📢 新闻 (News)
|
## 📢 新闻 (News)
|
||||||
2026-02-16 🎉 PicoClaw 在一周内突破了12K star! 感谢大家的关注!PicoClaw 的成长速度超乎我们预期. 由于PR数量的快速膨胀,我们亟需社区开发者参与维护. 我们需要的志愿者角色和roadmap已经发布到了[这里](docs/picoclaw_community_roadmap_260216.md), 期待你的参与!
|
|
||||||
|
2026-02-16 🎉 PicoClaw 在一周内突破了12K star! 感谢大家的关注!PicoClaw 的成长速度超乎我们预期. 由于PR数量的快速膨胀,我们亟需社区开发者参与维护. 我们需要的志愿者角色和roadmap已经发布到了[这里](docs/ROADMAP.md), 期待你的参与!
|
||||||
|
|
||||||
2026-02-13 🎉 **PicoClaw 在 4 天内突破 5000 Stars!** 感谢社区的支持!由于正值中国春节假期,PR 和 Issue 涌入较多,我们正在利用这段时间敲定 **项目路线图 (Roadmap)** 并组建 **开发者群组**,以便加速 PicoClaw 的开发。
|
2026-02-13 🎉 **PicoClaw 在 4 天内突破 5000 Stars!** 感谢社区的支持!由于正值中国春节假期,PR 和 Issue 涌入较多,我们正在利用这段时间敲定 **项目路线图 (Roadmap)** 并组建 **开发者群组**,以便加速 PicoClaw 的开发。
|
||||||
🚀 **行动号召:** 请在 GitHub Discussions 中提交您的功能请求 (Feature Requests)。我们将在接下来的周会上进行审查和优先级排序。
|
🚀 **行动号召:** 请在 GitHub Discussions 中提交您的功能请求 (Feature Requests)。我们将在接下来的周会上进行审查和优先级排序。
|
||||||
|
|
@ -70,7 +72,7 @@
|
||||||
🤖 **AI 自举**: 纯 Go 语言原生实现 — 95% 的核心代码由 Agent 生成,并经由“人机回环 (Human-in-the-loop)”微调。
|
🤖 **AI 自举**: 纯 Go 语言原生实现 — 95% 的核心代码由 Agent 生成,并经由“人机回环 (Human-in-the-loop)”微调。
|
||||||
|
|
||||||
| | OpenClaw | NanoBot | **PicoClaw** |
|
| | OpenClaw | NanoBot | **PicoClaw** |
|
||||||
| --- | --- | --- | --- |
|
| ------------------------------ | ------------- | ------------------------ | -------------------------------------- |
|
||||||
| **语言** | TypeScript | Python | **Go** |
|
| **语言** | TypeScript | Python | **Go** |
|
||||||
| **RAM** | >1GB | >100MB | **< 10MB** |
|
| **RAM** | >1GB | >100MB | **< 10MB** |
|
||||||
| **启动时间**</br>(0.8GHz core) | >500s | >30s | **<1s** |
|
| **启动时间**</br>(0.8GHz core) | >500s | >30s | **<1s** |
|
||||||
|
|
@ -101,9 +103,12 @@
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
### 📱 在手机上轻松运行
|
### 📱 在手机上轻松运行
|
||||||
|
|
||||||
picoclaw 可以将你10年前的老旧手机废物利用,变身成为你的AI助理!快速指南:
|
picoclaw 可以将你10年前的老旧手机废物利用,变身成为你的AI助理!快速指南:
|
||||||
|
|
||||||
1. 先去应用商店下载安装Termux
|
1. 先去应用商店下载安装Termux
|
||||||
2. 打开后执行指令
|
2. 打开后执行指令
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 注意: 下面的v0.1.1 可以换为你实际看到的最新版本
|
# 注意: 下面的v0.1.1 可以换为你实际看到的最新版本
|
||||||
wget https://github.com/sipeed/picoclaw/releases/download/v0.1.1/picoclaw-linux-arm64
|
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
|
pkg install proot
|
||||||
termux-chroot ./picoclaw-linux-arm64 onboard
|
termux-chroot ./picoclaw-linux-arm64 onboard
|
||||||
```
|
```
|
||||||
|
|
||||||
然后跟随下面的“快速开始”章节继续配置picoclaw即可使用!
|
然后跟随下面的“快速开始”章节继续配置picoclaw即可使用!
|
||||||
<img src="assets/termux.jpg" alt="PicoClaw" width="512">
|
<img src="assets/termux.jpg" alt="PicoClaw" width="512">
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
### 🐜 创新的低占用部署
|
### 🐜 创新的低占用部署
|
||||||
|
|
||||||
PicoClaw 几乎可以部署在任何 Linux 设备上!
|
PicoClaw 几乎可以部署在任何 Linux 设备上!
|
||||||
|
|
||||||
* $9.9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) E(网口) 或 W(WiFi6) 版本,用于极简家庭助手。
|
- $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),用于自动化服务器运维。
|
- $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),用于智能监控。
|
- $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)
|
[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)
|
||||||
|
|
||||||
|
|
@ -202,7 +205,7 @@ docker compose --profile gateway up -d
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
> 在 `~/.picoclaw/config.json` 中设置您的 API Key。
|
> 在 `~/.picoclaw/config.json` 中设置您的 API Key。
|
||||||
> 获取 API Key: [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu (智谱)](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM)
|
> 获取 API Key: [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu (智谱)](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM)
|
||||||
> 网络搜索是 **可选的** - 获取免费的 [Brave Search API](https://brave.com/search/api) (每月 2000 次免费查询)
|
> 网络搜索是 **可选的** - 获取免费的 [Tavily API](https://tavily.com) (每月 1000 次免费查询) 或 [Brave Search API](https://brave.com/search/api) (每月 2000 次免费查询)
|
||||||
|
|
||||||
**1. 初始化 (Initialize)**
|
**1. 初始化 (Initialize)**
|
||||||
|
|
||||||
|
|
@ -243,8 +246,9 @@ picoclaw onboard
|
||||||
"api_key": "YOUR_BRAVE_API_KEY",
|
"api_key": "YOUR_BRAVE_API_KEY",
|
||||||
"max_results": 5
|
"max_results": 5
|
||||||
},
|
},
|
||||||
"duckduckgo": {
|
"tavily": {
|
||||||
"enabled": true,
|
"enabled": false,
|
||||||
|
"api_key": "YOUR_TAVILY_API_KEY",
|
||||||
"max_results": 5
|
"max_results": 5
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -253,15 +257,14 @@ picoclaw onboard
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
> **新功能**: `model_list` 配置格式支持零代码添加 provider。详见[模型配置](#-模型配置-model_list)章节。
|
> **新功能**: `model_list` 配置格式支持零代码添加 provider。详见[模型配置](#模型配置-model_list)章节。
|
||||||
|
|
||||||
**3. 获取 API Key**
|
**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)
|
* **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 请求/月)
|
* **网络搜索** (可选): [Tavily](https://tavily.com) - 专为 AI Agent 优化 (1000 请求/月) · [Brave Search](https://brave.com/search/api) - 提供免费层级 (2000 请求/月)
|
||||||
|
|
||||||
> **注意**: 完整的配置模板请参考 `config.example.json`。
|
> **注意**: 完整的配置模板请参考 `config.example.json`。
|
||||||
|
|
||||||
|
|
@ -278,260 +281,28 @@ picoclaw agent -m "2+2 等于几?"
|
||||||
|
|
||||||
## 💬 聊天应用集成 (Chat Apps)
|
## 💬 聊天应用集成 (Chat Apps)
|
||||||
|
|
||||||
通过 Telegram, Discord, 钉钉或企业微信与您的 PicoClaw 对话。
|
PicoClaw 支持多种聊天平台,使您的 Agent 能够连接到任何地方。
|
||||||
|
|
||||||
| 渠道 | 设置难度 |
|
### 核心渠道
|
||||||
| --- | --- |
|
|
||||||
| **Telegram** | 简单 (仅需 token) |
|
| 渠道 | 设置难度 | 特性说明 | 文档链接 |
|
||||||
| **Discord** | 简单 (bot token + intents) |
|
| -------------------- | ----------- | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
|
||||||
| **QQ** | 简单 (AppID + AppSecret) |
|
| **Telegram** | ⭐ 简单 | 推荐,支持语音转文字,长轮询无需公网 | [查看文档](docs/channels/telegram/README.zh.md) |
|
||||||
| **钉钉 (DingTalk)** | 中等 (应用凭证) |
|
| **Discord** | ⭐ 简单 | Socket Mode,支持群组/私信,Bot 生态成熟 | [查看文档](docs/channels/discord/README.zh.md) |
|
||||||
| **企业微信 (WeCom)** | 中等 (企业ID + Webhook配置) |
|
| **Slack** | ⭐ 简单 | **Socket Mode** (无需公网 IP),企业级支持 | [查看文档](docs/channels/slack/README.zh.md) |
|
||||||
|
| **QQ** | ⭐⭐ 中等 | 官方机器人 API,适合国内社群 | [查看文档](docs/channels/qq/README.zh.md) |
|
||||||
<details>
|
| **钉钉 (DingTalk)** | ⭐⭐ 中等 | Stream 模式无需公网,企业办公首选 | [查看文档](docs/channels/dingtalk/README.zh.md) |
|
||||||
<summary><b>Telegram</b> (推荐)</summary>
|
| **企业微信 (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) |
|
||||||
**1. 创建机器人**
|
| **Line** | ⭐⭐⭐ 较难 | 需要 HTTPS Webhook | [查看文档](docs/channels/line/README.zh.md) |
|
||||||
|
| **OneBot** | ⭐⭐ 中等 | 兼容 NapCat/Go-CQHTTP,社区生态丰富 | [查看文档](docs/channels/onebot/README.zh.md) |
|
||||||
* 打开 Telegram,搜索 `@BotFather`
|
| **MaixCam** | ⭐ 简单 | 专为 AI 摄像头设计的硬件集成通道 | [查看文档](docs/channels/maixcam/README.zh.md) |
|
||||||
* 发送 `/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"],
|
|
||||||
"mention_only": false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
**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>
|
|
||||||
|
|
||||||
## <img src="assets/clawdchat-icon.png" width="24" height="24" alt="ClawdChat"> 加入 Agent 社交网络
|
## <img src="assets/clawdchat-icon.png" width="24" height="24" alt="ClawdChat"> 加入 Agent 社交网络
|
||||||
|
|
||||||
只需通过 CLI 或任何集成的聊天应用发送一条消息,即可将 PicoClaw 连接到 Agent 社交网络。
|
只需通过 CLI 或任何集成的聊天应用发送一条消息,即可将 PicoClaw 连接到 Agent 社交网络。
|
||||||
|
|
||||||
**阅读 `https://clawdchat.ai/skill.md` 并按照说明加入 [ClawdChat.ai](https://clawdchat.ai)
|
\*\*阅读 `https://clawdchat.ai/skill.md` 并按照说明加入 [ClawdChat.ai](https://clawdchat.ai)
|
||||||
|
|
||||||
## ⚙️ 配置详解
|
## ⚙️ 配置详解
|
||||||
|
|
||||||
|
|
@ -567,7 +338,6 @@ PicoClaw 可以自动执行周期性任务。在工作区创建 `HEARTBEAT.md`
|
||||||
- Check my email for important messages
|
- Check my email for important messages
|
||||||
- Review my calendar for upcoming events
|
- Review my calendar for upcoming events
|
||||||
- Check the weather forecast
|
- Check the weather forecast
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Agent 将每隔 30 分钟(可配置)读取此文件,并使用可用工具执行任务。
|
Agent 将每隔 30 分钟(可配置)读取此文件,并使用可用工具执行任务。
|
||||||
|
|
@ -580,18 +350,19 @@ Agent 将每隔 30 分钟(可配置)读取此文件,并使用可用工具
|
||||||
# Periodic Tasks
|
# Periodic Tasks
|
||||||
|
|
||||||
## Quick Tasks (respond directly)
|
## Quick Tasks (respond directly)
|
||||||
|
|
||||||
- Report current time
|
- Report current time
|
||||||
|
|
||||||
## Long Tasks (use spawn for async)
|
## Long Tasks (use spawn for async)
|
||||||
|
|
||||||
- Search the web for AI news and summarize
|
- Search the web for AI news and summarize
|
||||||
- Check email and report important messages
|
- Check email and report important messages
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**关键行为:**
|
**关键行为:**
|
||||||
|
|
||||||
| 特性 | 描述 |
|
| 特性 | 描述 |
|
||||||
| --- | --- |
|
| ---------------- | ---------------------------------------- |
|
||||||
| **spawn** | 创建异步子 Agent,不阻塞主心跳进程 |
|
| **spawn** | 创建异步子 Agent,不阻塞主心跳进程 |
|
||||||
| **独立上下文** | 子 Agent 拥有独立上下文,无会话历史 |
|
| **独立上下文** | 子 Agent 拥有独立上下文,无会话历史 |
|
||||||
| **message tool** | 子 Agent 通过 message 工具直接与用户通信 |
|
| **message tool** | 子 Agent 通过 message 工具直接与用户通信 |
|
||||||
|
|
@ -625,18 +396,17 @@ Agent 读取 HEARTBEAT.md
|
||||||
"interval": 30
|
"interval": 30
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
| 选项 | 默认值 | 描述 |
|
| 选项 | 默认值 | 描述 |
|
||||||
| --- | --- | --- |
|
| ---------- | ------ | ---------------------------- |
|
||||||
| `enabled` | `true` | 启用/禁用心跳 |
|
| `enabled` | `true` | 启用/禁用心跳 |
|
||||||
| `interval` | `30` | 检查间隔,单位分钟 (最小: 5) |
|
| `interval` | `30` | 检查间隔,单位分钟 (最小: 5) |
|
||||||
|
|
||||||
**环境变量:**
|
**环境变量:**
|
||||||
|
|
||||||
* `PICOCLAW_HEARTBEAT_ENABLED=false` 禁用
|
- `PICOCLAW_HEARTBEAT_ENABLED=false` 禁用
|
||||||
* `PICOCLAW_HEARTBEAT_INTERVAL=60` 更改间隔
|
- `PICOCLAW_HEARTBEAT_INTERVAL=60` 更改间隔
|
||||||
|
|
||||||
### 提供商 (Providers)
|
### 提供商 (Providers)
|
||||||
|
|
||||||
|
|
@ -644,7 +414,7 @@ Agent 读取 HEARTBEAT.md
|
||||||
> Groq 通过 Whisper 提供免费的语音转录。如果配置了 Groq,Telegram 语音消息将被自动转录为文字。
|
> Groq 通过 Whisper 提供免费的语音转录。如果配置了 Groq,Telegram 语音消息将被自动转录为文字。
|
||||||
|
|
||||||
| 提供商 | 用途 | 获取 API Key |
|
| 提供商 | 用途 | 获取 API Key |
|
||||||
| --- | --- | --- |
|
| -------------------- | ---------------------------- | -------------------------------------------------------------------- |
|
||||||
| `gemini` | LLM (Gemini 直连) | [aistudio.google.com](https://aistudio.google.com) |
|
| `gemini` | LLM (Gemini 直连) | [aistudio.google.com](https://aistudio.google.com) |
|
||||||
| `zhipu` | LLM (智谱直连) | [bigmodel.cn](bigmodel.cn) |
|
| `zhipu` | LLM (智谱直连) | [bigmodel.cn](bigmodel.cn) |
|
||||||
| `openrouter(待测试)` | LLM (推荐,可访问所有模型) | [openrouter.ai](https://openrouter.ai) |
|
| `openrouter(待测试)` | LLM (推荐,可访问所有模型) | [openrouter.ai](https://openrouter.ai) |
|
||||||
|
|
@ -669,7 +439,7 @@ Agent 读取 HEARTBEAT.md
|
||||||
#### 📋 所有支持的厂商
|
#### 📋 所有支持的厂商
|
||||||
|
|
||||||
| 厂商 | `model` 前缀 | 默认 API Base | 协议 | 获取 API Key |
|
| 厂商 | `model` 前缀 | 默认 API Base | 协议 | 获取 API Key |
|
||||||
|------|-------------|---------------|------|--------------|
|
| ------------------- | ----------------- | --------------------------------------------------- | --------- | ----------------------------------------------------------------- |
|
||||||
| **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [获取密钥](https://platform.openai.com) |
|
| **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [获取密钥](https://platform.openai.com) |
|
||||||
| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [获取密钥](https://console.anthropic.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) |
|
| **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [获取密钥](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) |
|
||||||
|
|
@ -720,6 +490,7 @@ Agent 读取 HEARTBEAT.md
|
||||||
#### 各厂商配置示例
|
#### 各厂商配置示例
|
||||||
|
|
||||||
**OpenAI**
|
**OpenAI**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.2",
|
"model_name": "gpt-5.2",
|
||||||
|
|
@ -729,6 +500,7 @@ Agent 读取 HEARTBEAT.md
|
||||||
```
|
```
|
||||||
|
|
||||||
**智谱 AI (GLM)**
|
**智谱 AI (GLM)**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"model_name": "glm-4.7",
|
"model_name": "glm-4.7",
|
||||||
|
|
@ -738,6 +510,7 @@ Agent 读取 HEARTBEAT.md
|
||||||
```
|
```
|
||||||
|
|
||||||
**DeepSeek**
|
**DeepSeek**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"model_name": "deepseek-chat",
|
"model_name": "deepseek-chat",
|
||||||
|
|
@ -747,6 +520,7 @@ Agent 读取 HEARTBEAT.md
|
||||||
```
|
```
|
||||||
|
|
||||||
**Anthropic (使用 OAuth)**
|
**Anthropic (使用 OAuth)**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"model_name": "claude-sonnet-4.6",
|
"model_name": "claude-sonnet-4.6",
|
||||||
|
|
@ -754,9 +528,11 @@ Agent 读取 HEARTBEAT.md
|
||||||
"auth_method": "oauth"
|
"auth_method": "oauth"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
> 运行 `picoclaw auth login --provider anthropic` 来设置 OAuth 凭证。
|
> 运行 `picoclaw auth login --provider anthropic` 来设置 OAuth 凭证。
|
||||||
|
|
||||||
**Ollama (本地)**
|
**Ollama (本地)**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"model_name": "llama3",
|
"model_name": "llama3",
|
||||||
|
|
@ -765,6 +541,7 @@ Agent 读取 HEARTBEAT.md
|
||||||
```
|
```
|
||||||
|
|
||||||
**自定义代理/API**
|
**自定义代理/API**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"model_name": "my-custom-model",
|
"model_name": "my-custom-model",
|
||||||
|
|
@ -802,6 +579,7 @@ Agent 读取 HEARTBEAT.md
|
||||||
旧的 `providers` 配置格式**已弃用**,但为向后兼容仍支持。
|
旧的 `providers` 配置格式**已弃用**,但为向后兼容仍支持。
|
||||||
|
|
||||||
**旧配置(已弃用):**
|
**旧配置(已弃用):**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"providers": {
|
"providers": {
|
||||||
|
|
@ -820,6 +598,7 @@ Agent 读取 HEARTBEAT.md
|
||||||
```
|
```
|
||||||
|
|
||||||
**新配置(推荐):**
|
**新配置(推荐):**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"model_list": [
|
"model_list": [
|
||||||
|
|
@ -844,7 +623,7 @@ Agent 读取 HEARTBEAT.md
|
||||||
|
|
||||||
**1. 获取 API key 和 base URL**
|
**1. 获取 API key 和 base URL**
|
||||||
|
|
||||||
* 获取 [API key](https://bigmodel.cn/usercenter/proj-mgmt/apikeys)
|
- 获取 [API key](https://bigmodel.cn/usercenter/proj-mgmt/apikeys)
|
||||||
|
|
||||||
**2. 配置**
|
**2. 配置**
|
||||||
|
|
||||||
|
|
@ -866,7 +645,6 @@ Agent 读取 HEARTBEAT.md
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**3. 运行**
|
**3. 运行**
|
||||||
|
|
@ -946,7 +724,6 @@ picoclaw agent -m "你好"
|
||||||
"interval": 30
|
"interval": 30
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
@ -954,7 +731,7 @@ picoclaw agent -m "你好"
|
||||||
## CLI 命令行参考
|
## CLI 命令行参考
|
||||||
|
|
||||||
| 命令 | 描述 |
|
| 命令 | 描述 |
|
||||||
| --- | --- |
|
| ------------------------- | ------------------ |
|
||||||
| `picoclaw onboard` | 初始化配置和工作区 |
|
| `picoclaw onboard` | 初始化配置和工作区 |
|
||||||
| `picoclaw agent -m "..."` | 与 Agent 对话 |
|
| `picoclaw agent -m "..."` | 与 Agent 对话 |
|
||||||
| `picoclaw agent` | 交互式聊天模式 |
|
| `picoclaw agent` | 交互式聊天模式 |
|
||||||
|
|
@ -967,9 +744,9 @@ picoclaw agent -m "你好"
|
||||||
|
|
||||||
PicoClaw 通过 `cron` 工具支持定时提醒和重复任务:
|
PicoClaw 通过 `cron` 工具支持定时提醒和重复任务:
|
||||||
|
|
||||||
* **一次性提醒**: "Remind me in 10 minutes" (10分钟后提醒我) → 10分钟后触发一次
|
- **一次性提醒**: "Remind me in 10 minutes" (10分钟后提醒我) → 10分钟后触发一次
|
||||||
* **重复任务**: "Remind me every 2 hours" (每2小时提醒我) → 每2小时触发
|
- **重复任务**: "Remind me every 2 hours" (每2小时提醒我) → 每2小时触发
|
||||||
* **Cron 表达式**: "Remind me at 9am daily" (每天上午9点提醒我) → 使用 cron 表达式
|
- **Cron 表达式**: "Remind me at 9am daily" (每天上午9点提醒我) → 使用 cron 表达式
|
||||||
|
|
||||||
任务存储在 `~/.picoclaw/workspace/cron/` 中并自动处理。
|
任务存储在 `~/.picoclaw/workspace/cron/` 中并自动处理。
|
||||||
|
|
||||||
|
|
@ -995,8 +772,9 @@ Discord: [https://discord.gg/V4sAZ9XWpN](https://discord.gg/V4sAZ9XWpN)
|
||||||
|
|
||||||
启用网络搜索:
|
启用网络搜索:
|
||||||
|
|
||||||
1. 在 [https://brave.com/search/api](https://brave.com/search/api) 获取免费 API Key (每月 2000 次免费查询)
|
1. 在 [https://tavily.com](https://tavily.com) (1000 次免费) 或 [https://brave.com/search/api](https://brave.com/search/api) 获取免费 API Key (2000 次免费)
|
||||||
2. 添加到 `~/.picoclaw/config.json`:
|
2. 添加到 `~/.picoclaw/config.json`:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"tools": {
|
"tools": {
|
||||||
|
|
@ -1013,11 +791,8 @@ Discord: [https://discord.gg/V4sAZ9XWpN](https://discord.gg/V4sAZ9XWpN)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
### 遇到内容过滤错误 (Content Filtering Errors)
|
### 遇到内容过滤错误 (Content Filtering Errors)
|
||||||
|
|
||||||
某些提供商(如智谱)有严格的内容过滤。尝试改写您的问题或使用其他模型。
|
某些提供商(如智谱)有严格的内容过滤。尝试改写您的问题或使用其他模型。
|
||||||
|
|
@ -1035,5 +810,5 @@ Discord: [https://discord.gg/V4sAZ9XWpN](https://discord.gg/V4sAZ9XWpN)
|
||||||
| **OpenRouter** | 200K tokens/月 | 多模型聚合 (Claude, GPT-4 等) |
|
| **OpenRouter** | 200K tokens/月 | 多模型聚合 (Claude, GPT-4 等) |
|
||||||
| **智谱 (Zhipu)** | 200K tokens/月 | 最适合中国用户 |
|
| **智谱 (Zhipu)** | 200K tokens/月 | 最适合中国用户 |
|
||||||
| **Brave Search** | 2000 次查询/月 | 网络搜索功能 |
|
| **Brave Search** | 2000 次查询/月 | 网络搜索功能 |
|
||||||
|
| **Tavily** | 1000 次查询/月 | AI Agent 搜索优化 |
|
||||||
| **Groq** | 提供免费层级 | 极速推理 (Llama, Mixtral) |
|
| **Groq** | 提供免费层级 | 极速推理 (Llama, Mixtral) |
|
||||||
| **Cerebras** | 提供免费层级 | 极速推理 (Llama, Qwen 等) |
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 141 KiB After Width: | Height: | Size: 141 KiB |
|
|
@ -13,6 +13,7 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/chzyer/readline"
|
"github.com/chzyer/readline"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/agent"
|
"github.com/sipeed/picoclaw/pkg/agent"
|
||||||
"github.com/sipeed/picoclaw/pkg/bus"
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
"github.com/sipeed/picoclaw/pkg/logger"
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
|
|
@ -74,10 +75,10 @@ func agentCmd() {
|
||||||
// Print agent startup info (only for interactive mode)
|
// Print agent startup info (only for interactive mode)
|
||||||
startupInfo := agentLoop.GetStartupInfo()
|
startupInfo := agentLoop.GetStartupInfo()
|
||||||
logger.InfoCF("agent", "Agent initialized",
|
logger.InfoCF("agent", "Agent initialized",
|
||||||
map[string]interface{}{
|
map[string]any{
|
||||||
"tools_count": startupInfo["tools"].(map[string]interface{})["count"],
|
"tools_count": startupInfo["tools"].(map[string]any)["count"],
|
||||||
"skills_total": startupInfo["skills"].(map[string]interface{})["total"],
|
"skills_total": startupInfo["skills"].(map[string]any)["total"],
|
||||||
"skills_available": startupInfo["skills"].(map[string]interface{})["available"],
|
"skills_available": startupInfo["skills"].(map[string]any)["available"],
|
||||||
})
|
})
|
||||||
|
|
||||||
if message != "" {
|
if message != "" {
|
||||||
|
|
@ -104,7 +105,6 @@ func interactiveMode(agentLoop *agent.AgentLoop, sessionKey string) {
|
||||||
InterruptPrompt: "^C",
|
InterruptPrompt: "^C",
|
||||||
EOFPrompt: "exit",
|
EOFPrompt: "exit",
|
||||||
})
|
})
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("Error initializing readline: %v\n", err)
|
fmt.Printf("Error initializing readline: %v\n", err)
|
||||||
fmt.Println("Falling back to simple input mode...")
|
fmt.Println("Falling back to simple input mode...")
|
||||||
|
|
@ -148,7 +148,7 @@ func interactiveMode(agentLoop *agent.AgentLoop, sessionKey string) {
|
||||||
func simpleInteractiveMode(agentLoop *agent.AgentLoop, sessionKey string) {
|
func simpleInteractiveMode(agentLoop *agent.AgentLoop, sessionKey string) {
|
||||||
reader := bufio.NewReader(os.Stdin)
|
reader := bufio.NewReader(os.Stdin)
|
||||||
for {
|
for {
|
||||||
fmt.Print(fmt.Sprintf("%s You: ", logo))
|
fmt.Printf("%s You: ", logo)
|
||||||
line, err := reader.ReadString('\n')
|
line, err := reader.ReadString('\n')
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == io.EOF {
|
if err == io.EOF {
|
||||||
|
|
|
||||||
|
|
@ -114,7 +114,7 @@ func authLoginOpenAI(useDeviceCode bool) {
|
||||||
os.Exit(1)
|
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)
|
fmt.Printf("Failed to save credentials: %v\n", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
@ -188,7 +188,7 @@ func authLoginGoogleAntigravity() {
|
||||||
fmt.Printf("Project: %s\n", projectID)
|
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)
|
fmt.Printf("Failed to save credentials: %v\n", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
@ -265,7 +265,7 @@ func authLoginPasteToken(provider string) {
|
||||||
os.Exit(1)
|
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)
|
fmt.Printf("Failed to save credentials: %v\n", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import (
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/agent"
|
"github.com/sipeed/picoclaw/pkg/agent"
|
||||||
|
|
@ -60,8 +61,8 @@ func gatewayCmd() {
|
||||||
// Print agent startup info
|
// Print agent startup info
|
||||||
fmt.Println("\n📦 Agent Status:")
|
fmt.Println("\n📦 Agent Status:")
|
||||||
startupInfo := agentLoop.GetStartupInfo()
|
startupInfo := agentLoop.GetStartupInfo()
|
||||||
toolsInfo := startupInfo["tools"].(map[string]interface{})
|
toolsInfo := startupInfo["tools"].(map[string]any)
|
||||||
skillsInfo := startupInfo["skills"].(map[string]interface{})
|
skillsInfo := startupInfo["skills"].(map[string]any)
|
||||||
fmt.Printf(" • Tools: %d loaded\n", toolsInfo["count"])
|
fmt.Printf(" • Tools: %d loaded\n", toolsInfo["count"])
|
||||||
fmt.Printf(" • Skills: %d/%d available\n",
|
fmt.Printf(" • Skills: %d/%d available\n",
|
||||||
skillsInfo["available"],
|
skillsInfo["available"],
|
||||||
|
|
@ -69,7 +70,7 @@ func gatewayCmd() {
|
||||||
|
|
||||||
// Log to file as well
|
// Log to file as well
|
||||||
logger.InfoCF("agent", "Agent initialized",
|
logger.InfoCF("agent", "Agent initialized",
|
||||||
map[string]interface{}{
|
map[string]any{
|
||||||
"tools_count": toolsInfo["count"],
|
"tools_count": toolsInfo["count"],
|
||||||
"skills_total": skillsInfo["total"],
|
"skills_total": skillsInfo["total"],
|
||||||
"skills_available": skillsInfo["available"],
|
"skills_available": skillsInfo["available"],
|
||||||
|
|
@ -77,7 +78,14 @@ func gatewayCmd() {
|
||||||
|
|
||||||
// Setup cron tool and service
|
// Setup cron tool and service
|
||||||
execTimeout := time.Duration(cfg.Tools.Cron.ExecTimeoutMinutes) * time.Minute
|
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(
|
heartbeatService := heartbeat.NewHeartbeatService(
|
||||||
cfg.WorkspacePath(),
|
cfg.WorkspacePath(),
|
||||||
|
|
@ -91,7 +99,8 @@ func gatewayCmd() {
|
||||||
channel, chatID = "cli", "direct"
|
channel, chatID = "cli", "direct"
|
||||||
}
|
}
|
||||||
// Use ProcessHeartbeat - no session history, each heartbeat is independent
|
// 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 {
|
if err != nil {
|
||||||
return tools.ErrorResult(fmt.Sprintf("Heartbeat error: %v", err))
|
return tools.ErrorResult(fmt.Sprintf("Heartbeat error: %v", err))
|
||||||
}
|
}
|
||||||
|
|
@ -113,8 +122,17 @@ func gatewayCmd() {
|
||||||
agentLoop.SetChannelManager(channelManager)
|
agentLoop.SetChannelManager(channelManager)
|
||||||
|
|
||||||
var transcriber *voice.GroqTranscriber
|
var transcriber *voice.GroqTranscriber
|
||||||
if cfg.Providers.Groq.APIKey != "" {
|
groqAPIKey := cfg.Providers.Groq.APIKey
|
||||||
transcriber = voice.NewGroqTranscriber(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")
|
logger.InfoC("voice", "Groq voice transcription enabled")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -181,7 +199,7 @@ func gatewayCmd() {
|
||||||
healthServer := health.NewServer(cfg.Gateway.Host, cfg.Gateway.Port)
|
healthServer := health.NewServer(cfg.Gateway.Host, cfg.Gateway.Port)
|
||||||
go func() {
|
go func() {
|
||||||
if err := healthServer.Start(); err != nil && err != http.ErrServerClosed {
|
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)
|
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")
|
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")
|
cronStorePath := filepath.Join(workspace, "cron", "jobs.json")
|
||||||
|
|
||||||
// Create cron service
|
// Create cron service
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,7 @@ func onboard() {
|
||||||
|
|
||||||
func copyEmbeddedToTarget(targetDir string) error {
|
func copyEmbeddedToTarget(targetDir string) error {
|
||||||
// Ensure target directory exists
|
// 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)
|
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)
|
targetPath := filepath.Join(targetDir, new_path)
|
||||||
|
|
||||||
// Ensure target file's directory exists
|
// 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)
|
return fmt.Errorf("Failed to create directory %s: %w", filepath.Dir(targetPath), err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write file
|
// 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)
|
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()
|
workspace := cfg.WorkspacePath()
|
||||||
targetDir := filepath.Join(workspace, "skills", slug)
|
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)
|
fmt.Printf("\u2717 Skill '%s' already installed at %s\n", slug, targetDir)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
@ -126,7 +126,7 @@ func skillsInstallFromRegistry(cfg *config.Config, registryName, slug string) {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||||
defer cancel()
|
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)
|
fmt.Printf("\u2717 Failed to create skills directory: %v\n", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
@ -193,7 +193,7 @@ func skillsInstallBuiltinCmd(workspace string) {
|
||||||
continue
|
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)
|
fmt.Printf("✗ Failed to create directory for %s: %v\n", skillName, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -196,6 +196,10 @@
|
||||||
"volcengine": {
|
"volcengine": {
|
||||||
"api_key": "",
|
"api_key": "",
|
||||||
"api_base": ""
|
"api_base": ""
|
||||||
|
},
|
||||||
|
"mistral": {
|
||||||
|
"api_key": "",
|
||||||
|
"api_base": "https://api.mistral.ai/v1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"tools": {
|
"tools": {
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,9 @@ services:
|
||||||
container_name: picoclaw-agent
|
container_name: picoclaw-agent
|
||||||
profiles:
|
profiles:
|
||||||
- agent
|
- agent
|
||||||
|
# Uncomment to access host network; leave commented unless needed.
|
||||||
|
#extra_hosts:
|
||||||
|
# - "host.docker.internal:host-gateway"
|
||||||
volumes:
|
volumes:
|
||||||
- ./config/config.json:/home/picoclaw/.picoclaw/config.json:ro
|
- ./config/config.json:/home/picoclaw/.picoclaw/config.json:ro
|
||||||
- picoclaw-workspace:/home/picoclaw/.picoclaw/workspace
|
- picoclaw-workspace:/home/picoclaw/.picoclaw/workspace
|
||||||
|
|
@ -29,6 +32,9 @@ services:
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
profiles:
|
profiles:
|
||||||
- gateway
|
- gateway
|
||||||
|
# Uncomment to access host network; leave commented unless needed.
|
||||||
|
#extra_hosts:
|
||||||
|
# - "host.docker.internal:host-gateway"
|
||||||
volumes:
|
volumes:
|
||||||
# Configuration file
|
# Configuration file
|
||||||
- ./config/config.json:/home/picoclaw/.picoclaw/config.json:ro
|
- ./config/config.json:/home/picoclaw/.picoclaw/config.json:ro
|
||||||
|
|
|
||||||
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,112 +0,0 @@
|
||||||
## 🚀 Join the PicoClaw Journey: Call for Community Volunteers & Roadmap Reveal
|
|
||||||
|
|
||||||
**Hello, PicoClaw Community!**
|
|
||||||
|
|
||||||
First, a massive thank you to everyone for your enthusiasm and PR contributions. It is because of you that PicoClaw continues to iterate and evolve so rapidly. Thanks to the simplicity and accessibility of the **Go language**, we’ve seen a non-stop stream of high-quality PRs!
|
|
||||||
|
|
||||||
PicoClaw is growing much faster than we anticipated. As we are currently in the midst of the **Chinese New Year holiday**, we are looking to recruit community volunteers to help us maintain this incredible momentum.
|
|
||||||
|
|
||||||
This document outlines the specific volunteer roles we need right now and provides a look at our upcoming **Roadmap**.
|
|
||||||
|
|
||||||
### 🎁 Community Perks
|
|
||||||
|
|
||||||
To show our appreciation, developers who officially join our community operations will receive:
|
|
||||||
|
|
||||||
* **Exclusive AI Hardware:** Our upcoming, unreleased AI device.
|
|
||||||
* **Token Discounts:** Potential discounts on LLM tokens (currently in negotiations with major providers).
|
|
||||||
|
|
||||||
### 🎥 Calling All Content Creators!
|
|
||||||
|
|
||||||
Not a developer? You can still help! We welcome users to post **PicoClaw reviews or tutorials**.
|
|
||||||
|
|
||||||
* **Twitter:** Use the tag **#picoclaw** and mention **@SipeedIO**.
|
|
||||||
* **Bilibili:** Mention **@Sipeed矽速科技** or send us a DM.
|
|
||||||
We will be rewarding high-quality content creators with the same perks as our community developers!
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🛠️ Urgent Volunteer Roles
|
|
||||||
|
|
||||||
We are looking for experts in the following areas:
|
|
||||||
|
|
||||||
1. **Issue/PR Reviewers**
|
|
||||||
* **The Mission:** With PRs and Issues exploding in volume, we need help with initial triage, evaluation, and merging.
|
|
||||||
* **Focus:** Preliminary merging and community health. Efficiency optimization and security audits will be handled by specialized roles.
|
|
||||||
|
|
||||||
|
|
||||||
2. **Resource Optimization Experts**
|
|
||||||
* **The Mission:** Rapid growth has introduced dependencies that are making PicoClaw a bit "heavy." We want to keep it lean.
|
|
||||||
* **Focus:** Analyzing resource growth between releases and trimming redundancy.
|
|
||||||
* **Priority:** **RAM usage optimization** > Binary size reduction.
|
|
||||||
|
|
||||||
|
|
||||||
3. **Security Audit & Bug Fixes**
|
|
||||||
* **The Mission:** Due to the "vibe coding" nature of our early stages, we need a thorough review of network security and AI permission management.
|
|
||||||
* **Focus:** Auditing the codebase for vulnerabilities and implementing robust fixes.
|
|
||||||
|
|
||||||
|
|
||||||
4. **Documentation & DX (Developer Experience)**
|
|
||||||
* **The Mission:** Our current README is a bit outdated. We need "step-by-step" guides that even beginners can follow.
|
|
||||||
* **Focus:** Creating clear, user-friendly documentation for both setup and development.
|
|
||||||
|
|
||||||
|
|
||||||
5. **AI-Powered CI/CD Optimization**
|
|
||||||
* **The Mission:** PicoClaw started as a "vibe coding" experiment; now we want to use AI to manage it.
|
|
||||||
* **Focus:** Automating builds with AI and exploring AI-driven issue resolution.
|
|
||||||
|
|
||||||
**How to Apply:** > If you are interested in any of the roles above, please send an email to support@sipeed.com with the subject line: [Apply: PicoClaw Expert Volunteer] + Your Desired Role.
|
|
||||||
Please include a brief introduction and any relevant experience or portfolio links. We will review all applications and grant project permissions to selected contributors!
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📍 The Roadmap
|
|
||||||
|
|
||||||
Interested in a specific feature? You can "claim" these tasks and start building:
|
|
||||||
|
|
||||||
###
|
|
||||||
* **Provider:**
|
|
||||||
* **Provider Refactor:** Currently being handled by **@Daming** (ETA: 5 days)
|
|
||||||
* You can still submit code; Daming will merge it into the new implementation.
|
|
||||||
* **Channels:**
|
|
||||||
* Support for OneBot, additional platforms
|
|
||||||
* attachments (images, audio, video, files).
|
|
||||||
* **Skills:**
|
|
||||||
* 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-Agent**
|
|
||||||
* **Model Routing:** Small models for easy tasks, large models for hard ones (to save tokens).
|
|
||||||
* **Swarm Mode.**
|
|
||||||
* **AIEOS Integration.**
|
|
||||||
|
|
||||||
|
|
||||||
* **Branding:**
|
|
||||||
* **Logo**: We need a cute logo! We’re leaning toward a **Mantis Shrimp**—small, but packs a legendary punch!
|
|
||||||
|
|
||||||
|
|
||||||
We have officially created these tasks as GitHub Issues, all marked with the roadmap tag.
|
|
||||||
This list will be updated continuously as we progress.
|
|
||||||
If you would like to claim a task, please feel free to start a conversation by commenting directly on the corresponding issue!
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🤝 How to Join
|
|
||||||
|
|
||||||
**Everything is open to your creativity!** If you have a wild idea, just PR it.
|
|
||||||
|
|
||||||
1. **The Fast Track:** Once you have at least **one merged PR**, you are eligible to join our **Developer Discord** to help plan the future of PicoClaw.
|
|
||||||
2. **The Application Track:** If you haven’t submitted a PR yet but want to dive in, email **support@sipeed.com** with the subject:
|
|
||||||
> `[Apply Join PicoClaw Dev Group] + Your GitHub Account`
|
|
||||||
> Include the role you're interested in and any evidence of your development experience.
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
### Looking Ahead
|
|
||||||
|
|
||||||
Powered by PicoClaw, we are crafting a Swarm AI Assistant to transform your environment into a seamless network of personal stewards. By automating the friction of daily life, we empower you to transcend the ordinary and freely explore your creative potential.
|
|
||||||
|
|
||||||
**Finally, Happy Chinese New Year to everyone!** May PicoClaw gallop forward in this **Year of the Horse!** 🐎
|
|
||||||
|
|
@ -80,7 +80,7 @@ Your workspace is at: %s
|
||||||
|
|
||||||
2. **Be helpful and accurate** - When using tools, briefly explain what you're doing.
|
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)
|
now, runtime, workspacePath, workspacePath, workspacePath, workspacePath, toolsSection, workspacePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -96,7 +96,9 @@ func (cb *ContextBuilder) buildToolsSection() string {
|
||||||
|
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
sb.WriteString("## Available Tools\n\n")
|
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")
|
sb.WriteString("You have access to the following tools:\n\n")
|
||||||
for _, s := range summaries {
|
for _, s := range summaries {
|
||||||
sb.WriteString(s)
|
sb.WriteString(s)
|
||||||
|
|
@ -157,7 +159,13 @@ func (cb *ContextBuilder) LoadBootstrapFiles() string {
|
||||||
return sb.String()
|
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{}
|
messages := []providers.Message{}
|
||||||
|
|
||||||
systemPrompt := cb.BuildSystemPrompt()
|
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)
|
// Log system prompt summary for debugging (debug mode only)
|
||||||
logger.DebugCF("agent", "System prompt built",
|
logger.DebugCF("agent", "System prompt built",
|
||||||
map[string]interface{}{
|
map[string]any{
|
||||||
"total_chars": len(systemPrompt),
|
"total_chars": len(systemPrompt),
|
||||||
"total_lines": strings.Count(systemPrompt, "\n") + 1,
|
"total_lines": strings.Count(systemPrompt, "\n") + 1,
|
||||||
"section_count": strings.Count(systemPrompt, "\n\n---\n\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)"
|
preview = preview[:500] + "... (truncated)"
|
||||||
}
|
}
|
||||||
logger.DebugCF("agent", "System prompt preview",
|
logger.DebugCF("agent", "System prompt preview",
|
||||||
map[string]interface{}{
|
map[string]any{
|
||||||
"preview": preview,
|
"preview": preview,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -218,12 +226,12 @@ func sanitizeHistoryForProvider(history []providers.Message) []providers.Message
|
||||||
switch msg.Role {
|
switch msg.Role {
|
||||||
case "tool":
|
case "tool":
|
||||||
if len(sanitized) == 0 {
|
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
|
continue
|
||||||
}
|
}
|
||||||
last := sanitized[len(sanitized)-1]
|
last := sanitized[len(sanitized)-1]
|
||||||
if last.Role != "assistant" || len(last.ToolCalls) == 0 {
|
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
|
continue
|
||||||
}
|
}
|
||||||
sanitized = append(sanitized, msg)
|
sanitized = append(sanitized, msg)
|
||||||
|
|
@ -231,12 +239,16 @@ func sanitizeHistoryForProvider(history []providers.Message) []providers.Message
|
||||||
case "assistant":
|
case "assistant":
|
||||||
if len(msg.ToolCalls) > 0 {
|
if len(msg.ToolCalls) > 0 {
|
||||||
if len(sanitized) == 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
|
continue
|
||||||
}
|
}
|
||||||
prev := sanitized[len(sanitized)-1]
|
prev := sanitized[len(sanitized)-1]
|
||||||
if prev.Role != "user" && prev.Role != "tool" {
|
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
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -250,7 +262,10 @@ func sanitizeHistoryForProvider(history []providers.Message) []providers.Message
|
||||||
return sanitized
|
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{
|
messages = append(messages, providers.Message{
|
||||||
Role: "tool",
|
Role: "tool",
|
||||||
Content: result,
|
Content: result,
|
||||||
|
|
@ -259,7 +274,11 @@ func (cb *ContextBuilder) AddToolResult(messages []providers.Message, toolCallID
|
||||||
return messages
|
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{
|
msg := providers.Message{
|
||||||
Role: "assistant",
|
Role: "assistant",
|
||||||
Content: content,
|
Content: content,
|
||||||
|
|
@ -289,13 +308,13 @@ func (cb *ContextBuilder) loadSkills() string {
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetSkillsInfo returns information about loaded skills.
|
// GetSkillsInfo returns information about loaded skills.
|
||||||
func (cb *ContextBuilder) GetSkillsInfo() map[string]interface{} {
|
func (cb *ContextBuilder) GetSkillsInfo() map[string]any {
|
||||||
allSkills := cb.skillsLoader.ListSkills()
|
allSkills := cb.skillsLoader.ListSkills()
|
||||||
skillNames := make([]string, 0, len(allSkills))
|
skillNames := make([]string, 0, len(allSkills))
|
||||||
for _, s := range allSkills {
|
for _, s := range allSkills {
|
||||||
skillNames = append(skillNames, s.Name)
|
skillNames = append(skillNames, s.Name)
|
||||||
}
|
}
|
||||||
return map[string]interface{}{
|
return map[string]any{
|
||||||
"total": len(allSkills),
|
"total": len(allSkills),
|
||||||
"available": len(allSkills),
|
"available": len(allSkills),
|
||||||
"names": skillNames,
|
"names": skillNames,
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@ func NewAgentInstance(
|
||||||
provider providers.LLMProvider,
|
provider providers.LLMProvider,
|
||||||
) *AgentInstance {
|
) *AgentInstance {
|
||||||
workspace := resolveAgentWorkspace(agentCfg, defaults)
|
workspace := resolveAgentWorkspace(agentCfg, defaults)
|
||||||
os.MkdirAll(workspace, 0755)
|
os.MkdirAll(workspace, 0o755)
|
||||||
|
|
||||||
model := resolveAgentModel(agentCfg, defaults)
|
model := resolveAgentModel(agentCfg, defaults)
|
||||||
fallbacks := resolveAgentFallbacks(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).
|
// 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() {
|
for _, agentID := range registry.ListAgentIDs() {
|
||||||
agent, ok := registry.GetAgent(agentID)
|
agent, ok := registry.GetAgent(agentID)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|
@ -92,6 +97,10 @@ func registerSharedTools(cfg *config.Config, msgBus *bus.MessageBus, registry *A
|
||||||
BraveAPIKey: cfg.Tools.Web.Brave.APIKey,
|
BraveAPIKey: cfg.Tools.Web.Brave.APIKey,
|
||||||
BraveMaxResults: cfg.Tools.Web.Brave.MaxResults,
|
BraveMaxResults: cfg.Tools.Web.Brave.MaxResults,
|
||||||
BraveEnabled: cfg.Tools.Web.Brave.Enabled,
|
BraveEnabled: cfg.Tools.Web.Brave.Enabled,
|
||||||
|
TavilyAPIKey: cfg.Tools.Web.Tavily.APIKey,
|
||||||
|
TavilyBaseURL: cfg.Tools.Web.Tavily.BaseURL,
|
||||||
|
TavilyMaxResults: cfg.Tools.Web.Tavily.MaxResults,
|
||||||
|
TavilyEnabled: cfg.Tools.Web.Tavily.Enabled,
|
||||||
DuckDuckGoMaxResults: cfg.Tools.Web.DuckDuckGo.MaxResults,
|
DuckDuckGoMaxResults: cfg.Tools.Web.DuckDuckGo.MaxResults,
|
||||||
DuckDuckGoEnabled: cfg.Tools.Web.DuckDuckGo.Enabled,
|
DuckDuckGoEnabled: cfg.Tools.Web.DuckDuckGo.Enabled,
|
||||||
PerplexityAPIKey: cfg.Tools.Web.Perplexity.APIKey,
|
PerplexityAPIKey: cfg.Tools.Web.Perplexity.APIKey,
|
||||||
|
|
@ -123,7 +132,10 @@ func registerSharedTools(cfg *config.Config, msgBus *bus.MessageBus, registry *A
|
||||||
MaxConcurrentSearches: cfg.Tools.Skills.MaxConcurrentSearches,
|
MaxConcurrentSearches: cfg.Tools.Skills.MaxConcurrentSearches,
|
||||||
ClawHub: skills.ClawHubConfig(cfg.Tools.Skills.Registries.ClawHub),
|
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.NewFindSkillsTool(registryMgr, searchCache))
|
||||||
agent.Tools.Register(tools.NewInstallSkillTool(registryMgr, agent.Workspace))
|
agent.Tools.Register(tools.NewInstallSkillTool(registryMgr, agent.Workspace))
|
||||||
|
|
||||||
|
|
@ -226,7 +238,10 @@ func (al *AgentLoop) ProcessDirect(ctx context.Context, content, sessionKey stri
|
||||||
return al.ProcessDirectWithChannel(ctx, content, sessionKey, "cli", "direct")
|
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{
|
msg := bus.InboundMessage{
|
||||||
Channel: channel,
|
Channel: channel,
|
||||||
SenderID: "cron",
|
SenderID: "cron",
|
||||||
|
|
@ -263,7 +278,7 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
|
||||||
logContent = utils.Truncate(msg.Content, 80)
|
logContent = utils.Truncate(msg.Content, 80)
|
||||||
}
|
}
|
||||||
logger.InfoCF("agent", fmt.Sprintf("Processing message from %s:%s: %s", msg.Channel, msg.SenderID, logContent),
|
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,
|
"channel": msg.Channel,
|
||||||
"chat_id": msg.ChatID,
|
"chat_id": msg.ChatID,
|
||||||
"sender_id": msg.SenderID,
|
"sender_id": msg.SenderID,
|
||||||
|
|
@ -302,7 +317,7 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.InfoCF("agent", "Routed message",
|
logger.InfoCF("agent", "Routed message",
|
||||||
map[string]interface{}{
|
map[string]any{
|
||||||
"agent_id": agent.ID,
|
"agent_id": agent.ID,
|
||||||
"session_key": sessionKey,
|
"session_key": sessionKey,
|
||||||
"matched_by": route.MatchedBy,
|
"matched_by": route.MatchedBy,
|
||||||
|
|
@ -325,7 +340,7 @@ func (al *AgentLoop) processSystemMessage(ctx context.Context, msg bus.InboundMe
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.InfoCF("agent", "Processing system message",
|
logger.InfoCF("agent", "Processing system message",
|
||||||
map[string]interface{}{
|
map[string]any{
|
||||||
"sender_id": msg.SenderID,
|
"sender_id": msg.SenderID,
|
||||||
"chat_id": msg.ChatID,
|
"chat_id": msg.ChatID,
|
||||||
})
|
})
|
||||||
|
|
@ -350,7 +365,7 @@ func (al *AgentLoop) processSystemMessage(ctx context.Context, msg bus.InboundMe
|
||||||
// Skip internal channels - only log, don't send to user
|
// Skip internal channels - only log, don't send to user
|
||||||
if constants.IsInternalChannel(originChannel) {
|
if constants.IsInternalChannel(originChannel) {
|
||||||
logger.InfoCF("agent", "Subagent completed (internal channel)",
|
logger.InfoCF("agent", "Subagent completed (internal channel)",
|
||||||
map[string]interface{}{
|
map[string]any{
|
||||||
"sender_id": msg.SenderID,
|
"sender_id": msg.SenderID,
|
||||||
"content_len": len(content),
|
"content_len": len(content),
|
||||||
"channel": originChannel,
|
"channel": originChannel,
|
||||||
|
|
@ -383,7 +398,7 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt
|
||||||
if !constants.IsInternalChannel(opts.Channel) {
|
if !constants.IsInternalChannel(opts.Channel) {
|
||||||
channelKey := fmt.Sprintf("%s:%s", opts.Channel, opts.ChatID)
|
channelKey := fmt.Sprintf("%s:%s", opts.Channel, opts.ChatID)
|
||||||
if err := al.RecordLastChannel(channelKey); err != nil {
|
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 +460,7 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt
|
||||||
// 9. Log response
|
// 9. Log response
|
||||||
responsePreview := utils.Truncate(finalContent, 120)
|
responsePreview := utils.Truncate(finalContent, 120)
|
||||||
logger.InfoCF("agent", fmt.Sprintf("Response: %s", responsePreview),
|
logger.InfoCF("agent", fmt.Sprintf("Response: %s", responsePreview),
|
||||||
map[string]interface{}{
|
map[string]any{
|
||||||
"agent_id": agent.ID,
|
"agent_id": agent.ID,
|
||||||
"session_key": opts.SessionKey,
|
"session_key": opts.SessionKey,
|
||||||
"iterations": iteration,
|
"iterations": iteration,
|
||||||
|
|
@ -456,7 +471,12 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt
|
||||||
}
|
}
|
||||||
|
|
||||||
// runLLMIteration executes the LLM call loop with tool handling.
|
// 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
|
iteration := 0
|
||||||
var finalContent string
|
var finalContent string
|
||||||
|
|
||||||
|
|
@ -464,7 +484,7 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance,
|
||||||
iteration++
|
iteration++
|
||||||
|
|
||||||
logger.DebugCF("agent", "LLM iteration",
|
logger.DebugCF("agent", "LLM iteration",
|
||||||
map[string]interface{}{
|
map[string]any{
|
||||||
"agent_id": agent.ID,
|
"agent_id": agent.ID,
|
||||||
"iteration": iteration,
|
"iteration": iteration,
|
||||||
"max": agent.MaxIterations,
|
"max": agent.MaxIterations,
|
||||||
|
|
@ -475,7 +495,7 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance,
|
||||||
|
|
||||||
// Log LLM request details
|
// Log LLM request details
|
||||||
logger.DebugCF("agent", "LLM request",
|
logger.DebugCF("agent", "LLM request",
|
||||||
map[string]interface{}{
|
map[string]any{
|
||||||
"agent_id": agent.ID,
|
"agent_id": agent.ID,
|
||||||
"iteration": iteration,
|
"iteration": iteration,
|
||||||
"model": agent.Model,
|
"model": agent.Model,
|
||||||
|
|
@ -488,7 +508,7 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance,
|
||||||
|
|
||||||
// Log full messages (detailed)
|
// Log full messages (detailed)
|
||||||
logger.DebugCF("agent", "Full LLM request",
|
logger.DebugCF("agent", "Full LLM request",
|
||||||
map[string]interface{}{
|
map[string]any{
|
||||||
"iteration": iteration,
|
"iteration": iteration,
|
||||||
"messages_json": formatMessagesForLog(messages),
|
"messages_json": formatMessagesForLog(messages),
|
||||||
"tools_json": formatToolsForLog(providerToolDefs),
|
"tools_json": formatToolsForLog(providerToolDefs),
|
||||||
|
|
@ -502,7 +522,7 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance,
|
||||||
if len(agent.Candidates) > 1 && al.fallback != nil {
|
if len(agent.Candidates) > 1 && al.fallback != nil {
|
||||||
fbResult, fbErr := al.fallback.Execute(ctx, agent.Candidates,
|
fbResult, fbErr := al.fallback.Execute(ctx, agent.Candidates,
|
||||||
func(ctx context.Context, provider, model string) (*providers.LLMResponse, error) {
|
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,
|
"max_tokens": agent.MaxTokens,
|
||||||
"temperature": agent.Temperature,
|
"temperature": agent.Temperature,
|
||||||
})
|
})
|
||||||
|
|
@ -514,11 +534,11 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance,
|
||||||
if fbResult.Provider != "" && len(fbResult.Attempts) > 0 {
|
if fbResult.Provider != "" && len(fbResult.Attempts) > 0 {
|
||||||
logger.InfoCF("agent", fmt.Sprintf("Fallback: succeeded with %s/%s after %d attempts",
|
logger.InfoCF("agent", fmt.Sprintf("Fallback: succeeded with %s/%s after %d attempts",
|
||||||
fbResult.Provider, fbResult.Model, len(fbResult.Attempts)+1),
|
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 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,
|
"max_tokens": agent.MaxTokens,
|
||||||
"temperature": agent.Temperature,
|
"temperature": agent.Temperature,
|
||||||
})
|
})
|
||||||
|
|
@ -539,7 +559,7 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance,
|
||||||
strings.Contains(errMsg, "length")
|
strings.Contains(errMsg, "length")
|
||||||
|
|
||||||
if isContextError && retry < maxRetries {
|
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(),
|
"error": err.Error(),
|
||||||
"retry": retry,
|
"retry": retry,
|
||||||
})
|
})
|
||||||
|
|
@ -566,7 +586,7 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance,
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.ErrorCF("agent", "LLM call failed",
|
logger.ErrorCF("agent", "LLM call failed",
|
||||||
map[string]interface{}{
|
map[string]any{
|
||||||
"agent_id": agent.ID,
|
"agent_id": agent.ID,
|
||||||
"iteration": iteration,
|
"iteration": iteration,
|
||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
|
|
@ -578,7 +598,7 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance,
|
||||||
if len(response.ToolCalls) == 0 {
|
if len(response.ToolCalls) == 0 {
|
||||||
finalContent = response.Content
|
finalContent = response.Content
|
||||||
logger.InfoCF("agent", "LLM response without tool calls (direct answer)",
|
logger.InfoCF("agent", "LLM response without tool calls (direct answer)",
|
||||||
map[string]interface{}{
|
map[string]any{
|
||||||
"agent_id": agent.ID,
|
"agent_id": agent.ID,
|
||||||
"iteration": iteration,
|
"iteration": iteration,
|
||||||
"content_chars": len(finalContent),
|
"content_chars": len(finalContent),
|
||||||
|
|
@ -597,7 +617,7 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance,
|
||||||
toolNames = append(toolNames, tc.Name)
|
toolNames = append(toolNames, tc.Name)
|
||||||
}
|
}
|
||||||
logger.InfoCF("agent", "LLM requested tool calls",
|
logger.InfoCF("agent", "LLM requested tool calls",
|
||||||
map[string]interface{}{
|
map[string]any{
|
||||||
"agent_id": agent.ID,
|
"agent_id": agent.ID,
|
||||||
"tools": toolNames,
|
"tools": toolNames,
|
||||||
"count": len(normalizedToolCalls),
|
"count": len(normalizedToolCalls),
|
||||||
|
|
@ -641,7 +661,7 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance,
|
||||||
argsJSON, _ := json.Marshal(tc.Arguments)
|
argsJSON, _ := json.Marshal(tc.Arguments)
|
||||||
argsPreview := utils.Truncate(string(argsJSON), 200)
|
argsPreview := utils.Truncate(string(argsJSON), 200)
|
||||||
logger.InfoCF("agent", fmt.Sprintf("Tool call: %s(%s)", tc.Name, argsPreview),
|
logger.InfoCF("agent", fmt.Sprintf("Tool call: %s(%s)", tc.Name, argsPreview),
|
||||||
map[string]interface{}{
|
map[string]any{
|
||||||
"agent_id": agent.ID,
|
"agent_id": agent.ID,
|
||||||
"tool": tc.Name,
|
"tool": tc.Name,
|
||||||
"iteration": iteration,
|
"iteration": iteration,
|
||||||
|
|
@ -656,14 +676,21 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance,
|
||||||
// The agent will handle user notification via processSystemMessage
|
// The agent will handle user notification via processSystemMessage
|
||||||
if !result.Silent && result.ForUser != "" {
|
if !result.Silent && result.ForUser != "" {
|
||||||
logger.InfoCF("agent", "Async tool completed, agent will handle notification",
|
logger.InfoCF("agent", "Async tool completed, agent will handle notification",
|
||||||
map[string]interface{}{
|
map[string]any{
|
||||||
"tool": tc.Name,
|
"tool": tc.Name,
|
||||||
"content_len": len(result.ForUser),
|
"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
|
// Send ForUser content to user immediately if not Silent
|
||||||
if !toolResult.Silent && toolResult.ForUser != "" && opts.SendResponse {
|
if !toolResult.Silent && toolResult.ForUser != "" && opts.SendResponse {
|
||||||
|
|
@ -673,7 +700,7 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance,
|
||||||
Content: toolResult.ForUser,
|
Content: toolResult.ForUser,
|
||||||
})
|
})
|
||||||
logger.DebugCF("agent", "Sent tool result to user",
|
logger.DebugCF("agent", "Sent tool result to user",
|
||||||
map[string]interface{}{
|
map[string]any{
|
||||||
"tool": tc.Name,
|
"tool": tc.Name,
|
||||||
"content_len": len(toolResult.ForUser),
|
"content_len": len(toolResult.ForUser),
|
||||||
})
|
})
|
||||||
|
|
@ -775,7 +802,10 @@ func (al *AgentLoop) forceCompression(agent *AgentInstance, sessionKey string) {
|
||||||
|
|
||||||
// Append compression note to the original system prompt instead of adding a new system message
|
// 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
|
// 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 := history[0]
|
||||||
enhancedSystemPrompt.Content = enhancedSystemPrompt.Content + compressionNote
|
enhancedSystemPrompt.Content = enhancedSystemPrompt.Content + compressionNote
|
||||||
newHistory = append(newHistory, enhancedSystemPrompt)
|
newHistory = append(newHistory, enhancedSystemPrompt)
|
||||||
|
|
@ -787,7 +817,7 @@ func (al *AgentLoop) forceCompression(agent *AgentInstance, sessionKey string) {
|
||||||
agent.Sessions.SetHistory(sessionKey, newHistory)
|
agent.Sessions.SetHistory(sessionKey, newHistory)
|
||||||
agent.Sessions.Save(sessionKey)
|
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,
|
"session_key": sessionKey,
|
||||||
"dropped_msgs": droppedCount,
|
"dropped_msgs": droppedCount,
|
||||||
"new_count": len(newHistory),
|
"new_count": len(newHistory),
|
||||||
|
|
@ -795,8 +825,8 @@ func (al *AgentLoop) forceCompression(agent *AgentInstance, sessionKey string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetStartupInfo returns information about loaded tools and skills for logging.
|
// GetStartupInfo returns information about loaded tools and skills for logging.
|
||||||
func (al *AgentLoop) GetStartupInfo() map[string]interface{} {
|
func (al *AgentLoop) GetStartupInfo() map[string]any {
|
||||||
info := make(map[string]interface{})
|
info := make(map[string]any)
|
||||||
|
|
||||||
agent := al.registry.GetDefaultAgent()
|
agent := al.registry.GetDefaultAgent()
|
||||||
if agent == nil {
|
if agent == nil {
|
||||||
|
|
@ -805,7 +835,7 @@ func (al *AgentLoop) GetStartupInfo() map[string]interface{} {
|
||||||
|
|
||||||
// Tools info
|
// Tools info
|
||||||
toolsList := agent.Tools.List()
|
toolsList := agent.Tools.List()
|
||||||
info["tools"] = map[string]interface{}{
|
info["tools"] = map[string]any{
|
||||||
"count": len(toolsList),
|
"count": len(toolsList),
|
||||||
"names": toolsList,
|
"names": toolsList,
|
||||||
}
|
}
|
||||||
|
|
@ -814,7 +844,7 @@ func (al *AgentLoop) GetStartupInfo() map[string]interface{} {
|
||||||
info["skills"] = agent.ContextBuilder.GetSkillsInfo()
|
info["skills"] = agent.ContextBuilder.GetSkillsInfo()
|
||||||
|
|
||||||
// Agents info
|
// Agents info
|
||||||
info["agents"] = map[string]interface{}{
|
info["agents"] = map[string]any{
|
||||||
"count": len(al.registry.ListAgentIDs()),
|
"count": len(al.registry.ListAgentIDs()),
|
||||||
"ids": al.registry.ListAgentIDs(),
|
"ids": al.registry.ListAgentIDs(),
|
||||||
}
|
}
|
||||||
|
|
@ -919,11 +949,21 @@ func (al *AgentLoop) summarizeSession(agent *AgentInstance, sessionKey string) {
|
||||||
s1, _ := al.summarizeBatch(ctx, agent, part1, "")
|
s1, _ := al.summarizeBatch(ctx, agent, part1, "")
|
||||||
s2, _ := al.summarizeBatch(ctx, agent, part2, "")
|
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)
|
mergePrompt := fmt.Sprintf(
|
||||||
resp, err := agent.Provider.Chat(ctx, []providers.Message{{Role: "user", Content: mergePrompt}}, nil, agent.Model, map[string]interface{}{
|
"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,
|
"max_tokens": 1024,
|
||||||
"temperature": 0.3,
|
"temperature": 0.3,
|
||||||
})
|
},
|
||||||
|
)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
finalSummary = resp.Content
|
finalSummary = resp.Content
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -945,7 +985,12 @@ func (al *AgentLoop) summarizeSession(agent *AgentInstance, sessionKey string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// summarizeBatch summarizes a batch of messages.
|
// summarizeBatch summarizes a batch of messages.
|
||||||
func (al *AgentLoop) summarizeBatch(ctx context.Context, agent *AgentInstance, batch []providers.Message, existingSummary string) (string, error) {
|
func (al *AgentLoop) summarizeBatch(
|
||||||
|
ctx context.Context,
|
||||||
|
agent *AgentInstance,
|
||||||
|
batch []providers.Message,
|
||||||
|
existingSummary string,
|
||||||
|
) (string, error) {
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
sb.WriteString("Provide a concise summary of this conversation segment, preserving core context and key points.\n")
|
sb.WriteString("Provide a concise summary of this conversation segment, preserving core context and key points.\n")
|
||||||
if existingSummary != "" {
|
if existingSummary != "" {
|
||||||
|
|
@ -959,10 +1004,16 @@ func (al *AgentLoop) summarizeBatch(ctx context.Context, agent *AgentInstance, b
|
||||||
}
|
}
|
||||||
prompt := sb.String()
|
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,
|
"max_tokens": 1024,
|
||||||
"temperature": 0.3,
|
"temperature": 0.3,
|
||||||
})
|
},
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -171,7 +171,7 @@ func TestToolRegistry_ToolRegistration(t *testing.T) {
|
||||||
// Verify tool is registered by checking it doesn't panic on GetStartupInfo
|
// Verify tool is registered by checking it doesn't panic on GetStartupInfo
|
||||||
// (actual tool retrieval is tested in tools package tests)
|
// (actual tool retrieval is tested in tools package tests)
|
||||||
info := al.GetStartupInfo()
|
info := al.GetStartupInfo()
|
||||||
toolsInfo := info["tools"].(map[string]interface{})
|
toolsInfo := info["tools"].(map[string]any)
|
||||||
toolsList := toolsInfo["names"].([]string)
|
toolsList := toolsInfo["names"].([]string)
|
||||||
|
|
||||||
// Check that our custom tool name is in the list
|
// Check that our custom tool name is in the list
|
||||||
|
|
@ -246,7 +246,7 @@ func TestToolRegistry_GetDefinitions(t *testing.T) {
|
||||||
al.RegisterTool(testTool)
|
al.RegisterTool(testTool)
|
||||||
|
|
||||||
info := al.GetStartupInfo()
|
info := al.GetStartupInfo()
|
||||||
toolsInfo := info["tools"].(map[string]interface{})
|
toolsInfo := info["tools"].(map[string]any)
|
||||||
toolsList := toolsInfo["names"].([]string)
|
toolsList := toolsInfo["names"].([]string)
|
||||||
|
|
||||||
// Check that our custom tool name is in the list
|
// 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")
|
t.Fatal("Expected 'tools' key in startup info")
|
||||||
}
|
}
|
||||||
|
|
||||||
toolsMap, ok := toolsInfo.(map[string]interface{})
|
toolsMap, ok := toolsInfo.(map[string]any)
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Fatal("Expected 'tools' to be a map")
|
t.Fatal("Expected 'tools' to be a map")
|
||||||
}
|
}
|
||||||
|
|
@ -349,7 +349,13 @@ type simpleMockProvider struct {
|
||||||
response string
|
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{
|
return &providers.LLMResponse{
|
||||||
Content: m.response,
|
Content: m.response,
|
||||||
ToolCalls: []providers.ToolCall{},
|
ToolCalls: []providers.ToolCall{},
|
||||||
|
|
@ -371,14 +377,14 @@ func (m *mockCustomTool) Description() string {
|
||||||
return "Mock custom tool for testing"
|
return "Mock custom tool for testing"
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockCustomTool) Parameters() map[string]interface{} {
|
func (m *mockCustomTool) Parameters() map[string]any {
|
||||||
return map[string]interface{}{
|
return map[string]any{
|
||||||
"type": "object",
|
"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")
|
return tools.SilentResult("Custom tool executed")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -396,14 +402,14 @@ func (m *mockContextualTool) Description() string {
|
||||||
return "Mock contextual tool"
|
return "Mock contextual tool"
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockContextualTool) Parameters() map[string]interface{} {
|
func (m *mockContextualTool) Parameters() map[string]any {
|
||||||
return map[string]interface{}{
|
return map[string]any{
|
||||||
"type": "object",
|
"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")
|
return tools.SilentResult("Contextual tool executed")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -523,7 +529,13 @@ type failFirstMockProvider struct {
|
||||||
successResp string
|
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++
|
m.currentCall++
|
||||||
if m.currentCall <= m.failures {
|
if m.currentCall <= m.failures {
|
||||||
return nil, m.failError
|
return nil, m.failError
|
||||||
|
|
@ -588,7 +600,13 @@ func TestAgentLoop_ContextExhaustionRetry(t *testing.T) {
|
||||||
|
|
||||||
// Call ProcessDirectWithChannel
|
// Call ProcessDirectWithChannel
|
||||||
// Note: ProcessDirectWithChannel calls processMessage which will execute runLLMIteration
|
// 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 {
|
if err != nil {
|
||||||
t.Fatalf("Expected success after retry, got error: %v", err)
|
t.Fatalf("Expected success after retry, got error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ func NewMemoryStore(workspace string) *MemoryStore {
|
||||||
memoryFile := filepath.Join(memoryDir, "MEMORY.md")
|
memoryFile := filepath.Join(memoryDir, "MEMORY.md")
|
||||||
|
|
||||||
// Ensure memory directory exists
|
// Ensure memory directory exists
|
||||||
os.MkdirAll(memoryDir, 0755)
|
os.MkdirAll(memoryDir, 0o755)
|
||||||
|
|
||||||
return &MemoryStore{
|
return &MemoryStore{
|
||||||
workspace: workspace,
|
workspace: workspace,
|
||||||
|
|
@ -58,7 +58,7 @@ func (ms *MemoryStore) ReadLongTerm() string {
|
||||||
|
|
||||||
// WriteLongTerm writes content to the long-term memory file (MEMORY.md).
|
// WriteLongTerm writes content to the long-term memory file (MEMORY.md).
|
||||||
func (ms *MemoryStore) WriteLongTerm(content string) error {
|
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.
|
// ReadToday reads today's daily note.
|
||||||
|
|
@ -78,7 +78,7 @@ func (ms *MemoryStore) AppendToday(content string) error {
|
||||||
|
|
||||||
// Ensure month directory exists
|
// Ensure month directory exists
|
||||||
monthDir := filepath.Dir(todayFile)
|
monthDir := filepath.Dir(todayFile)
|
||||||
os.MkdirAll(monthDir, 0755)
|
os.MkdirAll(monthDir, 0o755)
|
||||||
|
|
||||||
var existingContent string
|
var existingContent string
|
||||||
if data, err := os.ReadFile(todayFile); err == nil {
|
if data, err := os.ReadFile(todayFile); err == nil {
|
||||||
|
|
@ -95,7 +95,7 @@ func (ms *MemoryStore) AppendToday(content string) error {
|
||||||
newContent = existingContent + "\n" + content
|
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.
|
// GetRecentDailyNotes returns daily notes from the last N days.
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,13 @@ import (
|
||||||
|
|
||||||
type mockProvider struct{}
|
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{
|
return &providers.LLMResponse{
|
||||||
Content: "Mock response",
|
Content: "Mock response",
|
||||||
ToolCalls: []providers.ToolCall{},
|
ToolCalls: []providers.ToolCall{},
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,7 @@ func NewAgentRegistry(
|
||||||
instance := NewAgentInstance(ac, &cfg.Agents.Defaults, cfg, provider)
|
instance := NewAgentInstance(ac, &cfg.Agents.Defaults, cfg, provider)
|
||||||
registry.agents[id] = instance
|
registry.agents[id] = instance
|
||||||
logger.InfoCF("agent", "Registered agent",
|
logger.InfoCF("agent", "Registered agent",
|
||||||
map[string]interface{}{
|
map[string]any{
|
||||||
"agent_id": id,
|
"agent_id": id,
|
||||||
"name": ac.Name,
|
"name": ac.Name,
|
||||||
"workspace": instance.Workspace,
|
"workspace": instance.Workspace,
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,13 @@ import (
|
||||||
|
|
||||||
type mockRegistryProvider struct{}
|
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
|
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.
|
// Client credentials are the same ones used by OpenCode/pi-ai for Cloud Code Assist access.
|
||||||
func GoogleAntigravityOAuthConfig() OAuthProviderConfig {
|
func GoogleAntigravityOAuthConfig() OAuthProviderConfig {
|
||||||
// These are the same client credentials used by the OpenCode antigravity plugin.
|
// These are the same client credentials used by the OpenCode antigravity plugin.
|
||||||
clientID := decodeBase64("MTA3MTAwNjA2MDU5MS10bWhzc2luMmgyMWxjcmUyMzV2dG9sb2poNGc0MDNlcC5hcHBzLmdvb2dsZXVzZXJjb250ZW50LmNvbQ==")
|
clientID := decodeBase64(
|
||||||
|
"MTA3MTAwNjA2MDU5MS10bWhzc2luMmgyMWxjcmUyMzV2dG9sb2poNGc0MDNlcC5hcHBzLmdvb2dsZXVzZXJjb250ZW50LmNvbQ==",
|
||||||
|
)
|
||||||
clientSecret := decodeBase64("R09DU1BYLUs1OEZXUjQ4NkxkTEoxbUxCOHNYQzR6NnFEQWY=")
|
clientSecret := decodeBase64("R09DU1BYLUs1OEZXUjQ4NkxkTEoxbUxCOHNYQzR6NnFEQWY=")
|
||||||
return OAuthProviderConfig{
|
return OAuthProviderConfig{
|
||||||
Issuer: "https://accounts.google.com/o/oauth2/v2",
|
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("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.Printf(
|
||||||
fmt.Println("please complete the login in your local browser and then PASTE the final redirect URL (or just the code) here.")
|
"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)...")
|
fmt.Println("Waiting for authentication (browser or manual paste)...")
|
||||||
|
|
||||||
// Start manual input in a goroutine
|
// Start manual input in a goroutine
|
||||||
|
|
@ -253,8 +260,11 @@ func LoginDeviceCode(cfg OAuthProviderConfig) (*AuthCredential, error) {
|
||||||
deviceResp.Interval = 5
|
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",
|
fmt.Printf(
|
||||||
cfg.Issuer, deviceResp.UserCode)
|
"\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)
|
deadline := time.After(15 * time.Minute)
|
||||||
ticker := time.NewTicker(time.Duration(deviceResp.Interval) * time.Second)
|
ticker := time.NewTicker(time.Duration(deviceResp.Interval) * time.Second)
|
||||||
|
|
@ -491,15 +501,15 @@ func extractAccountID(token string) string {
|
||||||
return accountID
|
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 != "" {
|
if accountID, ok := authClaim["chatgpt_account_id"].(string); ok && accountID != "" {
|
||||||
return accountID
|
return accountID
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if orgs, ok := claims["organizations"].([]interface{}); ok {
|
if orgs, ok := claims["organizations"].([]any); ok {
|
||||||
for _, org := range orgs {
|
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 != "" {
|
if accountID, ok := orgMap["id"].(string); ok && accountID != "" {
|
||||||
return accountID
|
return accountID
|
||||||
}
|
}
|
||||||
|
|
@ -510,7 +520,7 @@ func extractAccountID(token string) string {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseJWTClaims(token string) (map[string]interface{}, error) {
|
func parseJWTClaims(token string) (map[string]any, error) {
|
||||||
parts := strings.Split(token, ".")
|
parts := strings.Split(token, ".")
|
||||||
if len(parts) < 2 {
|
if len(parts) < 2 {
|
||||||
return nil, fmt.Errorf("token is not a JWT")
|
return nil, fmt.Errorf("token is not a JWT")
|
||||||
|
|
@ -529,7 +539,7 @@ func parseJWTClaims(token string) (map[string]interface{}, error) {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
var claims map[string]interface{}
|
var claims map[string]any
|
||||||
if err := json.Unmarshal(decoded, &claims); err != nil {
|
if err := json.Unmarshal(decoded, &claims); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ import (
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
func makeJWTForClaims(t *testing.T, claims map[string]interface{}) string {
|
func makeJWTForClaims(t *testing.T, claims map[string]any) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none","typ":"JWT"}`))
|
header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none","typ":"JWT"}`))
|
||||||
|
|
@ -89,7 +89,7 @@ func TestBuildAuthorizeURLOpenAIExtras(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseTokenResponse(t *testing.T) {
|
func TestParseTokenResponse(t *testing.T) {
|
||||||
resp := map[string]interface{}{
|
resp := map[string]any{
|
||||||
"access_token": "test-access-token",
|
"access_token": "test-access-token",
|
||||||
"refresh_token": "test-refresh-token",
|
"refresh_token": "test-refresh-token",
|
||||||
"expires_in": 3600,
|
"expires_in": 3600,
|
||||||
|
|
@ -120,8 +120,8 @@ func TestParseTokenResponse(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseTokenResponseExtractsAccountIDFromIDToken(t *testing.T) {
|
func TestParseTokenResponseExtractsAccountIDFromIDToken(t *testing.T) {
|
||||||
idToken := makeJWTForClaims(t, map[string]interface{}{"chatgpt_account_id": "acc-id-from-id-token"})
|
idToken := makeJWTForClaims(t, map[string]any{"chatgpt_account_id": "acc-id-from-id-token"})
|
||||||
resp := map[string]interface{}{
|
resp := map[string]any{
|
||||||
"access_token": "opaque-access-token",
|
"access_token": "opaque-access-token",
|
||||||
"refresh_token": "test-refresh-token",
|
"refresh_token": "test-refresh-token",
|
||||||
"expires_in": 3600,
|
"expires_in": 3600,
|
||||||
|
|
@ -139,9 +139,9 @@ func TestParseTokenResponseExtractsAccountIDFromIDToken(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExtractAccountIDFromOrganizationsFallback(t *testing.T) {
|
func TestExtractAccountIDFromOrganizationsFallback(t *testing.T) {
|
||||||
token := makeJWTForClaims(t, map[string]interface{}{
|
token := makeJWTForClaims(t, map[string]any{
|
||||||
"organizations": []interface{}{
|
"organizations": []any{
|
||||||
map[string]interface{}{"id": "org_from_orgs"},
|
map[string]any{"id": "org_from_orgs"},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -160,7 +160,7 @@ func TestParseTokenResponseNoAccessToken(t *testing.T) {
|
||||||
|
|
||||||
func TestParseTokenResponseAccountIDFromIDToken(t *testing.T) {
|
func TestParseTokenResponseAccountIDFromIDToken(t *testing.T) {
|
||||||
idToken := makeJWTWithAccountID("acc-from-id")
|
idToken := makeJWTWithAccountID("acc-from-id")
|
||||||
resp := map[string]interface{}{
|
resp := map[string]any{
|
||||||
"access_token": "not-a-jwt",
|
"access_token": "not-a-jwt",
|
||||||
"refresh_token": "test-refresh-token",
|
"refresh_token": "test-refresh-token",
|
||||||
"expires_in": 3600,
|
"expires_in": 3600,
|
||||||
|
|
@ -180,7 +180,9 @@ func TestParseTokenResponseAccountIDFromIDToken(t *testing.T) {
|
||||||
|
|
||||||
func makeJWTWithAccountID(accountID string) string {
|
func makeJWTWithAccountID(accountID string) string {
|
||||||
header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none","typ":"JWT"}`))
|
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"
|
return header + "." + payload + ".sig"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -201,7 +203,7 @@ func TestExchangeCodeForTokens(t *testing.T) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
resp := map[string]interface{}{
|
resp := map[string]any{
|
||||||
"access_token": "mock-access-token",
|
"access_token": "mock-access-token",
|
||||||
"refresh_token": "mock-refresh-token",
|
"refresh_token": "mock-refresh-token",
|
||||||
"expires_in": 3600,
|
"expires_in": 3600,
|
||||||
|
|
@ -240,7 +242,7 @@ func TestRefreshAccessToken(t *testing.T) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
resp := map[string]interface{}{
|
resp := map[string]any{
|
||||||
"access_token": "refreshed-access-token",
|
"access_token": "refreshed-access-token",
|
||||||
"refresh_token": "refreshed-refresh-token",
|
"refresh_token": "refreshed-refresh-token",
|
||||||
"expires_in": 3600,
|
"expires_in": 3600,
|
||||||
|
|
@ -290,7 +292,7 @@ func TestRefreshAccessTokenNoRefreshToken(t *testing.T) {
|
||||||
|
|
||||||
func TestRefreshAccessTokenPreservesRefreshAndAccountID(t *testing.T) {
|
func TestRefreshAccessTokenPreservesRefreshAndAccountID(t *testing.T) {
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
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",
|
"access_token": "new-access-token-only",
|
||||||
"expires_in": 3600,
|
"expires_in": 3600,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,7 @@ func LoadStore() (*AuthStore, error) {
|
||||||
func SaveStore(store *AuthStore) error {
|
func SaveStore(store *AuthStore) error {
|
||||||
path := authFilePath()
|
path := authFilePath()
|
||||||
dir := filepath.Dir(path)
|
dir := filepath.Dir(path)
|
||||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -72,7 +72,7 @@ func SaveStore(store *AuthStore) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return os.WriteFile(path, data, 0600)
|
return os.WriteFile(path, data, 0o600)
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetCredential(provider string) (*AuthCredential, error) {
|
func GetCredential(provider string) (*AuthCredential, error) {
|
||||||
|
|
|
||||||
|
|
@ -108,7 +108,7 @@ func TestStoreFilePermissions(t *testing.T) {
|
||||||
t.Fatalf("Stat() error: %v", err)
|
t.Fatalf("Stat() error: %v", err)
|
||||||
}
|
}
|
||||||
perm := info.Mode().Perm()
|
perm := info.Mode().Perm()
|
||||||
if perm != 0600 {
|
if perm != 0o600 {
|
||||||
t.Errorf("file permissions = %o, want 0600", perm)
|
t.Errorf("file permissions = %o, want 0600", perm)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,14 +17,14 @@ type Channel interface {
|
||||||
}
|
}
|
||||||
|
|
||||||
type BaseChannel struct {
|
type BaseChannel struct {
|
||||||
config interface{}
|
config any
|
||||||
bus *bus.MessageBus
|
bus *bus.MessageBus
|
||||||
running bool
|
running bool
|
||||||
name string
|
name string
|
||||||
allowList []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{
|
return &BaseChannel{
|
||||||
config: config,
|
config: config,
|
||||||
bus: bus,
|
bus: bus,
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import (
|
||||||
|
|
||||||
"github.com/open-dingtalk/dingtalk-stream-sdk-go/chatbot"
|
"github.com/open-dingtalk/dingtalk-stream-sdk-go/chatbot"
|
||||||
"github.com/open-dingtalk/dingtalk-stream-sdk-go/client"
|
"github.com/open-dingtalk/dingtalk-stream-sdk-go/client"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/bus"
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
"github.com/sipeed/picoclaw/pkg/logger"
|
"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)
|
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,
|
"chat_id": msg.ChatID,
|
||||||
"preview": utils.Truncate(msg.Content, 100),
|
"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
|
// onChatBotMessageReceived implements the IChatBotMessageHandler function signature
|
||||||
// This is called by the Stream SDK when a new message arrives
|
// This is called by the Stream SDK when a new message arrives
|
||||||
// IChatBotMessageHandler is: func(c context.Context, data *chatbot.BotCallbackDataModel) ([]byte, error)
|
// 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
|
// Extract message content from Text field
|
||||||
content := data.Text.Content
|
content := data.Text.Content
|
||||||
if content == "" {
|
if content == "" {
|
||||||
// Try to extract from Content interface{} if Text is empty
|
// 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 {
|
if textContent, ok := contentMap["content"].(string); ok {
|
||||||
content = textContent
|
content = textContent
|
||||||
}
|
}
|
||||||
|
|
@ -163,7 +167,7 @@ func (c *DingTalkChannel) onChatBotMessageReceived(ctx context.Context, data *ch
|
||||||
metadata["peer_id"] = data.ConversationId
|
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_nick": senderNick,
|
||||||
"sender_id": senderID,
|
"sender_id": senderID,
|
||||||
"preview": utils.Truncate(content, 50),
|
"preview": utils.Truncate(content, 50),
|
||||||
|
|
@ -192,7 +196,6 @@ func (c *DingTalkChannel) SendDirectReply(ctx context.Context, sessionWebhook, c
|
||||||
titleBytes,
|
titleBytes,
|
||||||
contentBytes,
|
contentBytes,
|
||||||
)
|
)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to send reply: %w", err)
|
return fmt.Errorf("failed to send reply: %w", err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/bwmarrin/discordgo"
|
"github.com/bwmarrin/discordgo"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/bus"
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
"github.com/sipeed/picoclaw/pkg/logger"
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
|
|
@ -321,7 +322,7 @@ func (c *DiscordChannel) startTyping(chatID string) {
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
if err := c.session.ChannelTyping(chatID); err != nil {
|
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)
|
ticker := time.NewTicker(8 * time.Second)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
|
|
@ -336,7 +337,7 @@ func (c *DiscordChannel) startTyping(chatID string) {
|
||||||
return
|
return
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
if err := c.session.ChannelTyping(chatID); err != nil {
|
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
|
// 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) {
|
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
|
// Start is a stub method to satisfy the Channel interface
|
||||||
|
|
|
||||||
|
|
@ -65,7 +65,7 @@ func (c *FeishuChannel) Start(ctx context.Context) error {
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
if err := wsClient.Start(runCtx); err != nil {
|
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(),
|
"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)
|
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,
|
"chat_id": msg.ChatID,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -174,7 +174,7 @@ func (c *FeishuChannel) handleMessageReceive(_ context.Context, event *larkim.P2
|
||||||
metadata["peer_id"] = chatID
|
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,
|
"sender_id": senderID,
|
||||||
"chat_id": chatID,
|
"chat_id": chatID,
|
||||||
"preview": utils.Truncate(content, 80),
|
"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
|
// Fetch bot profile to get bot's userId for mention detection
|
||||||
if err := c.fetchBotInfo(); err != nil {
|
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(),
|
"error": err.Error(),
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
logger.InfoCF("line", "Bot info fetched", map[string]interface{}{
|
logger.InfoCF("line", "Bot info fetched", map[string]any{
|
||||||
"bot_user_id": c.botUserID,
|
"bot_user_id": c.botUserID,
|
||||||
"basic_id": c.botBasicID,
|
"basic_id": c.botBasicID,
|
||||||
"display_name": c.botDisplayName,
|
"display_name": c.botDisplayName,
|
||||||
|
|
@ -100,12 +100,12 @@ func (c *LINEChannel) Start(ctx context.Context) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
logger.InfoCF("line", "LINE webhook server listening", map[string]interface{}{
|
logger.InfoCF("line", "LINE webhook server listening", map[string]any{
|
||||||
"addr": addr,
|
"addr": addr,
|
||||||
"path": path,
|
"path": path,
|
||||||
})
|
})
|
||||||
if err := c.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
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(),
|
"error": err.Error(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -162,7 +162,7 @@ func (c *LINEChannel) Stop(ctx context.Context) error {
|
||||||
shutdownCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
shutdownCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
if err := c.httpServer.Shutdown(shutdownCtx); err != nil {
|
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(),
|
"error": err.Error(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -182,7 +182,7 @@ func (c *LINEChannel) webhookHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
body, err := io.ReadAll(r.Body)
|
body, err := io.ReadAll(r.Body)
|
||||||
if err != nil {
|
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(),
|
"error": err.Error(),
|
||||||
})
|
})
|
||||||
http.Error(w, "Bad request", http.StatusBadRequest)
|
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"`
|
Events []lineEvent `json:"events"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(body, &payload); err != nil {
|
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(),
|
"error": err.Error(),
|
||||||
})
|
})
|
||||||
http.Error(w, "Bad request", http.StatusBadRequest)
|
http.Error(w, "Bad request", http.StatusBadRequest)
|
||||||
|
|
@ -266,7 +266,7 @@ type lineMentionee struct {
|
||||||
|
|
||||||
func (c *LINEChannel) processEvent(event lineEvent) {
|
func (c *LINEChannel) processEvent(event lineEvent) {
|
||||||
if event.Type != "message" {
|
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,
|
"type": event.Type,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
|
|
@ -278,7 +278,7 @@ func (c *LINEChannel) processEvent(event lineEvent) {
|
||||||
|
|
||||||
var msg lineMessage
|
var msg lineMessage
|
||||||
if err := json.Unmarshal(event.Message, &msg); err != nil {
|
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(),
|
"error": err.Error(),
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
|
|
@ -286,7 +286,7 @@ func (c *LINEChannel) processEvent(event lineEvent) {
|
||||||
|
|
||||||
// In group chats, only respond when the bot is mentioned
|
// In group chats, only respond when the bot is mentioned
|
||||||
if isGroup && !c.isBotMentioned(msg) {
|
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,
|
"chat_id": chatID,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
|
|
@ -312,7 +312,7 @@ func (c *LINEChannel) processEvent(event lineEvent) {
|
||||||
defer func() {
|
defer func() {
|
||||||
for _, file := range localFiles {
|
for _, file := range localFiles {
|
||||||
if err := os.Remove(file); err != nil {
|
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,
|
"file": file,
|
||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
})
|
})
|
||||||
|
|
@ -374,7 +374,7 @@ func (c *LINEChannel) processEvent(event lineEvent) {
|
||||||
metadata["peer_id"] = senderID
|
metadata["peer_id"] = senderID
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.DebugCF("line", "Received message", map[string]interface{}{
|
logger.DebugCF("line", "Received message", map[string]any{
|
||||||
"sender_id": senderID,
|
"sender_id": senderID,
|
||||||
"chat_id": chatID,
|
"chat_id": chatID,
|
||||||
"message_type": msg.Type,
|
"message_type": msg.Type,
|
||||||
|
|
@ -505,7 +505,7 @@ func (c *LINEChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
|
||||||
tokenEntry := entry.(replyTokenEntry)
|
tokenEntry := entry.(replyTokenEntry)
|
||||||
if time.Since(tokenEntry.timestamp) < lineReplyTokenMaxAge {
|
if time.Since(tokenEntry.timestamp) < lineReplyTokenMaxAge {
|
||||||
if err := c.sendReply(ctx, tokenEntry.token, msg.Content, quoteToken); err == nil {
|
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,
|
"chat_id": msg.ChatID,
|
||||||
"quoted": quoteToken != "",
|
"quoted": quoteToken != "",
|
||||||
})
|
})
|
||||||
|
|
@ -533,7 +533,7 @@ func buildTextMessage(content, quoteToken string) map[string]string {
|
||||||
|
|
||||||
// sendReply sends a message using the LINE Reply API.
|
// sendReply sends a message using the LINE Reply API.
|
||||||
func (c *LINEChannel) sendReply(ctx context.Context, replyToken, content, quoteToken string) error {
|
func (c *LINEChannel) sendReply(ctx context.Context, replyToken, content, quoteToken string) error {
|
||||||
payload := map[string]interface{}{
|
payload := map[string]any{
|
||||||
"replyToken": replyToken,
|
"replyToken": replyToken,
|
||||||
"messages": []map[string]string{buildTextMessage(content, quoteToken)},
|
"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.
|
// sendPush sends a message using the LINE Push API.
|
||||||
func (c *LINEChannel) sendPush(ctx context.Context, to, content, quoteToken string) error {
|
func (c *LINEChannel) sendPush(ctx context.Context, to, content, quoteToken string) error {
|
||||||
payload := map[string]interface{}{
|
payload := map[string]any{
|
||||||
"to": to,
|
"to": to,
|
||||||
"messages": []map[string]string{buildTextMessage(content, quoteToken)},
|
"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.
|
// sendLoading sends a loading animation indicator to the chat.
|
||||||
func (c *LINEChannel) sendLoading(chatID string) {
|
func (c *LINEChannel) sendLoading(chatID string) {
|
||||||
payload := map[string]interface{}{
|
payload := map[string]any{
|
||||||
"chatId": chatID,
|
"chatId": chatID,
|
||||||
"loadingSeconds": 60,
|
"loadingSeconds": 60,
|
||||||
}
|
}
|
||||||
if err := c.callAPI(c.ctx, lineLoadingEndpoint, payload); err != nil {
|
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(),
|
"error": err.Error(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// callAPI makes an authenticated POST request to the LINE API.
|
// 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)
|
body, err := json.Marshal(payload)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to marshal payload: %w", err)
|
return fmt.Errorf("failed to marshal payload: %w", err)
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ type MaixCamMessage struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Tips string `json:"tips"`
|
Tips string `json:"tips"`
|
||||||
Timestamp float64 `json:"timestamp"`
|
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) {
|
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.listener = listener
|
||||||
c.setRunning(true)
|
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,
|
"host": c.config.Host,
|
||||||
"port": c.config.Port,
|
"port": c.config.Port,
|
||||||
})
|
})
|
||||||
|
|
@ -71,14 +71,14 @@ func (c *MaixCamChannel) acceptConnections(ctx context.Context) {
|
||||||
conn, err := c.listener.Accept()
|
conn, err := c.listener.Accept()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if c.running {
|
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(),
|
"error": err.Error(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return
|
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(),
|
"remote_addr": conn.RemoteAddr().String(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -112,7 +112,7 @@ func (c *MaixCamChannel) handleConnection(conn net.Conn, ctx context.Context) {
|
||||||
var msg MaixCamMessage
|
var msg MaixCamMessage
|
||||||
if err := decoder.Decode(&msg); err != nil {
|
if err := decoder.Decode(&msg); err != nil {
|
||||||
if err.Error() != "EOF" {
|
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(),
|
"error": err.Error(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -133,14 +133,14 @@ func (c *MaixCamChannel) processMessage(msg MaixCamMessage, conn net.Conn) {
|
||||||
case "status":
|
case "status":
|
||||||
c.handleStatusUpdate(msg)
|
c.handleStatusUpdate(msg)
|
||||||
default:
|
default:
|
||||||
logger.WarnCF("maixcam", "Unknown message type", map[string]interface{}{
|
logger.WarnCF("maixcam", "Unknown message type", map[string]any{
|
||||||
"type": msg.Type,
|
"type": msg.Type,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *MaixCamChannel) handlePersonDetection(msg MaixCamMessage) {
|
func (c *MaixCamChannel) handlePersonDetection(msg MaixCamMessage) {
|
||||||
logger.InfoCF("maixcam", "", map[string]interface{}{
|
logger.InfoCF("maixcam", "", map[string]any{
|
||||||
"timestamp": msg.Timestamp,
|
"timestamp": msg.Timestamp,
|
||||||
"data": msg.Data,
|
"data": msg.Data,
|
||||||
})
|
})
|
||||||
|
|
@ -178,7 +178,7 @@ func (c *MaixCamChannel) handlePersonDetection(msg MaixCamMessage) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *MaixCamChannel) handleStatusUpdate(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,
|
"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")
|
return fmt.Errorf("no connected MaixCam devices")
|
||||||
}
|
}
|
||||||
|
|
||||||
response := map[string]interface{}{
|
response := map[string]any{
|
||||||
"type": "command",
|
"type": "command",
|
||||||
"timestamp": float64(0),
|
"timestamp": float64(0),
|
||||||
"message": msg.Content,
|
"message": msg.Content,
|
||||||
|
|
@ -231,7 +231,7 @@ func (c *MaixCamChannel) Send(ctx context.Context, msg bus.OutboundMessage) erro
|
||||||
var sendErr error
|
var sendErr error
|
||||||
for conn := range c.clients {
|
for conn := range c.clients {
|
||||||
if _, err := conn.Write(data); err != nil {
|
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(),
|
"client": conn.RemoteAddr().String(),
|
||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,7 @@ func (m *Manager) initChannels() error {
|
||||||
logger.DebugC("channels", "Attempting to initialize Telegram channel")
|
logger.DebugC("channels", "Attempting to initialize Telegram channel")
|
||||||
telegram, err := NewTelegramChannel(m.config, m.bus)
|
telegram, err := NewTelegramChannel(m.config, m.bus)
|
||||||
if err != nil {
|
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(),
|
"error": err.Error(),
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -63,7 +63,7 @@ func (m *Manager) initChannels() error {
|
||||||
logger.DebugC("channels", "Attempting to initialize WhatsApp channel")
|
logger.DebugC("channels", "Attempting to initialize WhatsApp channel")
|
||||||
whatsapp, err := NewWhatsAppChannel(m.config.Channels.WhatsApp, m.bus)
|
whatsapp, err := NewWhatsAppChannel(m.config.Channels.WhatsApp, m.bus)
|
||||||
if err != nil {
|
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(),
|
"error": err.Error(),
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -76,7 +76,7 @@ func (m *Manager) initChannels() error {
|
||||||
logger.DebugC("channels", "Attempting to initialize Feishu channel")
|
logger.DebugC("channels", "Attempting to initialize Feishu channel")
|
||||||
feishu, err := NewFeishuChannel(m.config.Channels.Feishu, m.bus)
|
feishu, err := NewFeishuChannel(m.config.Channels.Feishu, m.bus)
|
||||||
if err != nil {
|
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(),
|
"error": err.Error(),
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -89,7 +89,7 @@ func (m *Manager) initChannels() error {
|
||||||
logger.DebugC("channels", "Attempting to initialize Discord channel")
|
logger.DebugC("channels", "Attempting to initialize Discord channel")
|
||||||
discord, err := NewDiscordChannel(m.config.Channels.Discord, m.bus)
|
discord, err := NewDiscordChannel(m.config.Channels.Discord, m.bus)
|
||||||
if err != nil {
|
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(),
|
"error": err.Error(),
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -102,7 +102,7 @@ func (m *Manager) initChannels() error {
|
||||||
logger.DebugC("channels", "Attempting to initialize MaixCam channel")
|
logger.DebugC("channels", "Attempting to initialize MaixCam channel")
|
||||||
maixcam, err := NewMaixCamChannel(m.config.Channels.MaixCam, m.bus)
|
maixcam, err := NewMaixCamChannel(m.config.Channels.MaixCam, m.bus)
|
||||||
if err != nil {
|
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(),
|
"error": err.Error(),
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -115,7 +115,7 @@ func (m *Manager) initChannels() error {
|
||||||
logger.DebugC("channels", "Attempting to initialize QQ channel")
|
logger.DebugC("channels", "Attempting to initialize QQ channel")
|
||||||
qq, err := NewQQChannel(m.config.Channels.QQ, m.bus)
|
qq, err := NewQQChannel(m.config.Channels.QQ, m.bus)
|
||||||
if err != nil {
|
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(),
|
"error": err.Error(),
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -128,7 +128,7 @@ func (m *Manager) initChannels() error {
|
||||||
logger.DebugC("channels", "Attempting to initialize DingTalk channel")
|
logger.DebugC("channels", "Attempting to initialize DingTalk channel")
|
||||||
dingtalk, err := NewDingTalkChannel(m.config.Channels.DingTalk, m.bus)
|
dingtalk, err := NewDingTalkChannel(m.config.Channels.DingTalk, m.bus)
|
||||||
if err != nil {
|
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(),
|
"error": err.Error(),
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -141,7 +141,7 @@ func (m *Manager) initChannels() error {
|
||||||
logger.DebugC("channels", "Attempting to initialize Slack channel")
|
logger.DebugC("channels", "Attempting to initialize Slack channel")
|
||||||
slackCh, err := NewSlackChannel(m.config.Channels.Slack, m.bus)
|
slackCh, err := NewSlackChannel(m.config.Channels.Slack, m.bus)
|
||||||
if err != nil {
|
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(),
|
"error": err.Error(),
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -154,7 +154,7 @@ func (m *Manager) initChannels() error {
|
||||||
logger.DebugC("channels", "Attempting to initialize LINE channel")
|
logger.DebugC("channels", "Attempting to initialize LINE channel")
|
||||||
line, err := NewLINEChannel(m.config.Channels.LINE, m.bus)
|
line, err := NewLINEChannel(m.config.Channels.LINE, m.bus)
|
||||||
if err != nil {
|
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(),
|
"error": err.Error(),
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -167,7 +167,7 @@ func (m *Manager) initChannels() error {
|
||||||
logger.DebugC("channels", "Attempting to initialize OneBot channel")
|
logger.DebugC("channels", "Attempting to initialize OneBot channel")
|
||||||
onebot, err := NewOneBotChannel(m.config.Channels.OneBot, m.bus)
|
onebot, err := NewOneBotChannel(m.config.Channels.OneBot, m.bus)
|
||||||
if err != nil {
|
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(),
|
"error": err.Error(),
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -180,7 +180,7 @@ func (m *Manager) initChannels() error {
|
||||||
logger.DebugC("channels", "Attempting to initialize WeCom channel")
|
logger.DebugC("channels", "Attempting to initialize WeCom channel")
|
||||||
wecom, err := NewWeComBotChannel(m.config.Channels.WeCom, m.bus)
|
wecom, err := NewWeComBotChannel(m.config.Channels.WeCom, m.bus)
|
||||||
if err != nil {
|
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(),
|
"error": err.Error(),
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -193,7 +193,7 @@ func (m *Manager) initChannels() error {
|
||||||
logger.DebugC("channels", "Attempting to initialize WeCom App channel")
|
logger.DebugC("channels", "Attempting to initialize WeCom App channel")
|
||||||
wecomApp, err := NewWeComAppChannel(m.config.Channels.WeComApp, m.bus)
|
wecomApp, err := NewWeComAppChannel(m.config.Channels.WeComApp, m.bus)
|
||||||
if err != nil {
|
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(),
|
"error": err.Error(),
|
||||||
})
|
})
|
||||||
} else {
|
} 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),
|
"enabled_channels": len(m.channels),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -226,11 +226,11 @@ func (m *Manager) StartAll(ctx context.Context) error {
|
||||||
go m.dispatchOutbound(dispatchCtx)
|
go m.dispatchOutbound(dispatchCtx)
|
||||||
|
|
||||||
for name, channel := range m.channels {
|
for name, channel := range m.channels {
|
||||||
logger.InfoCF("channels", "Starting channel", map[string]interface{}{
|
logger.InfoCF("channels", "Starting channel", map[string]any{
|
||||||
"channel": name,
|
"channel": name,
|
||||||
})
|
})
|
||||||
if err := channel.Start(ctx); err != nil {
|
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,
|
"channel": name,
|
||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
})
|
})
|
||||||
|
|
@ -253,11 +253,11 @@ func (m *Manager) StopAll(ctx context.Context) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
for name, channel := range m.channels {
|
for name, channel := range m.channels {
|
||||||
logger.InfoCF("channels", "Stopping channel", map[string]interface{}{
|
logger.InfoCF("channels", "Stopping channel", map[string]any{
|
||||||
"channel": name,
|
"channel": name,
|
||||||
})
|
})
|
||||||
if err := channel.Stop(ctx); err != nil {
|
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,
|
"channel": name,
|
||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
})
|
})
|
||||||
|
|
@ -292,14 +292,14 @@ func (m *Manager) dispatchOutbound(ctx context.Context) {
|
||||||
m.mu.RUnlock()
|
m.mu.RUnlock()
|
||||||
|
|
||||||
if !exists {
|
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,
|
"channel": msg.Channel,
|
||||||
})
|
})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := channel.Send(ctx, msg); err != nil {
|
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,
|
"channel": msg.Channel,
|
||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
})
|
})
|
||||||
|
|
@ -315,13 +315,13 @@ func (m *Manager) GetChannel(name string) (Channel, bool) {
|
||||||
return channel, ok
|
return channel, ok
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) GetStatus() map[string]interface{} {
|
func (m *Manager) GetStatus() map[string]any {
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
defer m.mu.RUnlock()
|
defer m.mu.RUnlock()
|
||||||
|
|
||||||
status := make(map[string]interface{})
|
status := make(map[string]any)
|
||||||
for name, channel := range m.channels {
|
for name, channel := range m.channels {
|
||||||
status[name] = map[string]interface{}{
|
status[name] = map[string]any{
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"running": channel.IsRunning(),
|
"running": channel.IsRunning(),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -88,13 +88,13 @@ type oneBotSender struct {
|
||||||
|
|
||||||
type oneBotAPIRequest struct {
|
type oneBotAPIRequest struct {
|
||||||
Action string `json:"action"`
|
Action string `json:"action"`
|
||||||
Params interface{} `json:"params"`
|
Params any `json:"params"`
|
||||||
Echo string `json:"echo,omitempty"`
|
Echo string `json:"echo,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type oneBotMessageSegment struct {
|
type oneBotMessageSegment struct {
|
||||||
Type string `json:"type"`
|
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) {
|
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) {
|
func (c *OneBotChannel) setMsgEmojiLike(messageID string, emojiID int, set bool) {
|
||||||
go func() {
|
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,
|
"message_id": messageID,
|
||||||
"emoji_id": emojiID,
|
"emoji_id": emojiID,
|
||||||
"set": set,
|
"set": set,
|
||||||
}, 5*time.Second)
|
}, 5*time.Second)
|
||||||
if err != nil {
|
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,
|
"message_id": messageID,
|
||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
})
|
})
|
||||||
|
|
@ -136,14 +136,14 @@ func (c *OneBotChannel) Start(ctx context.Context) error {
|
||||||
return fmt.Errorf("OneBot ws_url not configured")
|
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,
|
"ws_url": c.config.WSUrl,
|
||||||
})
|
})
|
||||||
|
|
||||||
c.ctx, c.cancel = context.WithCancel(ctx)
|
c.ctx, c.cancel = context.WithCancel(ctx)
|
||||||
|
|
||||||
if err := c.connect(); err != nil {
|
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(),
|
"error": err.Error(),
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -208,7 +208,7 @@ func (c *OneBotChannel) pinger(conn *websocket.Conn) {
|
||||||
err := conn.WriteMessage(websocket.PingMessage, nil)
|
err := conn.WriteMessage(websocket.PingMessage, nil)
|
||||||
c.writeMu.Unlock()
|
c.writeMu.Unlock()
|
||||||
if err != nil {
|
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(),
|
"error": err.Error(),
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
|
|
@ -220,7 +220,7 @@ func (c *OneBotChannel) pinger(conn *websocket.Conn) {
|
||||||
func (c *OneBotChannel) fetchSelfID() {
|
func (c *OneBotChannel) fetchSelfID() {
|
||||||
resp, err := c.sendAPIRequest("get_login_info", nil, 5*time.Second)
|
resp, err := c.sendAPIRequest("get_login_info", nil, 5*time.Second)
|
||||||
if err != nil {
|
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(),
|
"error": err.Error(),
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
|
|
@ -250,7 +250,7 @@ func (c *OneBotChannel) fetchSelfID() {
|
||||||
}
|
}
|
||||||
if uid, err := parseJSONInt64(info.UserID); err == nil && uid > 0 {
|
if uid, err := parseJSONInt64(info.UserID); err == nil && uid > 0 {
|
||||||
atomic.StoreInt64(&c.selfID, uid)
|
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,
|
"self_id": uid,
|
||||||
"nickname": info.Nickname,
|
"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),
|
"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()
|
c.mu.Lock()
|
||||||
conn := c.conn
|
conn := c.conn
|
||||||
c.mu.Unlock()
|
c.mu.Unlock()
|
||||||
|
|
@ -332,7 +332,7 @@ func (c *OneBotChannel) reconnectLoop() {
|
||||||
if conn == nil {
|
if conn == nil {
|
||||||
logger.InfoC("onebot", "Attempting to reconnect...")
|
logger.InfoC("onebot", "Attempting to reconnect...")
|
||||||
if err := c.connect(); err != nil {
|
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(),
|
"error": err.Error(),
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -405,7 +405,7 @@ func (c *OneBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) error
|
||||||
c.writeMu.Unlock()
|
c.writeMu.Unlock()
|
||||||
|
|
||||||
if err != nil {
|
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(),
|
"error": err.Error(),
|
||||||
})
|
})
|
||||||
return err
|
return err
|
||||||
|
|
@ -427,20 +427,20 @@ func (c *OneBotChannel) buildMessageSegments(chatID, content string) []oneBotMes
|
||||||
if msgID, ok := lastMsgID.(string); ok && msgID != "" {
|
if msgID, ok := lastMsgID.(string); ok && msgID != "" {
|
||||||
segments = append(segments, oneBotMessageSegment{
|
segments = append(segments, oneBotMessageSegment{
|
||||||
Type: "reply",
|
Type: "reply",
|
||||||
Data: map[string]interface{}{"id": msgID},
|
Data: map[string]any{"id": msgID},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
segments = append(segments, oneBotMessageSegment{
|
segments = append(segments, oneBotMessageSegment{
|
||||||
Type: "text",
|
Type: "text",
|
||||||
Data: map[string]interface{}{"text": content},
|
Data: map[string]any{"text": content},
|
||||||
})
|
})
|
||||||
|
|
||||||
return segments
|
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
|
chatID := msg.ChatID
|
||||||
segments := c.buildMessageSegments(chatID, msg.Content)
|
segments := c.buildMessageSegments(chatID, msg.Content)
|
||||||
|
|
||||||
|
|
@ -458,7 +458,7 @@ func (c *OneBotChannel) buildSendRequest(msg bus.OutboundMessage) (string, inter
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", nil, fmt.Errorf("invalid %s in chatID: %s", idKey, chatID)
|
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() {
|
func (c *OneBotChannel) listen() {
|
||||||
|
|
@ -478,7 +478,7 @@ func (c *OneBotChannel) listen() {
|
||||||
default:
|
default:
|
||||||
_, message, err := conn.ReadMessage()
|
_, message, err := conn.ReadMessage()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.ErrorCF("onebot", "WebSocket read error", map[string]interface{}{
|
logger.ErrorCF("onebot", "WebSocket read error", map[string]any{
|
||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
})
|
})
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
|
|
@ -494,14 +494,14 @@ func (c *OneBotChannel) listen() {
|
||||||
|
|
||||||
var raw oneBotRawEvent
|
var raw oneBotRawEvent
|
||||||
if err := json.Unmarshal(message, &raw); err != nil {
|
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(),
|
"error": err.Error(),
|
||||||
"payload": string(message),
|
"payload": string(message),
|
||||||
})
|
})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.DebugCF("onebot", "WebSocket event", map[string]interface{}{
|
logger.DebugCF("onebot", "WebSocket event", map[string]any{
|
||||||
"length": len(message),
|
"length": len(message),
|
||||||
"post_type": raw.PostType,
|
"post_type": raw.PostType,
|
||||||
"sub_type": raw.SubType,
|
"sub_type": raw.SubType,
|
||||||
|
|
@ -518,7 +518,7 @@ func (c *OneBotChannel) listen() {
|
||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
} else {
|
} 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,
|
"echo": raw.Echo,
|
||||||
"status": string(raw.Status),
|
"status": string(raw.Status),
|
||||||
})
|
})
|
||||||
|
|
@ -527,7 +527,7 @@ func (c *OneBotChannel) listen() {
|
||||||
}
|
}
|
||||||
|
|
||||||
if isAPIResponse(raw.Status) {
|
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),
|
"status": string(raw.Status),
|
||||||
})
|
})
|
||||||
continue
|
continue
|
||||||
|
|
@ -594,7 +594,7 @@ func (c *OneBotChannel) parseMessageSegments(raw json.RawMessage, selfID int64)
|
||||||
return parseMessageResult{Text: s, IsBotMentioned: mentioned}
|
return parseMessageResult{Text: s, IsBotMentioned: mentioned}
|
||||||
}
|
}
|
||||||
|
|
||||||
var segments []map[string]interface{}
|
var segments []map[string]any
|
||||||
if err := json.Unmarshal(raw, &segments); err != nil {
|
if err := json.Unmarshal(raw, &segments); err != nil {
|
||||||
return parseMessageResult{}
|
return parseMessageResult{}
|
||||||
}
|
}
|
||||||
|
|
@ -608,7 +608,7 @@ func (c *OneBotChannel) parseMessageSegments(raw json.RawMessage, selfID int64)
|
||||||
|
|
||||||
for _, seg := range segments {
|
for _, seg := range segments {
|
||||||
segType, _ := seg["type"].(string)
|
segType, _ := seg["type"].(string)
|
||||||
data, _ := seg["data"].(map[string]interface{})
|
data, _ := seg["data"].(map[string]any)
|
||||||
|
|
||||||
switch segType {
|
switch segType {
|
||||||
case "text":
|
case "text":
|
||||||
|
|
@ -662,7 +662,7 @@ func (c *OneBotChannel) parseMessageSegments(raw json.RawMessage, selfID int64)
|
||||||
result, err := c.transcriber.Transcribe(tctx, localPath)
|
result, err := c.transcriber.Transcribe(tctx, localPath)
|
||||||
tcancel()
|
tcancel()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.WarnCF("onebot", "Voice transcription failed", map[string]interface{}{
|
logger.WarnCF("onebot", "Voice transcription failed", map[string]any{
|
||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
})
|
})
|
||||||
textParts = append(textParts, "[voice (transcription failed)]")
|
textParts = append(textParts, "[voice (transcription failed)]")
|
||||||
|
|
@ -713,7 +713,7 @@ func (c *OneBotChannel) handleRawEvent(raw *oneBotRawEvent) {
|
||||||
case "message":
|
case "message":
|
||||||
if userID, err := parseJSONInt64(raw.UserID); err == nil && userID > 0 {
|
if userID, err := parseJSONInt64(raw.UserID); err == nil && userID > 0 {
|
||||||
if !c.IsAllowed(strconv.FormatInt(userID, 10)) {
|
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,
|
"user_id": userID,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
|
|
@ -722,7 +722,7 @@ func (c *OneBotChannel) handleRawEvent(raw *oneBotRawEvent) {
|
||||||
c.handleMessage(raw)
|
c.handleMessage(raw)
|
||||||
|
|
||||||
case "message_sent":
|
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_type": raw.MessageType,
|
||||||
"message_id": parseJSONString(raw.MessageID),
|
"message_id": parseJSONString(raw.MessageID),
|
||||||
})
|
})
|
||||||
|
|
@ -734,18 +734,18 @@ func (c *OneBotChannel) handleRawEvent(raw *oneBotRawEvent) {
|
||||||
c.handleNoticeEvent(raw)
|
c.handleNoticeEvent(raw)
|
||||||
|
|
||||||
case "request":
|
case "request":
|
||||||
logger.DebugCF("onebot", "Request event received", map[string]interface{}{
|
logger.DebugCF("onebot", "Request event received", map[string]any{
|
||||||
"sub_type": raw.SubType,
|
"sub_type": raw.SubType,
|
||||||
})
|
})
|
||||||
|
|
||||||
case "":
|
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,
|
"echo": raw.Echo,
|
||||||
"status": raw.Status,
|
"status": raw.Status,
|
||||||
})
|
})
|
||||||
|
|
||||||
default:
|
default:
|
||||||
logger.DebugCF("onebot", "Unknown post_type", map[string]interface{}{
|
logger.DebugCF("onebot", "Unknown post_type", map[string]any{
|
||||||
"post_type": raw.PostType,
|
"post_type": raw.PostType,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -753,14 +753,14 @@ func (c *OneBotChannel) handleRawEvent(raw *oneBotRawEvent) {
|
||||||
|
|
||||||
func (c *OneBotChannel) handleMetaEvent(raw *oneBotRawEvent) {
|
func (c *OneBotChannel) handleMetaEvent(raw *oneBotRawEvent) {
|
||||||
if raw.MetaEventType == "lifecycle" {
|
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" {
|
} else if raw.MetaEventType != "heartbeat" {
|
||||||
logger.DebugCF("onebot", "Meta event: "+raw.MetaEventType, nil)
|
logger.DebugCF("onebot", "Meta event: "+raw.MetaEventType, nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *OneBotChannel) handleNoticeEvent(raw *oneBotRawEvent) {
|
func (c *OneBotChannel) handleNoticeEvent(raw *oneBotRawEvent) {
|
||||||
fields := map[string]interface{}{
|
fields := map[string]any{
|
||||||
"notice_type": raw.NoticeType,
|
"notice_type": raw.NoticeType,
|
||||||
"sub_type": raw.SubType,
|
"sub_type": raw.SubType,
|
||||||
"group_id": parseJSONString(raw.GroupID),
|
"group_id": parseJSONString(raw.GroupID),
|
||||||
|
|
@ -780,7 +780,7 @@ func (c *OneBotChannel) handleMessage(raw *oneBotRawEvent) {
|
||||||
// Parse fields from raw event
|
// Parse fields from raw event
|
||||||
userID, err := parseJSONInt64(raw.UserID)
|
userID, err := parseJSONInt64(raw.UserID)
|
||||||
if err != nil {
|
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(),
|
"error": err.Error(),
|
||||||
"raw": string(raw.UserID),
|
"raw": string(raw.UserID),
|
||||||
})
|
})
|
||||||
|
|
@ -817,7 +817,7 @@ func (c *OneBotChannel) handleMessage(raw *oneBotRawEvent) {
|
||||||
var sender oneBotSender
|
var sender oneBotSender
|
||||||
if len(raw.Sender) > 0 {
|
if len(raw.Sender) > 0 {
|
||||||
if err := json.Unmarshal(raw.Sender, &sender); err != nil {
|
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(),
|
"error": err.Error(),
|
||||||
"sender": string(raw.Sender),
|
"sender": string(raw.Sender),
|
||||||
})
|
})
|
||||||
|
|
@ -829,7 +829,7 @@ func (c *OneBotChannel) handleMessage(raw *oneBotRawEvent) {
|
||||||
defer func() {
|
defer func() {
|
||||||
for _, f := range parsed.LocalFiles {
|
for _, f := range parsed.LocalFiles {
|
||||||
if err := os.Remove(f); err != nil {
|
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,
|
"path": f,
|
||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
})
|
})
|
||||||
|
|
@ -839,14 +839,14 @@ func (c *OneBotChannel) handleMessage(raw *oneBotRawEvent) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if c.isDuplicate(messageID) {
|
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,
|
"message_id": messageID,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if content == "" {
|
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,
|
"message_id": messageID,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
|
|
@ -889,7 +889,7 @@ func (c *OneBotChannel) handleMessage(raw *oneBotRawEvent) {
|
||||||
|
|
||||||
triggered, strippedContent := c.checkGroupTrigger(content, isBotMentioned)
|
triggered, strippedContent := c.checkGroupTrigger(content, isBotMentioned)
|
||||||
if !triggered {
|
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,
|
"sender": senderID,
|
||||||
"group": groupIDStr,
|
"group": groupIDStr,
|
||||||
"is_mentioned": isBotMentioned,
|
"is_mentioned": isBotMentioned,
|
||||||
|
|
@ -900,7 +900,7 @@ func (c *OneBotChannel) handleMessage(raw *oneBotRawEvent) {
|
||||||
content = strippedContent
|
content = strippedContent
|
||||||
|
|
||||||
default:
|
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,
|
"type": raw.MessageType,
|
||||||
"message_id": messageID,
|
"message_id": messageID,
|
||||||
"user_id": userID,
|
"user_id": userID,
|
||||||
|
|
@ -908,7 +908,7 @@ func (c *OneBotChannel) handleMessage(raw *oneBotRawEvent) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.InfoCF("onebot", "Received "+raw.MessageType+" message", map[string]interface{}{
|
logger.InfoCF("onebot", "Received "+raw.MessageType+" message", map[string]any{
|
||||||
"sender": senderID,
|
"sender": senderID,
|
||||||
"chat_id": chatID,
|
"chat_id": chatID,
|
||||||
"message_id": messageID,
|
"message_id": messageID,
|
||||||
|
|
@ -961,7 +961,10 @@ func truncate(s string, n int) string {
|
||||||
return string(runes[:n]) + "..."
|
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 {
|
if isBotMentioned {
|
||||||
return true, strings.TrimSpace(content)
|
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)
|
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,
|
"shards": wsInfo.Shards,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -87,7 +87,7 @@ func (c *QQChannel) Start(ctx context.Context) error {
|
||||||
// 在 goroutine 中启动 WebSocket 连接,避免阻塞
|
// 在 goroutine 中启动 WebSocket 连接,避免阻塞
|
||||||
go func() {
|
go func() {
|
||||||
if err := c.sessionManager.Start(wsInfo, c.tokenSource, &intent); err != nil {
|
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(),
|
"error": err.Error(),
|
||||||
})
|
})
|
||||||
c.setRunning(false)
|
c.setRunning(false)
|
||||||
|
|
@ -124,7 +124,7 @@ func (c *QQChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
|
||||||
// C2C 消息发送
|
// C2C 消息发送
|
||||||
_, err := c.api.PostC2CMessage(ctx, msg.ChatID, msgToCreate)
|
_, err := c.api.PostC2CMessage(ctx, msg.ChatID, msgToCreate)
|
||||||
if err != nil {
|
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(),
|
"error": err.Error(),
|
||||||
})
|
})
|
||||||
return err
|
return err
|
||||||
|
|
@ -157,7 +157,7 @@ func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.InfoCF("qq", "Received C2C message", map[string]interface{}{
|
logger.InfoCF("qq", "Received C2C message", map[string]any{
|
||||||
"sender": senderID,
|
"sender": senderID,
|
||||||
"length": len(content),
|
"length": len(content),
|
||||||
})
|
})
|
||||||
|
|
@ -199,7 +199,7 @@ func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.InfoCF("qq", "Received group AT message", map[string]interface{}{
|
logger.InfoCF("qq", "Received group AT message", map[string]any{
|
||||||
"sender": senderID,
|
"sender": senderID,
|
||||||
"group": data.GroupID,
|
"group": data.GroupID,
|
||||||
"length": len(content),
|
"length": len(content),
|
||||||
|
|
|
||||||
|
|
@ -75,7 +75,7 @@ func (c *SlackChannel) Start(ctx context.Context) error {
|
||||||
c.botUserID = authResp.UserID
|
c.botUserID = authResp.UserID
|
||||||
c.teamID = authResp.TeamID
|
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,
|
"bot_user_id": c.botUserID,
|
||||||
"team": authResp.Team,
|
"team": authResp.Team,
|
||||||
})
|
})
|
||||||
|
|
@ -85,7 +85,7 @@ func (c *SlackChannel) Start(ctx context.Context) error {
|
||||||
go func() {
|
go func() {
|
||||||
if err := c.socketClient.RunContext(c.ctx); err != nil {
|
if err := c.socketClient.RunContext(c.ctx); err != nil {
|
||||||
if 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(),
|
"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,
|
"channel_id": channelID,
|
||||||
"thread_ts": threadTS,
|
"thread_ts": threadTS,
|
||||||
})
|
})
|
||||||
|
|
@ -202,7 +202,7 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) {
|
||||||
|
|
||||||
// 检查白名单,避免为被拒绝的用户下载附件
|
// 检查白名单,避免为被拒绝的用户下载附件
|
||||||
if !c.IsAllowed(ev.User) {
|
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,
|
"user_id": ev.User,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
|
|
@ -238,7 +238,7 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) {
|
||||||
defer func() {
|
defer func() {
|
||||||
for _, file := range localFiles {
|
for _, file := range localFiles {
|
||||||
if err := os.Remove(file); err != nil {
|
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,
|
"file": file,
|
||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
})
|
})
|
||||||
|
|
@ -261,7 +261,7 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) {
|
||||||
result, err := c.transcriber.Transcribe(ctx, localPath)
|
result, err := c.transcriber.Transcribe(ctx, localPath)
|
||||||
|
|
||||||
if err != nil {
|
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)
|
content += fmt.Sprintf("\n[audio: %s (transcription failed)]", file.Name)
|
||||||
} else {
|
} else {
|
||||||
content += fmt.Sprintf("\n[voice transcription: %s]", result.Text)
|
content += fmt.Sprintf("\n[voice transcription: %s]", result.Text)
|
||||||
|
|
@ -293,7 +293,7 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) {
|
||||||
"team_id": c.teamID,
|
"team_id": c.teamID,
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.DebugCF("slack", "Received message", map[string]interface{}{
|
logger.DebugCF("slack", "Received message", map[string]any{
|
||||||
"sender_id": senderID,
|
"sender_id": senderID,
|
||||||
"chat_id": chatID,
|
"chat_id": chatID,
|
||||||
"preview": utils.Truncate(content, 50),
|
"preview": utils.Truncate(content, 50),
|
||||||
|
|
@ -309,7 +309,7 @@ func (c *SlackChannel) handleAppMention(ev *slackevents.AppMentionEvent) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if !c.IsAllowed(ev.User) {
|
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,
|
"user_id": ev.User,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
|
|
@ -375,7 +375,7 @@ func (c *SlackChannel) handleSlashCommand(event socketmode.Event) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if !c.IsAllowed(cmd.UserID) {
|
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,
|
"user_id": cmd.UserID,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
|
|
@ -400,7 +400,7 @@ func (c *SlackChannel) handleSlashCommand(event socketmode.Event) {
|
||||||
"team_id": c.teamID,
|
"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,
|
"sender_id": senderID,
|
||||||
"command": cmd.Command,
|
"command": cmd.Command,
|
||||||
"text": utils.Truncate(content, 50),
|
"text": utils.Truncate(content, 50),
|
||||||
|
|
@ -415,7 +415,7 @@ func (c *SlackChannel) downloadSlackFile(file slack.File) string {
|
||||||
downloadURL = file.URLPrivate
|
downloadURL = file.URLPrivate
|
||||||
}
|
}
|
||||||
if downloadURL == "" {
|
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 ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,10 +11,9 @@ import (
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
th "github.com/mymmrac/telego/telegohandler"
|
|
||||||
|
|
||||||
"github.com/mymmrac/telego"
|
"github.com/mymmrac/telego"
|
||||||
"github.com/mymmrac/telego/telegohandler"
|
"github.com/mymmrac/telego/telegohandler"
|
||||||
|
th "github.com/mymmrac/telego/telegohandler"
|
||||||
tu "github.com/mymmrac/telego/telegoutil"
|
tu "github.com/mymmrac/telego/telegoutil"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/bus"
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
|
@ -127,7 +126,7 @@ func (c *TelegramChannel) Start(ctx context.Context) error {
|
||||||
}, th.AnyMessage())
|
}, th.AnyMessage())
|
||||||
|
|
||||||
c.setRunning(true)
|
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(),
|
"username": c.bot.Username(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -140,6 +139,7 @@ func (c *TelegramChannel) Start(ctx context.Context) error {
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *TelegramChannel) Stop(ctx context.Context) error {
|
func (c *TelegramChannel) Stop(ctx context.Context) error {
|
||||||
logger.InfoC("telegram", "Stopping Telegram bot...")
|
logger.InfoC("telegram", "Stopping Telegram bot...")
|
||||||
c.setRunning(false)
|
c.setRunning(false)
|
||||||
|
|
@ -182,7 +182,7 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
|
||||||
tgMsg.ParseMode = telego.ModeHTML
|
tgMsg.ParseMode = telego.ModeHTML
|
||||||
|
|
||||||
if _, err = c.bot.SendMessage(ctx, tgMsg); err != nil {
|
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(),
|
"error": err.Error(),
|
||||||
})
|
})
|
||||||
tgMsg.ParseMode = ""
|
tgMsg.ParseMode = ""
|
||||||
|
|
@ -210,7 +210,7 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes
|
||||||
|
|
||||||
// 检查白名单,避免为被拒绝的用户下载附件
|
// 检查白名单,避免为被拒绝的用户下载附件
|
||||||
if !c.IsAllowed(senderID) {
|
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,
|
"user_id": senderID,
|
||||||
})
|
})
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -227,7 +227,7 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes
|
||||||
defer func() {
|
defer func() {
|
||||||
for _, file := range localFiles {
|
for _, file := range localFiles {
|
||||||
if err := os.Remove(file); err != nil {
|
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,
|
"file": file,
|
||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
})
|
})
|
||||||
|
|
@ -267,19 +267,19 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes
|
||||||
|
|
||||||
transcribedText := ""
|
transcribedText := ""
|
||||||
if c.transcriber != nil && c.transcriber.IsAvailable() {
|
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()
|
defer cancel()
|
||||||
|
|
||||||
result, err := c.transcriber.Transcribe(ctx, voicePath)
|
result, err := c.transcriber.Transcribe(transcriberCtx, voicePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.ErrorCF("telegram", "Voice transcription failed", map[string]interface{}{
|
logger.ErrorCF("telegram", "Voice transcription failed", map[string]any{
|
||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
"path": voicePath,
|
"path": voicePath,
|
||||||
})
|
})
|
||||||
transcribedText = "[voice (transcription failed)]"
|
transcribedText = "[voice (transcription failed)]"
|
||||||
} else {
|
} else {
|
||||||
transcribedText = fmt.Sprintf("[voice transcription: %s]", result.Text)
|
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,
|
"text": result.Text,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -322,7 +322,7 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes
|
||||||
content = "[empty message]"
|
content = "[empty message]"
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.DebugCF("telegram", "Received message", map[string]interface{}{
|
logger.DebugCF("telegram", "Received message", map[string]any{
|
||||||
"sender_id": senderID,
|
"sender_id": senderID,
|
||||||
"chat_id": fmt.Sprintf("%d", chatID),
|
"chat_id": fmt.Sprintf("%d", chatID),
|
||||||
"preview": utils.Truncate(content, 50),
|
"preview": utils.Truncate(content, 50),
|
||||||
|
|
@ -331,7 +331,7 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes
|
||||||
// Thinking indicator
|
// Thinking indicator
|
||||||
err := c.bot.SendChatAction(ctx, tu.ChatAction(tu.ID(chatID), telego.ChatActionTyping))
|
err := c.bot.SendChatAction(ctx, tu.ChatAction(tu.ID(chatID), telego.ChatActionTyping))
|
||||||
if err != nil {
|
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(),
|
"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 {
|
func (c *TelegramChannel) downloadPhoto(ctx context.Context, fileID string) string {
|
||||||
file, err := c.bot.GetFile(ctx, &telego.GetFileParams{FileID: fileID})
|
file, err := c.bot.GetFile(ctx, &telego.GetFileParams{FileID: fileID})
|
||||||
if err != nil {
|
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(),
|
"error": err.Error(),
|
||||||
})
|
})
|
||||||
return ""
|
return ""
|
||||||
|
|
@ -393,7 +393,7 @@ func (c *TelegramChannel) downloadFileWithInfo(file *telego.File, ext string) st
|
||||||
}
|
}
|
||||||
|
|
||||||
url := c.bot.FileDownloadURL(file.FilePath)
|
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
|
// Use FilePath as filename for better identification
|
||||||
filename := file.FilePath + ext
|
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 {
|
func (c *TelegramChannel) downloadFile(ctx context.Context, fileID, ext string) string {
|
||||||
file, err := c.bot.GetFile(ctx, &telego.GetFileParams{FileID: fileID})
|
file, err := c.bot.GetFile(ctx, &telego.GetFileParams{FileID: fileID})
|
||||||
if err != nil {
|
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(),
|
"error": err.Error(),
|
||||||
})
|
})
|
||||||
return ""
|
return ""
|
||||||
|
|
@ -463,7 +463,11 @@ func markdownToTelegramHTML(text string) string {
|
||||||
|
|
||||||
for i, code := range codeBlocks.codes {
|
for i, code := range codeBlocks.codes {
|
||||||
escaped := escapeHTML(code)
|
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
|
return text
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/mymmrac/telego"
|
"github.com/mymmrac/telego"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -35,6 +36,7 @@ func commandArgs(text string) string {
|
||||||
}
|
}
|
||||||
return strings.TrimSpace(parts[1])
|
return strings.TrimSpace(parts[1])
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *cmd) Help(ctx context.Context, message telego.Message) error {
|
func (c *cmd) Help(ctx context.Context, message telego.Message) error {
|
||||||
msg := `/start - Start the bot
|
msg := `/start - Start the bot
|
||||||
/help - Show this help message
|
/help - Show this help message
|
||||||
|
|
@ -96,6 +98,7 @@ func (c *cmd) Show(ctx context.Context, message telego.Message) error {
|
||||||
})
|
})
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *cmd) List(ctx context.Context, message telego.Message) error {
|
func (c *cmd) List(ctx context.Context, message telego.Message) error {
|
||||||
args := commandArgs(message.Text)
|
args := commandArgs(message.Text)
|
||||||
if args == "" {
|
if args == "" {
|
||||||
|
|
|
||||||
|
|
@ -134,7 +134,7 @@ func (c *WeComBotChannel) Start(ctx context.Context) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
c.setRunning(true)
|
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,
|
"address": addr,
|
||||||
"path": webhookPath,
|
"path": webhookPath,
|
||||||
})
|
})
|
||||||
|
|
@ -142,7 +142,7 @@ func (c *WeComBotChannel) Start(ctx context.Context) error {
|
||||||
// Start server in goroutine
|
// Start server in goroutine
|
||||||
go func() {
|
go func() {
|
||||||
if err := c.server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
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(),
|
"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")
|
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,
|
"chat_id": msg.ChatID,
|
||||||
"preview": utils.Truncate(msg.Content, 100),
|
"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
|
// Reference: https://developer.work.weixin.qq.com/document/path/101033
|
||||||
decryptedEchoStr, err := WeComDecryptMessageWithVerify(echostr, c.config.EncodingAESKey, "")
|
decryptedEchoStr, err := WeComDecryptMessageWithVerify(echostr, c.config.EncodingAESKey, "")
|
||||||
if err != nil {
|
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(),
|
"error": err.Error(),
|
||||||
})
|
})
|
||||||
http.Error(w, "Decryption failed", http.StatusInternalServerError)
|
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"`
|
AgentID string `xml:"AgentID"`
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := xml.Unmarshal(body, &encryptedMsg); err != nil {
|
if err = xml.Unmarshal(body, &encryptedMsg); err != nil {
|
||||||
logger.ErrorCF("wecom", "Failed to parse XML", map[string]interface{}{
|
logger.ErrorCF("wecom", "Failed to parse XML", map[string]any{
|
||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
})
|
})
|
||||||
http.Error(w, "Invalid XML", http.StatusBadRequest)
|
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
|
// Reference: https://developer.work.weixin.qq.com/document/path/101033
|
||||||
decryptedMsg, err := WeComDecryptMessageWithVerify(encryptedMsg.Encrypt, c.config.EncodingAESKey, "")
|
decryptedMsg, err := WeComDecryptMessageWithVerify(encryptedMsg.Encrypt, c.config.EncodingAESKey, "")
|
||||||
if err != nil {
|
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(),
|
"error": err.Error(),
|
||||||
})
|
})
|
||||||
http.Error(w, "Decryption failed", http.StatusInternalServerError)
|
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)
|
// Parse decrypted JSON message (AIBOT uses JSON format)
|
||||||
var msg WeComBotMessage
|
var msg WeComBotMessage
|
||||||
if err := json.Unmarshal([]byte(decryptedMsg), &msg); err != nil {
|
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(),
|
"error": err.Error(),
|
||||||
})
|
})
|
||||||
http.Error(w, "Invalid message format", http.StatusBadRequest)
|
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
|
// processMessage processes the received message
|
||||||
func (c *WeComBotChannel) processMessage(ctx context.Context, msg WeComBotMessage) {
|
func (c *WeComBotChannel) processMessage(ctx context.Context, msg WeComBotMessage) {
|
||||||
// Skip unsupported message types
|
// Skip unsupported message types
|
||||||
if msg.MsgType != "text" && msg.MsgType != "image" && msg.MsgType != "voice" && msg.MsgType != "file" && msg.MsgType != "mixed" {
|
if msg.MsgType != "text" && msg.MsgType != "image" && msg.MsgType != "voice" && msg.MsgType != "file" &&
|
||||||
logger.DebugCF("wecom", "Skipping non-supported message type", map[string]interface{}{
|
msg.MsgType != "mixed" {
|
||||||
|
logger.DebugCF("wecom", "Skipping non-supported message type", map[string]any{
|
||||||
"msg_type": msg.MsgType,
|
"msg_type": msg.MsgType,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
|
|
@ -332,7 +333,7 @@ func (c *WeComBotChannel) processMessage(ctx context.Context, msg WeComBotMessag
|
||||||
c.msgMu.Lock()
|
c.msgMu.Lock()
|
||||||
if c.processedMsgs[msgID] {
|
if c.processedMsgs[msgID] {
|
||||||
c.msgMu.Unlock()
|
c.msgMu.Unlock()
|
||||||
logger.DebugCF("wecom", "Skipping duplicate message", map[string]interface{}{
|
logger.DebugCF("wecom", "Skipping duplicate message", map[string]any{
|
||||||
"msg_id": msgID,
|
"msg_id": msgID,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
|
|
@ -399,7 +400,7 @@ func (c *WeComBotChannel) processMessage(ctx context.Context, msg WeComBotMessag
|
||||||
metadata["sender_id"] = senderID
|
metadata["sender_id"] = senderID
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.DebugCF("wecom", "Received message", map[string]interface{}{
|
logger.DebugCF("wecom", "Received message", map[string]any{
|
||||||
"sender_id": senderID,
|
"sender_id": senderID,
|
||||||
"msg_type": msg.MsgType,
|
"msg_type": msg.MsgType,
|
||||||
"peer_kind": peerKind,
|
"peer_kind": peerKind,
|
||||||
|
|
@ -468,7 +469,7 @@ func (c *WeComBotChannel) sendWebhookReply(ctx context.Context, userID, content
|
||||||
|
|
||||||
// handleHealth handles health check requests
|
// handleHealth handles health check requests
|
||||||
func (c *WeComBotChannel) handleHealth(w http.ResponseWriter, r *http.Request) {
|
func (c *WeComBotChannel) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||||
status := map[string]interface{}{
|
status := map[string]any{
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"running": c.IsRunning(),
|
"running": c.IsRunning(),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -145,7 +145,7 @@ func (c *WeComAppChannel) Start(ctx context.Context) error {
|
||||||
|
|
||||||
// Get initial access token
|
// Get initial access token
|
||||||
if err := c.refreshAccessToken(); err != nil {
|
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(),
|
"error": err.Error(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -171,7 +171,7 @@ func (c *WeComAppChannel) Start(ctx context.Context) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
c.setRunning(true)
|
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,
|
"address": addr,
|
||||||
"path": webhookPath,
|
"path": webhookPath,
|
||||||
})
|
})
|
||||||
|
|
@ -179,7 +179,7 @@ func (c *WeComAppChannel) Start(ctx context.Context) error {
|
||||||
// Start server in goroutine
|
// Start server in goroutine
|
||||||
go func() {
|
go func() {
|
||||||
if err := c.server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
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(),
|
"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")
|
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,
|
"chat_id": msg.ChatID,
|
||||||
"preview": utils.Truncate(msg.Content, 100),
|
"preview": utils.Truncate(msg.Content, 100),
|
||||||
})
|
})
|
||||||
|
|
@ -231,7 +231,7 @@ func (c *WeComAppChannel) handleWebhook(w http.ResponseWriter, r *http.Request)
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
|
|
||||||
// Log all incoming requests for debugging
|
// 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,
|
"method": r.Method,
|
||||||
"url": r.URL.String(),
|
"url": r.URL.String(),
|
||||||
"path": r.URL.Path,
|
"path": r.URL.Path,
|
||||||
|
|
@ -250,7 +250,7 @@ func (c *WeComAppChannel) handleWebhook(w http.ResponseWriter, r *http.Request)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.WarnCF("wecom_app", "Method not allowed", map[string]interface{}{
|
logger.WarnCF("wecom_app", "Method not allowed", map[string]any{
|
||||||
"method": r.Method,
|
"method": r.Method,
|
||||||
})
|
})
|
||||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
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")
|
nonce := query.Get("nonce")
|
||||||
echostr := query.Get("echostr")
|
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,
|
"msg_signature": msgSignature,
|
||||||
"timestamp": timestamp,
|
"timestamp": timestamp,
|
||||||
"nonce": nonce,
|
"nonce": nonce,
|
||||||
|
|
@ -280,7 +280,7 @@ func (c *WeComAppChannel) handleVerification(ctx context.Context, w http.Respons
|
||||||
|
|
||||||
// Verify signature
|
// Verify signature
|
||||||
if !WeComVerifySignature(c.config.Token, msgSignature, timestamp, nonce, echostr) {
|
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,
|
"token": c.config.Token,
|
||||||
"msg_signature": msgSignature,
|
"msg_signature": msgSignature,
|
||||||
"timestamp": timestamp,
|
"timestamp": timestamp,
|
||||||
|
|
@ -294,13 +294,13 @@ func (c *WeComAppChannel) handleVerification(ctx context.Context, w http.Respons
|
||||||
|
|
||||||
// Decrypt echostr with CorpID verification
|
// Decrypt echostr with CorpID verification
|
||||||
// For WeCom App (自建应用), receiveid should be corp_id
|
// 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,
|
"encoding_aes_key": c.config.EncodingAESKey,
|
||||||
"corp_id": c.config.CorpID,
|
"corp_id": c.config.CorpID,
|
||||||
})
|
})
|
||||||
decryptedEchoStr, err := WeComDecryptMessageWithVerify(echostr, c.config.EncodingAESKey, c.config.CorpID)
|
decryptedEchoStr, err := WeComDecryptMessageWithVerify(echostr, c.config.EncodingAESKey, c.config.CorpID)
|
||||||
if err != nil {
|
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(),
|
"error": err.Error(),
|
||||||
"encoding_aes_key": c.config.EncodingAESKey,
|
"encoding_aes_key": c.config.EncodingAESKey,
|
||||||
"corp_id": c.config.CorpID,
|
"corp_id": c.config.CorpID,
|
||||||
|
|
@ -309,7 +309,7 @@ func (c *WeComAppChannel) handleVerification(ctx context.Context, w http.Respons
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.DebugCF("wecom_app", "Successfully decrypted echostr", map[string]interface{}{
|
logger.DebugCF("wecom_app", "Successfully decrypted echostr", map[string]any{
|
||||||
"decrypted": decryptedEchoStr,
|
"decrypted": decryptedEchoStr,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -348,8 +348,8 @@ func (c *WeComAppChannel) handleMessageCallback(ctx context.Context, w http.Resp
|
||||||
AgentID string `xml:"AgentID"`
|
AgentID string `xml:"AgentID"`
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := xml.Unmarshal(body, &encryptedMsg); err != nil {
|
if err = xml.Unmarshal(body, &encryptedMsg); err != nil {
|
||||||
logger.ErrorCF("wecom_app", "Failed to parse XML", map[string]interface{}{
|
logger.ErrorCF("wecom_app", "Failed to parse XML", map[string]any{
|
||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
})
|
})
|
||||||
http.Error(w, "Invalid XML", http.StatusBadRequest)
|
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
|
// For WeCom App (自建应用), receiveid should be corp_id
|
||||||
decryptedMsg, err := WeComDecryptMessageWithVerify(encryptedMsg.Encrypt, c.config.EncodingAESKey, c.config.CorpID)
|
decryptedMsg, err := WeComDecryptMessageWithVerify(encryptedMsg.Encrypt, c.config.EncodingAESKey, c.config.CorpID)
|
||||||
if err != nil {
|
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(),
|
"error": err.Error(),
|
||||||
})
|
})
|
||||||
http.Error(w, "Decryption failed", http.StatusInternalServerError)
|
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
|
// Parse decrypted XML message
|
||||||
var msg WeComXMLMessage
|
var msg WeComXMLMessage
|
||||||
if err := xml.Unmarshal([]byte(decryptedMsg), &msg); err != nil {
|
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(),
|
"error": err.Error(),
|
||||||
})
|
})
|
||||||
http.Error(w, "Invalid message format", http.StatusBadRequest)
|
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) {
|
func (c *WeComAppChannel) processMessage(ctx context.Context, msg WeComXMLMessage) {
|
||||||
// Skip non-text messages for now (can be extended)
|
// Skip non-text messages for now (can be extended)
|
||||||
if msg.MsgType != "text" && msg.MsgType != "image" && msg.MsgType != "voice" {
|
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,
|
"msg_type": msg.MsgType,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
|
|
@ -408,7 +408,7 @@ func (c *WeComAppChannel) processMessage(ctx context.Context, msg WeComXMLMessag
|
||||||
c.msgMu.Lock()
|
c.msgMu.Lock()
|
||||||
if c.processedMsgs[msgID] {
|
if c.processedMsgs[msgID] {
|
||||||
c.msgMu.Unlock()
|
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,
|
"msg_id": msgID,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
|
|
@ -441,7 +441,7 @@ func (c *WeComAppChannel) processMessage(ctx context.Context, msg WeComXMLMessag
|
||||||
|
|
||||||
content := msg.Content
|
content := msg.Content
|
||||||
|
|
||||||
logger.DebugCF("wecom_app", "Received message", map[string]interface{}{
|
logger.DebugCF("wecom_app", "Received message", map[string]any{
|
||||||
"sender_id": senderID,
|
"sender_id": senderID,
|
||||||
"msg_type": msg.MsgType,
|
"msg_type": msg.MsgType,
|
||||||
"preview": utils.Truncate(content, 50),
|
"preview": utils.Truncate(content, 50),
|
||||||
|
|
@ -462,7 +462,7 @@ func (c *WeComAppChannel) tokenRefreshLoop() {
|
||||||
return
|
return
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
if err := c.refreshAccessToken(); err != nil {
|
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(),
|
"error": err.Error(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -628,7 +628,7 @@ func (c *WeComAppChannel) sendMarkdownMessage(ctx context.Context, accessToken,
|
||||||
|
|
||||||
// handleHealth handles health check requests
|
// handleHealth handles health check requests
|
||||||
func (c *WeComAppChannel) handleHealth(w http.ResponseWriter, r *http.Request) {
|
func (c *WeComAppChannel) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||||
status := map[string]interface{}{
|
status := map[string]any{
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"running": c.IsRunning(),
|
"running": c.IsRunning(),
|
||||||
"has_token": c.getAccessToken() != "",
|
"has_token": c.getAccessToken() != "",
|
||||||
|
|
|
||||||
|
|
@ -399,7 +399,11 @@ func TestWeComAppHandleVerification(t *testing.T) {
|
||||||
nonce := "test_nonce"
|
nonce := "test_nonce"
|
||||||
signature := generateSignatureApp("test_token", timestamp, nonce, encryptedEchostr)
|
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()
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
ch.handleVerification(context.Background(), w, req)
|
ch.handleVerification(context.Background(), w, req)
|
||||||
|
|
@ -429,7 +433,11 @@ func TestWeComAppHandleVerification(t *testing.T) {
|
||||||
timestamp := "1234567890"
|
timestamp := "1234567890"
|
||||||
nonce := "test_nonce"
|
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()
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
ch.handleVerification(context.Background(), w, req)
|
ch.handleVerification(context.Background(), w, req)
|
||||||
|
|
@ -481,7 +489,11 @@ func TestWeComAppHandleMessageCallback(t *testing.T) {
|
||||||
nonce := "test_nonce"
|
nonce := "test_nonce"
|
||||||
signature := generateSignatureApp("test_token", timestamp, nonce, encrypted)
|
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()
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
ch.handleMessageCallback(context.Background(), w, req)
|
ch.handleMessageCallback(context.Background(), w, req)
|
||||||
|
|
@ -510,7 +522,11 @@ func TestWeComAppHandleMessageCallback(t *testing.T) {
|
||||||
nonce := "test_nonce"
|
nonce := "test_nonce"
|
||||||
signature := generateSignatureApp("test_token", timestamp, 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()
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
ch.handleMessageCallback(context.Background(), w, req)
|
ch.handleMessageCallback(context.Background(), w, req)
|
||||||
|
|
@ -532,7 +548,11 @@ func TestWeComAppHandleMessageCallback(t *testing.T) {
|
||||||
timestamp := "1234567890"
|
timestamp := "1234567890"
|
||||||
nonce := "test_nonce"
|
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()
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
ch.handleMessageCallback(context.Background(), w, req)
|
ch.handleMessageCallback(context.Background(), w, req)
|
||||||
|
|
@ -646,7 +666,11 @@ func TestWeComAppHandleWebhook(t *testing.T) {
|
||||||
nonce := "test_nonce"
|
nonce := "test_nonce"
|
||||||
signature := generateSignatureApp("test_token", timestamp, nonce, encoded)
|
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()
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
ch.handleWebhook(w, req)
|
ch.handleWebhook(w, req)
|
||||||
|
|
@ -669,7 +693,11 @@ func TestWeComAppHandleWebhook(t *testing.T) {
|
||||||
nonce := "test_nonce"
|
nonce := "test_nonce"
|
||||||
signature := generateSignatureApp("test_token", timestamp, nonce, encryptedWrapper.Encrypt)
|
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()
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
ch.handleWebhook(w, req)
|
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) {
|
t.Run("WeComAccessTokenResponse structure", func(t *testing.T) {
|
||||||
jsonData := `{
|
jsonData := `{
|
||||||
"errcode": 0,
|
"errcode": 0,
|
||||||
|
|
|
||||||
|
|
@ -198,9 +198,7 @@ func TestWeComBotVerifySignature(t *testing.T) {
|
||||||
Token: "",
|
Token: "",
|
||||||
WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
|
WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
|
||||||
}
|
}
|
||||||
base := NewBaseChannel("wecom", cfgEmpty, msgBus, cfgEmpty.AllowFrom)
|
|
||||||
chEmpty := &WeComBotChannel{
|
chEmpty := &WeComBotChannel{
|
||||||
BaseChannel: base,
|
|
||||||
config: cfgEmpty,
|
config: cfgEmpty,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -358,7 +356,11 @@ func TestWeComBotHandleVerification(t *testing.T) {
|
||||||
nonce := "test_nonce"
|
nonce := "test_nonce"
|
||||||
signature := generateSignature("test_token", timestamp, nonce, encryptedEchostr)
|
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()
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
ch.handleVerification(context.Background(), w, req)
|
ch.handleVerification(context.Background(), w, req)
|
||||||
|
|
@ -388,7 +390,11 @@ func TestWeComBotHandleVerification(t *testing.T) {
|
||||||
timestamp := "1234567890"
|
timestamp := "1234567890"
|
||||||
nonce := "test_nonce"
|
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()
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
ch.handleVerification(context.Background(), w, req)
|
ch.handleVerification(context.Background(), w, req)
|
||||||
|
|
@ -437,7 +443,11 @@ func TestWeComBotHandleMessageCallback(t *testing.T) {
|
||||||
nonce := "test_nonce"
|
nonce := "test_nonce"
|
||||||
signature := generateSignature("test_token", timestamp, nonce, encrypted)
|
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()
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
ch.handleMessageCallback(context.Background(), w, req)
|
ch.handleMessageCallback(context.Background(), w, req)
|
||||||
|
|
@ -479,7 +489,11 @@ func TestWeComBotHandleMessageCallback(t *testing.T) {
|
||||||
nonce := "test_nonce"
|
nonce := "test_nonce"
|
||||||
signature := generateSignature("test_token", timestamp, nonce, encrypted)
|
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()
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
ch.handleMessageCallback(context.Background(), w, req)
|
ch.handleMessageCallback(context.Background(), w, req)
|
||||||
|
|
@ -508,7 +522,11 @@ func TestWeComBotHandleMessageCallback(t *testing.T) {
|
||||||
nonce := "test_nonce"
|
nonce := "test_nonce"
|
||||||
signature := generateSignature("test_token", timestamp, 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()
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
ch.handleMessageCallback(context.Background(), w, req)
|
ch.handleMessageCallback(context.Background(), w, req)
|
||||||
|
|
@ -530,7 +548,11 @@ func TestWeComBotHandleMessageCallback(t *testing.T) {
|
||||||
timestamp := "1234567890"
|
timestamp := "1234567890"
|
||||||
nonce := "test_nonce"
|
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()
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
ch.handleMessageCallback(context.Background(), w, req)
|
ch.handleMessageCallback(context.Background(), w, req)
|
||||||
|
|
@ -625,7 +647,11 @@ func TestWeComBotHandleWebhook(t *testing.T) {
|
||||||
nonce := "test_nonce"
|
nonce := "test_nonce"
|
||||||
signature := generateSignature("test_token", timestamp, nonce, encoded)
|
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()
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
ch.handleWebhook(w, req)
|
ch.handleWebhook(w, req)
|
||||||
|
|
@ -648,7 +674,11 @@ func TestWeComBotHandleWebhook(t *testing.T) {
|
||||||
nonce := "test_nonce"
|
nonce := "test_nonce"
|
||||||
signature := generateSignature("test_token", timestamp, nonce, encryptedWrapper.Encrypt)
|
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()
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
ch.handleWebhook(w, req)
|
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")
|
return fmt.Errorf("whatsapp connection not established")
|
||||||
}
|
}
|
||||||
|
|
||||||
payload := map[string]interface{}{
|
payload := map[string]any{
|
||||||
"type": "message",
|
"type": "message",
|
||||||
"to": msg.ChatID,
|
"to": msg.ChatID,
|
||||||
"content": msg.Content,
|
"content": msg.Content,
|
||||||
|
|
@ -126,7 +126,7 @@ func (c *WhatsAppChannel) listen(ctx context.Context) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
var msg map[string]interface{}
|
var msg map[string]any
|
||||||
if err := json.Unmarshal(message, &msg); err != nil {
|
if err := json.Unmarshal(message, &msg); err != nil {
|
||||||
log.Printf("Failed to unmarshal WhatsApp message: %v", err)
|
log.Printf("Failed to unmarshal WhatsApp message: %v", err)
|
||||||
continue
|
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)
|
senderID, ok := msg["from"].(string)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
|
|
@ -161,7 +161,7 @@ func (c *WhatsAppChannel) handleIncomingMessage(msg map[string]interface{}) {
|
||||||
}
|
}
|
||||||
|
|
||||||
var mediaPaths []string
|
var mediaPaths []string
|
||||||
if mediaData, ok := msg["media"].([]interface{}); ok {
|
if mediaData, ok := msg["media"].([]any); ok {
|
||||||
mediaPaths = make([]string, 0, len(mediaData))
|
mediaPaths = make([]string, 0, len(mediaData))
|
||||||
for _, m := range mediaData {
|
for _, m := range mediaData {
|
||||||
if path, ok := m.(string); ok {
|
if path, ok := m.(string); ok {
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ func (f *FlexibleStringSlice) UnmarshalJSON(data []byte) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try []interface{} to handle mixed types
|
// Try []interface{} to handle mixed types
|
||||||
var raw []interface{}
|
var raw []any
|
||||||
if err := json.Unmarshal(data, &raw); err != nil {
|
if err := json.Unmarshal(data, &raw); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -324,6 +324,7 @@ type ProvidersConfig struct {
|
||||||
GitHubCopilot ProviderConfig `json:"github_copilot"`
|
GitHubCopilot ProviderConfig `json:"github_copilot"`
|
||||||
Antigravity ProviderConfig `json:"antigravity"`
|
Antigravity ProviderConfig `json:"antigravity"`
|
||||||
Qwen ProviderConfig `json:"qwen"`
|
Qwen ProviderConfig `json:"qwen"`
|
||||||
|
Mistral ProviderConfig `json:"mistral"`
|
||||||
PicoLM PicoLMProviderConfig `json:"picolm"`
|
PicoLM PicoLMProviderConfig `json:"picolm"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -354,6 +355,7 @@ func (p ProvidersConfig) IsEmpty() bool {
|
||||||
p.GitHubCopilot.APIKey == "" && p.GitHubCopilot.APIBase == "" &&
|
p.GitHubCopilot.APIKey == "" && p.GitHubCopilot.APIBase == "" &&
|
||||||
p.Antigravity.APIKey == "" && p.Antigravity.APIBase == "" &&
|
p.Antigravity.APIKey == "" && p.Antigravity.APIBase == "" &&
|
||||||
p.Qwen.APIKey == "" && p.Qwen.APIBase == "" &&
|
p.Qwen.APIKey == "" && p.Qwen.APIBase == "" &&
|
||||||
|
p.Mistral.APIKey == "" && p.Mistral.APIBase == "" &&
|
||||||
p.PicoLM.Binary == "" && p.PicoLM.Model == ""
|
p.PicoLM.Binary == "" && p.PicoLM.Model == ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -372,7 +374,7 @@ type ProviderConfig struct {
|
||||||
APIBase string `json:"api_base" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_BASE"`
|
APIBase string `json:"api_base" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_BASE"`
|
||||||
Proxy string `json:"proxy,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_PROXY"`
|
Proxy string `json:"proxy,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_PROXY"`
|
||||||
AuthMethod string `json:"auth_method,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_AUTH_METHOD"`
|
AuthMethod string `json:"auth_method,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_AUTH_METHOD"`
|
||||||
ConnectMode string `json:"connect_mode,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_CONNECT_MODE"` //only for Github Copilot, `stdio` or `grpc`
|
ConnectMode string `json:"connect_mode,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_CONNECT_MODE"` // only for Github Copilot, `stdio` or `grpc`
|
||||||
}
|
}
|
||||||
|
|
||||||
type OpenAIProviderConfig struct {
|
type OpenAIProviderConfig struct {
|
||||||
|
|
@ -430,6 +432,13 @@ type BraveConfig struct {
|
||||||
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_BRAVE_MAX_RESULTS"`
|
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_BRAVE_MAX_RESULTS"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TavilyConfig struct {
|
||||||
|
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_TAVILY_ENABLED"`
|
||||||
|
APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_TAVILY_API_KEY"`
|
||||||
|
BaseURL string `json:"base_url" env:"PICOCLAW_TOOLS_WEB_TAVILY_BASE_URL"`
|
||||||
|
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_TAVILY_MAX_RESULTS"`
|
||||||
|
}
|
||||||
|
|
||||||
type DuckDuckGoConfig struct {
|
type DuckDuckGoConfig struct {
|
||||||
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_DUCKDUCKGO_ENABLED"`
|
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_DUCKDUCKGO_ENABLED"`
|
||||||
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_DUCKDUCKGO_MAX_RESULTS"`
|
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_DUCKDUCKGO_MAX_RESULTS"`
|
||||||
|
|
@ -443,6 +452,7 @@ type PerplexityConfig struct {
|
||||||
|
|
||||||
type WebToolsConfig struct {
|
type WebToolsConfig struct {
|
||||||
Brave BraveConfig `json:"brave"`
|
Brave BraveConfig `json:"brave"`
|
||||||
|
Tavily TavilyConfig `json:"tavily"`
|
||||||
DuckDuckGo DuckDuckGoConfig `json:"duckduckgo"`
|
DuckDuckGo DuckDuckGoConfig `json:"duckduckgo"`
|
||||||
Perplexity PerplexityConfig `json:"perplexity"`
|
Perplexity PerplexityConfig `json:"perplexity"`
|
||||||
}
|
}
|
||||||
|
|
@ -529,11 +539,11 @@ func SaveConfig(path string, cfg *Config) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
dir := filepath.Dir(path)
|
dir := filepath.Dir(path)
|
||||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return os.WriteFile(path, data, 0600)
|
return os.WriteFile(path, data, 0o600)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Config) WorkspacePath() string {
|
func (c *Config) WorkspacePath() string {
|
||||||
|
|
@ -648,7 +658,8 @@ func (c *Config) HasProvidersConfig() bool {
|
||||||
v.VolcEngine.APIKey != "" || v.VolcEngine.APIBase != "" ||
|
v.VolcEngine.APIKey != "" || v.VolcEngine.APIBase != "" ||
|
||||||
v.GitHubCopilot.APIKey != "" || v.GitHubCopilot.APIBase != "" ||
|
v.GitHubCopilot.APIKey != "" || v.GitHubCopilot.APIBase != "" ||
|
||||||
v.Antigravity.APIKey != "" || v.Antigravity.APIBase != "" ||
|
v.Antigravity.APIKey != "" || v.Antigravity.APIBase != "" ||
|
||||||
v.Qwen.APIKey != "" || v.Qwen.APIBase != ""
|
v.Qwen.APIKey != "" || v.Qwen.APIBase != "" ||
|
||||||
|
v.Mistral.APIKey != "" || v.Mistral.APIBase != ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// ValidateModelList validates all ModelConfig entries in the model_list.
|
// ValidateModelList validates all ModelConfig entries in the model_list.
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,7 @@ func TestAgentModelConfig_MarshalObject(t *testing.T) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("marshal: %v", err)
|
t.Fatalf("marshal: %v", err)
|
||||||
}
|
}
|
||||||
var result map[string]interface{}
|
var result map[string]any
|
||||||
json.Unmarshal(data, &result)
|
json.Unmarshal(data, &result)
|
||||||
if result["primary"] != "claude-opus" {
|
if result["primary"] != "claude-opus" {
|
||||||
t.Errorf("primary = %v", result["primary"])
|
t.Errorf("primary = %v", result["primary"])
|
||||||
|
|
@ -319,7 +319,7 @@ func TestSaveConfig_FilePermissions(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
perm := info.Mode().Perm()
|
perm := info.Mode().Perm()
|
||||||
if perm != 0600 {
|
if perm != 0o600 {
|
||||||
t.Errorf("config file has permission %04o, want 0600", perm)
|
t.Errorf("config file has permission %04o, want 0600", perm)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -255,6 +255,14 @@ func DefaultConfig() *Config {
|
||||||
APIKey: "ollama",
|
APIKey: "ollama",
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Mistral AI - https://console.mistral.ai/api-keys
|
||||||
|
{
|
||||||
|
ModelName: "mistral-small",
|
||||||
|
Model: "mistral/mistral-small-latest",
|
||||||
|
APIBase: "https://api.mistral.ai/v1",
|
||||||
|
APIKey: "",
|
||||||
|
},
|
||||||
|
|
||||||
// VLLM (local) - http://localhost:8000
|
// VLLM (local) - http://localhost:8000
|
||||||
{
|
{
|
||||||
ModelName: "local-model",
|
ModelName: "local-model",
|
||||||
|
|
|
||||||
|
|
@ -324,6 +324,22 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
|
||||||
}, true
|
}, true
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
providerNames: []string{"mistral"},
|
||||||
|
protocol: "mistral",
|
||||||
|
buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
|
||||||
|
if p.Mistral.APIKey == "" && p.Mistral.APIBase == "" {
|
||||||
|
return ModelConfig{}, false
|
||||||
|
}
|
||||||
|
return ModelConfig{
|
||||||
|
ModelName: "mistral",
|
||||||
|
Model: "mistral/mistral-small-latest",
|
||||||
|
APIKey: p.Mistral.APIKey,
|
||||||
|
APIBase: p.Mistral.APIBase,
|
||||||
|
Proxy: p.Mistral.Proxy,
|
||||||
|
}, true
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
providerNames: []string{"picolm"},
|
providerNames: []string{"picolm"},
|
||||||
protocol: "picolm",
|
protocol: "picolm",
|
||||||
|
|
|
||||||
|
|
@ -131,14 +131,15 @@ func TestConvertProvidersToModelList_AllProviders(t *testing.T) {
|
||||||
GitHubCopilot: ProviderConfig{ConnectMode: "grpc"},
|
GitHubCopilot: ProviderConfig{ConnectMode: "grpc"},
|
||||||
Antigravity: ProviderConfig{AuthMethod: "oauth"},
|
Antigravity: ProviderConfig{AuthMethod: "oauth"},
|
||||||
Qwen: ProviderConfig{APIKey: "key17"},
|
Qwen: ProviderConfig{APIKey: "key17"},
|
||||||
|
Mistral: ProviderConfig{APIKey: "key18"},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
result := ConvertProvidersToModelList(cfg)
|
result := ConvertProvidersToModelList(cfg)
|
||||||
|
|
||||||
// All 17 providers should be converted
|
// All 18 providers should be converted
|
||||||
if len(result) != 17 {
|
if len(result) != 18 {
|
||||||
t.Errorf("len(result) = %d, want 17", len(result))
|
t.Errorf("len(result) = %d, want 18", len(result))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -361,7 +362,10 @@ func TestConvertProvidersToModelList_ProviderNameAliases(t *testing.T) {
|
||||||
Agents: AgentsConfig{
|
Agents: AgentsConfig{
|
||||||
Defaults: AgentDefaults{
|
Defaults: AgentDefaults{
|
||||||
Provider: tt.providerAlias,
|
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{},
|
Providers: ProvidersConfig{},
|
||||||
|
|
@ -382,7 +386,10 @@ func TestConvertProvidersToModelList_ProviderNameAliases(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Need to fix the model name in config
|
// 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)
|
result := ConvertProvidersToModelList(cfg)
|
||||||
if len(result) != 1 {
|
if len(result) != 1 {
|
||||||
|
|
@ -515,7 +522,11 @@ func TestBuildModelWithProtocol_AlreadyHasPrefix(t *testing.T) {
|
||||||
func TestBuildModelWithProtocol_DifferentPrefix(t *testing.T) {
|
func TestBuildModelWithProtocol_DifferentPrefix(t *testing.T) {
|
||||||
result := buildModelWithProtocol("anthropic", "openrouter/claude-sonnet-4.6")
|
result := buildModelWithProtocol("anthropic", "openrouter/claude-sonnet-4.6")
|
||||||
if result != "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 {
|
func (cs *CronService) saveStoreUnsafe() error {
|
||||||
dir := filepath.Dir(cs.storePath)
|
dir := filepath.Dir(cs.storePath)
|
||||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -340,10 +340,16 @@ func (cs *CronService) saveStoreUnsafe() error {
|
||||||
return err
|
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()
|
cs.mu.Lock()
|
||||||
defer cs.mu.Unlock()
|
defer cs.mu.Unlock()
|
||||||
|
|
||||||
|
|
@ -465,7 +471,7 @@ func (cs *CronService) ListJobs(includeDisabled bool) []CronJob {
|
||||||
return enabled
|
return enabled
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cs *CronService) Status() map[string]interface{} {
|
func (cs *CronService) Status() map[string]any {
|
||||||
cs.mu.RLock()
|
cs.mu.RLock()
|
||||||
defer cs.mu.RUnlock()
|
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,
|
"enabled": cs.running,
|
||||||
"jobs": len(cs.store.Jobs),
|
"jobs": len(cs.store.Jobs),
|
||||||
"nextWakeAtMS": cs.getNextWakeMS(),
|
"nextWakeAtMS": cs.getNextWakeMS(),
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ func TestSaveStore_FilePermissions(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
perm := info.Mode().Perm()
|
perm := info.Mode().Perm()
|
||||||
if perm != 0600 {
|
if perm != 0o600 {
|
||||||
t.Errorf("cron store has permission %04o, want 0600", perm)
|
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 {
|
for _, src := range s.sources {
|
||||||
eventCh, err := src.Start(s.ctx)
|
eventCh, err := src.Start(s.ctx)
|
||||||
if err != nil {
|
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(),
|
"kind": src.Kind(),
|
||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
})
|
})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
go s.handleEvents(src.Kind(), eventCh)
|
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(),
|
"kind": src.Kind(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -115,7 +115,7 @@ func (s *Service) sendNotification(ev *events.DeviceEvent) {
|
||||||
|
|
||||||
lastChannel := s.state.GetLastChannel()
|
lastChannel := s.state.GetLastChannel()
|
||||||
if lastChannel == "" {
|
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(),
|
"event": ev.FormatMessage(),
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
|
|
@ -133,7 +133,7 @@ func (s *Service) sendNotification(ev *events.DeviceEvent) {
|
||||||
Content: msg,
|
Content: msg,
|
||||||
})
|
})
|
||||||
|
|
||||||
logger.InfoCF("devices", "Device notification sent", map[string]interface{}{
|
logger.InfoCF("devices", "Device notification sent", map[string]any{
|
||||||
"kind": ev.Kind,
|
"kind": ev.Kind,
|
||||||
"action": ev.Action,
|
"action": ev.Action,
|
||||||
"to": platform,
|
"to": platform,
|
||||||
|
|
|
||||||
|
|
@ -115,7 +115,7 @@ func (m *USBMonitor) Start(ctx context.Context) (<-chan *events.DeviceEvent, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := scanner.Err(); err != nil {
|
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()
|
cmd.Wait()
|
||||||
}()
|
}()
|
||||||
|
|
|
||||||
|
|
@ -193,7 +193,7 @@ func (hs *HeartbeatService) executeHeartbeat() {
|
||||||
if result.Async {
|
if result.Async {
|
||||||
hs.logInfo("Async task started: %s", result.ForLLM)
|
hs.logInfo("Async task started: %s", result.ForLLM)
|
||||||
logger.InfoCF("heartbeat", "Async heartbeat task started",
|
logger.InfoCF("heartbeat", "Async heartbeat task started",
|
||||||
map[string]interface{}{
|
map[string]any{
|
||||||
"message": result.ForLLM,
|
"message": result.ForLLM,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
|
|
@ -275,7 +275,7 @@ This file contains tasks for the heartbeat service to check periodically.
|
||||||
Add your heartbeat tasks below this line:
|
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)
|
hs.logError("Failed to create default HEARTBEAT.md: %v", err)
|
||||||
} else {
|
} else {
|
||||||
hs.logInfo("Created default HEARTBEAT.md template")
|
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
|
// log writes a message to the heartbeat log file
|
||||||
func (hs *HeartbeatService) log(level, format string, args ...any) {
|
func (hs *HeartbeatService) log(level, format string, args ...any) {
|
||||||
logFile := filepath.Join(hs.workspace, "heartbeat.log")
|
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 {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@ func TestExecuteHeartbeat_Async(t *testing.T) {
|
||||||
})
|
})
|
||||||
|
|
||||||
// Create HEARTBEAT.md
|
// 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)
|
// Execute heartbeat directly (internal method for testing)
|
||||||
hs.executeHeartbeat()
|
hs.executeHeartbeat()
|
||||||
|
|
@ -68,7 +68,7 @@ func TestExecuteHeartbeat_Error(t *testing.T) {
|
||||||
})
|
})
|
||||||
|
|
||||||
// Create HEARTBEAT.md
|
// 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()
|
hs.executeHeartbeat()
|
||||||
|
|
||||||
|
|
@ -106,7 +106,7 @@ func TestExecuteHeartbeat_Silent(t *testing.T) {
|
||||||
})
|
})
|
||||||
|
|
||||||
// Create HEARTBEAT.md
|
// 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()
|
hs.executeHeartbeat()
|
||||||
|
|
||||||
|
|
@ -174,7 +174,7 @@ func TestExecuteHeartbeat_NilResult(t *testing.T) {
|
||||||
})
|
})
|
||||||
|
|
||||||
// Create HEARTBEAT.md
|
// 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
|
// Should not panic with nil result
|
||||||
hs.executeHeartbeat()
|
hs.executeHeartbeat()
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@ type LogEntry struct {
|
||||||
Timestamp string `json:"timestamp"`
|
Timestamp string `json:"timestamp"`
|
||||||
Component string `json:"component,omitempty"`
|
Component string `json:"component,omitempty"`
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
Fields map[string]interface{} `json:"fields,omitempty"`
|
Fields map[string]any `json:"fields,omitempty"`
|
||||||
Caller string `json:"caller,omitempty"`
|
Caller string `json:"caller,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -71,7 +71,7 @@ func EnableFileLogging(filePath string) error {
|
||||||
mu.Lock()
|
mu.Lock()
|
||||||
defer mu.Unlock()
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to open log file: %w", err)
|
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 {
|
if level < currentLevel {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -150,7 +150,7 @@ func formatComponent(component string) string {
|
||||||
return fmt.Sprintf(" %s:", component)
|
return fmt.Sprintf(" %s:", component)
|
||||||
}
|
}
|
||||||
|
|
||||||
func formatFields(fields map[string]interface{}) string {
|
func formatFields(fields map[string]any) string {
|
||||||
var parts []string
|
var parts []string
|
||||||
for k, v := range fields {
|
for k, v := range fields {
|
||||||
parts = append(parts, fmt.Sprintf("%s=%v", k, v))
|
parts = append(parts, fmt.Sprintf("%s=%v", k, v))
|
||||||
|
|
@ -166,11 +166,11 @@ func DebugC(component string, message string) {
|
||||||
logMessage(DEBUG, component, message, nil)
|
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)
|
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)
|
logMessage(DEBUG, component, message, fields)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -182,11 +182,11 @@ func InfoC(component string, message string) {
|
||||||
logMessage(INFO, component, message, nil)
|
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)
|
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)
|
logMessage(INFO, component, message, fields)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -198,11 +198,11 @@ func WarnC(component string, message string) {
|
||||||
logMessage(WARN, component, message, nil)
|
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)
|
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)
|
logMessage(WARN, component, message, fields)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -214,11 +214,11 @@ func ErrorC(component string, message string) {
|
||||||
logMessage(ERROR, component, message, nil)
|
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)
|
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)
|
logMessage(ERROR, component, message, fields)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -230,10 +230,10 @@ func FatalC(component string, message string) {
|
||||||
logMessage(FATAL, component, message, nil)
|
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)
|
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)
|
logMessage(FATAL, component, message, fields)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -54,11 +54,11 @@ func TestLoggerWithComponent(t *testing.T) {
|
||||||
name string
|
name string
|
||||||
component string
|
component string
|
||||||
message string
|
message string
|
||||||
fields map[string]interface{}
|
fields map[string]any
|
||||||
}{
|
}{
|
||||||
{"Simple message", "test", "Hello, world!", nil},
|
{"Simple message", "test", "Hello, world!", nil},
|
||||||
{"Message with component", "discord", "Discord message", 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",
|
"user_id": "12345",
|
||||||
"count": 42,
|
"count": 42,
|
||||||
}},
|
}},
|
||||||
|
|
@ -128,12 +128,12 @@ func TestLoggerHelperFunctions(t *testing.T) {
|
||||||
Error("This should log")
|
Error("This should log")
|
||||||
|
|
||||||
InfoC("test", "Component message")
|
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")
|
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)
|
SetLevel(DEBUG)
|
||||||
DebugC("test", "Debug with component")
|
DebugC("test", "Debug with component")
|
||||||
WarnF("Warning with fields", map[string]interface{}{"key": "value"})
|
WarnF("Warning with fields", map[string]any{"key": "value"})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ var supportedProviders = map[string]bool{
|
||||||
"qwen": true,
|
"qwen": true,
|
||||||
"deepseek": true,
|
"deepseek": true,
|
||||||
"github_copilot": true,
|
"github_copilot": true,
|
||||||
|
"mistral": true,
|
||||||
}
|
}
|
||||||
|
|
||||||
var supportedChannels = map[string]bool{
|
var supportedChannels = map[string]bool{
|
||||||
|
|
@ -47,26 +48,26 @@ func findOpenClawConfig(openclawHome string) (string, error) {
|
||||||
return "", fmt.Errorf("no config file found in %s (tried openclaw.json, config.json)", openclawHome)
|
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)
|
data, err := os.ReadFile(configPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("reading OpenClaw config: %w", err)
|
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 {
|
if err := json.Unmarshal(data, &raw); err != nil {
|
||||||
return nil, fmt.Errorf("parsing OpenClaw config: %w", err)
|
return nil, fmt.Errorf("parsing OpenClaw config: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
converted := convertKeysToSnake(raw)
|
converted := convertKeysToSnake(raw)
|
||||||
result, ok := converted.(map[string]interface{})
|
result, ok := converted.(map[string]any)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, fmt.Errorf("unexpected config format")
|
return nil, fmt.Errorf("unexpected config format")
|
||||||
}
|
}
|
||||||
return result, nil
|
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()
|
cfg := config.DefaultConfig()
|
||||||
var warnings []string
|
var warnings []string
|
||||||
|
|
||||||
|
|
@ -92,7 +93,7 @@ func ConvertConfig(data map[string]interface{}) (*config.Config, []string, error
|
||||||
|
|
||||||
if providers, ok := getMap(data, "providers"); ok {
|
if providers, ok := getMap(data, "providers"); ok {
|
||||||
for name, val := range providers {
|
for name, val := range providers {
|
||||||
pMap, ok := val.(map[string]interface{})
|
pMap, ok := val.(map[string]any)
|
||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
@ -131,7 +132,7 @@ func ConvertConfig(data map[string]interface{}) (*config.Config, []string, error
|
||||||
|
|
||||||
if channels, ok := getMap(data, "channels"); ok {
|
if channels, ok := getMap(data, "channels"); ok {
|
||||||
for name, val := range channels {
|
for name, val := range channels {
|
||||||
cMap, ok := val.(map[string]interface{})
|
cMap, ok := val.(map[string]any)
|
||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
@ -318,16 +319,16 @@ func camelToSnake(s string) string {
|
||||||
return result.String()
|
return result.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
func convertKeysToSnake(data interface{}) interface{} {
|
func convertKeysToSnake(data any) any {
|
||||||
switch v := data.(type) {
|
switch v := data.(type) {
|
||||||
case map[string]interface{}:
|
case map[string]any:
|
||||||
result := make(map[string]interface{}, len(v))
|
result := make(map[string]any, len(v))
|
||||||
for key, val := range v {
|
for key, val := range v {
|
||||||
result[camelToSnake(key)] = convertKeysToSnake(val)
|
result[camelToSnake(key)] = convertKeysToSnake(val)
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
case []interface{}:
|
case []any:
|
||||||
result := make([]interface{}, len(v))
|
result := make([]any, len(v))
|
||||||
for i, val := range v {
|
for i, val := range v {
|
||||||
result[i] = convertKeysToSnake(val)
|
result[i] = convertKeysToSnake(val)
|
||||||
}
|
}
|
||||||
|
|
@ -342,16 +343,16 @@ func rewriteWorkspacePath(path string) string {
|
||||||
return path
|
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]
|
v, ok := data[key]
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
m, ok := v.(map[string]interface{})
|
m, ok := v.(map[string]any)
|
||||||
return m, ok
|
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]
|
v, ok := data[key]
|
||||||
if !ok {
|
if !ok {
|
||||||
return "", false
|
return "", false
|
||||||
|
|
@ -360,7 +361,7 @@ func getString(data map[string]interface{}, key string) (string, bool) {
|
||||||
return s, ok
|
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]
|
v, ok := data[key]
|
||||||
if !ok {
|
if !ok {
|
||||||
return 0, false
|
return 0, false
|
||||||
|
|
@ -369,7 +370,7 @@ func getFloat(data map[string]interface{}, key string) (float64, bool) {
|
||||||
return f, ok
|
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]
|
v, ok := data[key]
|
||||||
if !ok {
|
if !ok {
|
||||||
return false, false
|
return false, false
|
||||||
|
|
@ -378,19 +379,19 @@ func getBool(data map[string]interface{}, key string) (bool, bool) {
|
||||||
return b, ok
|
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 {
|
if v, ok := getBool(data, key); ok {
|
||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
return defaultVal
|
return defaultVal
|
||||||
}
|
}
|
||||||
|
|
||||||
func getStringSlice(data map[string]interface{}, key string) []string {
|
func getStringSlice(data map[string]any, key string) []string {
|
||||||
v, ok := data[key]
|
v, ok := data[key]
|
||||||
if !ok {
|
if !ok {
|
||||||
return []string{}
|
return []string{}
|
||||||
}
|
}
|
||||||
arr, ok := v.([]interface{})
|
arr, ok := v.([]any)
|
||||||
if !ok {
|
if !ok {
|
||||||
return []string{}
|
return []string{}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -67,7 +67,7 @@ func Run(opts Options) (*Result, error) {
|
||||||
return nil, err
|
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)
|
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)
|
fmt.Printf(" ✓ Converted config: %s\n", action.Destination)
|
||||||
}
|
}
|
||||||
case ActionCreateDir:
|
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)
|
result.Errors = append(result.Errors, err)
|
||||||
} else {
|
} else {
|
||||||
result.DirsCreated++
|
result.DirsCreated++
|
||||||
|
|
@ -174,9 +174,13 @@ func Execute(actions []Action, openclawHome, picoClawHome string) *Result {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
result.BackupsCreated++
|
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)
|
result.Errors = append(result.Errors, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
@ -188,7 +192,7 @@ func Execute(actions []Action, openclawHome, picoClawHome string) *Result {
|
||||||
fmt.Printf(" ✓ Copied %s\n", relPath(action.Source, openclawHome))
|
fmt.Printf(" ✓ Copied %s\n", relPath(action.Source, openclawHome))
|
||||||
}
|
}
|
||||||
case ActionCopy:
|
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)
|
result.Errors = append(result.Errors, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
@ -226,7 +230,7 @@ func executeConfigMigration(srcConfigPath, dstConfigPath, picoClawHome string) e
|
||||||
incoming = MergeConfig(existing, incoming)
|
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 err
|
||||||
}
|
}
|
||||||
return config.SaveConfig(dstConfigPath, incoming)
|
return config.SaveConfig(dstConfigPath, incoming)
|
||||||
|
|
|
||||||
|
|
@ -40,43 +40,43 @@ func TestCamelToSnake(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestConvertKeysToSnake(t *testing.T) {
|
func TestConvertKeysToSnake(t *testing.T) {
|
||||||
input := map[string]interface{}{
|
input := map[string]any{
|
||||||
"apiKey": "test-key",
|
"apiKey": "test-key",
|
||||||
"apiBase": "https://example.com",
|
"apiBase": "https://example.com",
|
||||||
"nested": map[string]interface{}{
|
"nested": map[string]any{
|
||||||
"maxTokens": float64(8192),
|
"maxTokens": float64(8192),
|
||||||
"allowFrom": []interface{}{"user1", "user2"},
|
"allowFrom": []any{"user1", "user2"},
|
||||||
"deeperLevel": map[string]interface{}{
|
"deeperLevel": map[string]any{
|
||||||
"clientId": "abc",
|
"clientId": "abc",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
result := convertKeysToSnake(input)
|
result := convertKeysToSnake(input)
|
||||||
m, ok := result.(map[string]interface{})
|
m, ok := result.(map[string]any)
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Fatal("expected map[string]interface{}")
|
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")
|
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")
|
t.Error("expected key 'api_base' after conversion")
|
||||||
}
|
}
|
||||||
|
|
||||||
nested, ok := m["nested"].(map[string]interface{})
|
nested, ok := m["nested"].(map[string]any)
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Fatal("expected nested map")
|
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")
|
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")
|
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 {
|
if !ok {
|
||||||
t.Fatal("expected deeper_level map")
|
t.Fatal("expected deeper_level map")
|
||||||
}
|
}
|
||||||
|
|
@ -89,15 +89,15 @@ func TestLoadOpenClawConfig(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
configPath := filepath.Join(tmpDir, "openclaw.json")
|
configPath := filepath.Join(tmpDir, "openclaw.json")
|
||||||
|
|
||||||
openclawConfig := map[string]interface{}{
|
openclawConfig := map[string]any{
|
||||||
"providers": map[string]interface{}{
|
"providers": map[string]any{
|
||||||
"anthropic": map[string]interface{}{
|
"anthropic": map[string]any{
|
||||||
"apiKey": "sk-ant-test123",
|
"apiKey": "sk-ant-test123",
|
||||||
"apiBase": "https://api.anthropic.com",
|
"apiBase": "https://api.anthropic.com",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"agents": map[string]interface{}{
|
"agents": map[string]any{
|
||||||
"defaults": map[string]interface{}{
|
"defaults": map[string]any{
|
||||||
"maxTokens": float64(4096),
|
"maxTokens": float64(4096),
|
||||||
"model": "claude-3-opus",
|
"model": "claude-3-opus",
|
||||||
},
|
},
|
||||||
|
|
@ -108,7 +108,7 @@ func TestLoadOpenClawConfig(t *testing.T) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := os.WriteFile(configPath, data, 0644); err != nil {
|
if err = os.WriteFile(configPath, data, 0o644); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -117,11 +117,11 @@ func TestLoadOpenClawConfig(t *testing.T) {
|
||||||
t.Fatalf("LoadOpenClawConfig: %v", err)
|
t.Fatalf("LoadOpenClawConfig: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
providers, ok := result["providers"].(map[string]interface{})
|
providers, ok := result["providers"].(map[string]any)
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Fatal("expected providers map")
|
t.Fatal("expected providers map")
|
||||||
}
|
}
|
||||||
anthropic, ok := providers["anthropic"].(map[string]interface{})
|
anthropic, ok := providers["anthropic"].(map[string]any)
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Fatal("expected anthropic map")
|
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"])
|
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 {
|
if !ok {
|
||||||
t.Fatal("expected agents map")
|
t.Fatal("expected agents map")
|
||||||
}
|
}
|
||||||
defaults, ok := agents["defaults"].(map[string]interface{})
|
defaults, ok := agents["defaults"].(map[string]any)
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Fatal("expected defaults map")
|
t.Fatal("expected defaults map")
|
||||||
}
|
}
|
||||||
|
|
@ -144,16 +144,16 @@ func TestLoadOpenClawConfig(t *testing.T) {
|
||||||
|
|
||||||
func TestConvertConfig(t *testing.T) {
|
func TestConvertConfig(t *testing.T) {
|
||||||
t.Run("providers mapping", func(t *testing.T) {
|
t.Run("providers mapping", func(t *testing.T) {
|
||||||
data := map[string]interface{}{
|
data := map[string]any{
|
||||||
"providers": map[string]interface{}{
|
"providers": map[string]any{
|
||||||
"anthropic": map[string]interface{}{
|
"anthropic": map[string]any{
|
||||||
"api_key": "sk-ant-test",
|
"api_key": "sk-ant-test",
|
||||||
"api_base": "https://api.anthropic.com",
|
"api_base": "https://api.anthropic.com",
|
||||||
},
|
},
|
||||||
"openrouter": map[string]interface{}{
|
"openrouter": map[string]any{
|
||||||
"api_key": "sk-or-test",
|
"api_key": "sk-or-test",
|
||||||
},
|
},
|
||||||
"groq": map[string]interface{}{
|
"groq": map[string]any{
|
||||||
"api_key": "gsk-test",
|
"api_key": "gsk-test",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -178,9 +178,9 @@ func TestConvertConfig(t *testing.T) {
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("unsupported provider warning", func(t *testing.T) {
|
t.Run("unsupported provider warning", func(t *testing.T) {
|
||||||
data := map[string]interface{}{
|
data := map[string]any{
|
||||||
"providers": map[string]interface{}{
|
"providers": map[string]any{
|
||||||
"unknown_provider": map[string]interface{}{
|
"unknown_provider": map[string]any{
|
||||||
"api_key": "sk-test",
|
"api_key": "sk-test",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -199,14 +199,14 @@ func TestConvertConfig(t *testing.T) {
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("channels mapping", func(t *testing.T) {
|
t.Run("channels mapping", func(t *testing.T) {
|
||||||
data := map[string]interface{}{
|
data := map[string]any{
|
||||||
"channels": map[string]interface{}{
|
"channels": map[string]any{
|
||||||
"telegram": map[string]interface{}{
|
"telegram": map[string]any{
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"token": "tg-token-123",
|
"token": "tg-token-123",
|
||||||
"allow_from": []interface{}{"user1"},
|
"allow_from": []any{"user1"},
|
||||||
},
|
},
|
||||||
"discord": map[string]interface{}{
|
"discord": map[string]any{
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"token": "disc-token-456",
|
"token": "disc-token-456",
|
||||||
},
|
},
|
||||||
|
|
@ -232,9 +232,9 @@ func TestConvertConfig(t *testing.T) {
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("unsupported channel warning", func(t *testing.T) {
|
t.Run("unsupported channel warning", func(t *testing.T) {
|
||||||
data := map[string]interface{}{
|
data := map[string]any{
|
||||||
"channels": map[string]interface{}{
|
"channels": map[string]any{
|
||||||
"email": map[string]interface{}{
|
"email": map[string]any{
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -253,9 +253,9 @@ func TestConvertConfig(t *testing.T) {
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("agent defaults", func(t *testing.T) {
|
t.Run("agent defaults", func(t *testing.T) {
|
||||||
data := map[string]interface{}{
|
data := map[string]any{
|
||||||
"agents": map[string]interface{}{
|
"agents": map[string]any{
|
||||||
"defaults": map[string]interface{}{
|
"defaults": map[string]any{
|
||||||
"model": "claude-3-opus",
|
"model": "claude-3-opus",
|
||||||
"max_tokens": float64(4096),
|
"max_tokens": float64(4096),
|
||||||
"temperature": 0.5,
|
"temperature": 0.5,
|
||||||
|
|
@ -287,7 +287,7 @@ func TestConvertConfig(t *testing.T) {
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("empty config", func(t *testing.T) {
|
t.Run("empty config", func(t *testing.T) {
|
||||||
data := map[string]interface{}{}
|
data := map[string]any{}
|
||||||
|
|
||||||
cfg, warnings, err := ConvertConfig(data)
|
cfg, warnings, err := ConvertConfig(data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -389,9 +389,9 @@ func TestPlanWorkspaceMigration(t *testing.T) {
|
||||||
srcDir := t.TempDir()
|
srcDir := t.TempDir()
|
||||||
dstDir := t.TempDir()
|
dstDir := t.TempDir()
|
||||||
|
|
||||||
os.WriteFile(filepath.Join(srcDir, "AGENTS.md"), []byte("# Agents"), 0644)
|
os.WriteFile(filepath.Join(srcDir, "AGENTS.md"), []byte("# Agents"), 0o644)
|
||||||
os.WriteFile(filepath.Join(srcDir, "SOUL.md"), []byte("# Soul"), 0644)
|
os.WriteFile(filepath.Join(srcDir, "SOUL.md"), []byte("# Soul"), 0o644)
|
||||||
os.WriteFile(filepath.Join(srcDir, "USER.md"), []byte("# User"), 0644)
|
os.WriteFile(filepath.Join(srcDir, "USER.md"), []byte("# User"), 0o644)
|
||||||
|
|
||||||
actions, err := PlanWorkspaceMigration(srcDir, dstDir, false)
|
actions, err := PlanWorkspaceMigration(srcDir, dstDir, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -420,8 +420,8 @@ func TestPlanWorkspaceMigration(t *testing.T) {
|
||||||
srcDir := t.TempDir()
|
srcDir := t.TempDir()
|
||||||
dstDir := t.TempDir()
|
dstDir := t.TempDir()
|
||||||
|
|
||||||
os.WriteFile(filepath.Join(srcDir, "AGENTS.md"), []byte("# Agents from OpenClaw"), 0644)
|
os.WriteFile(filepath.Join(srcDir, "AGENTS.md"), []byte("# Agents from OpenClaw"), 0o644)
|
||||||
os.WriteFile(filepath.Join(dstDir, "AGENTS.md"), []byte("# Existing Agents"), 0644)
|
os.WriteFile(filepath.Join(dstDir, "AGENTS.md"), []byte("# Existing Agents"), 0o644)
|
||||||
|
|
||||||
actions, err := PlanWorkspaceMigration(srcDir, dstDir, false)
|
actions, err := PlanWorkspaceMigration(srcDir, dstDir, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -443,8 +443,8 @@ func TestPlanWorkspaceMigration(t *testing.T) {
|
||||||
srcDir := t.TempDir()
|
srcDir := t.TempDir()
|
||||||
dstDir := t.TempDir()
|
dstDir := t.TempDir()
|
||||||
|
|
||||||
os.WriteFile(filepath.Join(srcDir, "AGENTS.md"), []byte("# Agents"), 0644)
|
os.WriteFile(filepath.Join(srcDir, "AGENTS.md"), []byte("# Agents"), 0o644)
|
||||||
os.WriteFile(filepath.Join(dstDir, "AGENTS.md"), []byte("# Existing"), 0644)
|
os.WriteFile(filepath.Join(dstDir, "AGENTS.md"), []byte("# Existing"), 0o644)
|
||||||
|
|
||||||
actions, err := PlanWorkspaceMigration(srcDir, dstDir, true)
|
actions, err := PlanWorkspaceMigration(srcDir, dstDir, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -463,8 +463,8 @@ func TestPlanWorkspaceMigration(t *testing.T) {
|
||||||
dstDir := t.TempDir()
|
dstDir := t.TempDir()
|
||||||
|
|
||||||
memDir := filepath.Join(srcDir, "memory")
|
memDir := filepath.Join(srcDir, "memory")
|
||||||
os.MkdirAll(memDir, 0755)
|
os.MkdirAll(memDir, 0o755)
|
||||||
os.WriteFile(filepath.Join(memDir, "MEMORY.md"), []byte("# Memory"), 0644)
|
os.WriteFile(filepath.Join(memDir, "MEMORY.md"), []byte("# Memory"), 0o644)
|
||||||
|
|
||||||
actions, err := PlanWorkspaceMigration(srcDir, dstDir, false)
|
actions, err := PlanWorkspaceMigration(srcDir, dstDir, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -494,8 +494,8 @@ func TestPlanWorkspaceMigration(t *testing.T) {
|
||||||
dstDir := t.TempDir()
|
dstDir := t.TempDir()
|
||||||
|
|
||||||
skillDir := filepath.Join(srcDir, "skills", "weather")
|
skillDir := filepath.Join(srcDir, "skills", "weather")
|
||||||
os.MkdirAll(skillDir, 0755)
|
os.MkdirAll(skillDir, 0o755)
|
||||||
os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("# Weather"), 0644)
|
os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("# Weather"), 0o644)
|
||||||
|
|
||||||
actions, err := PlanWorkspaceMigration(srcDir, dstDir, false)
|
actions, err := PlanWorkspaceMigration(srcDir, dstDir, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -518,7 +518,7 @@ func TestFindOpenClawConfig(t *testing.T) {
|
||||||
t.Run("finds openclaw.json", func(t *testing.T) {
|
t.Run("finds openclaw.json", func(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
configPath := filepath.Join(tmpDir, "openclaw.json")
|
configPath := filepath.Join(tmpDir, "openclaw.json")
|
||||||
os.WriteFile(configPath, []byte("{}"), 0644)
|
os.WriteFile(configPath, []byte("{}"), 0o644)
|
||||||
|
|
||||||
found, err := findOpenClawConfig(tmpDir)
|
found, err := findOpenClawConfig(tmpDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -532,7 +532,7 @@ func TestFindOpenClawConfig(t *testing.T) {
|
||||||
t.Run("falls back to config.json", func(t *testing.T) {
|
t.Run("falls back to config.json", func(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
configPath := filepath.Join(tmpDir, "config.json")
|
configPath := filepath.Join(tmpDir, "config.json")
|
||||||
os.WriteFile(configPath, []byte("{}"), 0644)
|
os.WriteFile(configPath, []byte("{}"), 0o644)
|
||||||
|
|
||||||
found, err := findOpenClawConfig(tmpDir)
|
found, err := findOpenClawConfig(tmpDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -546,8 +546,8 @@ func TestFindOpenClawConfig(t *testing.T) {
|
||||||
t.Run("prefers openclaw.json over config.json", func(t *testing.T) {
|
t.Run("prefers openclaw.json over config.json", func(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
openclawPath := filepath.Join(tmpDir, "openclaw.json")
|
openclawPath := filepath.Join(tmpDir, "openclaw.json")
|
||||||
os.WriteFile(openclawPath, []byte("{}"), 0644)
|
os.WriteFile(openclawPath, []byte("{}"), 0o644)
|
||||||
os.WriteFile(filepath.Join(tmpDir, "config.json"), []byte("{}"), 0644)
|
os.WriteFile(filepath.Join(tmpDir, "config.json"), []byte("{}"), 0o644)
|
||||||
|
|
||||||
found, err := findOpenClawConfig(tmpDir)
|
found, err := findOpenClawConfig(tmpDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -593,19 +593,19 @@ func TestRunDryRun(t *testing.T) {
|
||||||
picoClawHome := t.TempDir()
|
picoClawHome := t.TempDir()
|
||||||
|
|
||||||
wsDir := filepath.Join(openclawHome, "workspace")
|
wsDir := filepath.Join(openclawHome, "workspace")
|
||||||
os.MkdirAll(wsDir, 0755)
|
os.MkdirAll(wsDir, 0o755)
|
||||||
os.WriteFile(filepath.Join(wsDir, "SOUL.md"), []byte("# Soul"), 0644)
|
os.WriteFile(filepath.Join(wsDir, "SOUL.md"), []byte("# Soul"), 0o644)
|
||||||
os.WriteFile(filepath.Join(wsDir, "AGENTS.md"), []byte("# Agents"), 0644)
|
os.WriteFile(filepath.Join(wsDir, "AGENTS.md"), []byte("# Agents"), 0o644)
|
||||||
|
|
||||||
configData := map[string]interface{}{
|
configData := map[string]any{
|
||||||
"providers": map[string]interface{}{
|
"providers": map[string]any{
|
||||||
"anthropic": map[string]interface{}{
|
"anthropic": map[string]any{
|
||||||
"apiKey": "test-key",
|
"apiKey": "test-key",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
data, _ := json.Marshal(configData)
|
data, _ := json.Marshal(configData)
|
||||||
os.WriteFile(filepath.Join(openclawHome, "openclaw.json"), data, 0644)
|
os.WriteFile(filepath.Join(openclawHome, "openclaw.json"), data, 0o644)
|
||||||
|
|
||||||
opts := Options{
|
opts := Options{
|
||||||
DryRun: true,
|
DryRun: true,
|
||||||
|
|
@ -634,33 +634,33 @@ func TestRunFullMigration(t *testing.T) {
|
||||||
picoClawHome := t.TempDir()
|
picoClawHome := t.TempDir()
|
||||||
|
|
||||||
wsDir := filepath.Join(openclawHome, "workspace")
|
wsDir := filepath.Join(openclawHome, "workspace")
|
||||||
os.MkdirAll(wsDir, 0755)
|
os.MkdirAll(wsDir, 0o755)
|
||||||
os.WriteFile(filepath.Join(wsDir, "SOUL.md"), []byte("# Soul from OpenClaw"), 0644)
|
os.WriteFile(filepath.Join(wsDir, "SOUL.md"), []byte("# Soul from OpenClaw"), 0o644)
|
||||||
os.WriteFile(filepath.Join(wsDir, "AGENTS.md"), []byte("# Agents from OpenClaw"), 0644)
|
os.WriteFile(filepath.Join(wsDir, "AGENTS.md"), []byte("# Agents from OpenClaw"), 0o644)
|
||||||
os.WriteFile(filepath.Join(wsDir, "USER.md"), []byte("# User from OpenClaw"), 0644)
|
os.WriteFile(filepath.Join(wsDir, "USER.md"), []byte("# User from OpenClaw"), 0o644)
|
||||||
|
|
||||||
memDir := filepath.Join(wsDir, "memory")
|
memDir := filepath.Join(wsDir, "memory")
|
||||||
os.MkdirAll(memDir, 0755)
|
os.MkdirAll(memDir, 0o755)
|
||||||
os.WriteFile(filepath.Join(memDir, "MEMORY.md"), []byte("# Memory notes"), 0644)
|
os.WriteFile(filepath.Join(memDir, "MEMORY.md"), []byte("# Memory notes"), 0o644)
|
||||||
|
|
||||||
configData := map[string]interface{}{
|
configData := map[string]any{
|
||||||
"providers": map[string]interface{}{
|
"providers": map[string]any{
|
||||||
"anthropic": map[string]interface{}{
|
"anthropic": map[string]any{
|
||||||
"apiKey": "sk-ant-migrate-test",
|
"apiKey": "sk-ant-migrate-test",
|
||||||
},
|
},
|
||||||
"openrouter": map[string]interface{}{
|
"openrouter": map[string]any{
|
||||||
"apiKey": "sk-or-migrate-test",
|
"apiKey": "sk-or-migrate-test",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"channels": map[string]interface{}{
|
"channels": map[string]any{
|
||||||
"telegram": map[string]interface{}{
|
"telegram": map[string]any{
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"token": "tg-migrate-test",
|
"token": "tg-migrate-test",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
data, _ := json.Marshal(configData)
|
data, _ := json.Marshal(configData)
|
||||||
os.WriteFile(filepath.Join(openclawHome, "openclaw.json"), data, 0644)
|
os.WriteFile(filepath.Join(openclawHome, "openclaw.json"), data, 0o644)
|
||||||
|
|
||||||
opts := Options{
|
opts := Options{
|
||||||
Force: true,
|
Force: true,
|
||||||
|
|
@ -754,7 +754,7 @@ func TestRunMutuallyExclusiveFlags(t *testing.T) {
|
||||||
func TestBackupFile(t *testing.T) {
|
func TestBackupFile(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
filePath := filepath.Join(tmpDir, "test.md")
|
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 {
|
if err := backupFile(filePath); err != nil {
|
||||||
t.Fatalf("backupFile: %v", err)
|
t.Fatalf("backupFile: %v", err)
|
||||||
|
|
@ -775,7 +775,7 @@ func TestCopyFile(t *testing.T) {
|
||||||
srcPath := filepath.Join(tmpDir, "src.md")
|
srcPath := filepath.Join(tmpDir, "src.md")
|
||||||
dstPath := filepath.Join(tmpDir, "dst.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 {
|
if err := copyFile(srcPath, dstPath); err != nil {
|
||||||
t.Fatalf("copyFile: %v", err)
|
t.Fatalf("copyFile: %v", err)
|
||||||
|
|
@ -795,18 +795,18 @@ func TestRunConfigOnly(t *testing.T) {
|
||||||
picoClawHome := t.TempDir()
|
picoClawHome := t.TempDir()
|
||||||
|
|
||||||
wsDir := filepath.Join(openclawHome, "workspace")
|
wsDir := filepath.Join(openclawHome, "workspace")
|
||||||
os.MkdirAll(wsDir, 0755)
|
os.MkdirAll(wsDir, 0o755)
|
||||||
os.WriteFile(filepath.Join(wsDir, "SOUL.md"), []byte("# Soul"), 0644)
|
os.WriteFile(filepath.Join(wsDir, "SOUL.md"), []byte("# Soul"), 0o644)
|
||||||
|
|
||||||
configData := map[string]interface{}{
|
configData := map[string]any{
|
||||||
"providers": map[string]interface{}{
|
"providers": map[string]any{
|
||||||
"anthropic": map[string]interface{}{
|
"anthropic": map[string]any{
|
||||||
"apiKey": "sk-config-only",
|
"apiKey": "sk-config-only",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
data, _ := json.Marshal(configData)
|
data, _ := json.Marshal(configData)
|
||||||
os.WriteFile(filepath.Join(openclawHome, "openclaw.json"), data, 0644)
|
os.WriteFile(filepath.Join(openclawHome, "openclaw.json"), data, 0o644)
|
||||||
|
|
||||||
opts := Options{
|
opts := Options{
|
||||||
Force: true,
|
Force: true,
|
||||||
|
|
@ -835,18 +835,18 @@ func TestRunWorkspaceOnly(t *testing.T) {
|
||||||
picoClawHome := t.TempDir()
|
picoClawHome := t.TempDir()
|
||||||
|
|
||||||
wsDir := filepath.Join(openclawHome, "workspace")
|
wsDir := filepath.Join(openclawHome, "workspace")
|
||||||
os.MkdirAll(wsDir, 0755)
|
os.MkdirAll(wsDir, 0o755)
|
||||||
os.WriteFile(filepath.Join(wsDir, "SOUL.md"), []byte("# Soul"), 0644)
|
os.WriteFile(filepath.Join(wsDir, "SOUL.md"), []byte("# Soul"), 0o644)
|
||||||
|
|
||||||
configData := map[string]interface{}{
|
configData := map[string]any{
|
||||||
"providers": map[string]interface{}{
|
"providers": map[string]any{
|
||||||
"anthropic": map[string]interface{}{
|
"anthropic": map[string]any{
|
||||||
"apiKey": "sk-ws-only",
|
"apiKey": "sk-ws-only",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
data, _ := json.Marshal(configData)
|
data, _ := json.Marshal(configData)
|
||||||
os.WriteFile(filepath.Join(openclawHome, "openclaw.json"), data, 0644)
|
os.WriteFile(filepath.Join(openclawHome, "openclaw.json"), data, 0o644)
|
||||||
|
|
||||||
opts := Options{
|
opts := Options{
|
||||||
Force: true,
|
Force: true,
|
||||||
|
|
|
||||||
|
|
@ -9,16 +9,19 @@ import (
|
||||||
|
|
||||||
"github.com/anthropics/anthropic-sdk-go"
|
"github.com/anthropics/anthropic-sdk-go"
|
||||||
"github.com/anthropics/anthropic-sdk-go/option"
|
"github.com/anthropics/anthropic-sdk-go/option"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/providers/protocoltypes"
|
"github.com/sipeed/picoclaw/pkg/providers/protocoltypes"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ToolCall = protocoltypes.ToolCall
|
type (
|
||||||
type FunctionCall = protocoltypes.FunctionCall
|
ToolCall = protocoltypes.ToolCall
|
||||||
type LLMResponse = protocoltypes.LLMResponse
|
FunctionCall = protocoltypes.FunctionCall
|
||||||
type UsageInfo = protocoltypes.UsageInfo
|
LLMResponse = protocoltypes.LLMResponse
|
||||||
type Message = protocoltypes.Message
|
UsageInfo = protocoltypes.UsageInfo
|
||||||
type ToolDefinition = protocoltypes.ToolDefinition
|
Message = protocoltypes.Message
|
||||||
type ToolFunctionDefinition = protocoltypes.ToolFunctionDefinition
|
ToolDefinition = protocoltypes.ToolDefinition
|
||||||
|
ToolFunctionDefinition = protocoltypes.ToolFunctionDefinition
|
||||||
|
)
|
||||||
|
|
||||||
const defaultBaseURL = "https://api.anthropic.com"
|
const defaultBaseURL = "https://api.anthropic.com"
|
||||||
|
|
||||||
|
|
@ -61,7 +64,13 @@ func NewProviderWithTokenSourceAndBaseURL(token string, tokenSource func() (stri
|
||||||
return p
|
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
|
var opts []option.RequestOption
|
||||||
if p.tokenSource != nil {
|
if p.tokenSource != nil {
|
||||||
tok, err := p.tokenSource()
|
tok, err := p.tokenSource()
|
||||||
|
|
@ -92,7 +101,12 @@ func (p *Provider) BaseURL() string {
|
||||||
return p.baseURL
|
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 system []anthropic.TextBlockParam
|
||||||
var anthropicMessages []anthropic.MessageParam
|
var anthropicMessages []anthropic.MessageParam
|
||||||
|
|
||||||
|
|
@ -170,7 +184,7 @@ func translateTools(tools []ToolDefinition) []anthropic.ToolUnionParam {
|
||||||
if desc := t.Function.Description; desc != "" {
|
if desc := t.Function.Description; desc != "" {
|
||||||
tool.Description = anthropic.String(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))
|
required := make([]string, 0, len(req))
|
||||||
for _, r := range req {
|
for _, r := range req {
|
||||||
if s, ok := r.(string); ok {
|
if s, ok := r.(string); ok {
|
||||||
|
|
@ -195,10 +209,10 @@ func parseResponse(resp *anthropic.Message) *LLMResponse {
|
||||||
content += tb.Text
|
content += tb.Text
|
||||||
case "tool_use":
|
case "tool_use":
|
||||||
tu := block.AsToolUse()
|
tu := block.AsToolUse()
|
||||||
var args map[string]interface{}
|
var args map[string]any
|
||||||
if err := json.Unmarshal(tu.Input, &args); err != nil {
|
if err := json.Unmarshal(tu.Input, &args); err != nil {
|
||||||
log.Printf("anthropic: failed to decode tool call input for %q: %v", tu.Name, err)
|
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{
|
toolCalls = append(toolCalls, ToolCall{
|
||||||
ID: tu.ID,
|
ID: tu.ID,
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ func TestBuildParams_BasicMessage(t *testing.T) {
|
||||||
messages := []Message{
|
messages := []Message{
|
||||||
{Role: "user", Content: "Hello"},
|
{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,
|
"max_tokens": 1024,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -37,7 +37,7 @@ func TestBuildParams_SystemMessage(t *testing.T) {
|
||||||
{Role: "system", Content: "You are helpful"},
|
{Role: "system", Content: "You are helpful"},
|
||||||
{Role: "user", Content: "Hi"},
|
{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 {
|
if err != nil {
|
||||||
t.Fatalf("buildParams() error: %v", err)
|
t.Fatalf("buildParams() error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -62,13 +62,13 @@ func TestBuildParams_ToolCallMessage(t *testing.T) {
|
||||||
{
|
{
|
||||||
ID: "call_1",
|
ID: "call_1",
|
||||||
Name: "get_weather",
|
Name: "get_weather",
|
||||||
Arguments: map[string]interface{}{"city": "SF"},
|
Arguments: map[string]any{"city": "SF"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{Role: "tool", Content: `{"temp": 72}`, ToolCallID: "call_1"},
|
{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 {
|
if err != nil {
|
||||||
t.Fatalf("buildParams() error: %v", err)
|
t.Fatalf("buildParams() error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -84,17 +84,17 @@ func TestBuildParams_WithTools(t *testing.T) {
|
||||||
Function: ToolFunctionDefinition{
|
Function: ToolFunctionDefinition{
|
||||||
Name: "get_weather",
|
Name: "get_weather",
|
||||||
Description: "Get weather for a city",
|
Description: "Get weather for a city",
|
||||||
Parameters: map[string]interface{}{
|
Parameters: map[string]any{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": map[string]interface{}{
|
"properties": map[string]any{
|
||||||
"city": map[string]interface{}{"type": "string"},
|
"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 {
|
if err != nil {
|
||||||
t.Fatalf("buildParams() error: %v", err)
|
t.Fatalf("buildParams() error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -154,19 +154,19 @@ func TestProvider_ChatRoundTrip(t *testing.T) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var reqBody map[string]interface{}
|
var reqBody map[string]any
|
||||||
json.NewDecoder(r.Body).Decode(&reqBody)
|
json.NewDecoder(r.Body).Decode(&reqBody)
|
||||||
|
|
||||||
resp := map[string]interface{}{
|
resp := map[string]any{
|
||||||
"id": "msg_test",
|
"id": "msg_test",
|
||||||
"type": "message",
|
"type": "message",
|
||||||
"role": "assistant",
|
"role": "assistant",
|
||||||
"model": reqBody["model"],
|
"model": reqBody["model"],
|
||||||
"stop_reason": "end_turn",
|
"stop_reason": "end_turn",
|
||||||
"content": []map[string]interface{}{
|
"content": []map[string]any{
|
||||||
{"type": "text", "text": "Hello! How can I help you?"},
|
{"type": "text", "text": "Hello! How can I help you?"},
|
||||||
},
|
},
|
||||||
"usage": map[string]interface{}{
|
"usage": map[string]any{
|
||||||
"input_tokens": 15,
|
"input_tokens": 15,
|
||||||
"output_tokens": 8,
|
"output_tokens": 8,
|
||||||
},
|
},
|
||||||
|
|
@ -178,7 +178,7 @@ func TestProvider_ChatRoundTrip(t *testing.T) {
|
||||||
|
|
||||||
provider := NewProviderWithClient(createAnthropicTestClient(server.URL, "test-token"))
|
provider := NewProviderWithClient(createAnthropicTestClient(server.URL, "test-token"))
|
||||||
messages := []Message{{Role: "user", Content: "Hello"}}
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("Chat() error: %v", err)
|
t.Fatalf("Chat() error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -221,19 +221,19 @@ func TestProvider_ChatUsesTokenSource(t *testing.T) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var reqBody map[string]interface{}
|
var reqBody map[string]any
|
||||||
json.NewDecoder(r.Body).Decode(&reqBody)
|
json.NewDecoder(r.Body).Decode(&reqBody)
|
||||||
|
|
||||||
resp := map[string]interface{}{
|
resp := map[string]any{
|
||||||
"id": "msg_test",
|
"id": "msg_test",
|
||||||
"type": "message",
|
"type": "message",
|
||||||
"role": "assistant",
|
"role": "assistant",
|
||||||
"model": reqBody["model"],
|
"model": reqBody["model"],
|
||||||
"stop_reason": "end_turn",
|
"stop_reason": "end_turn",
|
||||||
"content": []map[string]interface{}{
|
"content": []map[string]any{
|
||||||
{"type": "text", "text": "ok"},
|
{"type": "text", "text": "ok"},
|
||||||
},
|
},
|
||||||
"usage": map[string]interface{}{
|
"usage": map[string]any{
|
||||||
"input_tokens": 1,
|
"input_tokens": 1,
|
||||||
"output_tokens": 1,
|
"output_tokens": 1,
|
||||||
},
|
},
|
||||||
|
|
@ -247,7 +247,13 @@ func TestProvider_ChatUsesTokenSource(t *testing.T) {
|
||||||
return "refreshed-token", nil
|
return "refreshed-token", nil
|
||||||
}, server.URL)
|
}, 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 {
|
if err != nil {
|
||||||
t.Fatalf("Chat() error: %v", err)
|
t.Fatalf("Chat() error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,13 @@ func NewAntigravityProvider() *AntigravityProvider {
|
||||||
// Chat implements LLMProvider.Chat using the Cloud Code Assist v1internal API.
|
// Chat implements LLMProvider.Chat using the Cloud Code Assist v1internal API.
|
||||||
// The v1internal endpoint wraps the standard Gemini request in an envelope with
|
// The v1internal endpoint wraps the standard Gemini request in an envelope with
|
||||||
// project, model, request, requestType, userAgent, and requestId fields.
|
// 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()
|
accessToken, projectID, err := p.tokenSource()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("antigravity auth: %w", err)
|
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, "google-antigravity/")
|
||||||
model = strings.TrimPrefix(model, "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,
|
"model": model,
|
||||||
"project": projectID,
|
"project": projectID,
|
||||||
"requestId": fmt.Sprintf("agent-%d-%s", time.Now().UnixMilli(), randomString(9)),
|
"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)
|
innerRequest := p.buildRequest(messages, tools, model, options)
|
||||||
|
|
||||||
// Wrap in v1internal envelope (matches pi-ai SDK format)
|
// Wrap in v1internal envelope (matches pi-ai SDK format)
|
||||||
envelope := map[string]interface{}{
|
envelope := map[string]any{
|
||||||
"project": projectID,
|
"project": projectID,
|
||||||
"model": model,
|
"model": model,
|
||||||
"request": innerRequest,
|
"request": innerRequest,
|
||||||
|
|
@ -115,7 +121,7 @@ func (p *AntigravityProvider) Chat(ctx context.Context, messages []Message, tool
|
||||||
}
|
}
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
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,
|
"status_code": resp.StatusCode,
|
||||||
"response": string(respBody),
|
"response": string(respBody),
|
||||||
"model": model,
|
"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)
|
// Check for empty response (some models might return valid success but empty text)
|
||||||
if llmResp.Content == "" && len(llmResp.ToolCalls) == 0 {
|
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
|
return llmResp, nil
|
||||||
|
|
@ -168,12 +176,12 @@ type antigravityPart struct {
|
||||||
|
|
||||||
type antigravityFunctionCall struct {
|
type antigravityFunctionCall struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Args map[string]interface{} `json:"args"`
|
Args map[string]any `json:"args"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type antigravityFunctionResponse struct {
|
type antigravityFunctionResponse struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Response map[string]interface{} `json:"response"`
|
Response map[string]any `json:"response"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type antigravityTool struct {
|
type antigravityTool struct {
|
||||||
|
|
@ -183,7 +191,7 @@ type antigravityTool struct {
|
||||||
type antigravityFuncDecl struct {
|
type antigravityFuncDecl struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Description string `json:"description,omitempty"`
|
Description string `json:"description,omitempty"`
|
||||||
Parameters interface{} `json:"parameters,omitempty"`
|
Parameters any `json:"parameters,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type antigravitySystemPrompt struct {
|
type antigravitySystemPrompt struct {
|
||||||
|
|
@ -195,7 +203,12 @@ type antigravityGenConfig struct {
|
||||||
Temperature float64 `json:"temperature,omitempty"`
|
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{}
|
req := antigravityRequest{}
|
||||||
toolCallNames := make(map[string]string)
|
toolCallNames := make(map[string]string)
|
||||||
|
|
||||||
|
|
@ -215,7 +228,7 @@ func (p *AntigravityProvider) buildRequest(messages []Message, tools []ToolDefin
|
||||||
Parts: []antigravityPart{{
|
Parts: []antigravityPart{{
|
||||||
FunctionResponse: &antigravityFunctionResponse{
|
FunctionResponse: &antigravityFunctionResponse{
|
||||||
Name: toolName,
|
Name: toolName,
|
||||||
Response: map[string]interface{}{
|
Response: map[string]any{
|
||||||
"result": msg.Content,
|
"result": msg.Content,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -237,9 +250,13 @@ func (p *AntigravityProvider) buildRequest(messages []Message, tools []ToolDefin
|
||||||
for _, tc := range msg.ToolCalls {
|
for _, tc := range msg.ToolCalls {
|
||||||
toolName, toolArgs, thoughtSignature := normalizeStoredToolCall(tc)
|
toolName, toolArgs, thoughtSignature := normalizeStoredToolCall(tc)
|
||||||
if toolName == "" {
|
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,
|
"tool_call_id": tc.ID,
|
||||||
})
|
},
|
||||||
|
)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if tc.ID != "" {
|
if tc.ID != "" {
|
||||||
|
|
@ -264,7 +281,7 @@ func (p *AntigravityProvider) buildRequest(messages []Message, tools []ToolDefin
|
||||||
Parts: []antigravityPart{{
|
Parts: []antigravityPart{{
|
||||||
FunctionResponse: &antigravityFunctionResponse{
|
FunctionResponse: &antigravityFunctionResponse{
|
||||||
Name: toolName,
|
Name: toolName,
|
||||||
Response: map[string]interface{}{
|
Response: map[string]any{
|
||||||
"result": msg.Content,
|
"result": msg.Content,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -311,7 +328,7 @@ func (p *AntigravityProvider) buildRequest(messages []Message, tools []ToolDefin
|
||||||
return req
|
return req
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizeStoredToolCall(tc ToolCall) (string, map[string]interface{}, string) {
|
func normalizeStoredToolCall(tc ToolCall) (string, map[string]any, string) {
|
||||||
name := tc.Name
|
name := tc.Name
|
||||||
args := tc.Arguments
|
args := tc.Arguments
|
||||||
thoughtSignature := ""
|
thoughtSignature := ""
|
||||||
|
|
@ -324,11 +341,11 @@ func normalizeStoredToolCall(tc ToolCall) (string, map[string]interface{}, strin
|
||||||
}
|
}
|
||||||
|
|
||||||
if args == nil {
|
if args == nil {
|
||||||
args = map[string]interface{}{}
|
args = map[string]any{}
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(args) == 0 && tc.Function != nil && tc.Function.Arguments != "" {
|
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 {
|
if err := json.Unmarshal([]byte(tc.Function.Arguments), &parsed); err == nil && parsed != nil {
|
||||||
args = parsed
|
args = parsed
|
||||||
}
|
}
|
||||||
|
|
@ -485,7 +502,10 @@ func (p *AntigravityProvider) parseSSEResponse(body string) (*LLMResponse, error
|
||||||
Function: &FunctionCall{
|
Function: &FunctionCall{
|
||||||
Name: part.FunctionCall.Name,
|
Name: part.FunctionCall.Name,
|
||||||
Arguments: string(argumentsJSON),
|
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,
|
"maxProperties": true,
|
||||||
}
|
}
|
||||||
|
|
||||||
func sanitizeSchemaForGemini(schema map[string]interface{}) map[string]interface{} {
|
func sanitizeSchemaForGemini(schema map[string]any) map[string]any {
|
||||||
if schema == nil {
|
if schema == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
result := make(map[string]interface{})
|
result := make(map[string]any)
|
||||||
for k, v := range schema {
|
for k, v := range schema {
|
||||||
if geminiUnsupportedKeywords[k] {
|
if geminiUnsupportedKeywords[k] {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// Recursively sanitize nested objects
|
// Recursively sanitize nested objects
|
||||||
switch val := v.(type) {
|
switch val := v.(type) {
|
||||||
case map[string]interface{}:
|
case map[string]any:
|
||||||
result[k] = sanitizeSchemaForGemini(val)
|
result[k] = sanitizeSchemaForGemini(val)
|
||||||
case []interface{}:
|
case []any:
|
||||||
sanitized := make([]interface{}, len(val))
|
sanitized := make([]any, len(val))
|
||||||
for i, item := range 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)
|
sanitized[i] = sanitizeSchemaForGemini(m)
|
||||||
} else {
|
} else {
|
||||||
sanitized[i] = item
|
sanitized[i] = item
|
||||||
|
|
@ -604,7 +624,9 @@ func createAntigravityTokenSource() func() (string, string, error) {
|
||||||
return "", "", fmt.Errorf("loading auth credentials: %w", err)
|
return "", "", fmt.Errorf("loading auth credentials: %w", err)
|
||||||
}
|
}
|
||||||
if cred == nil {
|
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
|
// Refresh if needed
|
||||||
|
|
@ -625,7 +647,9 @@ func createAntigravityTokenSource() func() (string, string, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if cred.IsExpired() {
|
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
|
projectID := cred.ProjectID
|
||||||
|
|
@ -633,7 +657,7 @@ func createAntigravityTokenSource() func() (string, string, error) {
|
||||||
// Try to fetch project ID from API
|
// Try to fetch project ID from API
|
||||||
fetchedID, err := FetchAntigravityProjectID(cred.AccessToken)
|
fetchedID, err := FetchAntigravityProjectID(cred.AccessToken)
|
||||||
if err != nil {
|
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(),
|
"error": err.Error(),
|
||||||
})
|
})
|
||||||
projectID = "rising-fact-p41fc" // Default fallback (same as OpenCode)
|
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.
|
// FetchAntigravityProjectID retrieves the Google Cloud project ID from the loadCodeAssist endpoint.
|
||||||
func FetchAntigravityProjectID(accessToken string) (string, error) {
|
func FetchAntigravityProjectID(accessToken string) (string, error) {
|
||||||
reqBody, _ := json.Marshal(map[string]interface{}{
|
reqBody, _ := json.Marshal(map[string]any{
|
||||||
"metadata": map[string]interface{}{
|
"metadata": map[string]any{
|
||||||
"ideType": "IDE_UNSPECIFIED",
|
"ideType": "IDE_UNSPECIFIED",
|
||||||
"platform": "PLATFORM_UNSPECIFIED",
|
"platform": "PLATFORM_UNSPECIFIED",
|
||||||
"pluginType": "GEMINI",
|
"pluginType": "GEMINI",
|
||||||
|
|
@ -695,7 +719,7 @@ func FetchAntigravityProjectID(accessToken string) (string, error) {
|
||||||
|
|
||||||
// FetchAntigravityModels fetches available models from the Cloud Code Assist API.
|
// FetchAntigravityModels fetches available models from the Cloud Code Assist API.
|
||||||
func FetchAntigravityModels(accessToken, projectID string) ([]AntigravityModelInfo, error) {
|
func FetchAntigravityModels(accessToken, projectID string) ([]AntigravityModelInfo, error) {
|
||||||
reqBody, _ := json.Marshal(map[string]interface{}{
|
reqBody, _ := json.Marshal(map[string]any{
|
||||||
"project": projectID,
|
"project": projectID,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -717,14 +741,18 @@ func FetchAntigravityModels(accessToken, projectID string) ([]AntigravityModelIn
|
||||||
|
|
||||||
body, _ := io.ReadAll(resp.Body)
|
body, _ := io.ReadAll(resp.Body)
|
||||||
if resp.StatusCode != http.StatusOK {
|
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 {
|
var result struct {
|
||||||
Models map[string]struct {
|
Models map[string]struct {
|
||||||
DisplayName string `json:"displayName"`
|
DisplayName string `json:"displayName"`
|
||||||
QuotaInfo struct {
|
QuotaInfo struct {
|
||||||
RemainingFraction interface{} `json:"remainingFraction"`
|
RemainingFraction any `json:"remainingFraction"`
|
||||||
ResetTime string `json:"resetTime"`
|
ResetTime string `json:"resetTime"`
|
||||||
IsExhausted bool `json:"isExhausted"`
|
IsExhausted bool `json:"isExhausted"`
|
||||||
} `json:"quotaInfo"`
|
} `json:"quotaInfo"`
|
||||||
|
|
@ -800,7 +828,7 @@ func (p *AntigravityProvider) parseAntigravityError(statusCode int, body []byte)
|
||||||
Code int `json:"code"`
|
Code int `json:"code"`
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Details []map[string]interface{} `json:"details"`
|
Details []map[string]any `json:"details"`
|
||||||
} `json:"error"`
|
} `json:"error"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -813,7 +841,7 @@ func (p *AntigravityProvider) parseAntigravityError(statusCode int, body []byte)
|
||||||
// Try to extract quota reset info
|
// Try to extract quota reset info
|
||||||
for _, detail := range errResp.Error.Details {
|
for _, detail := range errResp.Error.Details {
|
||||||
if typeVal, ok := detail["@type"].(string); ok && strings.HasSuffix(typeVal, "ErrorInfo") {
|
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 {
|
if delay, ok := metadata["quotaResetDelay"].(string); ok {
|
||||||
return fmt.Errorf("antigravity rate limit exceeded: %s (reset in %s)", msg, delay)
|
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.
|
// 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)
|
systemPrompt := p.buildSystemPrompt(messages, tools)
|
||||||
prompt := p.messagesToPrompt(messages)
|
prompt := p.messagesToPrompt(messages)
|
||||||
|
|
||||||
|
|
@ -111,7 +113,9 @@ func (p *ClaudeCliProvider) buildToolsPrompt(tools []ToolDefinition) string {
|
||||||
sb.WriteString("## Available Tools\n\n")
|
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("When you need to use a tool, respond with ONLY a JSON object:\n\n")
|
||||||
sb.WriteString("```json\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("\n```\n\n")
|
||||||
sb.WriteString("CRITICAL: The 'arguments' field MUST be a JSON-encoded STRING.\n\n")
|
sb.WriteString("CRITICAL: The 'arguments' field MUST be a JSON-encoded STRING.\n\n")
|
||||||
sb.WriteString("### Tool Definitions:\n\n")
|
sb.WriteString("### Tool Definitions:\n\n")
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,6 @@ func TestIntegration_RealClaudeCLI(t *testing.T) {
|
||||||
resp, err := p.Chat(ctx, []Message{
|
resp, err := p.Chat(ctx, []Message{
|
||||||
{Role: "user", Content: "Respond with only the word 'pong'. Nothing else."},
|
{Role: "user", Content: "Respond with only the word 'pong'. Nothing else."},
|
||||||
}, nil, "", nil)
|
}, nil, "", nil)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Chat() with real CLI error = %v", err)
|
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: "system", Content: "You are a calculator. Only respond with numbers. No text."},
|
||||||
{Role: "user", Content: "What is 2+2?"},
|
{Role: "user", Content: "What is 2+2?"},
|
||||||
}, nil, "", nil)
|
}, nil, "", nil)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Chat() error = %v", err)
|
t.Fatalf("Chat() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,12 +30,12 @@ func createMockCLI(t *testing.T, stdout, stderr string, exitCode int) string {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
|
|
||||||
if stdout != "" {
|
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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if stderr != "" {
|
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)
|
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))
|
sb.WriteString(fmt.Sprintf("exit %d\n", exitCode))
|
||||||
|
|
||||||
script := filepath.Join(dir, "claude")
|
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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
return script
|
return script
|
||||||
|
|
@ -67,7 +67,7 @@ func createSlowMockCLI(t *testing.T, sleepSeconds int) string {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
script := filepath.Join(dir, "claude")
|
script := filepath.Join(dir, "claude")
|
||||||
content := fmt.Sprintf("#!/bin/sh\nsleep %d\necho '{\"type\":\"result\",\"result\":\"late\"}'\n", sleepSeconds)
|
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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
return script
|
return script
|
||||||
|
|
@ -88,7 +88,7 @@ cat <<'EOFMOCK'
|
||||||
{"type":"result","result":"ok","session_id":"test"}
|
{"type":"result","result":"ok","session_id":"test"}
|
||||||
EOFMOCK
|
EOFMOCK
|
||||||
`, argsFile)
|
`, argsFile)
|
||||||
if err := os.WriteFile(script, []byte(content), 0755); err != nil {
|
if err := os.WriteFile(script, []byte(content), 0o755); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
return script
|
return script
|
||||||
|
|
@ -137,7 +137,6 @@ func TestChat_Success(t *testing.T) {
|
||||||
resp, err := p.Chat(context.Background(), []Message{
|
resp, err := p.Chat(context.Background(), []Message{
|
||||||
{Role: "user", Content: "Hello"},
|
{Role: "user", Content: "Hello"},
|
||||||
}, nil, "", nil)
|
}, nil, "", nil)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Chat() error = %v", err)
|
t.Fatalf("Chat() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -193,7 +192,6 @@ func TestChat_WithToolCallsInResponse(t *testing.T) {
|
||||||
resp, err := p.Chat(context.Background(), []Message{
|
resp, err := p.Chat(context.Background(), []Message{
|
||||||
{Role: "user", Content: "What's the weather?"},
|
{Role: "user", Content: "What's the weather?"},
|
||||||
}, nil, "", nil)
|
}, nil, "", nil)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Chat() error = %v", err)
|
t.Fatalf("Chat() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -403,7 +401,6 @@ func TestChat_EmptyWorkspaceDoesNotSetDir(t *testing.T) {
|
||||||
resp, err := p.Chat(context.Background(), []Message{
|
resp, err := p.Chat(context.Background(), []Message{
|
||||||
{Role: "user", Content: "Hello"},
|
{Role: "user", Content: "Hello"},
|
||||||
}, nil, "", nil)
|
}, nil, "", nil)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Chat() with empty workspace error = %v", err)
|
t.Fatalf("Chat() with empty workspace error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -622,10 +619,10 @@ func TestBuildSystemPrompt_WithTools(t *testing.T) {
|
||||||
Function: ToolFunctionDefinition{
|
Function: ToolFunctionDefinition{
|
||||||
Name: "get_weather",
|
Name: "get_weather",
|
||||||
Description: "Get weather for a location",
|
Description: "Get weather for a location",
|
||||||
Parameters: map[string]interface{}{
|
Parameters: map[string]any{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": map[string]interface{}{
|
"properties": map[string]any{
|
||||||
"location": map[string]interface{}{"type": "string"},
|
"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{
|
return &ClaudeProvider{
|
||||||
delegate: anthropicprovider.NewProviderWithTokenSourceAndBaseURL(token, tokenSource, apiBase),
|
delegate: anthropicprovider.NewProviderWithTokenSourceAndBaseURL(token, tokenSource, apiBase),
|
||||||
}
|
}
|
||||||
|
|
@ -39,7 +41,9 @@ func newClaudeProviderWithDelegate(delegate *anthropicprovider.Provider) *Claude
|
||||||
return &ClaudeProvider{delegate: delegate}
|
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)
|
resp, err := p.delegate.Chat(ctx, messages, tools, model, options)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import (
|
||||||
|
|
||||||
"github.com/anthropics/anthropic-sdk-go"
|
"github.com/anthropics/anthropic-sdk-go"
|
||||||
anthropicoption "github.com/anthropics/anthropic-sdk-go/option"
|
anthropicoption "github.com/anthropics/anthropic-sdk-go/option"
|
||||||
|
|
||||||
anthropicprovider "github.com/sipeed/picoclaw/pkg/providers/anthropic"
|
anthropicprovider "github.com/sipeed/picoclaw/pkg/providers/anthropic"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -22,19 +23,19 @@ func TestClaudeProvider_ChatRoundTrip(t *testing.T) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var reqBody map[string]interface{}
|
var reqBody map[string]any
|
||||||
json.NewDecoder(r.Body).Decode(&reqBody)
|
json.NewDecoder(r.Body).Decode(&reqBody)
|
||||||
|
|
||||||
resp := map[string]interface{}{
|
resp := map[string]any{
|
||||||
"id": "msg_test",
|
"id": "msg_test",
|
||||||
"type": "message",
|
"type": "message",
|
||||||
"role": "assistant",
|
"role": "assistant",
|
||||||
"model": reqBody["model"],
|
"model": reqBody["model"],
|
||||||
"stop_reason": "end_turn",
|
"stop_reason": "end_turn",
|
||||||
"content": []map[string]interface{}{
|
"content": []map[string]any{
|
||||||
{"type": "text", "text": "Hello! How can I help you?"},
|
{"type": "text", "text": "Hello! How can I help you?"},
|
||||||
},
|
},
|
||||||
"usage": map[string]interface{}{
|
"usage": map[string]any{
|
||||||
"input_tokens": 15,
|
"input_tokens": 15,
|
||||||
"output_tokens": 8,
|
"output_tokens": 8,
|
||||||
},
|
},
|
||||||
|
|
@ -48,7 +49,7 @@ func TestClaudeProvider_ChatRoundTrip(t *testing.T) {
|
||||||
provider := newClaudeProviderWithDelegate(delegate)
|
provider := newClaudeProviderWithDelegate(delegate)
|
||||||
|
|
||||||
messages := []Message{{Role: "user", Content: "Hello"}}
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("Chat() error: %v", err)
|
t.Fatalf("Chat() error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ func ReadCodexCliCredentials() (accessToken, accountID string, expiresAt time.Ti
|
||||||
}
|
}
|
||||||
|
|
||||||
var auth CodexCliAuth
|
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)
|
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) {
|
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
|
return token, accountID, nil
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ func TestReadCodexCliCredentials_Valid(t *testing.T) {
|
||||||
"account_id": "org-test123"
|
"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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -58,7 +58,7 @@ func TestReadCodexCliCredentials_EmptyToken(t *testing.T) {
|
||||||
authPath := filepath.Join(tmpDir, "auth.json")
|
authPath := filepath.Join(tmpDir, "auth.json")
|
||||||
|
|
||||||
authJSON := `{"tokens": {"access_token": "", "refresh_token": "r", "account_id": "a"}}`
|
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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -74,7 +74,7 @@ func TestReadCodexCliCredentials_InvalidJSON(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
authPath := filepath.Join(tmpDir, "auth.json")
|
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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -91,7 +91,7 @@ func TestReadCodexCliCredentials_NoAccountID(t *testing.T) {
|
||||||
authPath := filepath.Join(tmpDir, "auth.json")
|
authPath := filepath.Join(tmpDir, "auth.json")
|
||||||
|
|
||||||
authJSON := `{"tokens": {"access_token": "tok123", "refresh_token": "ref456"}}`
|
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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -112,12 +112,12 @@ func TestReadCodexCliCredentials_NoAccountID(t *testing.T) {
|
||||||
func TestReadCodexCliCredentials_CodexHomeEnv(t *testing.T) {
|
func TestReadCodexCliCredentials_CodexHomeEnv(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
customDir := filepath.Join(tmpDir, "custom-codex")
|
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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
authJSON := `{"tokens": {"access_token": "custom-token", "refresh_token": "r"}}`
|
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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -137,7 +137,7 @@ func TestCreateCodexCliTokenSource_Valid(t *testing.T) {
|
||||||
authPath := filepath.Join(tmpDir, "auth.json")
|
authPath := filepath.Join(tmpDir, "auth.json")
|
||||||
|
|
||||||
authJSON := `{"tokens": {"access_token": "fresh-token", "refresh_token": "r", "account_id": "acc"}}`
|
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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -161,7 +161,7 @@ func TestCreateCodexCliTokenSource_Expired(t *testing.T) {
|
||||||
authPath := filepath.Join(tmpDir, "auth.json")
|
authPath := filepath.Join(tmpDir, "auth.json")
|
||||||
|
|
||||||
authJSON := `{"tokens": {"access_token": "old-token", "refresh_token": "r"}}`
|
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)
|
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.
|
// 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 == "" {
|
if p.command == "" {
|
||||||
return nil, fmt.Errorf("codex command not configured")
|
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("## Available Tools\n\n")
|
||||||
sb.WriteString("When you need to use a tool, respond with ONLY a JSON object:\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("```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("\n```\n\n")
|
||||||
sb.WriteString("CRITICAL: The 'arguments' field MUST be a JSON-encoded STRING.\n\n")
|
sb.WriteString("CRITICAL: The 'arguments' field MUST be a JSON-encoded STRING.\n\n")
|
||||||
sb.WriteString("### Tool Definitions:\n\n")
|
sb.WriteString("### Tool Definitions:\n\n")
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,6 @@ func TestIntegration_RealCodexCLI(t *testing.T) {
|
||||||
resp, err := p.Chat(ctx, []Message{
|
resp, err := p.Chat(ctx, []Message{
|
||||||
{Role: "user", Content: "Respond with only the word 'pong'. Nothing else."},
|
{Role: "user", Content: "Respond with only the word 'pong'. Nothing else."},
|
||||||
}, nil, "", nil)
|
}, nil, "", nil)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Chat() with real CLI error = %v", err)
|
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: "system", Content: "You are a calculator. Only respond with numbers. No text."},
|
||||||
{Role: "user", Content: "What is 2+2?"},
|
{Role: "user", Content: "What is 2+2?"},
|
||||||
}, nil, "", nil)
|
}, nil, "", nil)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Chat() error = %v", err)
|
t.Fatalf("Chat() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -292,10 +292,10 @@ func TestBuildPrompt_WithTools(t *testing.T) {
|
||||||
Function: ToolFunctionDefinition{
|
Function: ToolFunctionDefinition{
|
||||||
Name: "get_weather",
|
Name: "get_weather",
|
||||||
Description: "Get current weather",
|
Description: "Get current weather",
|
||||||
Parameters: map[string]interface{}{
|
Parameters: map[string]any{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": map[string]interface{}{
|
"properties": map[string]any{
|
||||||
"city": map[string]interface{}{"type": "string"},
|
"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))
|
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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
return scriptPath
|
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":"item.completed","item":{"id":"1","type":"agent_message","text":"ok"}}'
|
||||||
echo '{"type":"turn.completed"}'`
|
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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -522,7 +522,7 @@ func TestCodexCliProvider_MockCLI_ContextCancel(t *testing.T) {
|
||||||
scriptPath := filepath.Join(tmpDir, "codex")
|
scriptPath := filepath.Join(tmpDir, "codex")
|
||||||
script := "#!/bin/bash\nsleep 60"
|
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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,12 +10,15 @@ import (
|
||||||
"github.com/openai/openai-go/v3"
|
"github.com/openai/openai-go/v3"
|
||||||
"github.com/openai/openai-go/v3/option"
|
"github.com/openai/openai-go/v3/option"
|
||||||
"github.com/openai/openai-go/v3/responses"
|
"github.com/openai/openai-go/v3/responses"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/auth"
|
"github.com/sipeed/picoclaw/pkg/auth"
|
||||||
"github.com/sipeed/picoclaw/pkg/logger"
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
)
|
)
|
||||||
|
|
||||||
const codexDefaultModel = "gpt-5.2"
|
const (
|
||||||
const codexDefaultInstructions = "You are Codex, a coding assistant."
|
codexDefaultModel = "gpt-5.2"
|
||||||
|
codexDefaultInstructions = "You are Codex, a coding assistant."
|
||||||
|
)
|
||||||
|
|
||||||
type CodexProvider struct {
|
type CodexProvider struct {
|
||||||
client *openai.Client
|
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 := NewCodexProvider(token, accountID)
|
||||||
p.tokenSource = tokenSource
|
p.tokenSource = tokenSource
|
||||||
return p
|
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
|
var opts []option.RequestOption
|
||||||
accountID := p.accountID
|
accountID := p.accountID
|
||||||
resolvedModel, fallbackReason := resolveCodexModel(model)
|
resolvedModel, fallbackReason := resolveCodexModel(model)
|
||||||
if fallbackReason != "" {
|
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,
|
"requested_model": model,
|
||||||
"resolved_model": resolvedModel,
|
"resolved_model": resolvedModel,
|
||||||
"reason": fallbackReason,
|
"reason": fallbackReason,
|
||||||
})
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
if p.tokenSource != nil {
|
if p.tokenSource != nil {
|
||||||
tok, accID, err := p.tokenSource()
|
tok, accID, err := p.tokenSource()
|
||||||
|
|
@ -74,10 +85,14 @@ func (p *CodexProvider) Chat(ctx context.Context, messages []Message, tools []To
|
||||||
if accountID != "" {
|
if accountID != "" {
|
||||||
opts = append(opts, option.WithHeader("Chatgpt-Account-Id", accountID))
|
opts = append(opts, option.WithHeader("Chatgpt-Account-Id", accountID))
|
||||||
} else {
|
} 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,
|
"requested_model": model,
|
||||||
"resolved_model": resolvedModel,
|
"resolved_model": resolvedModel,
|
||||||
})
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
params := buildCodexParams(messages, tools, resolvedModel, options, p.enableWebSearch)
|
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()
|
err := stream.Err()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fields := map[string]interface{}{
|
fields := map[string]any{
|
||||||
"requested_model": model,
|
"requested_model": model,
|
||||||
"resolved_model": resolvedModel,
|
"resolved_model": resolvedModel,
|
||||||
"messages_count": len(messages),
|
"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)
|
return nil, fmt.Errorf("codex API call: %w", err)
|
||||||
}
|
}
|
||||||
if resp == nil {
|
if resp == nil {
|
||||||
fields := map[string]interface{}{
|
fields := map[string]any{
|
||||||
"requested_model": model,
|
"requested_model": model,
|
||||||
"resolved_model": resolvedModel,
|
"resolved_model": resolvedModel,
|
||||||
"messages_count": len(messages),
|
"messages_count": len(messages),
|
||||||
|
|
@ -184,7 +199,9 @@ func resolveCodexModel(model string) (string, string) {
|
||||||
return codexDefaultModel, "unsupported model family"
|
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 inputItems responses.ResponseInputParam
|
||||||
var instructions string
|
var instructions string
|
||||||
|
|
||||||
|
|
@ -197,7 +214,9 @@ func buildCodexParams(messages []Message, tools []ToolDefinition, model string,
|
||||||
inputItems = append(inputItems, responses.ResponseInputItemUnionParam{
|
inputItems = append(inputItems, responses.ResponseInputItemUnionParam{
|
||||||
OfFunctionCallOutput: &responses.ResponseInputItemFunctionCallOutputParam{
|
OfFunctionCallOutput: &responses.ResponseInputItemFunctionCallOutputParam{
|
||||||
CallID: msg.ToolCallID,
|
CallID: msg.ToolCallID,
|
||||||
Output: responses.ResponseInputItemFunctionCallOutputOutputUnionParam{OfString: openai.Opt(msg.Content)},
|
Output: responses.ResponseInputItemFunctionCallOutputOutputUnionParam{
|
||||||
|
OfString: openai.Opt(msg.Content),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -221,7 +240,7 @@ func buildCodexParams(messages []Message, tools []ToolDefinition, model string,
|
||||||
for _, tc := range msg.ToolCalls {
|
for _, tc := range msg.ToolCalls {
|
||||||
name, args, ok := resolveCodexToolCall(tc)
|
name, args, ok := resolveCodexToolCall(tc)
|
||||||
if !ok {
|
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,
|
"call_id": tc.ID,
|
||||||
})
|
})
|
||||||
continue
|
continue
|
||||||
|
|
@ -246,7 +265,9 @@ func buildCodexParams(messages []Message, tools []ToolDefinition, model string,
|
||||||
inputItems = append(inputItems, responses.ResponseInputItemUnionParam{
|
inputItems = append(inputItems, responses.ResponseInputItemUnionParam{
|
||||||
OfFunctionCallOutput: &responses.ResponseInputItemFunctionCallOutputParam{
|
OfFunctionCallOutput: &responses.ResponseInputItemFunctionCallOutputParam{
|
||||||
CallID: msg.ToolCallID,
|
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":
|
case "function_call":
|
||||||
var args map[string]interface{}
|
var args map[string]any
|
||||||
if err := json.Unmarshal([]byte(item.Arguments), &args); err != nil {
|
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{
|
toolCalls = append(toolCalls, ToolCall{
|
||||||
ID: item.CallID,
|
ID: item.CallID,
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ func TestBuildCodexParams_BasicMessage(t *testing.T) {
|
||||||
messages := []Message{
|
messages := []Message{
|
||||||
{Role: "user", Content: "Hello"},
|
{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,
|
"max_tokens": 2048,
|
||||||
"temperature": 0.7,
|
"temperature": 0.7,
|
||||||
}, true)
|
}, true)
|
||||||
|
|
@ -39,7 +39,7 @@ func TestBuildCodexParams_SystemAsInstructions(t *testing.T) {
|
||||||
{Role: "system", Content: "You are helpful"},
|
{Role: "system", Content: "You are helpful"},
|
||||||
{Role: "user", Content: "Hi"},
|
{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() {
|
if !params.Instructions.Valid() {
|
||||||
t.Fatal("Instructions should be set")
|
t.Fatal("Instructions should be set")
|
||||||
}
|
}
|
||||||
|
|
@ -54,12 +54,12 @@ func TestBuildCodexParams_ToolCallConversation(t *testing.T) {
|
||||||
{
|
{
|
||||||
Role: "assistant",
|
Role: "assistant",
|
||||||
ToolCalls: []ToolCall{
|
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"},
|
{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 {
|
if params.Input.OfInputItemList == nil {
|
||||||
t.Fatal("Input.OfInputItemList should not be 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"},
|
{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 {
|
if params.Input.OfInputItemList == nil {
|
||||||
t.Fatal("Input.OfInputItemList should not be nil")
|
t.Fatal("Input.OfInputItemList should not be nil")
|
||||||
}
|
}
|
||||||
|
|
@ -114,16 +114,16 @@ func TestBuildCodexParams_WithTools(t *testing.T) {
|
||||||
Function: ToolFunctionDefinition{
|
Function: ToolFunctionDefinition{
|
||||||
Name: "get_weather",
|
Name: "get_weather",
|
||||||
Description: "Get weather",
|
Description: "Get weather",
|
||||||
Parameters: map[string]interface{}{
|
Parameters: map[string]any{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": map[string]interface{}{
|
"properties": map[string]any{
|
||||||
"city": map[string]interface{}{"type": "string"},
|
"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 {
|
if len(params.Tools) != 1 {
|
||||||
t.Fatalf("len(Tools) = %d, want 1", len(params.Tools))
|
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) {
|
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 {
|
if !params.Store.Valid() || params.Store.Or(true) != false {
|
||||||
t.Error("Store should be explicitly set to false")
|
t.Error("Store should be explicitly set to false")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBuildCodexParams_DefaultWebSearchEnabled(t *testing.T) {
|
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 {
|
if len(params.Tools) != 1 {
|
||||||
t.Fatalf("len(Tools) = %d, want 1", len(params.Tools))
|
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")
|
t.Fatal("Tool should include built-in web_search")
|
||||||
}
|
}
|
||||||
if params.Tools[0].OfWebSearch.Type != responses.WebSearchToolTypeWebSearch {
|
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{
|
Function: ToolFunctionDefinition{
|
||||||
Name: "web_search",
|
Name: "web_search",
|
||||||
Description: "local web search",
|
Description: "local web search",
|
||||||
Parameters: map[string]interface{}{
|
Parameters: map[string]any{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -172,14 +176,14 @@ func TestBuildCodexParams_WebSearchFunctionReplacedWithBuiltin(t *testing.T) {
|
||||||
Function: ToolFunctionDefinition{
|
Function: ToolFunctionDefinition{
|
||||||
Name: "read_file",
|
Name: "read_file",
|
||||||
Description: "read file",
|
Description: "read file",
|
||||||
Parameters: map[string]interface{}{
|
Parameters: map[string]any{
|
||||||
"type": "object",
|
"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 {
|
if len(params.Tools) != 2 {
|
||||||
t.Fatalf("len(Tools) = %d, want 2", len(params.Tools))
|
t.Fatalf("len(Tools) = %d, want 2", len(params.Tools))
|
||||||
}
|
}
|
||||||
|
|
@ -296,7 +300,7 @@ func TestCodexProvider_ChatRoundTrip(t *testing.T) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var reqBody map[string]interface{}
|
var reqBody map[string]any
|
||||||
if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil {
|
||||||
http.Error(w, "invalid json", http.StatusBadRequest)
|
http.Error(w, "invalid json", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
|
|
@ -309,38 +313,38 @@ func TestCodexProvider_ChatRoundTrip(t *testing.T) {
|
||||||
http.Error(w, "max_output_tokens is not supported", http.StatusBadRequest)
|
http.Error(w, "max_output_tokens is not supported", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
toolsAny, ok := reqBody["tools"].([]interface{})
|
toolsAny, ok := reqBody["tools"].([]any)
|
||||||
if !ok || len(toolsAny) != 1 {
|
if !ok || len(toolsAny) != 1 {
|
||||||
http.Error(w, "missing default web search tool", http.StatusBadRequest)
|
http.Error(w, "missing default web search tool", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
toolObj, ok := toolsAny[0].(map[string]interface{})
|
toolObj, ok := toolsAny[0].(map[string]any)
|
||||||
if !ok || toolObj["type"] != "web_search" {
|
if !ok || toolObj["type"] != "web_search" {
|
||||||
http.Error(w, "expected web_search tool", http.StatusBadRequest)
|
http.Error(w, "expected web_search tool", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
resp := map[string]interface{}{
|
resp := map[string]any{
|
||||||
"id": "resp_test",
|
"id": "resp_test",
|
||||||
"object": "response",
|
"object": "response",
|
||||||
"status": "completed",
|
"status": "completed",
|
||||||
"output": []map[string]interface{}{
|
"output": []map[string]any{
|
||||||
{
|
{
|
||||||
"id": "msg_1",
|
"id": "msg_1",
|
||||||
"type": "message",
|
"type": "message",
|
||||||
"role": "assistant",
|
"role": "assistant",
|
||||||
"status": "completed",
|
"status": "completed",
|
||||||
"content": []map[string]interface{}{
|
"content": []map[string]any{
|
||||||
{"type": "output_text", "text": "Hi from Codex!"},
|
{"type": "output_text", "text": "Hi from Codex!"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"usage": map[string]interface{}{
|
"usage": map[string]any{
|
||||||
"input_tokens": 12,
|
"input_tokens": 12,
|
||||||
"output_tokens": 6,
|
"output_tokens": 6,
|
||||||
"total_tokens": 18,
|
"total_tokens": 18,
|
||||||
"input_tokens_details": map[string]interface{}{"cached_tokens": 0},
|
"input_tokens_details": map[string]any{"cached_tokens": 0},
|
||||||
"output_tokens_details": map[string]interface{}{"reasoning_tokens": 0},
|
"output_tokens_details": map[string]any{"reasoning_tokens": 0},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
writeCompletedSSE(w, resp)
|
writeCompletedSSE(w, resp)
|
||||||
|
|
@ -351,7 +355,7 @@ func TestCodexProvider_ChatRoundTrip(t *testing.T) {
|
||||||
provider.client = createOpenAITestClient(server.URL, "test-token", "acc-123")
|
provider.client = createOpenAITestClient(server.URL, "test-token", "acc-123")
|
||||||
|
|
||||||
messages := []Message{{Role: "user", Content: "Hello"}}
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("Chat() error: %v", err)
|
t.Fatalf("Chat() error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -373,7 +377,7 @@ func TestCodexProvider_ChatRoundTrip_WebSearchDisabled(t *testing.T) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var reqBody map[string]interface{}
|
var reqBody map[string]any
|
||||||
if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil {
|
||||||
http.Error(w, "invalid json", http.StatusBadRequest)
|
http.Error(w, "invalid json", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
|
|
@ -383,27 +387,27 @@ func TestCodexProvider_ChatRoundTrip_WebSearchDisabled(t *testing.T) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
resp := map[string]interface{}{
|
resp := map[string]any{
|
||||||
"id": "resp_test",
|
"id": "resp_test",
|
||||||
"object": "response",
|
"object": "response",
|
||||||
"status": "completed",
|
"status": "completed",
|
||||||
"output": []map[string]interface{}{
|
"output": []map[string]any{
|
||||||
{
|
{
|
||||||
"id": "msg_1",
|
"id": "msg_1",
|
||||||
"type": "message",
|
"type": "message",
|
||||||
"role": "assistant",
|
"role": "assistant",
|
||||||
"status": "completed",
|
"status": "completed",
|
||||||
"content": []map[string]interface{}{
|
"content": []map[string]any{
|
||||||
{"type": "output_text", "text": "Hi from Codex!"},
|
{"type": "output_text", "text": "Hi from Codex!"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"usage": map[string]interface{}{
|
"usage": map[string]any{
|
||||||
"input_tokens": 4,
|
"input_tokens": 4,
|
||||||
"output_tokens": 3,
|
"output_tokens": 3,
|
||||||
"total_tokens": 7,
|
"total_tokens": 7,
|
||||||
"input_tokens_details": map[string]interface{}{"cached_tokens": 0},
|
"input_tokens_details": map[string]any{"cached_tokens": 0},
|
||||||
"output_tokens_details": map[string]interface{}{"reasoning_tokens": 0},
|
"output_tokens_details": map[string]any{"reasoning_tokens": 0},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
writeCompletedSSE(w, resp)
|
writeCompletedSSE(w, resp)
|
||||||
|
|
@ -415,7 +419,7 @@ func TestCodexProvider_ChatRoundTrip_WebSearchDisabled(t *testing.T) {
|
||||||
provider.client = createOpenAITestClient(server.URL, "test-token", "acc-123")
|
provider.client = createOpenAITestClient(server.URL, "test-token", "acc-123")
|
||||||
|
|
||||||
messages := []Message{{Role: "user", Content: "Hello"}}
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("Chat() error: %v", err)
|
t.Fatalf("Chat() error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -439,7 +443,7 @@ func TestCodexProvider_ChatRoundTrip_TokenSourceFallbackAccountID(t *testing.T)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var reqBody map[string]interface{}
|
var reqBody map[string]any
|
||||||
if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil {
|
||||||
http.Error(w, "invalid json", http.StatusBadRequest)
|
http.Error(w, "invalid json", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
|
|
@ -465,27 +469,27 @@ func TestCodexProvider_ChatRoundTrip_TokenSourceFallbackAccountID(t *testing.T)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
resp := map[string]interface{}{
|
resp := map[string]any{
|
||||||
"id": "resp_test",
|
"id": "resp_test",
|
||||||
"object": "response",
|
"object": "response",
|
||||||
"status": "completed",
|
"status": "completed",
|
||||||
"output": []map[string]interface{}{
|
"output": []map[string]any{
|
||||||
{
|
{
|
||||||
"id": "msg_1",
|
"id": "msg_1",
|
||||||
"type": "message",
|
"type": "message",
|
||||||
"role": "assistant",
|
"role": "assistant",
|
||||||
"status": "completed",
|
"status": "completed",
|
||||||
"content": []map[string]interface{}{
|
"content": []map[string]any{
|
||||||
{"type": "output_text", "text": "Hi from Codex!"},
|
{"type": "output_text", "text": "Hi from Codex!"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"usage": map[string]interface{}{
|
"usage": map[string]any{
|
||||||
"input_tokens": 8,
|
"input_tokens": 8,
|
||||||
"output_tokens": 4,
|
"output_tokens": 4,
|
||||||
"total_tokens": 12,
|
"total_tokens": 12,
|
||||||
"input_tokens_details": map[string]interface{}{"cached_tokens": 0},
|
"input_tokens_details": map[string]any{"cached_tokens": 0},
|
||||||
"output_tokens_details": map[string]interface{}{"reasoning_tokens": 0},
|
"output_tokens_details": map[string]any{"reasoning_tokens": 0},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
writeCompletedSSE(w, resp)
|
writeCompletedSSE(w, resp)
|
||||||
|
|
@ -499,7 +503,7 @@ func TestCodexProvider_ChatRoundTrip_TokenSourceFallbackAccountID(t *testing.T)
|
||||||
}
|
}
|
||||||
|
|
||||||
messages := []Message{{Role: "user", Content: "Hello"}}
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("Chat() error: %v", err)
|
t.Fatalf("Chat() error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -515,7 +519,7 @@ func TestCodexProvider_ChatRoundTrip_ModelFallbackFromUnsupported(t *testing.T)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var reqBody map[string]interface{}
|
var reqBody map[string]any
|
||||||
if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil {
|
||||||
http.Error(w, "invalid json", http.StatusBadRequest)
|
http.Error(w, "invalid json", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
|
|
@ -533,27 +537,27 @@ func TestCodexProvider_ChatRoundTrip_ModelFallbackFromUnsupported(t *testing.T)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
resp := map[string]interface{}{
|
resp := map[string]any{
|
||||||
"id": "resp_test",
|
"id": "resp_test",
|
||||||
"object": "response",
|
"object": "response",
|
||||||
"status": "completed",
|
"status": "completed",
|
||||||
"output": []map[string]interface{}{
|
"output": []map[string]any{
|
||||||
{
|
{
|
||||||
"id": "msg_1",
|
"id": "msg_1",
|
||||||
"type": "message",
|
"type": "message",
|
||||||
"role": "assistant",
|
"role": "assistant",
|
||||||
"status": "completed",
|
"status": "completed",
|
||||||
"content": []map[string]interface{}{
|
"content": []map[string]any{
|
||||||
{"type": "output_text", "text": "Hi from Codex!"},
|
{"type": "output_text", "text": "Hi from Codex!"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"usage": map[string]interface{}{
|
"usage": map[string]any{
|
||||||
"input_tokens": 8,
|
"input_tokens": 8,
|
||||||
"output_tokens": 4,
|
"output_tokens": 4,
|
||||||
"total_tokens": 12,
|
"total_tokens": 12,
|
||||||
"input_tokens_details": map[string]interface{}{"cached_tokens": 0},
|
"input_tokens_details": map[string]any{"cached_tokens": 0},
|
||||||
"output_tokens_details": map[string]interface{}{"reasoning_tokens": 0},
|
"output_tokens_details": map[string]any{"reasoning_tokens": 0},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
writeCompletedSSE(w, resp)
|
writeCompletedSSE(w, resp)
|
||||||
|
|
@ -588,7 +592,12 @@ func TestResolveCodexModel(t *testing.T) {
|
||||||
wantFallback bool
|
wantFallback bool
|
||||||
}{
|
}{
|
||||||
{name: "empty", input: "", wantModel: codexDefaultModel, wantFallback: true},
|
{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: "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: "openai prefix", input: "openai/gpt-5.2", wantModel: "gpt-5.2", wantFallback: false},
|
||||||
{name: "direct gpt", input: "gpt-4o", wantModel: "gpt-4o", 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
|
return &c
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeCompletedSSE(w http.ResponseWriter, response map[string]interface{}) {
|
func writeCompletedSSE(w http.ResponseWriter, response map[string]any) {
|
||||||
event := map[string]interface{}{
|
event := map[string]any{
|
||||||
"type": "response.completed",
|
"type": "response.completed",
|
||||||
"sequence_number": 1,
|
"sequence_number": 1,
|
||||||
"response": response,
|
"response": response,
|
||||||
|
|
|
||||||
|
|
@ -178,6 +178,15 @@ func resolveProviderSelection(cfg *config.Config) (providerSelection, error) {
|
||||||
sel.model = "deepseek-chat"
|
sel.model = "deepseek-chat"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
case "mistral":
|
||||||
|
if cfg.Providers.Mistral.APIKey != "" {
|
||||||
|
sel.apiKey = cfg.Providers.Mistral.APIKey
|
||||||
|
sel.apiBase = cfg.Providers.Mistral.APIBase
|
||||||
|
sel.proxy = cfg.Providers.Mistral.Proxy
|
||||||
|
if sel.apiBase == "" {
|
||||||
|
sel.apiBase = "https://api.mistral.ai/v1"
|
||||||
|
}
|
||||||
|
}
|
||||||
case "github_copilot", "copilot":
|
case "github_copilot", "copilot":
|
||||||
sel.providerType = providerTypeGitHubCopilot
|
sel.providerType = providerTypeGitHubCopilot
|
||||||
if cfg.Providers.GitHubCopilot.APIBase != "" {
|
if cfg.Providers.GitHubCopilot.APIBase != "" {
|
||||||
|
|
@ -281,6 +290,13 @@ func resolveProviderSelection(cfg *config.Config) (providerSelection, error) {
|
||||||
if sel.apiBase == "" {
|
if sel.apiBase == "" {
|
||||||
sel.apiBase = "http://localhost:11434/v1"
|
sel.apiBase = "http://localhost:11434/v1"
|
||||||
}
|
}
|
||||||
|
case (strings.Contains(lowerModel, "mistral") || strings.HasPrefix(model, "mistral/")) && cfg.Providers.Mistral.APIKey != "":
|
||||||
|
sel.apiKey = cfg.Providers.Mistral.APIKey
|
||||||
|
sel.apiBase = cfg.Providers.Mistral.APIBase
|
||||||
|
sel.proxy = cfg.Providers.Mistral.Proxy
|
||||||
|
if sel.apiBase == "" {
|
||||||
|
sel.apiBase = "https://api.mistral.ai/v1"
|
||||||
|
}
|
||||||
case cfg.Providers.VLLM.APIBase != "":
|
case cfg.Providers.VLLM.APIBase != "":
|
||||||
sel.apiKey = cfg.Providers.VLLM.APIKey
|
sel.apiKey = cfg.Providers.VLLM.APIKey
|
||||||
sel.apiBase = cfg.Providers.VLLM.APIBase
|
sel.apiBase = cfg.Providers.VLLM.APIBase
|
||||||
|
|
|
||||||
|
|
@ -88,7 +88,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
|
||||||
|
|
||||||
case "openrouter", "groq", "zhipu", "gemini", "nvidia",
|
case "openrouter", "groq", "zhipu", "gemini", "nvidia",
|
||||||
"ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras",
|
"ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras",
|
||||||
"volcengine", "vllm", "qwen":
|
"volcengine", "vllm", "qwen", "mistral":
|
||||||
// All other OpenAI-compatible HTTP providers
|
// All other OpenAI-compatible HTTP providers
|
||||||
if cfg.APIKey == "" && cfg.APIBase == "" {
|
if cfg.APIKey == "" && cfg.APIBase == "" {
|
||||||
return nil, "", fmt.Errorf("api_key or api_base is required for HTTP-based protocol %q", protocol)
|
return nil, "", fmt.Errorf("api_key or api_base is required for HTTP-based protocol %q", protocol)
|
||||||
|
|
@ -193,6 +193,8 @@ func getDefaultAPIBase(protocol string) string {
|
||||||
return "https://dashscope.aliyuncs.com/compatible-mode/v1"
|
return "https://dashscope.aliyuncs.com/compatible-mode/v1"
|
||||||
case "vllm":
|
case "vllm":
|
||||||
return "http://localhost:8000/v1"
|
return "http://localhost:8000/v1"
|
||||||
|
case "mistral":
|
||||||
|
return "https://api.mistral.ai/v1"
|
||||||
default:
|
default:
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -110,7 +110,11 @@ func (fc *FallbackChain) Execute(
|
||||||
Model: candidate.Model,
|
Model: candidate.Model,
|
||||||
Skipped: true,
|
Skipped: true,
|
||||||
Reason: FailoverRateLimit,
|
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
|
continue
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -462,7 +462,13 @@ func TestResolveCandidates_EmptyPrimary(t *testing.T) {
|
||||||
func TestFallbackExhaustedError_Message(t *testing.T) {
|
func TestFallbackExhaustedError_Message(t *testing.T) {
|
||||||
e := &FallbackExhaustedError{
|
e := &FallbackExhaustedError{
|
||||||
Attempts: []FallbackAttempt{
|
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},
|
{Provider: "anthropic", Model: "claude", Skipped: true},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,9 @@ package providers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
json "encoding/json"
|
|
||||||
|
|
||||||
copilot "github.com/github/copilot-sdk/go"
|
copilot "github.com/github/copilot-sdk/go"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -17,7 +16,6 @@ type GitHubCopilotProvider struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewGitHubCopilotProvider(uri string, connectMode string, model string) (*GitHubCopilotProvider, error) {
|
func NewGitHubCopilotProvider(uri string, connectMode string, model string) (*GitHubCopilotProvider, error) {
|
||||||
|
|
||||||
var session *copilot.Session
|
var session *copilot.Session
|
||||||
if connectMode == "" {
|
if connectMode == "" {
|
||||||
connectMode = "grpc"
|
connectMode = "grpc"
|
||||||
|
|
@ -25,13 +23,15 @@ func NewGitHubCopilotProvider(uri string, connectMode string, model string) (*Gi
|
||||||
switch connectMode {
|
switch connectMode {
|
||||||
|
|
||||||
case "stdio":
|
case "stdio":
|
||||||
//todo
|
// todo
|
||||||
case "grpc":
|
case "grpc":
|
||||||
client := copilot.NewClient(&copilot.ClientOptions{
|
client := copilot.NewClient(&copilot.ClientOptions{
|
||||||
CLIUrl: uri,
|
CLIUrl: uri,
|
||||||
})
|
})
|
||||||
if err := client.Start(context.Background()); err != nil {
|
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()
|
defer client.Stop()
|
||||||
session, _ = client.CreateSession(context.Background(), &copilot.SessionConfig{
|
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
|
// 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 {
|
type tempMessage struct {
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
|
|
@ -73,10 +75,8 @@ func (p *GitHubCopilotProvider) Chat(ctx context.Context, messages []Message, to
|
||||||
FinishReason: "stop",
|
FinishReason: "stop",
|
||||||
Content: content,
|
Content: content,
|
||||||
}, nil
|
}, nil
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *GitHubCopilotProvider) GetDefaultModel() string {
|
func (p *GitHubCopilotProvider) GetDefaultModel() string {
|
||||||
|
|
||||||
return "gpt-4.1"
|
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)
|
return p.delegate.Chat(ctx, messages, tools, model, options)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,15 +15,17 @@ import (
|
||||||
"github.com/sipeed/picoclaw/pkg/providers/protocoltypes"
|
"github.com/sipeed/picoclaw/pkg/providers/protocoltypes"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ToolCall = protocoltypes.ToolCall
|
type (
|
||||||
type FunctionCall = protocoltypes.FunctionCall
|
ToolCall = protocoltypes.ToolCall
|
||||||
type LLMResponse = protocoltypes.LLMResponse
|
FunctionCall = protocoltypes.FunctionCall
|
||||||
type UsageInfo = protocoltypes.UsageInfo
|
LLMResponse = protocoltypes.LLMResponse
|
||||||
type Message = protocoltypes.Message
|
UsageInfo = protocoltypes.UsageInfo
|
||||||
type ToolDefinition = protocoltypes.ToolDefinition
|
Message = protocoltypes.Message
|
||||||
type ToolFunctionDefinition = protocoltypes.ToolFunctionDefinition
|
ToolDefinition = protocoltypes.ToolDefinition
|
||||||
type ExtraContent = protocoltypes.ExtraContent
|
ToolFunctionDefinition = protocoltypes.ToolFunctionDefinition
|
||||||
type GoogleExtra = protocoltypes.GoogleExtra
|
ExtraContent = protocoltypes.ExtraContent
|
||||||
|
GoogleExtra = protocoltypes.GoogleExtra
|
||||||
|
)
|
||||||
|
|
||||||
type Provider struct {
|
type Provider struct {
|
||||||
apiKey string
|
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 == "" {
|
if p.apiBase == "" {
|
||||||
return nil, fmt.Errorf("API base not configured")
|
return nil, fmt.Errorf("API base not configured")
|
||||||
}
|
}
|
||||||
|
|
||||||
model = normalizeModel(model, p.apiBase)
|
model = normalizeModel(model, p.apiBase)
|
||||||
|
|
||||||
requestBody := map[string]interface{}{
|
requestBody := map[string]any{
|
||||||
"model": model,
|
"model": model,
|
||||||
"messages": messages,
|
"messages": messages,
|
||||||
}
|
}
|
||||||
|
|
@ -83,7 +91,8 @@ func (p *Provider) Chat(ctx context.Context, messages []Message, tools []ToolDef
|
||||||
if fieldName == "" {
|
if fieldName == "" {
|
||||||
// Fallback: detect from model name for backward compatibility
|
// Fallback: detect from model name for backward compatibility
|
||||||
lowerModel := strings.ToLower(model)
|
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"
|
fieldName = "max_completion_tokens"
|
||||||
} else {
|
} else {
|
||||||
fieldName = "max_tokens"
|
fieldName = "max_tokens"
|
||||||
|
|
@ -173,7 +182,7 @@ func parseResponse(body []byte) (*LLMResponse, error) {
|
||||||
choice := apiResponse.Choices[0]
|
choice := apiResponse.Choices[0]
|
||||||
toolCalls := make([]ToolCall, 0, len(choice.Message.ToolCalls))
|
toolCalls := make([]ToolCall, 0, len(choice.Message.ToolCalls))
|
||||||
for _, tc := range choice.Message.ToolCalls {
|
for _, tc := range choice.Message.ToolCalls {
|
||||||
arguments := make(map[string]interface{})
|
arguments := make(map[string]any)
|
||||||
name := ""
|
name := ""
|
||||||
|
|
||||||
// Extract thought_signature from Gemini/Google-specific extra content
|
// Extract thought_signature from Gemini/Google-specific extra content
|
||||||
|
|
@ -231,14 +240,14 @@ func normalizeModel(model, apiBase string) string {
|
||||||
|
|
||||||
prefix := strings.ToLower(model[:idx])
|
prefix := strings.ToLower(model[:idx])
|
||||||
switch prefix {
|
switch prefix {
|
||||||
case "moonshot", "nvidia", "groq", "ollama", "deepseek", "google", "openrouter", "zhipu":
|
case "moonshot", "nvidia", "groq", "ollama", "deepseek", "google", "openrouter", "zhipu", "mistral":
|
||||||
return model[idx+1:]
|
return model[idx+1:]
|
||||||
default:
|
default:
|
||||||
return model
|
return model
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func asInt(v interface{}) (int, bool) {
|
func asInt(v any) (int, bool) {
|
||||||
switch val := v.(type) {
|
switch val := v.(type) {
|
||||||
case int:
|
case int:
|
||||||
return val, true
|
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) {
|
switch val := v.(type) {
|
||||||
case float64:
|
case float64:
|
||||||
return val, true
|
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