Merge branch 'sipeed:main' into main
This commit is contained in:
commit
babd6cf8eb
37 changed files with 2539 additions and 899 deletions
|
|
@ -18,7 +18,7 @@
|
|||
<a href="https://discord.gg/V4sAZ9XWpN"><img src="https://img.shields.io/badge/Discord-Community-4c60eb?style=flat&logo=discord&logoColor=white" alt="Discord"></a>
|
||||
</p>
|
||||
|
||||
[中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | **Français** | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [Malay](README.my.md) | [English](README.md)
|
||||
[中文](README.zh.md) | [日本語](README.ja.md) | [한국어](README.ko.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | **Français** | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [Malay](README.my.md) | [English](README.md)
|
||||
|
||||
</div>
|
||||
|
||||
|
|
@ -622,4 +622,3 @@ WeChat :
|
|||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@
|
|||
<a href="https://discord.gg/V4sAZ9XWpN"><img src="https://img.shields.io/badge/Discord-Community-4c60eb?style=flat&logo=discord&logoColor=white" alt="Discord"></a>
|
||||
</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) | [Italiano](README.it.md) | [Malay](README.my.md) | [English](README.md) | **Bahasa Indonesia**
|
||||
[中文](README.zh.md) | [日本語](README.ja.md) | [한국어](README.ko.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | **Bahasa Indonesia** | [Malay](README.my.md) | [English](README.md)
|
||||
|
||||
</div>
|
||||
|
||||
|
|
@ -615,4 +615,3 @@ Discord: <https://discord.gg/V4sAZ9XWpN>
|
|||
|
||||
WeChat:
|
||||
<img src="assets/wechat.png" alt="Kode QR grup WeChat" width="512">
|
||||
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@
|
|||
<a href="https://discord.gg/V4sAZ9XWpN"><img src="https://img.shields.io/badge/Discord-Community-4c60eb?style=flat&logo=discord&logoColor=white" alt="Discord"></a>
|
||||
</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) | **Italiano** | [Bahasa Indonesia](README.id.md) | [Malay](README.my.md) | [English](README.md)
|
||||
[中文](README.zh.md) | [日本語](README.ja.md) | [한국어](README.ko.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | **Italiano** | [Bahasa Indonesia](README.id.md) | [Malay](README.my.md) | [English](README.md)
|
||||
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@
|
|||
<a href="https://discord.gg/V4sAZ9XWpN"><img src="https://img.shields.io/badge/Discord-Community-4c60eb?style=flat&logo=discord&logoColor=white" alt="Discord"></a>
|
||||
</p>
|
||||
|
||||
[中文](README.zh.md) | **日本語** | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [Malay](README.my.md) | [English](README.md)
|
||||
[中文](README.zh.md) | **日本語** | [한국어](README.ko.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [Malay](README.my.md) | [English](README.md)
|
||||
|
||||
</div>
|
||||
|
||||
|
|
|
|||
626
README.ko.md
Normal file
626
README.ko.md
Normal file
|
|
@ -0,0 +1,626 @@
|
|||
<div align="center">
|
||||
<img src="assets/logo.webp" alt="PicoClaw" width="512">
|
||||
|
||||
<h1>PicoClaw: Go로 작성된 초고효율 AI 어시스턴트</h1>
|
||||
|
||||
<h3>$10 하드웨어 · 10MB RAM · ms 부팅 · Let's Go, PicoClaw!</h3>
|
||||
<p>
|
||||
<img src="https://img.shields.io/badge/Go-1.25+-00ADD8?style=flat&logo=go&logoColor=white" alt="Go">
|
||||
<img src="https://img.shields.io/badge/Arch-x86__64%2C%20ARM64%2C%20MIPS%2C%20RISC--V%2C%20LoongArch-blue" alt="Hardware">
|
||||
<img src="https://img.shields.io/badge/license-MIT-green" alt="License">
|
||||
<br>
|
||||
<a href="https://picoclaw.io"><img src="https://img.shields.io/badge/Website-picoclaw.io-blue?style=flat&logo=google-chrome&logoColor=white" alt="Website"></a>
|
||||
<a href="https://docs.picoclaw.io/"><img src="https://img.shields.io/badge/Docs-Official-007acc?style=flat&logo=read-the-docs&logoColor=white" alt="Docs"></a>
|
||||
<a href="https://deepwiki.com/sipeed/picoclaw"><img src="https://img.shields.io/badge/Wiki-DeepWiki-FFA500?style=flat&logo=wikipedia&logoColor=white" alt="Wiki"></a>
|
||||
<br>
|
||||
<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="./assets/wechat.png"><img src="https://img.shields.io/badge/WeChat-Group-41d56b?style=flat&logo=wechat&logoColor=white"></a>
|
||||
<a href="https://discord.gg/V4sAZ9XWpN"><img src="https://img.shields.io/badge/Discord-Community-4c60eb?style=flat&logo=discord&logoColor=white" alt="Discord"></a>
|
||||
</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) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [Malay](README.my.md) | [English](README.md)
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
> **PicoClaw**는 [Sipeed](https://sipeed.com)가 시작한 독립적인 오픈소스 프로젝트입니다. 처음부터 끝까지 **Go**로 새로 작성되었으며, OpenClaw, NanoBot, 혹은 다른 어떤 프로젝트의 포크도 아닙니다.
|
||||
|
||||
**PicoClaw**는 [NanoBot](https://github.com/HKUDS/nanobot)에서 영감을 받은 초경량 개인용 AI 어시스턴트입니다. **Go**로 처음부터 다시 구현되었고, "셀프 부트스트래핑" 방식으로 만들어졌습니다. 즉, AI 에이전트 자체가 아키텍처 전환과 코드 최적화를 주도했습니다.
|
||||
|
||||
**$10 하드웨어에서 10MB 미만 RAM으로 동작**합니다. OpenClaw보다 메모리를 99% 적게 쓰고, Mac mini보다 98% 저렴합니다!
|
||||
|
||||
<table align="center">
|
||||
<tr align="center">
|
||||
<td align="center" valign="top">
|
||||
<p align="center">
|
||||
<img src="assets/picoclaw_mem.gif" width="360" height="240">
|
||||
</p>
|
||||
</td>
|
||||
<td align="center" valign="top">
|
||||
<p align="center">
|
||||
<img src="assets/licheervnano.png" width="400" height="240">
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
> [!CAUTION]
|
||||
> **보안 안내**
|
||||
>
|
||||
> * **암호화폐 없음:** PicoClaw는 공식 토큰이나 암호화폐를 **발행한 적이 없습니다**. `pump.fun` 또는 기타 거래 플랫폼에서의 모든 주장은 **사기**입니다.
|
||||
> * **공식 도메인:** **유일한** 공식 웹사이트는 **[picoclaw.io](https://picoclaw.io)** 이며, 회사 웹사이트는 **[sipeed.com](https://sipeed.com)** 입니다.
|
||||
> * **주의:** 많은 `.ai/.org/.com/.net/...` 도메인이 제3자에 의해 등록되어 있습니다. 신뢰하지 마세요.
|
||||
> * **참고:** PicoClaw는 빠르게 초기 개발이 진행 중입니다. 아직 해결되지 않은 보안 문제가 있을 수 있습니다. v1.0 이전에는 프로덕션 배포를 권장하지 않습니다.
|
||||
> * **참고:** PicoClaw는 최근 많은 PR을 병합했습니다. 최근 빌드는 10~20MB RAM을 사용할 수 있습니다. 기능이 안정화된 뒤 리소스 최적화를 진행할 예정입니다.
|
||||
|
||||
## 📢 뉴스
|
||||
|
||||
2026-03-31 📱 **Android 지원!** PicoClaw가 이제 Android에서 실행됩니다! APK는 [picoclaw.io](https://picoclaw.io/download)에서 다운로드하세요.
|
||||
|
||||
2026-03-25 🚀 **v0.2.4 출시!** 에이전트 아키텍처 전면 개편(SubTurn, Hooks, Steering, EventBus), WeChat/WeCom 통합, 보안 강화(`.security.yml`, 민감 정보 필터링), 새 프로바이더(AWS Bedrock, Azure, Xiaomi MiMo), 그리고 35건의 버그 수정이 포함되었습니다. PicoClaw는 **26K 스타**를 달성했습니다!
|
||||
|
||||
2026-03-17 🚀 **v0.2.3 출시!** 시스템 트레이 UI(Windows 및 Linux), 서브에이전트 상태 조회(`spawn_status`), 실험적 게이트웨이 핫 리로드, Cron 보안 게이트, 그리고 2건의 보안 수정이 추가되었습니다. PicoClaw는 **25K 스타**를 달성했습니다!
|
||||
|
||||
2026-03-09 🎉 **v0.2.1 — 역대 최대 업데이트!** MCP 프로토콜 지원, 4개의 새 채널(Matrix/IRC/WeCom/Discord Proxy), 3개의 새 프로바이더(Kimi/Minimax/Avian), 비전 파이프라인, JSONL 메모리 저장소, 모델 라우팅이 추가되었습니다.
|
||||
|
||||
2026-02-28 📦 **v0.2.0** 이 Docker Compose 및 WebUI 런처 지원과 함께 출시되었습니다.
|
||||
|
||||
<details>
|
||||
<summary>이전 뉴스...</summary>
|
||||
|
||||
2026-02-26 🎉 PicoClaw가 단 17일 만에 **20K 스타**를 달성했습니다! 채널 자동 오케스트레이션과 기능 인터페이스가 적용되었습니다.
|
||||
|
||||
2026-02-16 🎉 PicoClaw가 1주일 만에 **12K 스타**를 돌파했습니다! 커뮤니티 메인터너 역할과 [로드맵](ROADMAP.md)이 공식적으로 공개되었습니다.
|
||||
|
||||
2026-02-13 🎉 PicoClaw가 4일 만에 **5000 스타**를 돌파했습니다! 프로젝트 로드맵과 개발자 그룹이 준비 중입니다.
|
||||
|
||||
2026-02-09 🎉 **PicoClaw 출시!** $10 하드웨어와 10MB 미만 RAM에서 동작하는 AI 에이전트를 단 1일 만에 만들었습니다. Let's Go, PicoClaw!
|
||||
|
||||
</details>
|
||||
|
||||
## ✨ 기능
|
||||
|
||||
🪶 **초경량**: 코어 메모리 사용량이 10MB 미만으로 OpenClaw보다 99% 작습니다.*
|
||||
|
||||
💰 **최소 비용**: $10짜리 하드웨어에서도 충분히 구동되어 Mac mini보다 98% 저렴합니다.
|
||||
|
||||
⚡️ **초고속 부팅**: 시작 속도가 400배 빠릅니다. 0.6GHz 싱글코어 프로세서에서도 1초 미만에 부팅됩니다.
|
||||
|
||||
🌍 **진정한 이식성**: RISC-V, ARM, MIPS, x86 아키텍처 전반에 단일 바이너리로 동작합니다. 하나의 바이너리로 어디서나 실행됩니다!
|
||||
|
||||
🤖 **AI 부트스트래핑**: 순수 Go 네이티브 구현입니다. 코어 코드의 95%는 에이전트가 생성했고, 사람이 검토하며 다듬었습니다.
|
||||
|
||||
🔌 **MCP 지원**: 네이티브 [Model Context Protocol](https://modelcontextprotocol.io/) 통합을 제공하여 어떤 MCP 서버든 연결해 에이전트 기능을 확장할 수 있습니다.
|
||||
|
||||
👁️ **비전 파이프라인**: 이미지와 파일을 에이전트에 직접 보낼 수 있으며, 멀티모달 LLM용 base64 인코딩이 자동으로 처리됩니다.
|
||||
|
||||
🧠 **스마트 라우팅**: 규칙 기반 모델 라우팅으로 간단한 질의는 경량 모델에 보내 API 비용을 절약합니다.
|
||||
|
||||
_*최근 빌드는 급격한 PR 병합으로 인해 10~20MB를 사용할 수 있습니다. 리소스 최적화는 계획되어 있습니다. 부팅 속도 비교는 0.8GHz 싱글코어 벤치마크를 기준으로 합니다(아래 표 참고)._
|
||||
|
||||
<div align="center">
|
||||
|
||||
| | OpenClaw | NanoBot | **PicoClaw** |
|
||||
| ------------------------------ | ------------- | ------------------------ | -------------------------------------- |
|
||||
| **언어** | TypeScript | Python | **Go** |
|
||||
| **RAM** | >1GB | >100MB | **< 10MB*** |
|
||||
| **부팅 시간**</br>(0.8GHz 코어) | >500초 | >30초 | **<1초** |
|
||||
| **비용** | Mac Mini $599 | 대부분의 Linux 보드 ~$50 | **모든 Linux 보드**</br>**최저 $10부터** |
|
||||
|
||||
<img src="assets/compare.jpg" alt="PicoClaw" width="512">
|
||||
|
||||
</div>
|
||||
|
||||
> **[하드웨어 호환 목록](docs/hardware-compatibility.md)** — 테스트된 모든 보드를 확인하세요. $5 RISC-V 보드부터 Raspberry Pi, Android 스마트폰까지 포함됩니다. 사용 중인 보드가 없나요? PR을 보내주세요!
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/hardware-banner.jpg" alt="PicoClaw Hardware Compatibility" width="100%">
|
||||
</p>
|
||||
|
||||
## 🦾 데모
|
||||
|
||||
### 🛠️ 표준 어시스턴트 워크플로
|
||||
|
||||
<table align="center">
|
||||
<tr align="center">
|
||||
<th><p align="center">풀스택 엔지니어 모드</p></th>
|
||||
<th><p align="center">로깅 및 계획</p></th>
|
||||
<th><p align="center">웹 검색 및 학습</p></th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><p align="center"><img src="assets/picoclaw_code.gif" width="240" height="180"></p></td>
|
||||
<td align="center"><p align="center"><img src="assets/picoclaw_memory.gif" width="240" height="180"></p></td>
|
||||
<td align="center"><p align="center"><img src="assets/picoclaw_search.gif" width="240" height="180"></p></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">개발 · 배포 · 확장</td>
|
||||
<td align="center">스케줄링 · 자동화 · 기억</td>
|
||||
<td align="center">탐색 · 인사이트 · 트렌드</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
### 🐜 혁신적인 초저사양 배포
|
||||
|
||||
PicoClaw는 사실상 거의 모든 Linux 장치에 배포할 수 있습니다!
|
||||
|
||||
- 최소형 홈 어시스턴트를 위해 $9.9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) E(이더넷) 또는 W(WiFi6) 에디션
|
||||
- 서버 자동 운영을 위해 $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html) 또는 $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html)
|
||||
- 스마트 감시를 위해 $50 [MaixCAM](https://www.aliexpress.com/item/1005008053333693.html) 또는 $100 [MaixCAM2](https://www.kickstarter.com/projects/zepan/maixcam2-build-your-next-gen-4k-ai-camera)
|
||||
|
||||
<https://private-user-images.githubusercontent.com/83055338/547056448-e7b031ff-d6f5-4468-bcca-5726b6fecb5c.mp4>
|
||||
|
||||
🌟 더 많은 배포 사례가 기다리고 있습니다!
|
||||
|
||||
## 📦 설치
|
||||
|
||||
### picoclaw.io에서 다운로드(권장)
|
||||
|
||||
**[picoclaw.io](https://picoclaw.io)** 를 방문하세요. 공식 웹사이트가 플랫폼을 자동 감지하고 원클릭 다운로드를 제공합니다. 아키텍처를 직접 고를 필요가 없습니다.
|
||||
|
||||
### 사전 컴파일된 바이너리 다운로드
|
||||
|
||||
또는 [GitHub Releases](https://github.com/sipeed/picoclaw/releases) 페이지에서 플랫폼에 맞는 바이너리를 다운로드할 수 있습니다.
|
||||
|
||||
### 소스에서 빌드(개발용)
|
||||
|
||||
```bash
|
||||
git clone https://github.com/sipeed/picoclaw.git
|
||||
|
||||
cd picoclaw
|
||||
make deps
|
||||
|
||||
# 코어 바이너리 빌드
|
||||
make build
|
||||
|
||||
# WebUI 런처 빌드 (WebUI 모드에 필요)
|
||||
make build-launcher
|
||||
|
||||
# 여러 플랫폼용 빌드
|
||||
make build-all
|
||||
|
||||
# Raspberry Pi Zero 2 W용 빌드 (32비트: make build-linux-arm, 64비트: make build-linux-arm64)
|
||||
make build-pi-zero
|
||||
|
||||
# 빌드 후 설치
|
||||
make install
|
||||
```
|
||||
|
||||
**Raspberry Pi Zero 2 W:** OS에 맞는 바이너리를 사용하세요. 32비트 Raspberry Pi OS는 `make build-linux-arm`, 64비트는 `make build-linux-arm64`입니다. 또는 `make build-pi-zero`로 둘 다 빌드할 수 있습니다.
|
||||
|
||||
## 🚀 빠른 시작 가이드
|
||||
|
||||
### 🌐 WebUI Launcher (데스크톱 권장)
|
||||
|
||||
WebUI Launcher는 설정과 채팅을 위한 브라우저 기반 인터페이스를 제공합니다. 명령줄을 몰라도 가장 쉽게 시작할 수 있는 방법입니다.
|
||||
|
||||
**옵션 1: 더블클릭(데스크톱)**
|
||||
|
||||
[picoclaw.io](https://picoclaw.io)에서 다운로드한 뒤 `picoclaw-launcher`를 더블클릭하세요(Windows에서는 `picoclaw-launcher.exe`). 브라우저가 자동으로 `http://localhost:18800`을 엽니다.
|
||||
|
||||
**옵션 2: 명령줄**
|
||||
|
||||
```bash
|
||||
picoclaw-launcher
|
||||
# 브라우저에서 http://localhost:18800 열기
|
||||
```
|
||||
|
||||
> [!TIP]
|
||||
> **원격 접속 / Docker / VM:** 모든 인터페이스에서 수신하려면 `-public` 플래그를 추가하세요.
|
||||
> ```bash
|
||||
> picoclaw-launcher -public
|
||||
> ```
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/launcher-webui.jpg" alt="WebUI Launcher" width="600">
|
||||
</p>
|
||||
|
||||
**시작 방법:**
|
||||
|
||||
WebUI를 연 뒤 다음 순서로 진행하세요. **1)** 프로바이더 설정(LLM API 키 추가) -> **2)** 채널 설정(예: Telegram) -> **3)** 게이트웨이 시작 -> **4)** 채팅!
|
||||
|
||||
자세한 WebUI 문서는 [docs.picoclaw.io](https://docs.picoclaw.io)를 참고하세요.
|
||||
|
||||
<details>
|
||||
<summary><b>Docker(대안)</b></summary>
|
||||
|
||||
```bash
|
||||
# 1. 이 저장소를 클론
|
||||
git clone https://github.com/sipeed/picoclaw.git
|
||||
cd picoclaw
|
||||
|
||||
# 2. 첫 실행 - docker/data/config.json을 자동 생성한 뒤 종료
|
||||
# (config.json과 workspace/가 모두 없을 때만 실행됨)
|
||||
docker compose -f docker/docker-compose.yml --profile launcher up
|
||||
# 컨테이너가 "First-run setup complete."를 출력하고 종료됩니다.
|
||||
|
||||
# 3. API 키 설정
|
||||
vim docker/data/config.json
|
||||
|
||||
# 4. 시작
|
||||
docker compose -f docker/docker-compose.yml --profile launcher up -d
|
||||
# http://localhost:18800 열기
|
||||
```
|
||||
|
||||
> **Docker / VM 사용자:** 게이트웨이는 기본적으로 `127.0.0.1`에서 수신합니다. 호스트에서 접근 가능하게 하려면 `PICOCLAW_GATEWAY_HOST=0.0.0.0`을 설정하거나 `-public` 플래그를 사용하세요.
|
||||
|
||||
```bash
|
||||
# 로그 확인
|
||||
docker compose -f docker/docker-compose.yml logs -f
|
||||
|
||||
# 중지
|
||||
docker compose -f docker/docker-compose.yml --profile launcher down
|
||||
|
||||
# 업데이트
|
||||
docker compose -f docker/docker-compose.yml pull
|
||||
docker compose -f docker/docker-compose.yml --profile launcher up -d
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>macOS - 첫 실행 보안 경고</b></summary>
|
||||
|
||||
macOS에서는 인터넷에서 다운로드한 앱이고 Mac App Store 공증을 거치지 않았기 때문에, 첫 실행 시 `picoclaw-launcher`가 차단될 수 있습니다.
|
||||
|
||||
**1단계:** `picoclaw-launcher`를 더블클릭합니다. 그러면 보안 경고가 표시됩니다.
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/macos-gatekeeper-warning.jpg" alt="macOS Gatekeeper warning" width="400">
|
||||
</p>
|
||||
|
||||
> *"picoclaw-launcher"을(를) 열 수 없습니다. Apple에서 이 앱이 악성 소프트웨어가 없으며 Mac이나 개인 정보를 해치지 않는다고 확인할 수 없습니다.*
|
||||
|
||||
**2단계:** **시스템 설정** -> **개인정보 보호 및 보안** 으로 이동한 뒤 **보안** 섹션까지 스크롤하여 **그래도 열기(Open Anyway)** 를 클릭하고, 대화상자에서 다시 한 번 **그래도 열기**를 확인합니다.
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/macos-gatekeeper-allow.jpg" alt="macOS Privacy & Security — Open Anyway" width="600">
|
||||
</p>
|
||||
|
||||
이 과정을 한 번만 거치면 이후에는 `picoclaw-launcher`가 정상적으로 열립니다.
|
||||
|
||||
</details>
|
||||
|
||||
### 💻 TUI Launcher (헤드리스 / SSH 권장)
|
||||
|
||||
TUI(Terminal UI) Launcher는 설정과 관리를 위한 모든 기능을 갖춘 터미널 인터페이스를 제공합니다. 서버, Raspberry Pi, 기타 헤드리스 환경에 적합합니다.
|
||||
|
||||
```bash
|
||||
picoclaw-launcher-tui
|
||||
```
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/launcher-tui.jpg" alt="TUI Launcher" width="600">
|
||||
</p>
|
||||
|
||||
**시작 방법:**
|
||||
|
||||
TUI 메뉴를 사용해 다음 순서로 진행하세요. **1)** 프로바이더 설정 -> **2)** 채널 설정 -> **3)** 게이트웨이 시작 -> **4)** 채팅!
|
||||
|
||||
자세한 TUI 문서는 [docs.picoclaw.io](https://docs.picoclaw.io)를 참고하세요.
|
||||
|
||||
### 📱 Android
|
||||
|
||||
오래된 스마트폰에 새 생명을 불어넣어 보세요! PicoClaw를 설치하면 스마트 AI 어시스턴트로 바꿀 수 있습니다.
|
||||
|
||||
**옵션 1: APK 설치**
|
||||
|
||||
미리보기:
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td><img src="assets/fui_main_page.jpg" width="200"></td>
|
||||
<td><img src="assets/fui_web_page.jpg" width="200"></td>
|
||||
<td><img src="assets/fui_log_page.jpg" width="200"></td>
|
||||
<td><img src="assets/fui_setting_page.jpg" width="200"></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
[picoclaw.io](https://picoclaw.io/download/)에서 APK를 다운로드해 바로 설치하세요. Termux가 필요 없습니다!
|
||||
|
||||
**옵션 2: Termux**
|
||||
|
||||
<details>
|
||||
<summary><b>터미널 런처 (리소스 제약 환경용)</b></summary>
|
||||
|
||||
1. [Termux](https://github.com/termux/termux-app)를 설치합니다([GitHub Releases](https://github.com/termux/termux-app/releases)에서 다운로드하거나 F-Droid / Google Play에서 검색).
|
||||
2. 다음 명령을 실행합니다.
|
||||
|
||||
```bash
|
||||
# 최신 릴리스 다운로드
|
||||
wget https://github.com/sipeed/picoclaw/releases/latest/download/picoclaw_Linux_arm64.tar.gz
|
||||
tar xzf picoclaw_Linux_arm64.tar.gz
|
||||
pkg install proot
|
||||
termux-chroot ./picoclaw onboard # chroot가 표준 Linux 파일시스템 레이아웃을 제공합니다
|
||||
```
|
||||
|
||||
그다음 아래의 터미널 런처 섹션을 따라 설정을 마무리하세요.
|
||||
|
||||
<img src="assets/termux.jpg" alt="PicoClaw on Termux" width="512">
|
||||
|
||||
런처 UI 없이 `picoclaw` 코어 바이너리만 있는 최소 환경에서는 명령줄과 JSON 설정 파일만으로도 모든 설정을 마칠 수 있습니다.
|
||||
|
||||
**1. 초기화**
|
||||
|
||||
```bash
|
||||
picoclaw onboard
|
||||
```
|
||||
|
||||
그러면 `~/.picoclaw/config.json`과 워크스페이스 디렉터리가 생성됩니다.
|
||||
|
||||
**2. 설정** (`~/.picoclaw/config.json`)
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"model_name": "gpt-5.4"
|
||||
}
|
||||
},
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "gpt-5.4",
|
||||
"model": "openai/gpt-5.4",
|
||||
// api_key는 이제 .security.yml에서 로드됩니다.
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
> 사용 가능한 모든 옵션이 포함된 전체 설정 템플릿은 저장소의 `config/config.example.json`을 참고하세요.
|
||||
>
|
||||
> 참고: `config.example.json` 형식은 버전 0이며 민감 정보가 포함되어 있습니다. 실행 시 자동으로 버전 1+로 마이그레이션되며, 이후 `config.json`에는 비민감 정보만 저장되고 민감 정보는 `.security.yml`에 저장됩니다. 민감 정보를 직접 수정해야 한다면 `docs/security_configuration.md`를 참고하세요.
|
||||
|
||||
**3. 채팅**
|
||||
|
||||
```bash
|
||||
# 단발성 질문
|
||||
picoclaw agent -m "2+2는 얼마야?"
|
||||
|
||||
# 대화형 모드
|
||||
picoclaw agent
|
||||
|
||||
# 채팅 앱 연동용 게이트웨이 시작
|
||||
picoclaw gateway
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## 🔌 프로바이더(LLM)
|
||||
|
||||
PicoClaw는 `model_list` 설정을 통해 30개 이상의 LLM 프로바이더를 지원합니다. 형식은 `protocol/model`입니다.
|
||||
|
||||
| 프로바이더 | 프로토콜 | API Key | 비고 |
|
||||
|----------|----------|---------|------|
|
||||
| [OpenAI](https://platform.openai.com/api-keys) | `openai/` | 필수 | GPT-5.4, GPT-4o, o3 등 |
|
||||
| [Anthropic](https://console.anthropic.com/settings/keys) | `anthropic/` | 필수 | Claude Opus 4.6, Sonnet 4.6 등 |
|
||||
| [Google Gemini](https://aistudio.google.com/apikey) | `gemini/` | 필수 | Gemini 3 Flash, 2.5 Pro 등 |
|
||||
| [OpenRouter](https://openrouter.ai/keys) | `openrouter/` | 필수 | 200개 이상의 모델, 통합 API |
|
||||
| [Zhipu (GLM)](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | `zhipu/` | 필수 | GLM-4.7, GLM-5 등 |
|
||||
| [DeepSeek](https://platform.deepseek.com/api_keys) | `deepseek/` | 필수 | DeepSeek-V3, DeepSeek-R1 |
|
||||
| [Volcengine](https://console.volcengine.com) | `volcengine/` | 필수 | Doubao, Ark 모델 |
|
||||
| [Qwen](https://dashscope.console.aliyun.com/apiKey) | `qwen/` | 필수 | Qwen3, Qwen-Max 등 |
|
||||
| [Groq](https://console.groq.com/keys) | `groq/` | 필수 | 빠른 추론(Llama, Mixtral) |
|
||||
| [Moonshot (Kimi)](https://platform.moonshot.cn/console/api-keys) | `moonshot/` | 필수 | Kimi 모델 |
|
||||
| [Minimax](https://platform.minimaxi.com/user-center/basic-information/interface-key) | `minimax/` | 필수 | MiniMax 모델 |
|
||||
| [Mistral](https://console.mistral.ai/api-keys) | `mistral/` | 필수 | Mistral Large, Codestral |
|
||||
| [NVIDIA NIM](https://build.nvidia.com/) | `nvidia/` | 필수 | NVIDIA 호스팅 모델 |
|
||||
| [Cerebras](https://cloud.cerebras.ai/) | `cerebras/` | 필수 | 빠른 추론 |
|
||||
| [Novita AI](https://novita.ai/) | `novita/` | 필수 | 다양한 오픈 모델 |
|
||||
| [Xiaomi MiMo](https://platform.xiaomimimo.com/) | `mimo/` | 필수 | MiMo 모델 |
|
||||
| [Ollama](https://ollama.com/) | `ollama/` | 불필요 | 로컬 모델, 셀프 호스팅 |
|
||||
| [vLLM](https://docs.vllm.ai/) | `vllm/` | 불필요 | 로컬 배포, OpenAI 호환 |
|
||||
| [LiteLLM](https://docs.litellm.ai/) | `litellm/` | 환경에 따라 다름 | 100개 이상의 프로바이더를 위한 프록시 |
|
||||
| [Azure OpenAI](https://portal.azure.com/) | `azure/` | 필수 | 엔터프라이즈 Azure 배포 |
|
||||
| [GitHub Copilot](https://github.com/features/copilot) | `github-copilot/` | OAuth | 디바이스 코드 로그인 |
|
||||
| [Antigravity](https://console.cloud.google.com/) | `antigravity/` | OAuth | Google Cloud AI |
|
||||
| [AWS Bedrock](https://console.aws.amazon.com/bedrock)* | `bedrock/` | AWS 자격 증명 | AWS에서 Claude, Llama, Mistral 사용 |
|
||||
|
||||
> \* AWS Bedrock은 빌드 태그 `go build -tags bedrock`이 필요합니다. 모든 AWS 파티션(aws, aws-cn, aws-us-gov)에서 엔드포인트를 자동 해석하려면 `api_base`를 리전명(예: `us-east-1`)으로 설정하세요. 전체 엔드포인트 URL을 직접 사용할 경우에는 환경 변수 또는 AWS config/profile을 통해 `AWS_REGION`도 함께 설정해야 합니다.
|
||||
|
||||
<details>
|
||||
<summary><b>로컬 배포(Ollama, vLLM 등)</b></summary>
|
||||
|
||||
**Ollama:**
|
||||
```json
|
||||
{
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "local-llama",
|
||||
"model": "ollama/llama3.1:8b",
|
||||
"api_base": "http://localhost:11434/v1"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**vLLM:**
|
||||
```json
|
||||
{
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "local-vllm",
|
||||
"model": "vllm/your-model",
|
||||
"api_base": "http://localhost:8000/v1"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
프로바이더 전체 설정은 [프로바이더와 모델](docs/providers.md)을 참고하세요.
|
||||
|
||||
</details>
|
||||
|
||||
## 💬 채널(채팅 앱)
|
||||
|
||||
18개 이상의 메시징 플랫폼을 통해 PicoClaw와 대화할 수 있습니다.
|
||||
|
||||
| 채널 | 설정 | 프로토콜 | 문서 |
|
||||
|---------|------|----------|------|
|
||||
| **Telegram** | 쉬움(봇 토큰) | Long polling | [가이드](docs/channels/telegram/README.md) |
|
||||
| **Discord** | 쉬움(봇 토큰 + intents) | WebSocket | [가이드](docs/channels/discord/README.md) |
|
||||
| **WhatsApp** | 쉬움(QR 스캔 또는 브리지 URL) | Native / Bridge | [가이드](docs/chat-apps.md#whatsapp) |
|
||||
| **Weixin** | 쉬움(네이티브 QR 스캔) | iLink API | [가이드](docs/chat-apps.md#weixin) |
|
||||
| **QQ** | 쉬움(AppID + AppSecret) | WebSocket | [가이드](docs/channels/qq/README.md) |
|
||||
| **Slack** | 쉬움(봇 + 앱 토큰) | Socket Mode | [가이드](docs/channels/slack/README.md) |
|
||||
| **Matrix** | 중간(homeserver + 토큰) | Sync API | [가이드](docs/channels/matrix/README.md) |
|
||||
| **DingTalk** | 중간(클라이언트 자격 증명) | Stream | [가이드](docs/channels/dingtalk/README.md) |
|
||||
| **Feishu / Lark** | 중간(App ID + Secret) | WebSocket/SDK | [가이드](docs/channels/feishu/README.md) |
|
||||
| **LINE** | 중간(인증 정보 + webhook) | Webhook | [가이드](docs/channels/line/README.md) |
|
||||
| **WeCom** | 쉬움(QR 로그인 또는 수동 설정) | WebSocket | [가이드](docs/channels/wecom/README.md) |
|
||||
| **VK** | 쉬움(그룹 토큰) | Long Poll | [가이드](docs/channels/vk/README.md) |
|
||||
| **IRC** | 중간(서버 + 닉네임) | IRC protocol | [가이드](docs/chat-apps.md#irc) |
|
||||
| **OneBot** | 중간(WebSocket URL) | OneBot v11 | [가이드](docs/channels/onebot/README.md) |
|
||||
| **MaixCam** | 쉬움(활성화) | TCP socket | [가이드](docs/channels/maixcam/README.md) |
|
||||
| **Pico** | 쉬움(활성화) | 네이티브 프로토콜 | 내장 |
|
||||
| **Pico Client** | 쉬움(WebSocket URL) | WebSocket | 내장 |
|
||||
|
||||
> webhook 기반 채널은 모두 하나의 게이트웨이 HTTP 서버(`gateway.host`:`gateway.port`, 기본값 `127.0.0.1:18790`)를 공유합니다. Feishu는 WebSocket/SDK 모드를 사용하며 이 공용 HTTP 서버를 사용하지 않습니다.
|
||||
|
||||
> 로그 상세도는 `gateway.log_level`(기본값: `warn`)로 제어됩니다. 지원 값은 `debug`, `info`, `warn`, `error`, `fatal`입니다. `PICOCLAW_LOG_LEVEL` 환경 변수로도 설정할 수 있습니다. 자세한 내용은 [설정 문서](docs/configuration.md#gateway-log-level)를 참고하세요.
|
||||
|
||||
자세한 채널 설정 방법은 [채팅 앱 설정 가이드](docs/chat-apps.md)를 참고하세요.
|
||||
|
||||
## 🔧 도구
|
||||
|
||||
### 🔍 웹 검색
|
||||
|
||||
PicoClaw는 최신 정보를 제공하기 위해 웹 검색을 수행할 수 있습니다. `tools.web`에서 설정하세요.
|
||||
|
||||
| 검색 엔진 | API Key | 무료 제공량 | 링크 |
|
||||
|-----------|---------|-------------|------|
|
||||
| DuckDuckGo | 불필요 | 무제한 | 내장 백업 검색 |
|
||||
| [Baidu Search](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | 필수 | 하루 1000회 쿼리 | AI 기반, 중국 시장 최적화 |
|
||||
| [Tavily](https://tavily.com) | 필수 | 월 1000회 쿼리 | AI 에이전트에 최적화 |
|
||||
| [Brave Search](https://brave.com/search/api) | 필수 | 월 2000회 쿼리 | 빠르고 프라이빗함 |
|
||||
| [Perplexity](https://www.perplexity.ai) | 필수 | 유료 | AI 기반 검색 |
|
||||
| [SearXNG](https://github.com/searxng/searxng) | 불필요 | 셀프 호스팅 | 무료 메타 검색 엔진 |
|
||||
| [GLM Search](https://open.bigmodel.cn/) | 필수 | 상이함 | Zhipu 웹 검색 |
|
||||
|
||||
### ⚙️ 기타 도구
|
||||
|
||||
PicoClaw에는 파일 작업, 코드 실행, 스케줄링 등을 위한 내장 도구가 포함되어 있습니다. 자세한 내용은 [도구 설정](docs/tools_configuration.md)을 참고하세요.
|
||||
|
||||
## 🎯 스킬
|
||||
|
||||
스킬은 에이전트 기능을 확장하는 모듈형 구성 요소입니다. 워크스페이스 안의 `SKILL.md` 파일에서 로드됩니다.
|
||||
|
||||
**ClawHub에서 스킬 설치:**
|
||||
|
||||
```bash
|
||||
picoclaw skills search "web scraping"
|
||||
picoclaw skills install <skill-name>
|
||||
```
|
||||
|
||||
**ClawHub 토큰 설정**(선택 사항, 더 높은 호출 한도용):
|
||||
|
||||
`config.json`에 다음을 추가하세요.
|
||||
```json
|
||||
{
|
||||
"tools": {
|
||||
"skills": {
|
||||
"registries": {
|
||||
"clawhub": {
|
||||
"auth_token": "your-clawhub-token"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
자세한 내용은 [도구 설정 - 스킬](docs/tools_configuration.md#skills-tool)를 참고하세요.
|
||||
|
||||
## 🔗 MCP (Model Context Protocol)
|
||||
|
||||
PicoClaw는 [MCP](https://modelcontextprotocol.io/)를 기본 지원합니다. 어떤 MCP 서버든 연결하여 외부 도구와 데이터 소스로 에이전트 기능을 확장할 수 있습니다.
|
||||
|
||||
```json
|
||||
{
|
||||
"tools": {
|
||||
"mcp": {
|
||||
"enabled": true,
|
||||
"servers": {
|
||||
"filesystem": {
|
||||
"enabled": true,
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
MCP 전체 설정(stdio, SSE, HTTP 전송 방식, 도구 탐색)은 [도구 설정 - MCP](docs/tools_configuration.md#mcp-tool)를 참고하세요.
|
||||
|
||||
## <img src="assets/clawdchat-icon.png" width="24" height="24" alt="ClawdChat"> 에이전트 소셜 네트워크 참여하기
|
||||
|
||||
CLI 또는 통합된 채팅 앱에서 메시지를 한 번만 보내면 PicoClaw를 에이전트 소셜 네트워크에 연결할 수 있습니다.
|
||||
|
||||
**`https://clawdchat.ai/skill.md`를 읽고 안내에 따라 [ClawdChat.ai](https://clawdchat.ai)에 참여하세요**
|
||||
|
||||
## 🖥️ CLI 레퍼런스
|
||||
|
||||
| 명령어 | 설명 |
|
||||
| ------------------------- | ------------------------------ |
|
||||
| `picoclaw onboard` | 설정 및 워크스페이스 초기화 |
|
||||
| `picoclaw auth weixin` | QR로 WeChat 계정 연결 |
|
||||
| `picoclaw agent -m "..."` | 에이전트와 채팅 |
|
||||
| `picoclaw agent` | 대화형 채팅 모드 |
|
||||
| `picoclaw gateway` | 게이트웨이 시작 |
|
||||
| `picoclaw status` | 상태 표시 |
|
||||
| `picoclaw version` | 버전 정보 표시 |
|
||||
| `picoclaw model` | 기본 모델 조회 또는 변경 |
|
||||
| `picoclaw cron list` | 모든 예약 작업 목록 표시 |
|
||||
| `picoclaw cron add ...` | 예약 작업 추가 |
|
||||
| `picoclaw cron disable` | 예약 작업 비활성화 |
|
||||
| `picoclaw cron remove` | 예약 작업 삭제 |
|
||||
| `picoclaw skills list` | 설치된 스킬 목록 표시 |
|
||||
| `picoclaw skills install` | 스킬 설치 |
|
||||
| `picoclaw migrate` | 이전 버전 데이터 마이그레이션 |
|
||||
| `picoclaw auth login` | 프로바이더 인증 |
|
||||
|
||||
### ⏰ 예약 작업 / 리마인더
|
||||
|
||||
PicoClaw는 `cron` 도구를 통해 예약 리마인더와 반복 작업을 지원합니다.
|
||||
|
||||
* **1회성 리마인더**: "10분 후에 알려줘" -> 10분 후 한 번 실행
|
||||
* **반복 작업**: "2시간마다 알려줘" -> 2시간마다 실행
|
||||
* **Cron 표현식**: "매일 오전 9시에 알려줘" -> cron 표현식 사용
|
||||
|
||||
현재 지원하는 스케줄 유형, 실행 모드, 명령 작업 게이트, 저장 방식은 [docs/cron.md](docs/cron.md)를 참고하세요.
|
||||
|
||||
## 📚 문서
|
||||
|
||||
이 README보다 더 자세한 가이드는 다음 문서를 참고하세요.
|
||||
|
||||
| 주제 | 설명 |
|
||||
|------|------|
|
||||
| [도커 & 빠른 시작](docs/docker.md) | Docker Compose 설정, 런처/에이전트 모드 |
|
||||
| [채팅 앱](docs/chat-apps.md) | 17개 이상의 채널 설정 가이드 |
|
||||
| [설정](docs/configuration.md) | 환경 변수, 워크스페이스 레이아웃, 보안 샌드박스 |
|
||||
| [예약 작업과 Cron](docs/cron.md) | Cron 스케줄 유형, 전달 모드, 명령 게이트, 작업 저장 |
|
||||
| [프로바이더와 모델](docs/providers.md) | 30개 이상의 LLM 프로바이더, 모델 라우팅, model_list 설정 |
|
||||
| [Spawn & 비동기 작업](docs/spawn-tasks.md) | 빠른 작업, spawn을 이용한 장기 작업, 비동기 서브에이전트 오케스트레이션 |
|
||||
| [Hooks](docs/hooks/README.md) | 이벤트 기반 Hook 시스템: 관찰자, 인터셉터, 승인 훅 |
|
||||
| [Steering](docs/steering.md) | 실행 중인 에이전트 루프에서 도구 호출 사이에 메시지 주입 |
|
||||
| [SubTurn](docs/subturn.md) | 서브에이전트 조정, 동시성 제어, 생명주기 |
|
||||
| [문제 해결](docs/troubleshooting.md) | 자주 발생하는 문제와 해결 방법 |
|
||||
| [도구 설정](docs/tools_configuration.md) | 도구별 활성화/비활성화, exec 정책, MCP, 스킬 |
|
||||
| [하드웨어 호환성](docs/hardware-compatibility.md) | 테스트된 보드, 최소 요구사항 |
|
||||
|
||||
## 🤝 기여 & 로드맵
|
||||
|
||||
PR은 언제든 환영합니다! 코드베이스는 의도적으로 작고 읽기 쉽게 유지하고 있습니다.
|
||||
|
||||
가이드라인은 [커뮤니티 로드맵](https://github.com/sipeed/picoclaw/issues/988)과 [CONTRIBUTING.md](CONTRIBUTING.md)를 참고하세요.
|
||||
|
||||
개발자 그룹도 준비 중입니다. 첫 PR이 머지되면 함께할 수 있습니다!
|
||||
|
||||
커뮤니티 그룹:
|
||||
|
||||
Discord: <https://discord.gg/V4sAZ9XWpN>
|
||||
|
||||
WeChat:
|
||||
<img src="assets/wechat.png" alt="WeChat group QR code" width="512">
|
||||
|
|
@ -18,7 +18,7 @@
|
|||
<a href="https://discord.gg/V4sAZ9XWpN"><img src="https://img.shields.io/badge/Discord-Community-4c60eb?style=flat&logo=discord&logoColor=white" alt="Discord"></a>
|
||||
</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) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [Malay](README.my.md) | **English**
|
||||
[中文](README.zh.md) | [日本語](README.ja.md) | [한국어](README.ko.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [Malay](README.my.md) | **English**
|
||||
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@
|
|||
<a href="https://discord.gg/V4sAZ9XWpN"><img src="https://img.shields.io/badge/Discord-Community-4c60eb?style=flat&logo=discord&logoColor=white" alt="Discord"></a>
|
||||
</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) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | **Malay** | [English](README.md)
|
||||
[中文](README.zh.md) | [日本語](README.ja.md) | [한국어](README.ko.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | **Malay** | [English](README.md)
|
||||
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@
|
|||
<a href="https://discord.gg/V4sAZ9XWpN"><img src="https://img.shields.io/badge/Discord-Community-4c60eb?style=flat&logo=discord&logoColor=white" alt="Discord"></a>
|
||||
</p>
|
||||
|
||||
[中文](README.zh.md) | [日本語](README.ja.md) | **Português** | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [Malay](README.my.md) | [English](README.md)
|
||||
[中文](README.zh.md) | [日本語](README.ja.md) | [한국어](README.ko.md) | **Português** | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [Malay](README.my.md) | [English](README.md)
|
||||
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@
|
|||
<a href="https://discord.gg/V4sAZ9XWpN"><img src="https://img.shields.io/badge/Discord-Community-4c60eb?style=flat&logo=discord&logoColor=white" alt="Discord"></a>
|
||||
</p>
|
||||
|
||||
[中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | **Tiếng Việt** | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [Malay](README.my.md) | [English](README.md)
|
||||
[中文](README.zh.md) | [日本語](README.ja.md) | [한국어](README.ko.md) | [Português](README.pt-br.md) | **Tiếng Việt** | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [Malay](README.my.md) | [English](README.md)
|
||||
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@
|
|||
<a href="https://discord.gg/V4sAZ9XWpN"><img src="https://img.shields.io/badge/Discord-Community-4c60eb?style=flat&logo=discord&logoColor=white" alt="Discord"></a>
|
||||
</p>
|
||||
|
||||
**中文** | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [Malay](README.my.md) | [English](README.md)
|
||||
**中文** | [日本語](README.ja.md) | [한국어](README.ko.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [Malay](README.my.md) | [English](README.md)
|
||||
|
||||
</div>
|
||||
|
||||
|
|
@ -620,4 +620,3 @@ WeChat:
|
|||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -26,18 +26,9 @@ RUN apk add --no-cache ca-certificates tzdata curl
|
|||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD wget -q --spider http://localhost:18790/health || exit 1
|
||||
|
||||
# Copy binary
|
||||
# Copy binary and first-run entrypoint (same as release image).
|
||||
COPY --from=builder /src/build/picoclaw /usr/local/bin/picoclaw
|
||||
COPY docker/entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
# Create non-root user and group
|
||||
RUN addgroup -g 1000 picoclaw && \
|
||||
adduser -D -u 1000 -G picoclaw picoclaw
|
||||
|
||||
# Switch to non-root user
|
||||
USER picoclaw
|
||||
|
||||
# Run onboard to create initial directories and config
|
||||
RUN /usr/local/bin/picoclaw onboard
|
||||
|
||||
ENTRYPOINT ["picoclaw"]
|
||||
CMD ["gateway"]
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
|
|
|
|||
|
|
@ -48,20 +48,13 @@ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
|||
# Copy binary
|
||||
COPY --from=builder /src/build/picoclaw /usr/local/bin/picoclaw
|
||||
|
||||
# Reuse existing node user (UID/GID 1000) — rename to picoclaw
|
||||
RUN deluser node 2>/dev/null; delgroup node 2>/dev/null; \
|
||||
addgroup -g 1000 picoclaw 2>/dev/null; \
|
||||
adduser -D -u 1000 -G picoclaw -h /home/picoclaw picoclaw 2>/dev/null || true
|
||||
|
||||
USER picoclaw
|
||||
|
||||
# Run onboard to create initial directories and config
|
||||
RUN /usr/local/bin/picoclaw onboard
|
||||
|
||||
# Copy default workspace
|
||||
COPY --chown=picoclaw:picoclaw workspace/ /home/picoclaw/.picoclaw/workspace/
|
||||
COPY workspace/ /root/.picoclaw/workspace/
|
||||
|
||||
VOLUME /home/picoclaw/.picoclaw/workspace
|
||||
VOLUME /root/.picoclaw/workspace
|
||||
|
||||
ENTRYPOINT ["picoclaw"]
|
||||
CMD ["gateway"]
|
||||
|
|
|
|||
25
go.mod
25
go.mod
|
|
@ -9,9 +9,8 @@ require (
|
|||
github.com/adhocore/gronx v1.19.6
|
||||
github.com/anthropics/anthropic-sdk-go v1.26.0
|
||||
github.com/atc0005/go-teams-notify/v2 v2.14.0
|
||||
github.com/atotto/clipboard v0.1.4
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.5
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.12
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.14
|
||||
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.4
|
||||
github.com/bwmarrin/discordgo v0.29.0
|
||||
github.com/caarlos0/env/v11 v11.4.0
|
||||
|
|
@ -26,8 +25,8 @@ require (
|
|||
github.com/larksuite/oapi-sdk-go/v3 v3.5.3
|
||||
github.com/mdp/qrterminal/v3 v3.2.1
|
||||
github.com/minio/selfupdate v0.6.0
|
||||
github.com/modelcontextprotocol/go-sdk v1.4.1
|
||||
github.com/mymmrac/telego v1.7.0
|
||||
github.com/modelcontextprotocol/go-sdk v1.5.0
|
||||
github.com/mymmrac/telego v1.8.0
|
||||
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1
|
||||
github.com/openai/openai-go/v3 v3.22.0
|
||||
github.com/pion/rtp v1.10.1
|
||||
|
|
@ -46,7 +45,7 @@ require (
|
|||
google.golang.org/protobuf v1.36.11
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
maunium.net/go/mautrix v0.26.4
|
||||
modernc.org/sqlite v1.48.0
|
||||
modernc.org/sqlite v1.48.2
|
||||
rsc.io/qr v0.2.0
|
||||
)
|
||||
|
||||
|
|
@ -54,17 +53,17 @@ require (
|
|||
aead.dev/minisign v0.2.0 // indirect
|
||||
filippo.io/edwards25519 v1.2.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.12 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.14 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.8 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.13 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.9 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.9 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.15 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 // indirect
|
||||
github.com/aws/smithy-go v1.24.2 // indirect
|
||||
github.com/beeper/argo-go v1.1.2 // indirect
|
||||
github.com/cloudflare/circl v1.6.3 // indirect
|
||||
|
|
@ -131,7 +130,7 @@ require (
|
|||
golang.org/x/crypto v0.49.0
|
||||
golang.org/x/net v0.52.0
|
||||
golang.org/x/sync v0.20.0
|
||||
golang.org/x/sys v0.42.0
|
||||
golang.org/x/sys v0.43.0
|
||||
)
|
||||
|
||||
replace github.com/bwmarrin/discordgo => github.com/yeongaori/discordgo-fork v0.0.0-20260319072544-e8e546f5d532
|
||||
|
|
|
|||
54
go.sum
54
go.sum
|
|
@ -23,18 +23,16 @@ github.com/anthropics/anthropic-sdk-go v1.26.0 h1:oUTzFaUpAevfuELAP1sjL6CQJ9HHAf
|
|||
github.com/anthropics/anthropic-sdk-go v1.26.0/go.mod h1:qUKmaW+uuPB64iy1l+4kOSvaLqPXnHTTBKH6RVZ7q5Q=
|
||||
github.com/atc0005/go-teams-notify/v2 v2.14.0 h1:7N+xw+COnYANLREaAveQ65rsNQ12nIZJED9nMLyscCo=
|
||||
github.com/atc0005/go-teams-notify/v2 v2.14.0/go.mod h1:EECsWM2b0Hvoz7O+QdlsvyN2KCUOFQCGj8bUBXv3A3Q=
|
||||
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.12 h1:O3csC7HUGn2895eNrLytOJQdoL2xyJy0iYXhoZ1OmP0=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.12/go.mod h1:96zTvoOFR4FURjI+/5wY1vc1ABceROO4lWgWJuxgy0g=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.12 h1:oqtA6v+y5fZg//tcTWahyN9PEn5eDU/Wpvc2+kJ4aY8=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.12/go.mod h1:U3R1RtSHx6NB0DvEQFGyf/0sbrpJrluENHdPy1j/3TE=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20 h1:zOgq3uezl5nznfoK3ODuqbhVg1JzAGDUhXOsU0IDCAo=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20/go.mod h1:z/MVwUARehy6GAg/yQ1GO2IMl0k++cu1ohP9zo887wE=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.14 h1:opVIRo/ZbbI8OIqSOKmpFaY7IwfFUOCCXBsUpJOwDdI=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.14/go.mod h1:U4/V0uKxh0Tl5sxmCBZ3AecYny4UNlVmObYjKuuaiOo=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.14 h1:n+UcGWAIZHkXzYt87uMFBv/l8THYELoX6gVcUvgl6fI=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.14/go.mod h1:cJKuyWB59Mqi0jM3nFYQRmnHVQIcgoxjEMAbLkpr62w=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21 h1:NUS3K4BTDArQqNu2ih7yeDLaS3bmHD0YndtA6UP884g=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21/go.mod h1:YWNWJQNjKigKY1RHVJCuupeWDrrHjRqHm0N9rdrWzYI=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 h1:Rgg6wvjjtX8bNHcvi9OnXWwcE0a2vGpbwmtICOsvcf4=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21/go.mod h1:A/kJFst/nm//cyqonihbdpQZwiUhhzpqTsdbhDdRF9c=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 h1:PEgGVtPoB6NTpPrBgqSE5hE/o47Ij9qk/SEZFbUOe9A=
|
||||
|
|
@ -45,16 +43,16 @@ github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.4 h1:W6tKfa/s37faUnwJ7
|
|||
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.4/go.mod h1:BZ+9thH0QOTDUwE8KAv/ZwUzsNC7CSMJXj/wtnZMs5k=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20 h1:2HvVAIq+YqgGotK6EkMf+KIEqTISmTYh5zLpYyeTo1Y=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20/go.mod h1:V4X406Y666khGa8ghKmphma/7C0DAtEQYhkq9z4vpbk=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.8 h1:0GFOLzEbOyZABS3PhYfBIx2rNBACYcKty+XGkTgw1ow=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.8/go.mod h1:LXypKvk85AROkKhOG6/YEcHFPoX+prKTowKnVdcaIxE=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.13 h1:kiIDLZ005EcKomYYITtfsjn7dtOwHDOFy7IbPXKek2o=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.13/go.mod h1:2h/xGEowcW/g38g06g3KpRWDlT+OTfxxI0o1KqayAB8=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17 h1:jzKAXIlhZhJbnYwHbvUQZEB8KfgAEuG0dc08Bkda7NU=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17/go.mod h1:Al9fFsXjv4KfbzQHGe6V4NZSZQXecFcvaIF4e70FoRA=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.9 h1:Cng+OOwCHmFljXIxpEVXAGMnBia8MSU6Ch5i9PgBkcU=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.9/go.mod h1:LrlIndBDdjA/EeXeyNBle+gyCwTlizzW5ycgWnvIxkk=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 h1:c31//R3xgIJMSC8S6hEVq+38DcvUlgFY0FM6mSI5oto=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21/go.mod h1:r6+pf23ouCB718FUxaqzZdbpYFyDtehyZcmP5KL9FkA=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.9 h1:QKZH0S178gCmFEgst8hN0mCX1KxLgHBKKY/CLqwP8lg=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.9/go.mod h1:7yuQJoT+OoH8aqIxw9vwF+8KpvLZ8AWmvmUWHsGQZvI=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.15 h1:lFd1+ZSEYJZYvv9d6kXzhkZu07si3f+GQ1AaYwa2LUM=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.15/go.mod h1:WSvS1NLr7JaPunCXqpJnWk1Bjo7IxzZXrZi1QQCkuqM=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19 h1:dzztQ1YmfPrxdrOiuZRMF6fuOwWlWpD2StNLTceKpys=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19/go.mod h1:YO8TrYtFdl5w/4vmjL8zaBSsiNp3w0L1FfKVKenZT7w=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 h1:p8ogvvLugcR/zLBXTXrTkj0RYBUdErbMnAFFp12Lm/U=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.10/go.mod h1:60dv0eZJfeVXfbT1tFJinbHrDfSJ2GZl4Q//OSSNAVw=
|
||||
github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng=
|
||||
github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
||||
github.com/beeper/argo-go v1.1.2 h1:UQI2G8F+NLfGTOmTUI0254pGKx/HUU/etbUGTJv91Fs=
|
||||
|
|
@ -117,8 +115,8 @@ github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
|
|||
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
|
||||
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
|
||||
|
|
@ -187,10 +185,10 @@ github.com/mdp/qrterminal/v3 v3.2.1 h1:6+yQjiiOsSuXT5n9/m60E54vdgFsw0zhADHhHLrFe
|
|||
github.com/mdp/qrterminal/v3 v3.2.1/go.mod h1:jOTmXvnBsMy5xqLniO0R++Jmjs2sTm9dFSuQ5kpz/SU=
|
||||
github.com/minio/selfupdate v0.6.0 h1:i76PgT0K5xO9+hjzKcacQtO7+MjJ4JKA8Ak8XQ9DDwU=
|
||||
github.com/minio/selfupdate v0.6.0/go.mod h1:bO02GTIPCMQFTEvE5h4DjYB58bCoZ35XLeBf0buTDdM=
|
||||
github.com/modelcontextprotocol/go-sdk v1.4.1 h1:M4x9GyIPj+HoIlHNGpK2hq5o3BFhC+78PkEaldQRphc=
|
||||
github.com/modelcontextprotocol/go-sdk v1.4.1/go.mod h1:Bo/mS87hPQqHSRkMv4dQq1XCu6zv4INdXnFZabkNU6s=
|
||||
github.com/mymmrac/telego v1.7.0 h1:yRO/l00tFGG4nY66ufUKb4ARqv7qx9+LsjQv/b0NEyo=
|
||||
github.com/mymmrac/telego v1.7.0/go.mod h1:pdLV346EgVuq7Xrh3kMggeBiazeHhsdEoK0RTEOPXRM=
|
||||
github.com/modelcontextprotocol/go-sdk v1.5.0 h1:CHU0FIX9kpueNkxuYtfYQn1Z0slhFzBZuq+x6IiblIU=
|
||||
github.com/modelcontextprotocol/go-sdk v1.5.0/go.mod h1:gggDIhoemhWs3BGkGwd1umzEXCEMMvAnhTrnbXJKKKA=
|
||||
github.com/mymmrac/telego v1.8.0 h1:EvIprWo9Cn0MHgumvvqNXPAXO1yJj3pu2cdCCeDxbow=
|
||||
github.com/mymmrac/telego v1.8.0/go.mod h1:pdLV346EgVuq7Xrh3kMggeBiazeHhsdEoK0RTEOPXRM=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
|
||||
|
|
@ -375,8 +373,8 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
|
|
@ -458,8 +456,8 @@ modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
|||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.48.0 h1:ElZyLop3Q2mHYk5IFPPXADejZrlHu7APbpB0sF78bq4=
|
||||
modernc.org/sqlite v1.48.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
|
||||
modernc.org/sqlite v1.48.2 h1:5CnW4uP8joZtA0LedVqLbZV5GD7F/0x91AXeSyjoh5c=
|
||||
modernc.org/sqlite v1.48.2/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
|
|
|
|||
|
|
@ -88,6 +88,7 @@ type processOptions struct {
|
|||
DefaultResponse string // Response when LLM returns empty
|
||||
EnableSummary bool // Whether to trigger summarization
|
||||
SendResponse bool // Whether to send response via bus
|
||||
AllowInterimPicoPublish bool // Whether pico tool-call interim text can be published when SendResponse is false
|
||||
SuppressToolFeedback bool // Whether to suppress inline tool feedback messages
|
||||
NoHistory bool // If true, don't load session history (for heartbeat)
|
||||
SkipInitialSteeringPoll bool // If true, skip the steering poll at loop start (used by Continue)
|
||||
|
|
@ -1398,18 +1399,19 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
|
|||
})
|
||||
|
||||
opts := processOptions{
|
||||
SessionKey: sessionKey,
|
||||
Channel: msg.Channel,
|
||||
ChatID: msg.ChatID,
|
||||
MessageID: msg.MessageID,
|
||||
ReplyToMessageID: inboundMetadata(msg, metadataKeyReplyToMessage),
|
||||
SenderID: msg.SenderID,
|
||||
SenderDisplayName: msg.Sender.DisplayName,
|
||||
UserMessage: msg.Content,
|
||||
Media: msg.Media,
|
||||
DefaultResponse: defaultResponse,
|
||||
EnableSummary: true,
|
||||
SendResponse: false,
|
||||
SessionKey: sessionKey,
|
||||
Channel: msg.Channel,
|
||||
ChatID: msg.ChatID,
|
||||
MessageID: msg.MessageID,
|
||||
ReplyToMessageID: inboundMetadata(msg, metadataKeyReplyToMessage),
|
||||
SenderID: msg.SenderID,
|
||||
SenderDisplayName: msg.Sender.DisplayName,
|
||||
UserMessage: msg.Content,
|
||||
Media: msg.Media,
|
||||
DefaultResponse: defaultResponse,
|
||||
EnableSummary: true,
|
||||
SendResponse: false,
|
||||
AllowInterimPicoPublish: true,
|
||||
}
|
||||
|
||||
// context-dependent commands check their own Runtime fields and report
|
||||
|
|
@ -2253,6 +2255,26 @@ turnLoop:
|
|||
}
|
||||
logger.DebugCF("agent", "LLM response", llmResponseFields)
|
||||
|
||||
if al.bus != nil && ts.channel == "pico" && len(response.ToolCalls) > 0 && ts.opts.AllowInterimPicoPublish {
|
||||
if strings.TrimSpace(response.Content) != "" {
|
||||
outCtx, outCancel := context.WithTimeout(turnCtx, 3*time.Second)
|
||||
err := al.bus.PublishOutbound(outCtx, bus.OutboundMessage{
|
||||
Channel: ts.channel,
|
||||
ChatID: ts.chatID,
|
||||
Content: response.Content,
|
||||
})
|
||||
outCancel()
|
||||
if err != nil {
|
||||
logger.WarnCF("agent", "Failed to publish pico interim tool-call content", map[string]any{
|
||||
"error": err.Error(),
|
||||
"channel": ts.channel,
|
||||
"chat_id": ts.chatID,
|
||||
"iteration": iteration,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(response.ToolCalls) == 0 || gracefulTerminal {
|
||||
responseContent := response.Content
|
||||
if responseContent == "" && response.ReasoningContent != "" {
|
||||
|
|
@ -2390,7 +2412,7 @@ turnLoop:
|
|||
string(argsJSON),
|
||||
al.cfg.Agents.Defaults.GetToolFeedbackMaxArgsLength(),
|
||||
)
|
||||
feedbackMsg := fmt.Sprintf("\U0001f527 `%s`\n```\n%s\n```", toolName, feedbackPreview)
|
||||
feedbackMsg := utils.FormatToolFeedbackMessage(toolName, feedbackPreview)
|
||||
fbCtx, fbCancel := context.WithTimeout(turnCtx, 3*time.Second)
|
||||
_ = al.bus.PublishOutbound(fbCtx, bus.OutboundMessage{
|
||||
Channel: ts.channel,
|
||||
|
|
@ -2672,7 +2694,7 @@ turnLoop:
|
|||
string(argsJSON),
|
||||
al.cfg.Agents.Defaults.GetToolFeedbackMaxArgsLength(),
|
||||
)
|
||||
feedbackMsg := fmt.Sprintf("\U0001f527 `%s`\n```\n%s\n```", tc.Name, feedbackPreview)
|
||||
feedbackMsg := utils.FormatToolFeedbackMessage(tc.Name, feedbackPreview)
|
||||
fbCtx, fbCancel := context.WithTimeout(turnCtx, 3*time.Second)
|
||||
_ = al.bus.PublishOutbound(fbCtx, bus.OutboundMessage{
|
||||
Channel: ts.channel,
|
||||
|
|
|
|||
|
|
@ -1069,6 +1069,40 @@ func (m *toolFeedbackProvider) GetDefaultModel() string {
|
|||
return "heartbeat-tool-feedback-model"
|
||||
}
|
||||
|
||||
type picoInterleavedContentProvider struct {
|
||||
calls int
|
||||
}
|
||||
|
||||
func (m *picoInterleavedContentProvider) Chat(
|
||||
ctx context.Context,
|
||||
messages []providers.Message,
|
||||
tools []providers.ToolDefinition,
|
||||
model string,
|
||||
opts map[string]any,
|
||||
) (*providers.LLMResponse, error) {
|
||||
m.calls++
|
||||
if m.calls == 1 {
|
||||
return &providers.LLMResponse{
|
||||
Content: "intermediate model text",
|
||||
ToolCalls: []providers.ToolCall{{
|
||||
ID: "call_tool_limit_test",
|
||||
Type: "function",
|
||||
Name: "tool_limit_test_tool",
|
||||
Arguments: map[string]any{"value": "x"},
|
||||
}},
|
||||
}, nil
|
||||
}
|
||||
|
||||
return &providers.LLMResponse{
|
||||
Content: "final model text",
|
||||
ToolCalls: []providers.ToolCall{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *picoInterleavedContentProvider) GetDefaultModel() string {
|
||||
return "pico-interleaved-content-model"
|
||||
}
|
||||
|
||||
type toolLimitOnlyProvider struct{}
|
||||
|
||||
func (m *toolLimitOnlyProvider) Chat(
|
||||
|
|
@ -2732,6 +2766,133 @@ func TestProcessMessage_PublishesToolFeedbackWhenEnabled(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestRun_PicoPublishesAssistantContentDuringToolCallsWithoutFinalDuplicate(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
cfg := &config.Config{
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
Workspace: tmpDir,
|
||||
ModelName: "test-model",
|
||||
MaxTokens: 4096,
|
||||
MaxToolIterations: 10,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
msgBus := bus.NewMessageBus()
|
||||
provider := &picoInterleavedContentProvider{}
|
||||
al := NewAgentLoop(cfg, msgBus, provider)
|
||||
|
||||
agent := al.GetRegistry().GetDefaultAgent()
|
||||
if agent == nil {
|
||||
t.Fatal("expected default agent")
|
||||
}
|
||||
agent.Tools.Register(&toolLimitTestTool{})
|
||||
|
||||
runCtx, runCancel := context.WithCancel(context.Background())
|
||||
defer runCancel()
|
||||
|
||||
runDone := make(chan error, 1)
|
||||
go func() {
|
||||
runDone <- al.Run(runCtx)
|
||||
}()
|
||||
|
||||
if err := msgBus.PublishInbound(context.Background(), bus.InboundMessage{
|
||||
Channel: "pico",
|
||||
SenderID: "user-1",
|
||||
ChatID: "session-1",
|
||||
Content: "run with tools",
|
||||
}); err != nil {
|
||||
t.Fatalf("PublishInbound() error = %v", err)
|
||||
}
|
||||
|
||||
outputs := make([]string, 0, 2)
|
||||
deadline := time.After(2 * time.Second)
|
||||
for len(outputs) < 2 {
|
||||
select {
|
||||
case outbound := <-msgBus.OutboundChan():
|
||||
outputs = append(outputs, outbound.Content)
|
||||
case <-deadline:
|
||||
t.Fatalf("timed out waiting for pico outputs, got %v", outputs)
|
||||
}
|
||||
}
|
||||
|
||||
if outputs[0] != "intermediate model text" {
|
||||
t.Fatalf("first outbound content = %q, want %q", outputs[0], "intermediate model text")
|
||||
}
|
||||
if outputs[1] != "final model text" {
|
||||
t.Fatalf("second outbound content = %q, want %q", outputs[1], "final model text")
|
||||
}
|
||||
|
||||
runCancel()
|
||||
select {
|
||||
case err := <-runDone:
|
||||
if err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("timed out waiting for Run() to exit")
|
||||
}
|
||||
|
||||
select {
|
||||
case outbound := <-msgBus.OutboundChan():
|
||||
if outbound.Content == "final model text" {
|
||||
t.Fatalf("unexpected duplicate final pico output: %+v", outbound)
|
||||
}
|
||||
case <-time.After(200 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunAgentLoop_PicoSkipsInterimPublishWhenNotAllowed(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
cfg := &config.Config{
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
Workspace: tmpDir,
|
||||
ModelName: "test-model",
|
||||
MaxTokens: 4096,
|
||||
MaxToolIterations: 10,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
msgBus := bus.NewMessageBus()
|
||||
provider := &picoInterleavedContentProvider{}
|
||||
al := NewAgentLoop(cfg, msgBus, provider)
|
||||
|
||||
agent := al.GetRegistry().GetDefaultAgent()
|
||||
if agent == nil {
|
||||
t.Fatal("expected default agent")
|
||||
}
|
||||
agent.Tools.Register(&toolLimitTestTool{})
|
||||
|
||||
response, err := al.runAgentLoop(context.Background(), agent, processOptions{
|
||||
SessionKey: "agent:main:pico:session-1",
|
||||
Channel: "pico",
|
||||
ChatID: "session-1",
|
||||
UserMessage: "run with tools",
|
||||
DefaultResponse: defaultResponse,
|
||||
EnableSummary: false,
|
||||
SendResponse: false,
|
||||
AllowInterimPicoPublish: false,
|
||||
SuppressToolFeedback: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("runAgentLoop() error = %v", err)
|
||||
}
|
||||
if response != "final model text" {
|
||||
t.Fatalf("runAgentLoop() response = %q, want %q", response, "final model text")
|
||||
}
|
||||
|
||||
select {
|
||||
case outbound := <-msgBus.OutboundChan():
|
||||
t.Fatalf("unexpected outbound message when interim publish disabled: %+v", outbound)
|
||||
case <-time.After(200 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveMediaRefs_ResolvesToBase64(t *testing.T) {
|
||||
store := media.NewFileMediaStore()
|
||||
dir := t.TempDir()
|
||||
|
|
|
|||
|
|
@ -111,7 +111,7 @@ func (p *startupBlockedProvider) GetDefaultModel() string {
|
|||
}
|
||||
|
||||
// Run starts the gateway runtime using the configuration loaded from configPath.
|
||||
func Run(debug bool, homePath, configPath string, allowEmptyStartup bool) error {
|
||||
func Run(debug bool, homePath, configPath string, allowEmptyStartup bool) (runErr error) {
|
||||
panicPath := filepath.Join(homePath, logPath, panicFile)
|
||||
panicFunc, err := logger.InitPanic(panicPath)
|
||||
if err != nil {
|
||||
|
|
@ -129,14 +129,25 @@ func Run(debug bool, homePath, configPath string, allowEmptyStartup bool) error
|
|||
} else {
|
||||
logger.SetLevelFromString(config.ResolveGatewayLogLevel(configPath))
|
||||
}
|
||||
defer func() {
|
||||
if runErr != nil {
|
||||
logger.ErrorCF("gateway", "Gateway startup failed", map[string]any{
|
||||
"config_path": configPath,
|
||||
"error": runErr.Error(),
|
||||
"home_path": homePath,
|
||||
"allow_empty": allowEmptyStartup,
|
||||
"debug": debug,
|
||||
})
|
||||
}
|
||||
}()
|
||||
|
||||
cfg, err := config.LoadConfig(configPath)
|
||||
if err != nil {
|
||||
logger.Fatalf("error loading config: %v", err)
|
||||
return fmt.Errorf("error loading config: %w", err)
|
||||
}
|
||||
|
||||
if err = preCheckConfig(cfg); err != nil {
|
||||
logger.Fatalf("config pre-check failed: %v", err)
|
||||
return fmt.Errorf("config pre-check failed: %w", err)
|
||||
}
|
||||
|
||||
// Debug mode permanently overrides the config log level to DEBUG.
|
||||
|
|
|
|||
108
pkg/gateway/gateway_test.go
Normal file
108
pkg/gateway/gateway_test.go
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
package gateway
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
func TestRun_StartupFailuresReturnErrorAndEmitStructuredLog(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
prepare func(t *testing.T, dir string) string
|
||||
wantErr string
|
||||
wantLogSub string
|
||||
}{
|
||||
{
|
||||
name: "invalid config returns load error",
|
||||
prepare: func(t *testing.T, dir string) string {
|
||||
t.Helper()
|
||||
cfgPath := filepath.Join(dir, "invalid-config.json")
|
||||
if err := os.WriteFile(cfgPath, []byte("{invalid-json"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile(invalid config) error = %v", err)
|
||||
}
|
||||
return cfgPath
|
||||
},
|
||||
wantErr: "error loading config:",
|
||||
wantLogSub: "error loading config:",
|
||||
},
|
||||
{
|
||||
name: "invalid config returns pre-check error",
|
||||
prepare: func(t *testing.T, dir string) string {
|
||||
t.Helper()
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.Gateway.Port = 0
|
||||
cfgPath := filepath.Join(dir, "config.json")
|
||||
if err := config.SaveConfig(cfgPath, cfg); err != nil {
|
||||
t.Fatalf("SaveConfig() error = %v", err)
|
||||
}
|
||||
return cfgPath
|
||||
},
|
||||
wantErr: "config pre-check failed: invalid gateway port: 0",
|
||||
wantLogSub: "config pre-check failed: invalid gateway port: 0",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
homeDir := t.TempDir()
|
||||
configPath := tt.prepare(t, homeDir)
|
||||
|
||||
cmd := exec.Command(os.Args[0], "-test.run=TestGatewayRunStartupFailureHelper")
|
||||
cmd.Env = append(os.Environ(),
|
||||
"GO_WANT_GATEWAY_RUN_HELPER=1",
|
||||
"PICO_TEST_HOME="+homeDir,
|
||||
"PICO_TEST_CONFIG="+configPath,
|
||||
)
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("helper exited unexpectedly: %v\noutput:\n%s", err, string(output))
|
||||
}
|
||||
|
||||
out := string(output)
|
||||
if !strings.Contains(out, tt.wantErr) {
|
||||
t.Fatalf("helper output missing expected error substring %q:\n%s", tt.wantErr, out)
|
||||
}
|
||||
|
||||
logData, readErr := os.ReadFile(filepath.Join(homeDir, logPath, logFile))
|
||||
if readErr != nil {
|
||||
t.Fatalf("ReadFile(gateway.log) error = %v", readErr)
|
||||
}
|
||||
logText := string(logData)
|
||||
if !strings.Contains(logText, "Gateway startup failed") {
|
||||
t.Fatalf("gateway.log missing structured startup failure log:\n%s", logText)
|
||||
}
|
||||
if !strings.Contains(logText, tt.wantLogSub) {
|
||||
t.Fatalf("gateway.log missing expected failure detail %q:\n%s", tt.wantLogSub, logText)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatewayRunStartupFailureHelper(t *testing.T) {
|
||||
if os.Getenv("GO_WANT_GATEWAY_RUN_HELPER") != "1" {
|
||||
return
|
||||
}
|
||||
|
||||
homeDir := os.Getenv("PICO_TEST_HOME")
|
||||
configPath := os.Getenv("PICO_TEST_CONFIG")
|
||||
|
||||
err := Run(false, homeDir, configPath, false)
|
||||
if err == nil {
|
||||
fmt.Fprintln(os.Stdout, "expected startup error, got nil")
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
fmt.Fprintln(os.Stdout, err.Error())
|
||||
os.Exit(0)
|
||||
}
|
||||
|
|
@ -212,6 +212,9 @@ func TestExistingExposePaths_SkipsMissingPaths(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestPrepareCommand_AppliesUserEnv(t *testing.T) {
|
||||
if !isSupportedOn(runtime.GOOS) {
|
||||
t.Skipf("isolation not supported on %s", runtime.GOOS)
|
||||
}
|
||||
t.Setenv(config.EnvHome, filepath.Join(t.TempDir(), "home"))
|
||||
if runtime.GOOS == "linux" {
|
||||
binDir := filepath.Join(t.TempDir(), "bin")
|
||||
|
|
|
|||
|
|
@ -254,6 +254,22 @@ func TestDecodeToolCallArguments_ObjectJSON(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestDecodeToolCallArguments_ObjectJSON_NewlineEscape(t *testing.T) {
|
||||
raw := json.RawMessage(`{"content":"line1\nline2"}`)
|
||||
args := DecodeToolCallArguments(raw, "write_file")
|
||||
if args["content"] != "line1\nline2" {
|
||||
t.Errorf("content = %q, want newline-expanded string", args["content"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeToolCallArguments_ObjectJSON_LiteralBackslashN(t *testing.T) {
|
||||
raw := json.RawMessage(`{"content":"line1\\nline2"}`)
|
||||
args := DecodeToolCallArguments(raw, "write_file")
|
||||
if args["content"] != `line1\nline2` {
|
||||
t.Errorf("content = %q, want literal backslash-n", args["content"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeToolCallArguments_StringJSON(t *testing.T) {
|
||||
raw := json.RawMessage(`"{\"city\":\"SF\"}"`)
|
||||
args := DecodeToolCallArguments(raw, "test")
|
||||
|
|
|
|||
70
pkg/seahorse/fts5_sanitize.go
Normal file
70
pkg/seahorse/fts5_sanitize.go
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
package seahorse
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// phraseRegex matches complete quoted phrases like "exact phrase".
|
||||
// Compiled once at package level to avoid per-call overhead.
|
||||
var phraseRegex = regexp.MustCompile(`"([^"]+)"`)
|
||||
|
||||
// SanitizeFTS5Query escapes user input for safe use in an FTS5 MATCH expression.
|
||||
//
|
||||
// FTS5 treats certain characters as operators:
|
||||
// - `-` (NOT), `+` (required), `*` (prefix), `^` (initial token)
|
||||
// - `OR`, `AND`, `NOT`, `NEAR` (boolean/proximity operators)
|
||||
// - `:` (column filter — e.g. `agent:foo` means "search column agent")
|
||||
// - `"` (phrase query), `(` `)` (grouping)
|
||||
//
|
||||
// Strategy: wrap each whitespace-delimited token in double quotes so FTS5
|
||||
// treats it as a literal phrase token. User-quoted phrases ("...") are
|
||||
// preserved as-is. Internal double quotes are stripped. Empty tokens are
|
||||
// dropped. Tokens are joined with spaces (implicit AND).
|
||||
//
|
||||
// Returns empty string for blank input so callers can skip the MATCH query.
|
||||
//
|
||||
// Examples:
|
||||
//
|
||||
// "sub-agent restrict" → `"sub-agent" "restrict"`
|
||||
// "lcm_expand OR crash" → `"lcm_expand" "OR" "crash"`
|
||||
// `hello "world"` → `"hello" "world"`
|
||||
func SanitizeFTS5Query(raw string) string {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Preserve user-quoted phrases: extract "..." groups first, then tokenize the rest.
|
||||
var parts []string
|
||||
lastIndex := 0
|
||||
|
||||
for _, loc := range phraseRegex.FindAllStringIndex(raw, -1) {
|
||||
// Process unquoted text before this phrase
|
||||
before := raw[lastIndex:loc[0]]
|
||||
for _, t := range strings.Fields(before) {
|
||||
t = strings.ReplaceAll(t, `"`, "")
|
||||
if t != "" {
|
||||
parts = append(parts, `"`+t+`"`)
|
||||
}
|
||||
}
|
||||
// Preserve the phrase as-is (strip internal quotes for safety)
|
||||
phrase := strings.TrimSpace(strings.ReplaceAll(raw[loc[0]+1:loc[1]-1], `"`, ""))
|
||||
if phrase != "" {
|
||||
parts = append(parts, `"`+phrase+`"`)
|
||||
}
|
||||
lastIndex = loc[1]
|
||||
}
|
||||
|
||||
// Process unquoted text after last phrase
|
||||
for _, t := range strings.Fields(raw[lastIndex:]) {
|
||||
t = strings.ReplaceAll(t, `"`, "")
|
||||
if t != "" {
|
||||
parts = append(parts, `"`+t+`"`)
|
||||
}
|
||||
}
|
||||
|
||||
if len(parts) == 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
237
pkg/seahorse/fts5_sanitize_test.go
Normal file
237
pkg/seahorse/fts5_sanitize_test.go
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
package seahorse
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSanitizeFTS5Query(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
// Basic tokens
|
||||
{"hello world", `"hello" "world"`},
|
||||
{"database", `"database"`},
|
||||
|
||||
// FTS5 operators neutralized
|
||||
{"sub-agent", `"sub-agent"`},
|
||||
{"agent:main", `"agent:main"`},
|
||||
{"+required", `"+required"`},
|
||||
{"prefix*", `"prefix*"`},
|
||||
{"^initial", `"^initial"`},
|
||||
{"crash OR restart", `"crash" "OR" "restart"`},
|
||||
{"NOT excluded", `"NOT" "excluded"`},
|
||||
{"(grouped)", `"(grouped)"`},
|
||||
|
||||
// User-quoted phrases preserved
|
||||
{`"exact phrase" other`, `"exact phrase" "other"`},
|
||||
{`before "middle phrase" after`, `"before" "middle phrase" "after"`},
|
||||
|
||||
// Unmatched quotes stripped
|
||||
{`"unmatched`, `"unmatched"`},
|
||||
{`hello"world`, `"helloworld"`},
|
||||
|
||||
// NEAR operator neutralized
|
||||
{"NEAR/2 agent", `"NEAR/2" "agent"`},
|
||||
|
||||
// Empty input
|
||||
{"", ""},
|
||||
{" ", ""},
|
||||
|
||||
// CJK unaffected
|
||||
{"数据库连接", `"数据库连接"`},
|
||||
{"数据库 连接", `"数据库" "连接"`},
|
||||
{"sub-agent重启", `"sub-agent重启"`},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
got := SanitizeFTS5Query(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("SanitizeFTS5Query(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFTS5SpecialCharsShouldNotError verifies that user input containing
|
||||
// FTS5 special characters does not cause errors when searching.
|
||||
func TestFTS5SpecialCharsShouldNotError(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
conv, _ := s.GetOrCreateConversation(ctx, "test:fts5-sanitize")
|
||||
re := &RetrievalEngine{store: s}
|
||||
|
||||
// Seed data with content containing special characters
|
||||
s.AddMessage(ctx, conv.ConversationID, "user", "the sub-agent restarted after crash", 10)
|
||||
s.AddMessage(ctx, conv.ConversationID, "assistant", "agent:main session restored successfully", 10)
|
||||
s.AddMessage(ctx, conv.ConversationID, "user", "use NOT operator in the query filter", 10)
|
||||
s.CreateSummary(ctx, CreateSummaryInput{
|
||||
ConversationID: conv.ConversationID,
|
||||
Kind: SummaryKindLeaf,
|
||||
Depth: 0,
|
||||
Content: "sub-agent crashed and was restarted by the orchestrator",
|
||||
TokenCount: 50,
|
||||
})
|
||||
s.CreateSummary(ctx, CreateSummaryInput{
|
||||
ConversationID: conv.ConversationID,
|
||||
Kind: SummaryKindLeaf,
|
||||
Depth: 0,
|
||||
Content: "agent:main handled the restart procedure",
|
||||
TokenCount: 50,
|
||||
})
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
pattern string
|
||||
wantSummaryMin int
|
||||
wantMessageMin int
|
||||
}{
|
||||
{
|
||||
name: "hyphen in search term",
|
||||
pattern: "sub-agent",
|
||||
wantSummaryMin: 1,
|
||||
wantMessageMin: 1,
|
||||
},
|
||||
{
|
||||
name: "colon in search term",
|
||||
pattern: "agent:main",
|
||||
wantSummaryMin: 1,
|
||||
wantMessageMin: 1,
|
||||
},
|
||||
{
|
||||
name: "unmatched double quote",
|
||||
pattern: `"sub-agent`,
|
||||
wantSummaryMin: 1,
|
||||
wantMessageMin: 1,
|
||||
},
|
||||
{
|
||||
name: "plus sign",
|
||||
pattern: "+agent",
|
||||
wantSummaryMin: 0,
|
||||
wantMessageMin: 0,
|
||||
},
|
||||
{
|
||||
name: "parentheses",
|
||||
pattern: "(agent)",
|
||||
wantSummaryMin: 0,
|
||||
wantMessageMin: 0,
|
||||
},
|
||||
{
|
||||
name: "NOT keyword",
|
||||
pattern: "NOT operator",
|
||||
wantSummaryMin: 0,
|
||||
wantMessageMin: 1,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, err := re.Grep(ctx, GrepInput{
|
||||
Pattern: tt.pattern,
|
||||
Scope: "both",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Grep(%q) returned error: %v", tt.pattern, err)
|
||||
}
|
||||
if len(result.Summaries) < tt.wantSummaryMin {
|
||||
t.Errorf("Grep(%q) summaries = %d, want >= %d",
|
||||
tt.pattern, len(result.Summaries), tt.wantSummaryMin)
|
||||
}
|
||||
if len(result.Messages) < tt.wantMessageMin {
|
||||
t.Errorf("Grep(%q) messages = %d, want >= %d",
|
||||
tt.pattern, len(result.Messages), tt.wantMessageMin)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFTS5OperatorsNotInterpreted verifies that FTS5 operators are treated
|
||||
// as literal text, not as query syntax. Each case constructs data where
|
||||
// boolean interpretation would produce different results than literal matching.
|
||||
func TestFTS5OperatorsNotInterpreted(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
conv, _ := s.GetOrCreateConversation(ctx, "test:fts5-operators")
|
||||
re := &RetrievalEngine{store: s}
|
||||
|
||||
// "restart only" — contains "restart" but NOT "crash".
|
||||
// If OR is treated as boolean, "crash OR restart" would match this.
|
||||
// With sanitization (literal AND), it should NOT match.
|
||||
s.AddMessage(ctx, conv.ConversationID, "user", "restart the service now please", 10)
|
||||
|
||||
// "subcommand" — starts with "sub" but is not "sub-agent".
|
||||
// If * is treated as prefix wildcard, "sub*" would match this.
|
||||
// With sanitization (literal "sub*"), it should NOT match.
|
||||
s.AddMessage(ctx, conv.ConversationID, "user", "run the subcommand to deploy", 10)
|
||||
|
||||
// "agent grouped" — contains "agent" but not "(agent)".
|
||||
// If () is treated as grouping, "(agent)" would match this.
|
||||
// With sanitization (literal "(agent)"), it should NOT match.
|
||||
s.AddMessage(ctx, conv.ConversationID, "user", "the agent processed the request", 10)
|
||||
|
||||
// Same patterns in summaries
|
||||
s.CreateSummary(ctx, CreateSummaryInput{
|
||||
ConversationID: conv.ConversationID,
|
||||
Kind: SummaryKindLeaf,
|
||||
Depth: 0,
|
||||
Content: "restart procedure completed without any crash involvement",
|
||||
TokenCount: 50,
|
||||
})
|
||||
s.CreateSummary(ctx, CreateSummaryInput{
|
||||
ConversationID: conv.ConversationID,
|
||||
Kind: SummaryKindLeaf,
|
||||
Depth: 0,
|
||||
Content: "subprocess and subcommand management overview",
|
||||
TokenCount: 50,
|
||||
})
|
||||
|
||||
t.Run("OR must not be boolean", func(t *testing.T) {
|
||||
// "crash OR restart" as literal means all three tokens must appear.
|
||||
// The message "restart the service now please" has "restart" but not "crash" or "OR".
|
||||
// Boolean OR would match it; literal AND should not.
|
||||
result, err := re.Grep(ctx, GrepInput{Pattern: "crash OR restart", Scope: "message"})
|
||||
if err != nil {
|
||||
t.Fatalf("Grep returned error: %v", err)
|
||||
}
|
||||
if len(result.Messages) != 0 {
|
||||
t.Errorf(
|
||||
"OR treated as boolean: got %d messages, want 0 (only-restart message should not match literal AND of 'crash','OR','restart')",
|
||||
len(result.Messages),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("asterisk must not be prefix wildcard", func(t *testing.T) {
|
||||
// "sub*" as literal means exact trigram match on "sub*".
|
||||
// The message "run the subcommand to deploy" contains "sub" as prefix.
|
||||
// Prefix wildcard would match it; literal should not.
|
||||
result, err := re.Grep(ctx, GrepInput{Pattern: "sub*", Scope: "message"})
|
||||
if err != nil {
|
||||
t.Fatalf("Grep returned error: %v", err)
|
||||
}
|
||||
if len(result.Messages) != 0 {
|
||||
t.Errorf(
|
||||
"asterisk treated as prefix wildcard: got %d messages, want 0 (literal 'sub*' does not appear in any message)",
|
||||
len(result.Messages),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("parentheses must not be grouping", func(t *testing.T) {
|
||||
// "(agent)" as literal means exact trigram match on "(agent)".
|
||||
// The message "the agent processed the request" contains "agent" without parens.
|
||||
// Grouping would match it; literal should not.
|
||||
result, err := re.Grep(ctx, GrepInput{Pattern: "(agent)", Scope: "message"})
|
||||
if err != nil {
|
||||
t.Fatalf("Grep returned error: %v", err)
|
||||
}
|
||||
if len(result.Messages) != 0 {
|
||||
t.Errorf(
|
||||
"parentheses treated as grouping: got %d messages, want 0 (literal '(agent)' does not appear in any message)",
|
||||
len(result.Messages),
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -1178,9 +1178,14 @@ func (s *Store) SearchSummaries(ctx context.Context, input SearchInput) ([]Searc
|
|||
}
|
||||
|
||||
func (s *Store) searchSummariesFTS(ctx context.Context, input SearchInput) ([]SearchResult, error) {
|
||||
sanitized := SanitizeFTS5Query(input.Pattern)
|
||||
if sanitized == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Build WHERE clause for filters (used in both count and data queries)
|
||||
whereClauses := []string{"summaries_fts MATCH ?"}
|
||||
args := []any{input.Pattern}
|
||||
args := []any{sanitized}
|
||||
|
||||
if input.ConversationID > 0 && !input.AllConversations {
|
||||
whereClauses = append(whereClauses, "s.conversation_id = ?")
|
||||
|
|
@ -1326,9 +1331,14 @@ func (s *Store) SearchMessages(ctx context.Context, input SearchInput) ([]Search
|
|||
}
|
||||
|
||||
func (s *Store) searchMessagesFTS(ctx context.Context, input SearchInput) ([]SearchResult, error) {
|
||||
sanitized := SanitizeFTS5Query(input.Pattern)
|
||||
if sanitized == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Build WHERE clause for filters (used in both count and data queries)
|
||||
whereClauses := []string{"messages_fts MATCH ?"}
|
||||
args := []any{input.Pattern}
|
||||
args := []any{sanitized}
|
||||
|
||||
if input.ConversationID > 0 && !input.AllConversations {
|
||||
whereClauses = append(whereClauses, "m.conversation_id = ?")
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ func (t *EditFileTool) Name() string {
|
|||
}
|
||||
|
||||
func (t *EditFileTool) Description() string {
|
||||
return "Edit a file by replacing old_text with new_text. The old_text must exist exactly in the file. In `function.arguments`, use \\n for newline and \\\\n for literal backslash-n."
|
||||
return "Edit a file by replacing old_text with new_text. The old_text must exist exactly in the file. Standard JSON escaping applies: \\n for newline and \\\\n for literal backslash-n."
|
||||
}
|
||||
|
||||
func (t *EditFileTool) Parameters() map[string]any {
|
||||
|
|
@ -42,11 +42,11 @@ func (t *EditFileTool) Parameters() map[string]any {
|
|||
},
|
||||
"old_text": map[string]any{
|
||||
"type": "string",
|
||||
"description": "The exact text to find and replace. In `function.arguments`, use \\n for newline and \\\\n for literal backslash-n.",
|
||||
"description": "The exact text to find and replace. Standard JSON escaping applies: \\n for newline and \\\\n for literal backslash-n.",
|
||||
},
|
||||
"new_text": map[string]any{
|
||||
"type": "string",
|
||||
"description": "The text to replace with. In `function.arguments`, use \\n for newline and \\\\n for literal backslash-n.",
|
||||
"description": "The text to replace with. Standard JSON escaping applies: \\n for newline and \\\\n for literal backslash-n.",
|
||||
},
|
||||
},
|
||||
"required": []string{"path", "old_text", "new_text"},
|
||||
|
|
@ -92,7 +92,7 @@ func (t *AppendFileTool) Name() string {
|
|||
}
|
||||
|
||||
func (t *AppendFileTool) Description() string {
|
||||
return "Append content to the end of a file. In `function.arguments`, use \\n for newline and \\\\n for literal backslash-n."
|
||||
return "Append content to the end of a file. Standard JSON escaping applies: \\n for newline and \\\\n for literal backslash-n."
|
||||
}
|
||||
|
||||
func (t *AppendFileTool) Parameters() map[string]any {
|
||||
|
|
@ -105,7 +105,7 @@ func (t *AppendFileTool) Parameters() map[string]any {
|
|||
},
|
||||
"content": map[string]any{
|
||||
"type": "string",
|
||||
"description": "The content to append. In `function.arguments`, use \\n for newline and \\\\n for literal backslash-n.",
|
||||
"description": "The content to append. Standard JSON escaping applies: \\n for newline and \\\\n for literal backslash-n.",
|
||||
},
|
||||
},
|
||||
"required": []string{"path", "content"},
|
||||
|
|
|
|||
|
|
@ -870,7 +870,7 @@ func (t *WriteFileTool) Name() string {
|
|||
}
|
||||
|
||||
func (t *WriteFileTool) Description() string {
|
||||
return "Write content to a file. In `function.arguments`, use \\n for a newline and \\\\n for a literal backslash-n sequence. Content is written byte-for-byte after argument decoding. If the file already exists, you must set overwrite=true to replace it."
|
||||
return "Write content to a file. Content is written byte-for-byte after argument decoding. Standard JSON escaping applies: \\n for newline and \\\\n for a literal backslash-n sequence. If the file already exists, you must set overwrite=true to replace it."
|
||||
}
|
||||
|
||||
func (t *WriteFileTool) Parameters() map[string]any {
|
||||
|
|
@ -883,7 +883,7 @@ func (t *WriteFileTool) Parameters() map[string]any {
|
|||
},
|
||||
"content": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Content to write to the file. In `function.arguments`, use \\n for newline and \\\\n for literal backslash-n.",
|
||||
"description": "Content to write to the file. Standard JSON escaping applies: \\n for newline and \\\\n for literal backslash-n.",
|
||||
},
|
||||
"overwrite": map[string]any{
|
||||
"type": "boolean",
|
||||
|
|
|
|||
9
pkg/utils/tool_feedback.go
Normal file
9
pkg/utils/tool_feedback.go
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
package utils
|
||||
|
||||
import "fmt"
|
||||
|
||||
// FormatToolFeedbackMessage renders the tool name and arguments preview in the
|
||||
// same markdown shape used by live tool feedback and session reconstruction.
|
||||
func FormatToolFeedbackMessage(toolName, argsPreview string) string {
|
||||
return fmt.Sprintf("\U0001f527 `%s`\n```\n%s\n```", toolName, argsPreview)
|
||||
}
|
||||
11
pkg/utils/tool_feedback_test.go
Normal file
11
pkg/utils/tool_feedback_test.go
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
package utils
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestFormatToolFeedbackMessage(t *testing.T) {
|
||||
got := FormatToolFeedbackMessage("read_file", "{\"path\":\"README.md\"}")
|
||||
want := "\U0001f527 `read_file`\n```\n{\"path\":\"README.md\"}\n```"
|
||||
if got != want {
|
||||
t.Fatalf("FormatToolFeedbackMessage() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
|
@ -81,8 +81,13 @@ type launcherAuthHandlers struct {
|
|||
loginLimit *loginRateLimiter
|
||||
}
|
||||
|
||||
func (h *launcherAuthHandlers) usesLegacyTokenAuth() bool {
|
||||
return h.store == nil && h.storeErr == nil && h.token != ""
|
||||
}
|
||||
|
||||
// isStoreInitialized safely queries the store.
|
||||
// Returns (false, nil) when no store is configured (storeErr also nil).
|
||||
// Returns (true, nil) when legacy token auth is active without a password store.
|
||||
// Returns (false, nil) when no store/token fallback is configured.
|
||||
// Returns (false, err) on store errors — callers must treat this as a 5xx, not as
|
||||
// "uninitialized", to keep auth fail-closed.
|
||||
// Exception: handleLogin swallows storeErr and falls back to token auth so
|
||||
|
|
@ -95,6 +100,9 @@ func (h *launcherAuthHandlers) isStoreInitialized(ctx context.Context) (bool, er
|
|||
"to recover, stop the application, delete the database file and restart ",
|
||||
h.storeErr)
|
||||
}
|
||||
if h.usesLegacyTokenAuth() {
|
||||
return true, nil
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
return h.store.IsInitialized(ctx)
|
||||
|
|
@ -129,7 +137,7 @@ func (h *launcherAuthHandlers) handleLogin(w http.ResponseWriter, r *http.Reques
|
|||
}
|
||||
}
|
||||
|
||||
if initialized {
|
||||
if initialized && h.store != nil {
|
||||
// Bcrypt path: verify against the stored hash.
|
||||
var err error
|
||||
ok, err = h.store.VerifyPassword(r.Context(), in)
|
||||
|
|
@ -218,6 +226,14 @@ func (h *launcherAuthHandlers) handleStatus(w http.ResponseWriter, r *http.Reque
|
|||
func (h *launcherAuthHandlers) handleSetup(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if h.usesLegacyTokenAuth() {
|
||||
w.WriteHeader(http.StatusNotImplemented)
|
||||
_, _ = w.Write(
|
||||
[]byte(`{"error":"password setup is unavailable on this platform; use the dashboard token instead"}`),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if h.store == nil {
|
||||
w.WriteHeader(http.StatusNotImplemented)
|
||||
_, _ = w.Write([]byte(`{"error":"password store not configured"}`))
|
||||
|
|
|
|||
|
|
@ -75,6 +75,67 @@ func TestLauncherAuthLoginAndStatus(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
func TestLauncherAuthLegacyTokenFallbackReportsInitialized(t *testing.T) {
|
||||
key := make([]byte, 32)
|
||||
const tok = "legacy-fallback-token"
|
||||
sess := middleware.SessionCookieValue(key, tok)
|
||||
mux := http.NewServeMux()
|
||||
RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{
|
||||
DashboardToken: tok,
|
||||
SessionCookie: sess,
|
||||
})
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/auth/status", nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status code = %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Authenticated bool `json:"authenticated"`
|
||||
Initialized bool `json:"initialized"`
|
||||
}
|
||||
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !body.Initialized {
|
||||
t.Fatalf("initialized = false, want true in legacy token fallback mode")
|
||||
}
|
||||
if body.Authenticated {
|
||||
t.Fatalf("unexpected authenticated=true: %+v", body)
|
||||
}
|
||||
|
||||
rec = httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/auth/login", strings.NewReader(`{"password":"`+tok+`"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
mux.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("login code = %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestLauncherAuthSetupRejectedInLegacyTokenFallback(t *testing.T) {
|
||||
key := make([]byte, 32)
|
||||
sess := middleware.SessionCookieValue(key, "legacy-token")
|
||||
mux := http.NewServeMux()
|
||||
RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{
|
||||
DashboardToken: "legacy-token",
|
||||
SessionCookie: sess,
|
||||
})
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/api/auth/setup",
|
||||
strings.NewReader(`{"password":"12345678","confirm":"12345678"}`),
|
||||
)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
mux.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusNotImplemented {
|
||||
t.Fatalf("setup code = %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestLauncherAuthLogoutRequiresPostAndJSON(t *testing.T) {
|
||||
key := make([]byte, 32)
|
||||
sess := middleware.SessionCookieValue(key, "tok")
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import (
|
|||
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/providers"
|
||||
"github.com/sipeed/picoclaw/pkg/utils"
|
||||
)
|
||||
|
||||
// registerSessionRoutes binds session list and detail endpoints to the ServeMux.
|
||||
|
|
@ -76,6 +77,11 @@ const (
|
|||
handledToolResponseSummaryText = "Requested output delivered via tool attachment."
|
||||
)
|
||||
|
||||
func defaultToolFeedbackMaxArgsLength() int {
|
||||
defaults := config.AgentDefaults{}
|
||||
return defaults.GetToolFeedbackMaxArgsLength()
|
||||
}
|
||||
|
||||
// extractPicoSessionID extracts the session UUID from a full session key.
|
||||
// Returns the UUID and true if the key matches the Pico session pattern.
|
||||
func extractPicoSessionID(key string) (string, bool) {
|
||||
|
|
@ -202,7 +208,7 @@ func (h *Handler) readJSONLSession(dir, sessionID string) (sessionFile, error) {
|
|||
}, nil
|
||||
}
|
||||
|
||||
func buildSessionListItem(sessionID string, sess sessionFile) sessionListItem {
|
||||
func buildSessionListItem(sessionID string, sess sessionFile, toolFeedbackMaxArgsLength int) sessionListItem {
|
||||
preview := ""
|
||||
for _, msg := range sess.Messages {
|
||||
if msg.Role == "user" {
|
||||
|
|
@ -219,7 +225,7 @@ func buildSessionListItem(sessionID string, sess sessionFile) sessionListItem {
|
|||
}
|
||||
title := preview
|
||||
|
||||
validMessageCount := len(visibleSessionMessages(sess.Messages))
|
||||
validMessageCount := len(visibleSessionMessages(sess.Messages, toolFeedbackMaxArgsLength))
|
||||
|
||||
return sessionListItem{
|
||||
ID: sessionID,
|
||||
|
|
@ -260,7 +266,7 @@ func sessionMessagePreview(msg providers.Message) string {
|
|||
return ""
|
||||
}
|
||||
|
||||
func visibleSessionMessages(messages []providers.Message) []sessionChatMessage {
|
||||
func visibleSessionMessages(messages []providers.Message, toolFeedbackMaxArgsLength int) []sessionChatMessage {
|
||||
transcript := make([]sessionChatMessage, 0, len(messages))
|
||||
|
||||
for _, msg := range messages {
|
||||
|
|
@ -275,6 +281,11 @@ func visibleSessionMessages(messages []providers.Message) []sessionChatMessage {
|
|||
}
|
||||
|
||||
case "assistant":
|
||||
toolSummaryMessages := visibleAssistantToolSummaryMessages(msg.ToolCalls, toolFeedbackMaxArgsLength)
|
||||
if len(toolSummaryMessages) > 0 {
|
||||
transcript = append(transcript, toolSummaryMessages...)
|
||||
}
|
||||
|
||||
visibleToolMessages := visibleAssistantToolMessages(msg.ToolCalls)
|
||||
if len(visibleToolMessages) > 0 {
|
||||
transcript = append(transcript, visibleToolMessages...)
|
||||
|
|
@ -283,7 +294,7 @@ func visibleSessionMessages(messages []providers.Message) []sessionChatMessage {
|
|||
// Pico web chat can persist both visible `message` tool output and a
|
||||
// later plain assistant reply in the same turn. Hide only the fixed
|
||||
// internal summary that marks handled tool delivery.
|
||||
if len(visibleToolMessages) > 0 || !sessionMessageVisible(msg) || assistantMessageInternalOnly(msg) {
|
||||
if !sessionMessageVisible(msg) || assistantMessageInternalOnly(msg) {
|
||||
continue
|
||||
}
|
||||
|
||||
|
|
@ -302,6 +313,52 @@ func assistantMessageInternalOnly(msg providers.Message) bool {
|
|||
return strings.TrimSpace(msg.Content) == handledToolResponseSummaryText
|
||||
}
|
||||
|
||||
func visibleAssistantToolSummaryMessages(
|
||||
toolCalls []providers.ToolCall,
|
||||
toolFeedbackMaxArgsLength int,
|
||||
) []sessionChatMessage {
|
||||
if len(toolCalls) == 0 {
|
||||
return nil
|
||||
}
|
||||
if toolFeedbackMaxArgsLength <= 0 {
|
||||
toolFeedbackMaxArgsLength = defaultToolFeedbackMaxArgsLength()
|
||||
}
|
||||
|
||||
messages := make([]sessionChatMessage, 0, len(toolCalls))
|
||||
for _, tc := range toolCalls {
|
||||
name := tc.Name
|
||||
argsJSON := ""
|
||||
if tc.Function != nil {
|
||||
if name == "" {
|
||||
name = tc.Function.Name
|
||||
}
|
||||
argsJSON = tc.Function.Arguments
|
||||
}
|
||||
|
||||
if strings.TrimSpace(name) == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.TrimSpace(argsJSON) == "" && len(tc.Arguments) > 0 {
|
||||
if encodedArgs, err := json.Marshal(tc.Arguments); err == nil {
|
||||
argsJSON = string(encodedArgs)
|
||||
}
|
||||
}
|
||||
|
||||
argsPreview := strings.TrimSpace(argsJSON)
|
||||
if argsPreview == "" {
|
||||
argsPreview = "{}"
|
||||
}
|
||||
|
||||
messages = append(messages, sessionChatMessage{
|
||||
Role: "assistant",
|
||||
Content: utils.FormatToolFeedbackMessage(name, utils.Truncate(argsPreview, toolFeedbackMaxArgsLength)),
|
||||
})
|
||||
}
|
||||
|
||||
return messages
|
||||
}
|
||||
|
||||
func visibleAssistantToolMessages(toolCalls []providers.ToolCall) []sessionChatMessage {
|
||||
if len(toolCalls) == 0 {
|
||||
return nil
|
||||
|
|
@ -347,7 +404,19 @@ func (h *Handler) sessionsDir() (string, error) {
|
|||
return "", err
|
||||
}
|
||||
|
||||
workspace := cfg.Agents.Defaults.Workspace
|
||||
return resolveSessionsDir(cfg.Agents.Defaults.Workspace), nil
|
||||
}
|
||||
|
||||
func (h *Handler) sessionRuntimeSettings() (string, int, error) {
|
||||
cfg, err := config.LoadConfig(h.configPath)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
|
||||
return resolveSessionsDir(cfg.Agents.Defaults.Workspace), cfg.Agents.Defaults.GetToolFeedbackMaxArgsLength(), nil
|
||||
}
|
||||
|
||||
func resolveSessionsDir(workspace string) string {
|
||||
if workspace == "" {
|
||||
home, _ := os.UserHomeDir()
|
||||
workspace = filepath.Join(home, ".picoclaw", "workspace")
|
||||
|
|
@ -363,14 +432,14 @@ func (h *Handler) sessionsDir() (string, error) {
|
|||
}
|
||||
}
|
||||
|
||||
return filepath.Join(workspace, "sessions"), nil
|
||||
return filepath.Join(workspace, "sessions")
|
||||
}
|
||||
|
||||
// handleListSessions returns a list of Pico session summaries.
|
||||
//
|
||||
// GET /api/sessions
|
||||
func (h *Handler) handleListSessions(w http.ResponseWriter, r *http.Request) {
|
||||
dir, err := h.sessionsDir()
|
||||
dir, toolFeedbackMaxArgsLength, err := h.sessionRuntimeSettings()
|
||||
if err != nil {
|
||||
http.Error(w, "failed to resolve sessions directory", http.StatusInternalServerError)
|
||||
return
|
||||
|
|
@ -454,7 +523,7 @@ func (h *Handler) handleListSessions(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
seen[sessionID] = struct{}{}
|
||||
items = append(items, buildSessionListItem(sessionID, sess))
|
||||
items = append(items, buildSessionListItem(sessionID, sess, toolFeedbackMaxArgsLength))
|
||||
}
|
||||
|
||||
// Sort by updated descending (most recent first)
|
||||
|
|
@ -502,7 +571,7 @@ func (h *Handler) handleGetSession(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
dir, err := h.sessionsDir()
|
||||
dir, toolFeedbackMaxArgsLength, err := h.sessionRuntimeSettings()
|
||||
if err != nil {
|
||||
http.Error(w, "failed to resolve sessions directory", http.StatusInternalServerError)
|
||||
return
|
||||
|
|
@ -529,7 +598,7 @@ func (h *Handler) handleGetSession(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
}
|
||||
|
||||
messages := visibleSessionMessages(sess.Messages)
|
||||
messages := visibleSessionMessages(sess.Messages, toolFeedbackMaxArgsLength)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import (
|
|||
"github.com/sipeed/picoclaw/pkg/memory"
|
||||
"github.com/sipeed/picoclaw/pkg/providers"
|
||||
"github.com/sipeed/picoclaw/pkg/session"
|
||||
"github.com/sipeed/picoclaw/pkg/utils"
|
||||
)
|
||||
|
||||
func sessionsTestDir(t *testing.T, configPath string) string {
|
||||
|
|
@ -273,11 +274,14 @@ func TestHandleGetSession_ReconstructsVisibleMessageToolOutput(t *testing.T) {
|
|||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("Unmarshal() error = %v", err)
|
||||
}
|
||||
if len(resp.Messages) != 2 {
|
||||
t.Fatalf("len(resp.Messages) = %d, want 2", len(resp.Messages))
|
||||
if len(resp.Messages) != 3 {
|
||||
t.Fatalf("len(resp.Messages) = %d, want 3", len(resp.Messages))
|
||||
}
|
||||
if resp.Messages[1].Role != "assistant" || resp.Messages[1].Content != "visible tool output" {
|
||||
t.Fatalf("assistant message = %#v, want visible tool output", resp.Messages[1])
|
||||
if !strings.Contains(resp.Messages[1].Content, "`message`") {
|
||||
t.Fatalf("tool summary message = %#v, want message tool summary", resp.Messages[1])
|
||||
}
|
||||
if resp.Messages[2].Role != "assistant" || resp.Messages[2].Content != "visible tool output" {
|
||||
t.Fatalf("assistant message = %#v, want visible tool output", resp.Messages[2])
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -336,14 +340,17 @@ func TestHandleGetSession_PreservesFinalAssistantReplyAfterMessageToolOutput(t *
|
|||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("Unmarshal() error = %v", err)
|
||||
}
|
||||
if len(resp.Messages) != 3 {
|
||||
t.Fatalf("len(resp.Messages) = %d, want 3", len(resp.Messages))
|
||||
if len(resp.Messages) != 4 {
|
||||
t.Fatalf("len(resp.Messages) = %d, want 4", len(resp.Messages))
|
||||
}
|
||||
if resp.Messages[1].Role != "assistant" || resp.Messages[1].Content != "visible tool output" {
|
||||
t.Fatalf("interim assistant message = %#v, want visible tool output", resp.Messages[1])
|
||||
if !strings.Contains(resp.Messages[1].Content, "`message`") {
|
||||
t.Fatalf("tool summary message = %#v, want message tool summary", resp.Messages[1])
|
||||
}
|
||||
if resp.Messages[2].Role != "assistant" || resp.Messages[2].Content != "final assistant reply" {
|
||||
t.Fatalf("final assistant message = %#v, want final assistant reply", resp.Messages[2])
|
||||
if resp.Messages[2].Role != "assistant" || resp.Messages[2].Content != "visible tool output" {
|
||||
t.Fatalf("interim assistant message = %#v, want visible tool output", resp.Messages[2])
|
||||
}
|
||||
if resp.Messages[3].Role != "assistant" || resp.Messages[3].Content != "final assistant reply" {
|
||||
t.Fatalf("final assistant message = %#v, want final assistant reply", resp.Messages[3])
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -400,8 +407,152 @@ func TestHandleListSessions_MessageCountUsesVisibleTranscript(t *testing.T) {
|
|||
if len(items) != 1 {
|
||||
t.Fatalf("len(items) = %d, want 1", len(items))
|
||||
}
|
||||
if items[0].MessageCount != 2 {
|
||||
t.Fatalf("items[0].MessageCount = %d, want 2", items[0].MessageCount)
|
||||
if items[0].MessageCount != 3 {
|
||||
t.Fatalf("items[0].MessageCount = %d, want 3", items[0].MessageCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleGetSession_PreservesToolSummaryAndAssistantContent(t *testing.T) {
|
||||
configPath, cleanup := setupOAuthTestEnv(t)
|
||||
defer cleanup()
|
||||
|
||||
dir := sessionsTestDir(t, configPath)
|
||||
store, err := memory.NewJSONLStore(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("NewJSONLStore() error = %v", err)
|
||||
}
|
||||
|
||||
sessionKey := picoSessionPrefix + "detail-tool-summary-and-content"
|
||||
for _, msg := range []providers.Message{
|
||||
{Role: "user", Content: "check file"},
|
||||
{
|
||||
Role: "assistant",
|
||||
Content: "model final reply",
|
||||
ToolCalls: []providers.ToolCall{
|
||||
{
|
||||
ID: "call_1",
|
||||
Type: "function",
|
||||
Function: &providers.FunctionCall{
|
||||
Name: "read_file",
|
||||
Arguments: `{"path":"README.md","start_line":1,"end_line":10}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} {
|
||||
if err := store.AddFullMessage(nil, sessionKey, msg); err != nil {
|
||||
t.Fatalf("AddFullMessage() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
h := NewHandler(configPath)
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/sessions/detail-tool-summary-and-content", nil)
|
||||
mux.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Messages []struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
} `json:"messages"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("Unmarshal() error = %v", err)
|
||||
}
|
||||
if len(resp.Messages) != 3 {
|
||||
t.Fatalf("len(resp.Messages) = %d, want 3", len(resp.Messages))
|
||||
}
|
||||
if resp.Messages[0].Role != "user" || resp.Messages[0].Content != "check file" {
|
||||
t.Fatalf("first message = %#v, want user/check file", resp.Messages[0])
|
||||
}
|
||||
if !strings.Contains(resp.Messages[1].Content, "`read_file`") {
|
||||
t.Fatalf("tool summary message = %#v, want read_file summary", resp.Messages[1])
|
||||
}
|
||||
if resp.Messages[2].Role != "assistant" || resp.Messages[2].Content != "model final reply" {
|
||||
t.Fatalf("assistant message = %#v, want model final reply", resp.Messages[2])
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleGetSession_UsesConfiguredToolFeedbackMaxArgsLength(t *testing.T) {
|
||||
configPath, cleanup := setupOAuthTestEnv(t)
|
||||
defer cleanup()
|
||||
|
||||
cfg, err := config.LoadConfig(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig() error = %v", err)
|
||||
}
|
||||
cfg.Agents.Defaults.ToolFeedback.MaxArgsLength = 20
|
||||
err = config.SaveConfig(configPath, cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("SaveConfig() error = %v", err)
|
||||
}
|
||||
|
||||
dir := sessionsTestDir(t, configPath)
|
||||
store, err := memory.NewJSONLStore(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("NewJSONLStore() error = %v", err)
|
||||
}
|
||||
|
||||
argsJSON := `{"path":"README.md","start_line":1,"end_line":10,"extra":"abcdefghijklmnopqrstuvwxyz"}`
|
||||
sessionKey := picoSessionPrefix + "detail-tool-summary-max-args"
|
||||
err = store.AddFullMessage(nil, sessionKey, providers.Message{Role: "user", Content: "check file"})
|
||||
if err != nil {
|
||||
t.Fatalf("AddFullMessage(user) error = %v", err)
|
||||
}
|
||||
err = store.AddFullMessage(nil, sessionKey, providers.Message{
|
||||
Role: "assistant",
|
||||
ToolCalls: []providers.ToolCall{{
|
||||
ID: "call_1",
|
||||
Type: "function",
|
||||
Function: &providers.FunctionCall{
|
||||
Name: "read_file",
|
||||
Arguments: argsJSON,
|
||||
},
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("AddFullMessage(assistant) error = %v", err)
|
||||
}
|
||||
|
||||
h := NewHandler(configPath)
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/sessions/detail-tool-summary-max-args", nil)
|
||||
mux.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Messages []struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
} `json:"messages"`
|
||||
}
|
||||
err = json.Unmarshal(rec.Body.Bytes(), &resp)
|
||||
if err != nil {
|
||||
t.Fatalf("Unmarshal() error = %v", err)
|
||||
}
|
||||
if len(resp.Messages) < 2 {
|
||||
t.Fatalf("len(resp.Messages) = %d, want at least 2", len(resp.Messages))
|
||||
}
|
||||
|
||||
wantPreview := utils.Truncate(argsJSON, 20)
|
||||
if !strings.Contains(resp.Messages[1].Content, wantPreview) {
|
||||
t.Fatalf("tool summary = %q, want preview %q", resp.Messages[1].Content, wantPreview)
|
||||
}
|
||||
if strings.Contains(resp.Messages[1].Content, argsJSON) {
|
||||
t.Fatalf("tool summary = %q, expected configured truncation", resp.Messages[1].Content)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
7
web/backend/dashboardauth/platform.go
Normal file
7
web/backend/dashboardauth/platform.go
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
package dashboardauth
|
||||
|
||||
import "errors"
|
||||
|
||||
// ErrUnsupportedPlatform reports that the SQLite-backed password store is not
|
||||
// available for the current target platform.
|
||||
var ErrUnsupportedPlatform = errors.New("dashboard password store is unavailable on this platform")
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
//go:build !mipsle && !netbsd && !(freebsd && arm)
|
||||
|
||||
// Package dashboardauth provides a bcrypt-backed SQLite store for the
|
||||
// launcher dashboard password. The database contains a single row (id=1)
|
||||
// with the bcrypt hash; no plaintext is ever persisted.
|
||||
|
|
|
|||
60
web/backend/dashboardauth/store_unsupported.go
Normal file
60
web/backend/dashboardauth/store_unsupported.go
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
//go:build mipsle || netbsd || (freebsd && arm)
|
||||
|
||||
package dashboardauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
// Store is unavailable on platforms where modernc sqlite/libc does not build.
|
||||
type Store struct {
|
||||
path string
|
||||
}
|
||||
|
||||
// New reports that the password store is unavailable on this platform.
|
||||
func New(dir string) (*Store, error) {
|
||||
path := filepath.Join(dir, DBFilename)
|
||||
s, err := Open(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open %q: %w", path, err)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Open reports that the password store is unavailable on this platform.
|
||||
func Open(path string) (*Store, error) {
|
||||
return nil, unsupportedPlatformError()
|
||||
}
|
||||
|
||||
// Close is a no-op for unsupported platforms.
|
||||
func (s *Store) Close() error { return nil }
|
||||
|
||||
// DBPath returns the configured path, if any.
|
||||
func (s *Store) DBPath() string {
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
return s.path
|
||||
}
|
||||
|
||||
// IsInitialized reports that the store is unavailable on this platform.
|
||||
func (s *Store) IsInitialized(context.Context) (bool, error) {
|
||||
return false, unsupportedPlatformError()
|
||||
}
|
||||
|
||||
// SetPassword reports that the store is unavailable on this platform.
|
||||
func (s *Store) SetPassword(context.Context, string) error {
|
||||
return unsupportedPlatformError()
|
||||
}
|
||||
|
||||
// VerifyPassword reports that the store is unavailable on this platform.
|
||||
func (s *Store) VerifyPassword(context.Context, string) (bool, error) {
|
||||
return false, unsupportedPlatformError()
|
||||
}
|
||||
|
||||
func unsupportedPlatformError() error {
|
||||
return fmt.Errorf("%w (%s/%s)", ErrUnsupportedPlatform, runtime.GOOS, runtime.GOARCH)
|
||||
}
|
||||
|
|
@ -229,11 +229,21 @@ func main() {
|
|||
|
||||
// Open the bcrypt password store (creates the DB file on first run).
|
||||
authStore, authStoreErr := dashboardauth.New(picoHome)
|
||||
if authStoreErr != nil {
|
||||
logger.ErrorC("web", fmt.Sprintf("Warning: could not open auth store: %v", authStoreErr))
|
||||
authStore = nil
|
||||
} else {
|
||||
var passwordStore api.PasswordStore
|
||||
if authStoreErr == nil {
|
||||
passwordStore = authStore
|
||||
defer authStore.Close()
|
||||
} else if errors.Is(authStoreErr, dashboardauth.ErrUnsupportedPlatform) {
|
||||
logger.InfoC(
|
||||
"web",
|
||||
fmt.Sprintf(
|
||||
"Dashboard password store unavailable on this platform; falling back to token login: %v",
|
||||
authStoreErr,
|
||||
),
|
||||
)
|
||||
authStoreErr = nil
|
||||
} else {
|
||||
logger.ErrorC("web", fmt.Sprintf("Warning: could not open auth store: %v", authStoreErr))
|
||||
}
|
||||
|
||||
// Determine listen address
|
||||
|
|
@ -250,7 +260,7 @@ func main() {
|
|||
api.RegisterLauncherAuthRoutes(mux, api.LauncherAuthRouteOpts{
|
||||
DashboardToken: dashboardToken,
|
||||
SessionCookie: dashboardSessionCookie,
|
||||
PasswordStore: authStore,
|
||||
PasswordStore: passwordStore,
|
||||
StoreError: authStoreErr,
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@
|
|||
"@fontsource-variable/inter": "^5.2.8",
|
||||
"@tabler/icons-react": "^3.40.0",
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@tanstack/react-query": "^5.96.1",
|
||||
"@tanstack/react-query": "^5.97.0",
|
||||
"@tanstack/react-router": "^1.167.0",
|
||||
"@tanstack/react-router-devtools": "^1.163.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
|
|
@ -27,17 +27,17 @@
|
|||
"dayjs": "^1.11.20",
|
||||
"i18next": "^26.0.3",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"jotai": "^2.18.1",
|
||||
"jotai": "^2.19.1",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react": "19.2.5",
|
||||
"react-dom": "19.2.5",
|
||||
"react-i18next": "^17.0.2",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-textarea-autosize": "^8.5.9",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"rehype-sanitize": "^6.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"shadcn": "^4.1.2",
|
||||
"shadcn": "^4.2.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "^4.2.2",
|
||||
|
|
@ -63,6 +63,6 @@
|
|||
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.57.1",
|
||||
"vite": "^8.0.3"
|
||||
"vite": "^8.0.8"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
1528
web/frontend/pnpm-lock.yaml
generated
1528
web/frontend/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
Loading…
Add table
Reference in a new issue