Merge branch 'main' into ovms_enabling
This commit is contained in:
commit
3f79e102e5
65 changed files with 2076 additions and 2623 deletions
|
|
@ -1,3 +1,5 @@
|
|||
# Do NOT exclude LICENSE or .github — scripts/copydir.go uses them as repo-root anchors
|
||||
# during `go generate`, which runs inside `make build` in the Dockerfile.
|
||||
.git
|
||||
.gitignore
|
||||
build/
|
||||
|
|
@ -6,5 +8,4 @@ config/
|
|||
.env
|
||||
.env.example
|
||||
*.md
|
||||
LICENSE
|
||||
assets/
|
||||
|
|
|
|||
160
.github/workflows/nightly.yml
vendored
160
.github/workflows/nightly.yml
vendored
|
|
@ -74,10 +74,7 @@ jobs:
|
|||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Login to Docker Hub
|
||||
if: env.DOCKERHUB_USERNAME != ''
|
||||
uses: docker/login-action@v4
|
||||
env:
|
||||
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
with:
|
||||
registry: docker.io
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
|
|
@ -89,10 +86,6 @@ jobs:
|
|||
- name: Create local tag for GoReleaser
|
||||
run: git tag "${{ steps.version.outputs.version }}"
|
||||
|
||||
- name: Lowercase owner for Docker tags
|
||||
id: repo
|
||||
run: echo "owner=$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Run GoReleaser
|
||||
uses: goreleaser/goreleaser-action@v7
|
||||
with:
|
||||
|
|
@ -101,7 +94,7 @@ jobs:
|
|||
args: release --clean
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
REPO_OWNER: ${{ steps.repo.outputs.owner }}
|
||||
GITHUB_REPOSITORY_OWNER: ${{ github.repository_owner }}
|
||||
DOCKERHUB_IMAGE_NAME: ${{ vars.DOCKERHUB_REPOSITORY }}
|
||||
GOVERSION: ${{ steps.setup-go.outputs.go-version }}
|
||||
GORELEASER_CURRENT_TAG: ${{ steps.version.outputs.version }}
|
||||
|
|
@ -151,154 +144,3 @@ jobs:
|
|||
--prerelease \
|
||||
--latest=false \
|
||||
"${ASSETS[@]}"
|
||||
|
||||
build-macos-launcher:
|
||||
name: Build macOS Launcher (${{ matrix.arch_name }})
|
||||
runs-on: macos-latest
|
||||
permissions:
|
||||
contents: read
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- goarch: arm64
|
||||
arch_name: arm64
|
||||
- goarch: amd64
|
||||
arch_name: x86_64
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Go from go.mod
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: 10.33.0
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
cache-dependency-path: web/frontend/pnpm-lock.yaml
|
||||
|
||||
- name: Build frontend
|
||||
run: |
|
||||
cd web/frontend
|
||||
CI=true pnpm install --frozen-lockfile
|
||||
pnpm build:backend
|
||||
|
||||
- name: Compute version
|
||||
id: version
|
||||
run: |
|
||||
DATE=$(date -u +%Y%m%d)
|
||||
SHA=$(git rev-parse --short=8 HEAD)
|
||||
BASE_VERSION=$(git describe --tags --match "v*" --exclude "*nightly*" --abbrev=0 2>/dev/null || true)
|
||||
if [ -z "$BASE_VERSION" ] || [ "$BASE_VERSION" = "v0.0.0" ]; then
|
||||
VERSION="v0.0.0-nightly.${DATE}.${SHA}"
|
||||
else
|
||||
VERSION="${BASE_VERSION}-nightly.${DATE}.${SHA}"
|
||||
fi
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build picoclaw-launcher with CGO
|
||||
env:
|
||||
CGO_ENABLED: "1"
|
||||
GOOS: darwin
|
||||
GOARCH: ${{ matrix.goarch }}
|
||||
run: |
|
||||
SDK_PATH=$(xcrun --show-sdk-path)
|
||||
export CGO_CFLAGS="-isysroot ${SDK_PATH} -mmacosx-version-min=11.0"
|
||||
export CGO_LDFLAGS="-isysroot ${SDK_PATH}"
|
||||
|
||||
go generate ./...
|
||||
go build -tags "goolm,stdjson" \
|
||||
-ldflags "-s -w \
|
||||
-X github.com/sipeed/picoclaw/pkg/config.Version=${{ steps.version.outputs.version }} \
|
||||
-X github.com/sipeed/picoclaw/pkg/config.GitCommit=$(git rev-parse --short HEAD) \
|
||||
-X github.com/sipeed/picoclaw/pkg/config.BuildTime=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||||
-o picoclaw-launcher-cgo \
|
||||
./web/backend
|
||||
|
||||
- name: Sign and notarize launcher binary
|
||||
if: env.MACOS_SIGN_P12 != ''
|
||||
env:
|
||||
MACOS_SIGN_P12: ${{ secrets.MACOS_SIGN_P12 }}
|
||||
MACOS_SIGN_PASSWORD: ${{ secrets.MACOS_SIGN_PASSWORD }}
|
||||
MACOS_NOTARY_ISSUER_ID: ${{ secrets.MACOS_NOTARY_ISSUER_ID }}
|
||||
MACOS_NOTARY_KEY_ID: ${{ secrets.MACOS_NOTARY_KEY_ID }}
|
||||
MACOS_NOTARY_KEY: ${{ secrets.MACOS_NOTARY_KEY }}
|
||||
run: |
|
||||
pip3 install rcodesign
|
||||
|
||||
echo "$MACOS_SIGN_P12" | base64 -d > cert.p12
|
||||
|
||||
rcodesign sign \
|
||||
--p12-file cert.p12 \
|
||||
--p12-password "$MACOS_SIGN_PASSWORD" \
|
||||
picoclaw-launcher-cgo
|
||||
|
||||
echo "$MACOS_NOTARY_KEY" > notary-key.p8
|
||||
|
||||
rcodesign notary-submit \
|
||||
--api-key-path notary-key.p8 \
|
||||
--api-issuer "$MACOS_NOTARY_ISSUER_ID" \
|
||||
--wait \
|
||||
picoclaw-launcher-cgo
|
||||
|
||||
rm -f cert.p12 notary-key.p8
|
||||
|
||||
- name: Upload launcher artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: macos-launcher-${{ matrix.arch_name }}
|
||||
path: picoclaw-launcher-cgo
|
||||
retention-days: 1
|
||||
|
||||
patch-macos-archives:
|
||||
name: Patch macOS Archives
|
||||
needs: [nightly, build-macos-launcher]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- arch_name: arm64
|
||||
- arch_name: x86_64
|
||||
steps:
|
||||
- name: Download launcher artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: macos-launcher-${{ matrix.arch_name }}
|
||||
|
||||
- name: Patch darwin release archive
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
ARCHIVE_NAME="picoclaw_Darwin_${{ matrix.arch_name }}.tar.gz"
|
||||
|
||||
gh release download nightly \
|
||||
--repo "${{ github.repository }}" \
|
||||
--pattern "${ARCHIVE_NAME}" \
|
||||
--dir ./patch-tmp
|
||||
|
||||
mkdir -p ./patch-extracted
|
||||
tar xzf "./patch-tmp/${ARCHIVE_NAME}" -C ./patch-extracted
|
||||
|
||||
cp picoclaw-launcher-cgo ./patch-extracted/picoclaw-launcher
|
||||
chmod +x ./patch-extracted/picoclaw-launcher
|
||||
|
||||
tar czf "${ARCHIVE_NAME}" -C ./patch-extracted .
|
||||
|
||||
gh release upload nightly \
|
||||
--repo "${{ github.repository }}" \
|
||||
"${ARCHIVE_NAME}" --clobber
|
||||
|
||||
echo "✅ Patched ${ARCHIVE_NAME} with CGO launcher (systray enabled)"
|
||||
|
|
|
|||
151
.github/workflows/release.yml
vendored
151
.github/workflows/release.yml
vendored
|
|
@ -80,10 +80,7 @@ jobs:
|
|||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Login to Docker Hub
|
||||
if: env.DOCKERHUB_USERNAME != ''
|
||||
uses: docker/login-action@v4
|
||||
env:
|
||||
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
with:
|
||||
registry: docker.io
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
|
|
@ -92,10 +89,6 @@ jobs:
|
|||
- name: Install zip
|
||||
run: sudo apt-get install -y zip
|
||||
|
||||
- name: Lowercase owner for Docker tags
|
||||
id: repo
|
||||
run: echo "owner=$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Run GoReleaser
|
||||
uses: goreleaser/goreleaser-action@v7
|
||||
with:
|
||||
|
|
@ -104,7 +97,7 @@ jobs:
|
|||
args: release --clean
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
REPO_OWNER: ${{ steps.repo.outputs.owner }}
|
||||
GITHUB_REPOSITORY_OWNER: ${{ github.repository_owner }}
|
||||
DOCKERHUB_IMAGE_NAME: ${{ vars.DOCKERHUB_REPOSITORY }}
|
||||
GOVERSION: ${{ steps.setup-go.outputs.go-version }}
|
||||
INCLUDE_ANDROID_BUNDLE: "true"
|
||||
|
|
@ -123,149 +116,9 @@ jobs:
|
|||
--draft=${{ inputs.draft }} \
|
||||
--prerelease=${{ inputs.prerelease }}
|
||||
|
||||
build-macos-launcher:
|
||||
name: Build macOS Launcher (${{ matrix.arch_name }})
|
||||
runs-on: macos-latest
|
||||
permissions:
|
||||
contents: read
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- goarch: arm64
|
||||
arch_name: arm64
|
||||
- goarch: amd64
|
||||
arch_name: x86_64
|
||||
steps:
|
||||
- name: Checkout tag
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ inputs.tag }}
|
||||
|
||||
- name: Setup Go from go.mod
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: 10.33.0
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
cache-dependency-path: web/frontend/pnpm-lock.yaml
|
||||
|
||||
- name: Build frontend
|
||||
run: |
|
||||
cd web/frontend
|
||||
CI=true pnpm install --frozen-lockfile
|
||||
pnpm build:backend
|
||||
|
||||
- name: Build picoclaw-launcher with CGO
|
||||
env:
|
||||
CGO_ENABLED: "1"
|
||||
GOOS: darwin
|
||||
GOARCH: ${{ matrix.goarch }}
|
||||
run: |
|
||||
SDK_PATH=$(xcrun --show-sdk-path)
|
||||
export CGO_CFLAGS="-isysroot ${SDK_PATH} -mmacosx-version-min=11.0"
|
||||
export CGO_LDFLAGS="-isysroot ${SDK_PATH}"
|
||||
|
||||
go generate ./...
|
||||
go build -tags "goolm,stdjson" \
|
||||
-ldflags "-s -w \
|
||||
-X github.com/sipeed/picoclaw/pkg/config.Version=${{ inputs.tag }} \
|
||||
-X github.com/sipeed/picoclaw/pkg/config.GitCommit=$(git rev-parse --short HEAD) \
|
||||
-X github.com/sipeed/picoclaw/pkg/config.BuildTime=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||||
-o picoclaw-launcher-cgo \
|
||||
./web/backend
|
||||
|
||||
- name: Sign and notarize launcher binary
|
||||
if: env.MACOS_SIGN_P12 != ''
|
||||
env:
|
||||
MACOS_SIGN_P12: ${{ secrets.MACOS_SIGN_P12 }}
|
||||
MACOS_SIGN_PASSWORD: ${{ secrets.MACOS_SIGN_PASSWORD }}
|
||||
MACOS_NOTARY_ISSUER_ID: ${{ secrets.MACOS_NOTARY_ISSUER_ID }}
|
||||
MACOS_NOTARY_KEY_ID: ${{ secrets.MACOS_NOTARY_KEY_ID }}
|
||||
MACOS_NOTARY_KEY: ${{ secrets.MACOS_NOTARY_KEY }}
|
||||
run: |
|
||||
pip3 install rcodesign
|
||||
|
||||
echo "$MACOS_SIGN_P12" | base64 -d > cert.p12
|
||||
|
||||
rcodesign sign \
|
||||
--p12-file cert.p12 \
|
||||
--p12-password "$MACOS_SIGN_PASSWORD" \
|
||||
picoclaw-launcher-cgo
|
||||
|
||||
echo "$MACOS_NOTARY_KEY" > notary-key.p8
|
||||
|
||||
rcodesign notary-submit \
|
||||
--api-key-path notary-key.p8 \
|
||||
--api-issuer "$MACOS_NOTARY_ISSUER_ID" \
|
||||
--wait \
|
||||
picoclaw-launcher-cgo
|
||||
|
||||
rm -f cert.p12 notary-key.p8
|
||||
|
||||
- name: Upload launcher artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: macos-launcher-${{ matrix.arch_name }}
|
||||
path: picoclaw-launcher-cgo
|
||||
retention-days: 1
|
||||
|
||||
patch-macos-archives:
|
||||
name: Patch macOS Archives
|
||||
needs: [release, build-macos-launcher]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- arch_name: arm64
|
||||
- arch_name: x86_64
|
||||
steps:
|
||||
- name: Download launcher artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: macos-launcher-${{ matrix.arch_name }}
|
||||
|
||||
- name: Patch darwin release archive
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
ARCHIVE_NAME="picoclaw_Darwin_${{ matrix.arch_name }}.tar.gz"
|
||||
|
||||
gh release download "${TAG}" \
|
||||
--repo "${{ github.repository }}" \
|
||||
--pattern "${ARCHIVE_NAME}" \
|
||||
--dir ./patch-tmp
|
||||
|
||||
mkdir -p ./patch-extracted
|
||||
tar xzf "./patch-tmp/${ARCHIVE_NAME}" -C ./patch-extracted
|
||||
|
||||
cp picoclaw-launcher-cgo ./patch-extracted/picoclaw-launcher
|
||||
chmod +x ./patch-extracted/picoclaw-launcher
|
||||
|
||||
tar czf "${ARCHIVE_NAME}" -C ./patch-extracted .
|
||||
|
||||
gh release upload "${TAG}" \
|
||||
--repo "${{ github.repository }}" \
|
||||
"${ARCHIVE_NAME}" --clobber
|
||||
|
||||
echo "Patched ${ARCHIVE_NAME} with CGO launcher (systray enabled)"
|
||||
|
||||
upload-tos:
|
||||
name: Upload to TOS
|
||||
needs: [release, patch-macos-archives]
|
||||
needs: release
|
||||
if: ${{ inputs.upload_tos }}
|
||||
uses: ./.github/workflows/upload-tos.yml
|
||||
with:
|
||||
|
|
|
|||
|
|
@ -100,49 +100,6 @@ builds:
|
|||
- goos: netbsd
|
||||
goarch: arm
|
||||
|
||||
- id: picoclaw-launcher-tui
|
||||
binary: picoclaw-launcher-tui
|
||||
env:
|
||||
- CGO_ENABLED=0
|
||||
tags:
|
||||
- goolm
|
||||
- stdjson
|
||||
ldflags:
|
||||
- -s -w
|
||||
- -X github.com/sipeed/picoclaw/pkg/config.Version={{ .Version }}
|
||||
- -X github.com/sipeed/picoclaw/pkg/config.GitCommit={{ .ShortCommit }}
|
||||
- -X github.com/sipeed/picoclaw/pkg/config.BuildTime={{ .Date }}
|
||||
- -X github.com/sipeed/picoclaw/pkg/config.GoVersion={{ with index .Env "GOVERSION" }}{{ . }}{{ else }}unknown{{ end }}
|
||||
goos:
|
||||
- linux
|
||||
- windows
|
||||
- darwin
|
||||
- freebsd
|
||||
- netbsd
|
||||
goarch:
|
||||
- amd64
|
||||
- arm64
|
||||
- riscv64
|
||||
- loong64
|
||||
- arm
|
||||
- s390x
|
||||
- mipsle
|
||||
goarm:
|
||||
- "6"
|
||||
- "7"
|
||||
gomips:
|
||||
- softfloat
|
||||
main: ./cmd/picoclaw-launcher-tui
|
||||
ignore:
|
||||
- goos: windows
|
||||
goarch: arm
|
||||
- goos: netbsd
|
||||
goarch: s390x
|
||||
- goos: netbsd
|
||||
goarch: mips64
|
||||
- goos: netbsd
|
||||
goarch: arm
|
||||
|
||||
dockers_v2:
|
||||
- id: picoclaw
|
||||
dockerfile: docker/Dockerfile.goreleaser
|
||||
|
|
@ -151,8 +108,8 @@ dockers_v2:
|
|||
ids:
|
||||
- picoclaw
|
||||
images:
|
||||
- "ghcr.io/{{ .Env.REPO_OWNER }}/picoclaw"
|
||||
- '{{ with .Env.DOCKERHUB_IMAGE_NAME }}docker.io/{{ . }}{{ end }}'
|
||||
- "ghcr.io/{{ .Env.GITHUB_REPOSITORY_OWNER }}/picoclaw"
|
||||
- 'docker.io/{{ .Env.DOCKERHUB_IMAGE_NAME }}'
|
||||
tags:
|
||||
- '{{ if isEnvSet "NIGHTLY_BUILD" }}nightly{{ else }}{{ .Tag }}{{ end }}'
|
||||
- '{{ if isEnvSet "NIGHTLY_BUILD" }}nightly{{ else }}latest{{ end }}'
|
||||
|
|
@ -166,10 +123,9 @@ dockers_v2:
|
|||
ids:
|
||||
- picoclaw
|
||||
- picoclaw-launcher
|
||||
- picoclaw-launcher-tui
|
||||
images:
|
||||
- "ghcr.io/{{ .Env.REPO_OWNER }}/picoclaw"
|
||||
- '{{ with .Env.DOCKERHUB_IMAGE_NAME }}docker.io/{{ . }}{{ end }}'
|
||||
- "ghcr.io/{{ .Env.GITHUB_REPOSITORY_OWNER }}/picoclaw"
|
||||
- 'docker.io/{{ .Env.DOCKERHUB_IMAGE_NAME }}'
|
||||
tags:
|
||||
- '{{ if isEnvSet "NIGHTLY_BUILD" }}nightly-launcher{{ else }}{{ .Tag }}-launcher{{ end }}'
|
||||
- '{{ if isEnvSet "NIGHTLY_BUILD" }}nightly-launcher{{ else }}launcher{{ end }}'
|
||||
|
|
@ -184,7 +140,6 @@ notarize:
|
|||
ids:
|
||||
- picoclaw
|
||||
- picoclaw-launcher
|
||||
- picoclaw-launcher-tui
|
||||
sign:
|
||||
certificate: "{{.Env.MACOS_SIGN_P12}}"
|
||||
password: "{{.Env.MACOS_SIGN_PASSWORD}}"
|
||||
|
|
@ -215,7 +170,6 @@ nfpms:
|
|||
ids:
|
||||
- picoclaw
|
||||
- picoclaw-launcher
|
||||
- picoclaw-launcher-tui
|
||||
package_name: picoclaw
|
||||
file_name_template: >-
|
||||
{{ .PackageName }}_
|
||||
|
|
@ -224,7 +178,7 @@ nfpms:
|
|||
{{- else if eq .Arch "arm" }}armv{{ .Arm }}
|
||||
{{- else }}{{ .Arch }}{{ end }}
|
||||
vendor: picoclaw
|
||||
homepage: https://github.com/{{ .Env.REPO_OWNER }}/picoclaw
|
||||
homepage: https://github.com/{{ .Env.GITHUB_REPOSITORY_OWNER }}/picoclaw
|
||||
maintainer: picoclaw contributors
|
||||
description: picoclaw - a tool for managing and running tasks
|
||||
license: MIT
|
||||
|
|
|
|||
14
Makefile
14
Makefile
|
|
@ -236,20 +236,6 @@ endif
|
|||
build-launcher-frontend:
|
||||
@$(MAKE) -C web build-frontend
|
||||
|
||||
## build-launcher-tui: Build the picoclaw-launcher TUI binary
|
||||
build-launcher-tui:
|
||||
@echo "Building picoclaw-launcher-tui for $(PLATFORM)/$(ARCH)..."
|
||||
ifeq ($(OS),Windows_NT)
|
||||
@$(POWERSHELL) "New-Item -ItemType Directory -Force -Path '$(BUILD_DIR)' | Out-Null"
|
||||
@$(GO) build $(GOFLAGS) -o $(BUILD_DIR)/picoclaw-launcher-tui-$(PLATFORM)-$(ARCH)$(EXT) ./cmd/picoclaw-launcher-tui
|
||||
@$(POWERSHELL) "Copy-Item -LiteralPath '$(BUILD_DIR)/picoclaw-launcher-tui-$(PLATFORM)-$(ARCH)$(EXT)' -Destination '$(BUILD_DIR)/picoclaw-launcher-tui$(EXT)' -Force"
|
||||
else
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
@$(GO) build $(GOFLAGS) -o $(BUILD_DIR)/picoclaw-launcher-tui-$(PLATFORM)-$(ARCH) ./cmd/picoclaw-launcher-tui
|
||||
@ln -sf picoclaw-launcher-tui-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/picoclaw-launcher-tui
|
||||
endif
|
||||
@echo "Build complete: $(BUILD_DIR)/picoclaw-launcher-tui$(EXT)"
|
||||
|
||||
## build-whatsapp-native: Build with WhatsApp native (whatsmeow) support; larger binary
|
||||
build-whatsapp-native: generate
|
||||
## @echo "Building $(BINARY_NAME) with WhatsApp native for $(PLATFORM)/$(ARCH)..."
|
||||
|
|
|
|||
18
README.md
18
README.md
|
|
@ -291,24 +291,6 @@ After this one-time step, `picoclaw-launcher` will open normally on subsequent l
|
|||
|
||||
</details>
|
||||
|
||||
### 💻 TUI Launcher (Recommended for Headless / SSH)
|
||||
|
||||
The TUI (Terminal UI) Launcher provides a full-featured terminal interface for configuration and management. Ideal for servers, Raspberry Pi, and other headless environments.
|
||||
|
||||
```bash
|
||||
picoclaw-launcher-tui
|
||||
```
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/launcher-tui.jpg" alt="TUI Launcher" width="600">
|
||||
</p>
|
||||
|
||||
**Getting started:**
|
||||
|
||||
Use the TUI menus to: **1)** Configure a Provider -> **2)** Configure a Channel -> **3)** Start the Gateway -> **4)** Chat!
|
||||
|
||||
For detailed TUI documentation, see [docs.picoclaw.io](https://docs.picoclaw.io).
|
||||
|
||||
<a id="-run-on-old-android-phones"></a>
|
||||
### 📱 Android
|
||||
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 271 KiB |
|
|
@ -1,69 +0,0 @@
|
|||
# Picoclaw Launcher TUI
|
||||
|
||||
This directory contains the terminal-based TUI launcher for `picoclaw`.
|
||||
It provides a lightweight, terminal-native user interface for managing, configuring, and interacting with the core `picoclaw` engine, without requiring a web browser or graphical environment.
|
||||
|
||||
## Architecture
|
||||
|
||||
The TUI launcher is implemented purely in Go with no external runtime dependencies:
|
||||
* **`main.go`**: Application entry point, handles initialization and main event loop
|
||||
* **`ui/`**: TUI interface components built on tview + tcell framework:
|
||||
- `home.go`: Main dashboard with navigation menu
|
||||
- `schemes.go`: AI model scheme management
|
||||
- `users.go`: User and API key management for model providers
|
||||
- `channels.go`: Communication channel (Telegram/Discord/WeChat etc.) configuration editor
|
||||
- `gateway.go`: PicoClaw gateway daemon lifecycle management (start/stop/status)
|
||||
- `app.go`: Core TUI application framework and navigation logic
|
||||
- `models.go`: Data structures and state management
|
||||
* **`config/`**: Configuration management layer, integrates with the core picoclaw configuration system
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
* Go 1.25+
|
||||
* Terminal with 256-color support (most modern terminals are compatible)
|
||||
|
||||
### Development
|
||||
|
||||
Run the TUI launcher directly in development mode:
|
||||
|
||||
```bash
|
||||
# From project root
|
||||
go run ./cmd/picoclaw-launcher-tui
|
||||
|
||||
# Or from this directory
|
||||
go run .
|
||||
```
|
||||
|
||||
### Build
|
||||
|
||||
Build the standalone TUI launcher binary:
|
||||
|
||||
```bash
|
||||
# From project root (recommended)
|
||||
make build-launcher-tui
|
||||
|
||||
# Output will be at:
|
||||
# build/picoclaw-launcher-tui-<platform>-<arch>
|
||||
# with symlink build/picoclaw-launcher-tui
|
||||
|
||||
# Or build directly from this directory
|
||||
go build -o picoclaw-launcher-tui .
|
||||
```
|
||||
|
||||
### Key Features
|
||||
|
||||
* 🖥️ Terminal-native interface - works over SSH, on headless servers, and in low-resource environments
|
||||
* ⚙️ AI model scheme and API key management
|
||||
* 📱 Communication channel configuration editor (Telegram/Discord/WeChat etc.)
|
||||
* 🔄 PicoClaw gateway daemon management (start/stop/status monitoring)
|
||||
* 💬 One-click launch of interactive AI chat session
|
||||
* 🎯 Keyboard-first design with intuitive shortcuts
|
||||
|
||||
### Other Commands
|
||||
|
||||
```bash
|
||||
# Run with custom config file path
|
||||
go run . /path/to/custom/config.json
|
||||
```
|
||||
|
|
@ -1,236 +0,0 @@
|
|||
// PicoClaw - Ultra-lightweight personal AI agent
|
||||
// License: MIT
|
||||
//
|
||||
// Copyright (c) 2026 PicoClaw contributors
|
||||
|
||||
// Package config provides types and I/O for ~/.picoclaw/tui.toml.
|
||||
package config
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/BurntSushi/toml"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/fileutil"
|
||||
)
|
||||
|
||||
// DefaultConfigPath returns the default path to the tui.toml config file.
|
||||
func DefaultConfigPath() string {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
home = "."
|
||||
}
|
||||
return filepath.Join(home, ".picoclaw", "tui.toml")
|
||||
}
|
||||
|
||||
// TUIConfig is the top-level structure of ~/.picoclaw/tui.toml.
|
||||
type TUIConfig struct {
|
||||
Version string `toml:"version"`
|
||||
Model Model `toml:"model"`
|
||||
Provider Provider `toml:"provider"`
|
||||
}
|
||||
|
||||
type Model struct {
|
||||
Type string `toml:"type"` // "provider" (default) | "manual"
|
||||
}
|
||||
|
||||
type Provider struct {
|
||||
Schemes []Scheme `toml:"schemes"`
|
||||
Users []User `toml:"users"`
|
||||
Current ProviderCurrent `toml:"current"`
|
||||
}
|
||||
|
||||
type Scheme struct {
|
||||
Name string `toml:"name"` // unique key
|
||||
BaseURL string `toml:"baseURL"` // required
|
||||
Type string `toml:"type"` // "openai-compatible" (default) | "anthropic"
|
||||
}
|
||||
|
||||
type User struct {
|
||||
Name string `toml:"name"`
|
||||
Scheme string `toml:"scheme"` // references Scheme.Name; (Name+Scheme) is unique
|
||||
Type string `toml:"type"` // "key" (default) | "OAuth"
|
||||
Key string `toml:"key"`
|
||||
}
|
||||
|
||||
type ProviderCurrent struct {
|
||||
Scheme string `toml:"scheme"` // references Scheme.Name
|
||||
User string `toml:"user"` // references User.Name where User.Scheme == Scheme
|
||||
Model string `toml:"model"` // from GET <baseURL>/models
|
||||
}
|
||||
|
||||
// DefaultConfig returns a minimal valid TUIConfig.
|
||||
func DefaultConfig() *TUIConfig {
|
||||
return &TUIConfig{
|
||||
Version: "1.0",
|
||||
Model: Model{Type: "provider"},
|
||||
Provider: Provider{
|
||||
Schemes: []Scheme{},
|
||||
Users: []User{},
|
||||
Current: ProviderCurrent{},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Load reads the TUI config from path. Returns a default config if the file does not exist.
|
||||
func Load(path string) (*TUIConfig, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if os.IsNotExist(err) {
|
||||
return DefaultConfig(), nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read config file %q: %w", path, err)
|
||||
}
|
||||
|
||||
cfg := DefaultConfig()
|
||||
if _, err := toml.Decode(string(data), cfg); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse config file %q: %w", path, err)
|
||||
}
|
||||
|
||||
applyDefaults(cfg)
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// Save writes cfg to path atomically (safe for flash / SD storage).
|
||||
func Save(path string, cfg *TUIConfig) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
return fmt.Errorf("failed to create config directory: %w", err)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
enc := toml.NewEncoder(&buf)
|
||||
if err := enc.Encode(cfg); err != nil {
|
||||
return fmt.Errorf("failed to encode config: %w", err)
|
||||
}
|
||||
if err := fileutil.WriteFileAtomic(path, buf.Bytes(), 0o600); err != nil {
|
||||
return fmt.Errorf("failed to write config file %q: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func applyDefaults(cfg *TUIConfig) {
|
||||
if cfg.Version == "" {
|
||||
cfg.Version = "1.0"
|
||||
}
|
||||
if cfg.Model.Type == "" {
|
||||
cfg.Model.Type = "provider"
|
||||
}
|
||||
for i := range cfg.Provider.Schemes {
|
||||
if cfg.Provider.Schemes[i].Type == "" {
|
||||
cfg.Provider.Schemes[i].Type = "openai-compatible"
|
||||
}
|
||||
}
|
||||
for i := range cfg.Provider.Users {
|
||||
if cfg.Provider.Users[i].Type == "" {
|
||||
cfg.Provider.Users[i].Type = "key"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SchemeByName returns the first Scheme whose Name matches, or nil.
|
||||
func (p *Provider) SchemeByName(name string) *Scheme {
|
||||
for i := range p.Schemes {
|
||||
if p.Schemes[i].Name == name {
|
||||
return &p.Schemes[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UsersForScheme returns all users whose Scheme field matches schemeName.
|
||||
func (p *Provider) UsersForScheme(schemeName string) []User {
|
||||
var out []User
|
||||
for _, u := range p.Users {
|
||||
if u.Scheme == schemeName {
|
||||
out = append(out, u)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// SyncSelectedModelToMainConfig syncs the currently selected model to ~/.picoclaw/config.json
|
||||
// Adds/replaces a "tui-prefer" model entry and sets it as the default model.
|
||||
// Preserves all other existing fields in the config file unchanged.
|
||||
func SyncSelectedModelToMainConfig(scheme Scheme, user User, modelID string) error {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
home = "."
|
||||
}
|
||||
mainConfigPath := filepath.Join(home, ".picoclaw", "config.json")
|
||||
|
||||
var cfg map[string]any
|
||||
if data, readErr := os.ReadFile(mainConfigPath); readErr == nil {
|
||||
if unmarshalErr := json.Unmarshal(data, &cfg); unmarshalErr != nil {
|
||||
cfg = make(map[string]any)
|
||||
}
|
||||
} else {
|
||||
cfg = make(map[string]any)
|
||||
}
|
||||
|
||||
if _, ok := cfg["agents"]; !ok {
|
||||
cfg["agents"] = make(map[string]any)
|
||||
}
|
||||
agents, ok := cfg["agents"].(map[string]any)
|
||||
if ok {
|
||||
if _, ok := agents["defaults"]; !ok {
|
||||
agents["defaults"] = make(map[string]any)
|
||||
}
|
||||
defaults, ok := agents["defaults"].(map[string]any)
|
||||
if ok {
|
||||
defaults["model"] = "tui-prefer"
|
||||
}
|
||||
}
|
||||
|
||||
tuiModel := map[string]any{
|
||||
"model_name": "tui-prefer",
|
||||
"model": modelID,
|
||||
"api_key": user.Key,
|
||||
"api_base": scheme.BaseURL,
|
||||
}
|
||||
|
||||
modelList := []any{}
|
||||
if ml, ok := cfg["model_list"].([]any); ok {
|
||||
modelList = ml
|
||||
}
|
||||
|
||||
found := false
|
||||
for i, m := range modelList {
|
||||
if entry, ok := m.(map[string]any); ok {
|
||||
if name, ok := entry["model_name"].(string); ok && name == "tui-prefer" {
|
||||
modelList[i] = tuiModel
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
modelList = append(modelList, tuiModel)
|
||||
}
|
||||
cfg["model_list"] = modelList
|
||||
|
||||
data, err := json.MarshalIndent(cfg, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(mainConfigPath), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(mainConfigPath, data, 0o600)
|
||||
}
|
||||
|
||||
func (cfg *TUIConfig) CurrentModelLabel() string {
|
||||
cur := cfg.Provider.Current
|
||||
if cur.Model == "" {
|
||||
return "(not configured)"
|
||||
}
|
||||
label := cur.Scheme
|
||||
if label != "" {
|
||||
label += " / "
|
||||
}
|
||||
return label + cur.Model
|
||||
}
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
// PicoClaw - Ultra-lightweight personal AI agent
|
||||
// License: MIT
|
||||
//
|
||||
// Copyright (c) 2026 PicoClaw contributors
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
|
||||
tuicfg "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/config"
|
||||
"github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/ui"
|
||||
)
|
||||
|
||||
func main() {
|
||||
configPath := tuicfg.DefaultConfigPath()
|
||||
if len(os.Args) > 1 {
|
||||
configPath = os.Args[1]
|
||||
}
|
||||
|
||||
configDir := filepath.Dir(configPath)
|
||||
if _, err := os.Stat(configDir); os.IsNotExist(err) {
|
||||
cmd := exec.Command("picoclaw", "onboard")
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
_ = cmd.Run()
|
||||
}
|
||||
|
||||
cfg, err := tuicfg.Load(configPath)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "picoclaw-launcher-tui: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
app := ui.New(cfg, configPath)
|
||||
// Bind model selection hook to sync to main config
|
||||
app.OnModelSelected = func(scheme tuicfg.Scheme, user tuicfg.User, modelID string) {
|
||||
_ = tuicfg.SyncSelectedModelToMainConfig(scheme, user, modelID)
|
||||
}
|
||||
if err := app.Run(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "picoclaw-launcher-tui: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,325 +0,0 @@
|
|||
// PicoClaw - Ultra-lightweight personal AI agent
|
||||
// License: MIT
|
||||
//
|
||||
// Copyright (c) 2026 PicoClaw contributors
|
||||
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"github.com/gdamore/tcell/v2"
|
||||
"github.com/rivo/tview"
|
||||
|
||||
tuicfg "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/config"
|
||||
)
|
||||
|
||||
// App is the root TUI application.
|
||||
type App struct {
|
||||
tapp *tview.Application
|
||||
pages *tview.Pages
|
||||
pageStack []string
|
||||
cfg *tuicfg.TUIConfig
|
||||
configPath string
|
||||
pageRefreshFns map[string]func()
|
||||
headerModelTV *tview.TextView
|
||||
modalOpen map[string]bool
|
||||
|
||||
// OnModelSelected is called when a model is selected in the UI.
|
||||
// Can be nil to disable.
|
||||
OnModelSelected func(scheme tuicfg.Scheme, user tuicfg.User, modelID string)
|
||||
|
||||
modelCache map[string][]modelEntry
|
||||
modelCacheMu sync.RWMutex
|
||||
refreshMu sync.Mutex
|
||||
}
|
||||
|
||||
// cacheKey returns the map key for a (scheme, user) pair.
|
||||
func cacheKey(schemeName, userName string) string {
|
||||
return fmt.Sprintf("%s/%s", schemeName, userName)
|
||||
}
|
||||
|
||||
// cachedModels returns a defensive copy of the cached model list for a user (may be nil).
|
||||
func (a *App) cachedModels(schemeName, userName string) []modelEntry {
|
||||
a.modelCacheMu.RLock()
|
||||
defer a.modelCacheMu.RUnlock()
|
||||
entries := a.modelCache[cacheKey(schemeName, userName)]
|
||||
return append([]modelEntry(nil), entries...)
|
||||
}
|
||||
|
||||
// refreshModelCache fetches models for every user in the config concurrently.
|
||||
// Serialized by refreshMu so concurrent calls don't race on the cache map.
|
||||
// When all fetches complete it calls onDone via QueueUpdateDraw.
|
||||
func (a *App) refreshModelCache(onDone func()) {
|
||||
go func() {
|
||||
a.refreshMu.Lock()
|
||||
defer a.refreshMu.Unlock()
|
||||
|
||||
users := a.cfg.Provider.Users
|
||||
schemes := a.cfg.Provider.Schemes
|
||||
|
||||
schemeURL := make(map[string]string, len(schemes))
|
||||
for _, s := range schemes {
|
||||
schemeURL[s.Name] = s.BaseURL
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for _, u := range users {
|
||||
baseURL, ok := schemeURL[u.Scheme]
|
||||
if !ok || baseURL == "" {
|
||||
continue
|
||||
}
|
||||
if u.Key == "" {
|
||||
a.modelCacheMu.Lock()
|
||||
if a.modelCache == nil {
|
||||
a.modelCache = make(map[string][]modelEntry)
|
||||
}
|
||||
a.modelCache[cacheKey(u.Scheme, u.Name)] = nil
|
||||
a.modelCacheMu.Unlock()
|
||||
continue
|
||||
}
|
||||
wg.Add(1)
|
||||
bURL := baseURL
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
entries, err := fetchModels(bURL, u.Key)
|
||||
a.modelCacheMu.Lock()
|
||||
if a.modelCache == nil {
|
||||
a.modelCache = make(map[string][]modelEntry)
|
||||
}
|
||||
if err != nil || len(entries) == 0 {
|
||||
a.modelCache[cacheKey(u.Scheme, u.Name)] = nil
|
||||
} else {
|
||||
a.modelCache[cacheKey(u.Scheme, u.Name)] = entries
|
||||
}
|
||||
a.modelCacheMu.Unlock()
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
if onDone != nil {
|
||||
a.tapp.QueueUpdateDraw(onDone)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// New creates and wires up the TUI application.
|
||||
func New(cfg *tuicfg.TUIConfig, configPath string) *App {
|
||||
// Cyberpunk Theme Colors
|
||||
// Dark background
|
||||
tview.Styles.PrimitiveBackgroundColor = tcell.NewHexColor(0x050510) // Deep Void
|
||||
tview.Styles.ContrastBackgroundColor = tcell.NewHexColor(0x1a1a2e) // Dark Indigo
|
||||
tview.Styles.MoreContrastBackgroundColor = tcell.NewHexColor(0x2a2a40)
|
||||
|
||||
// Borders and Titles
|
||||
tview.Styles.BorderColor = tcell.NewHexColor(0x00f0ff) // Neon Cyan
|
||||
tview.Styles.TitleColor = tcell.NewHexColor(0x00f0ff) // Neon Cyan
|
||||
tview.Styles.GraphicsColor = tcell.NewHexColor(0xff00ff) // Neon Magenta
|
||||
|
||||
// Text
|
||||
tview.Styles.PrimaryTextColor = tcell.NewHexColor(0xe0e0e0) // Off-white
|
||||
tview.Styles.SecondaryTextColor = tcell.NewHexColor(0x00f0ff) // Neon Cyan
|
||||
tview.Styles.TertiaryTextColor = tcell.NewHexColor(0x39ff14) // Neon Lime
|
||||
tview.Styles.InverseTextColor = tcell.NewHexColor(0x000000) // Black
|
||||
tview.Styles.ContrastSecondaryTextColor = tcell.NewHexColor(0xff00ff) // Neon Magenta
|
||||
|
||||
a := &App{
|
||||
tapp: tview.NewApplication(),
|
||||
pages: tview.NewPages(),
|
||||
pageStack: []string{},
|
||||
cfg: cfg,
|
||||
configPath: configPath,
|
||||
pageRefreshFns: make(map[string]func()),
|
||||
modalOpen: make(map[string]bool),
|
||||
}
|
||||
|
||||
a.tapp.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyEscape {
|
||||
if len(a.modalOpen) > 0 {
|
||||
return event
|
||||
}
|
||||
return a.goBack()
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
a.buildPages()
|
||||
return a
|
||||
}
|
||||
|
||||
// Run starts the TUI event loop.
|
||||
func (a *App) Run() error {
|
||||
return a.tapp.SetRoot(a.pages, true).EnableMouse(true).Run()
|
||||
}
|
||||
|
||||
func (a *App) buildPages() {
|
||||
a.pages.AddPage("home", a.newHomePage(), true, true)
|
||||
a.pageStack = []string{"home"}
|
||||
}
|
||||
|
||||
func (a *App) navigateTo(name string, page tview.Primitive) {
|
||||
a.pages.RemovePage(name)
|
||||
a.pages.AddPage(name, page, true, false)
|
||||
a.pageStack = append(a.pageStack, name)
|
||||
a.pages.SwitchToPage(name)
|
||||
}
|
||||
|
||||
func (a *App) goBack() *tcell.EventKey {
|
||||
if len(a.pageStack) <= 1 {
|
||||
return nil
|
||||
}
|
||||
popped := a.pageStack[len(a.pageStack)-1]
|
||||
a.pageStack = a.pageStack[:len(a.pageStack)-1]
|
||||
a.pages.RemovePage(popped)
|
||||
prev := a.pageStack[len(a.pageStack)-1]
|
||||
if fn, ok := a.pageRefreshFns[prev]; ok {
|
||||
fn()
|
||||
}
|
||||
if prev == "home" && a.headerModelTV != nil {
|
||||
a.headerModelTV.SetText(a.cfg.CurrentModelLabel() + " ")
|
||||
}
|
||||
a.pages.SwitchToPage(prev)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) showModal(name string, primitive tview.Primitive) {
|
||||
a.modalOpen[name] = true
|
||||
a.pages.AddPage(name, primitive, true, true)
|
||||
}
|
||||
|
||||
func (a *App) hideModal(name string) {
|
||||
delete(a.modalOpen, name)
|
||||
a.pages.HidePage(name)
|
||||
a.pages.RemovePage(name)
|
||||
}
|
||||
|
||||
func (a *App) save() {
|
||||
if err := tuicfg.Save(a.configPath, a.cfg); err != nil {
|
||||
a.showError("save failed: " + err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) showError(msg string) {
|
||||
modal := tview.NewModal().
|
||||
SetText(" [red::b]ERROR[-::-]\n\n" + msg).
|
||||
AddButtons([]string{"OK"}).
|
||||
SetDoneFunc(func(_ int, _ string) {
|
||||
a.hideModal("error")
|
||||
})
|
||||
// Cyberpunk Modal Style
|
||||
modal.SetBackgroundColor(tcell.NewHexColor(0x1a1a2e)) // Deep Indigo
|
||||
modal.SetTextColor(tcell.NewHexColor(0xffffff)) // White
|
||||
modal.SetButtonBackgroundColor(tcell.NewHexColor(0xff2a2a)) // Neon Red
|
||||
modal.SetButtonTextColor(tcell.NewHexColor(0xffffff)) // White
|
||||
a.showModal("error", modal)
|
||||
}
|
||||
|
||||
func (a *App) confirmDelete(label string, onConfirm func()) {
|
||||
modal := tview.NewModal().
|
||||
SetText(" [red::b]DELETE WARNING[-::-]\n\nDelete " + label + "?\n[gray]This action cannot be undone.[-]").
|
||||
AddButtons([]string{"Delete", "Cancel"}).
|
||||
SetDoneFunc(func(_ int, buttonLabel string) {
|
||||
a.hideModal("confirm-delete")
|
||||
if buttonLabel == "Delete" {
|
||||
onConfirm()
|
||||
}
|
||||
})
|
||||
// Cyberpunk Modal Style
|
||||
modal.SetBackgroundColor(tcell.NewHexColor(0x1a1a2e)) // Deep Indigo
|
||||
modal.SetTextColor(tcell.NewHexColor(0xffffff)) // White
|
||||
modal.SetButtonBackgroundColor(tcell.NewHexColor(0xff2a2a)) // Neon Red for danger
|
||||
modal.SetButtonTextColor(tcell.NewHexColor(0xffffff)) // White
|
||||
a.showModal("confirm-delete", modal)
|
||||
}
|
||||
|
||||
func centeredForm(form *tview.Form, widthPct, height int) tview.Primitive {
|
||||
return tview.NewFlex().
|
||||
AddItem(tview.NewBox(), 0, 1, false).
|
||||
AddItem(tview.NewFlex().SetDirection(tview.FlexRow).
|
||||
AddItem(tview.NewBox(), 0, 1, false).
|
||||
AddItem(form, height, 1, true).
|
||||
AddItem(tview.NewBox(), 0, 1, false), 0, widthPct, true).
|
||||
AddItem(tview.NewBox(), 0, 1, false)
|
||||
}
|
||||
|
||||
func hintBar(text string) *tview.TextView {
|
||||
tv := tview.NewTextView().
|
||||
SetText(text).
|
||||
SetDynamicColors(true).
|
||||
SetTextAlign(tview.AlignCenter).
|
||||
SetTextColor(tcell.NewHexColor(0x00f0ff)) // Neon Cyan
|
||||
tv.SetBackgroundColor(tcell.NewHexColor(0x2a2a40)) // Darker Indigo
|
||||
return tv
|
||||
}
|
||||
|
||||
func (a *App) buildShell(pageID string, content tview.Primitive, hint string) tview.Primitive {
|
||||
var modelTV *tview.TextView
|
||||
if pageID == "home" {
|
||||
if a.headerModelTV == nil {
|
||||
a.headerModelTV = tview.NewTextView()
|
||||
a.headerModelTV.SetTextAlign(tview.AlignRight).
|
||||
SetTextColor(tcell.NewHexColor(0x39ff14)). // Neon Lime
|
||||
SetDynamicColors(true).
|
||||
SetBackgroundColor(tcell.NewHexColor(0x050510))
|
||||
}
|
||||
modelTV = a.headerModelTV
|
||||
modelTV.SetText("MODEL: " + a.cfg.CurrentModelLabel() + " ")
|
||||
} else {
|
||||
modelTV = tview.NewTextView()
|
||||
modelTV.SetBackgroundColor(tcell.NewHexColor(0x050510))
|
||||
}
|
||||
|
||||
headerLeft := tview.NewTextView().
|
||||
SetText(" [#ff00ff::b]///[#00f0ff] PICOCLAW LAUNCHER [#ff00ff]///").
|
||||
SetDynamicColors(true).
|
||||
SetBackgroundColor(tcell.NewHexColor(0x050510))
|
||||
|
||||
header := tview.NewFlex().
|
||||
AddItem(headerLeft, 0, 1, false).
|
||||
AddItem(modelTV, 0, 1, false)
|
||||
|
||||
sidebar := tview.NewTextView().
|
||||
SetDynamicColors(true).
|
||||
SetWrap(false)
|
||||
sidebar.SetBackgroundColor(tcell.NewHexColor(0x1a1a2e)) // Deep Indigo
|
||||
|
||||
// Cyberpunk Sidebar Styling
|
||||
activePrefix := "[#39ff14::b]>> " // Neon Lime arrow
|
||||
activeSuffix := "[-]"
|
||||
inactivePrefix := "[#808080] "
|
||||
inactiveSuffix := "[-]"
|
||||
|
||||
sbText := "\n\n" // Top padding
|
||||
|
||||
menuItem := func(id, label string) string {
|
||||
if pageID == id {
|
||||
return activePrefix + label + activeSuffix + "\n\n"
|
||||
}
|
||||
return inactivePrefix + label + inactiveSuffix + "\n\n"
|
||||
}
|
||||
|
||||
sbText += menuItem("home", "HOME")
|
||||
sbText += menuItem("schemes", "SCHEMES")
|
||||
sbText += menuItem("users", "USERS")
|
||||
sbText += menuItem("models", "MODELS")
|
||||
sbText += menuItem("channels", "CHANNELS")
|
||||
sbText += menuItem("gateway", "GATEWAY")
|
||||
|
||||
sidebar.SetText(sbText)
|
||||
|
||||
footer := hintBar(hint)
|
||||
|
||||
grid := tview.NewGrid().
|
||||
SetRows(1, 0, 1).
|
||||
SetColumns(20, 0). // Slightly wider sidebar
|
||||
AddItem(header, 0, 0, 1, 2, 0, 0, false).
|
||||
AddItem(sidebar, 1, 0, 1, 1, 0, 0, false).
|
||||
AddItem(content, 1, 1, 1, 1, 0, 0, true).
|
||||
AddItem(footer, 2, 0, 1, 2, 0, 0, false)
|
||||
|
||||
// Add a border around the content area if possible, or ensure content has its own border
|
||||
// grid.SetBorders(false) // Grid borders usually look bad, handled by components
|
||||
|
||||
return grid
|
||||
}
|
||||
|
|
@ -1,202 +0,0 @@
|
|||
// PicoClaw - Ultra-lightweight personal AI agent
|
||||
// License: MIT
|
||||
//
|
||||
// Copyright (c) 2026 PicoClaw contributors
|
||||
|
||||
package ui
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strconv"
|
||||
|
||||
"github.com/gdamore/tcell/v2"
|
||||
"github.com/rivo/tview"
|
||||
)
|
||||
|
||||
func (a *App) newChannelsPage() tview.Primitive {
|
||||
list := tview.NewList()
|
||||
list.SetBorder(true).
|
||||
SetTitle(" [#00f0ff::b] COMMUNICATION CHANNELS ").
|
||||
SetTitleColor(tcell.NewHexColor(0x00f0ff)).
|
||||
SetBorderColor(tcell.NewHexColor(0x00f0ff))
|
||||
list.SetMainTextColor(tcell.NewHexColor(0xe0e0e0))
|
||||
list.SetSecondaryTextColor(tcell.NewHexColor(0x808080))
|
||||
list.SetSelectedStyle(
|
||||
tcell.StyleDefault.Background(tcell.NewHexColor(0xff00ff)).Foreground(tcell.NewHexColor(0x050510)),
|
||||
)
|
||||
list.SetHighlightFullLine(true)
|
||||
list.SetBackgroundColor(tcell.NewHexColor(0x050510))
|
||||
|
||||
rebuild := func() {
|
||||
sel := list.GetCurrentItem()
|
||||
list.Clear()
|
||||
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
home = "."
|
||||
}
|
||||
configPath := filepath.Join(home, ".picoclaw", "config.json")
|
||||
|
||||
var cfg map[string]any
|
||||
if data, err := os.ReadFile(configPath); err == nil {
|
||||
_ = json.Unmarshal(data, &cfg)
|
||||
}
|
||||
|
||||
if chRaw, ok := cfg["channels"].(map[string]any); ok {
|
||||
for name, ch := range chRaw {
|
||||
chMap, ok := ch.(map[string]any)
|
||||
enabled := "disabled"
|
||||
if ok {
|
||||
if e, ok := chMap["enabled"].(bool); ok && e {
|
||||
enabled = "enabled"
|
||||
}
|
||||
}
|
||||
list.AddItem(name, fmt.Sprintf("Status: %s", enabled), 0, func() {
|
||||
a.showChannelEditForm(configPath, name, chMap)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if sel >= 0 && sel < list.GetItemCount() {
|
||||
list.SetCurrentItem(sel)
|
||||
}
|
||||
}
|
||||
rebuild()
|
||||
|
||||
a.pageRefreshFns["channels"] = rebuild
|
||||
|
||||
list.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyEscape {
|
||||
return a.goBack()
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
return a.buildShell("channels", list, " [#ff00ff]Enter:[-] edit [#ff2a2a]ESC:[-] back ")
|
||||
}
|
||||
|
||||
func (a *App) showChannelEditForm(configPath, channelName string, existing map[string]any) {
|
||||
form := tview.NewForm()
|
||||
form.SetBorder(true).
|
||||
SetTitle(" [::b]EDIT CHANNEL ").
|
||||
SetTitleColor(tcell.NewHexColor(0x39ff14)).
|
||||
SetBorderColor(tcell.NewHexColor(0x00f0ff))
|
||||
form.SetBackgroundColor(tcell.NewHexColor(0x1a1a2e))
|
||||
form.SetFieldBackgroundColor(tcell.NewHexColor(0x050510))
|
||||
form.SetFieldTextColor(tcell.NewHexColor(0x00f0ff))
|
||||
form.SetLabelColor(tcell.NewHexColor(0xe0e0e0))
|
||||
form.SetButtonBackgroundColor(tcell.NewHexColor(0xff00ff))
|
||||
form.SetButtonTextColor(tcell.NewHexColor(0xffffff))
|
||||
|
||||
fields := make(map[string]*tview.InputField)
|
||||
var nameField *tview.InputField
|
||||
|
||||
if channelName == "" {
|
||||
nameField = tview.NewInputField().
|
||||
SetLabel("Channel Name").
|
||||
SetText("").
|
||||
SetFieldWidth(28)
|
||||
form.AddFormItem(nameField)
|
||||
}
|
||||
|
||||
for k, v := range existing {
|
||||
if reflect.ValueOf(v).Kind() == reflect.Map || reflect.ValueOf(v).Kind() == reflect.Slice {
|
||||
continue
|
||||
}
|
||||
valStr := fmt.Sprintf("%v", v)
|
||||
field := tview.NewInputField().
|
||||
SetLabel(k).
|
||||
SetText(valStr).
|
||||
SetFieldWidth(28)
|
||||
form.AddFormItem(field)
|
||||
fields[k] = field
|
||||
}
|
||||
|
||||
form.AddButton("SAVE", func() {
|
||||
var cfg map[string]any
|
||||
if data, err := os.ReadFile(configPath); err == nil {
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
cfg = make(map[string]any)
|
||||
}
|
||||
} else {
|
||||
cfg = make(map[string]any)
|
||||
}
|
||||
|
||||
if _, ok := cfg["channels"]; !ok {
|
||||
cfg["channels"] = make(map[string]any)
|
||||
}
|
||||
channels, ok := cfg["channels"].(map[string]any)
|
||||
if !ok {
|
||||
channels = make(map[string]any)
|
||||
cfg["channels"] = channels
|
||||
}
|
||||
|
||||
finalName := channelName
|
||||
if channelName == "" {
|
||||
if nameField == nil || nameField.GetText() == "" {
|
||||
a.showError("Channel name is required")
|
||||
return
|
||||
}
|
||||
finalName = nameField.GetText()
|
||||
}
|
||||
|
||||
updated := make(map[string]any)
|
||||
if existing != nil {
|
||||
for k, v := range existing {
|
||||
updated[k] = v
|
||||
}
|
||||
}
|
||||
for k, field := range fields {
|
||||
val := field.GetText()
|
||||
if val == "true" {
|
||||
updated[k] = true
|
||||
} else if val == "false" {
|
||||
updated[k] = false
|
||||
} else if num, err := strconv.Atoi(val); err == nil {
|
||||
updated[k] = num
|
||||
} else {
|
||||
updated[k] = val
|
||||
}
|
||||
}
|
||||
|
||||
if channelName != "" && finalName != channelName {
|
||||
delete(channels, channelName)
|
||||
}
|
||||
channels[finalName] = updated
|
||||
|
||||
data, err := json.MarshalIndent(cfg, "", " ")
|
||||
if err != nil {
|
||||
a.showError(fmt.Sprintf("Failed to save config: %v", err))
|
||||
return
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(configPath), 0o700); err != nil {
|
||||
a.showError(fmt.Sprintf("Failed to create config directory: %v", err))
|
||||
return
|
||||
}
|
||||
if err := os.WriteFile(configPath, data, 0o600); err != nil {
|
||||
a.showError(fmt.Sprintf("Failed to write config: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
a.hideModal("channel-edit")
|
||||
a.goBack()
|
||||
})
|
||||
|
||||
form.AddButton("CANCEL", func() {
|
||||
a.hideModal("channel-edit")
|
||||
})
|
||||
|
||||
form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyEscape {
|
||||
a.hideModal("channel-edit")
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
a.showModal("channel-edit", centeredForm(form, 4, 20))
|
||||
}
|
||||
|
|
@ -1,229 +0,0 @@
|
|||
// PicoClaw - Ultra-lightweight personal AI agent
|
||||
// License: MIT
|
||||
//
|
||||
// Copyright (c) 2026 PicoClaw contributors
|
||||
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gdamore/tcell/v2"
|
||||
"github.com/rivo/tview"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
ppid "github.com/sipeed/picoclaw/pkg/pid"
|
||||
)
|
||||
|
||||
type gatewayStatus struct {
|
||||
running bool
|
||||
pid int
|
||||
version string
|
||||
}
|
||||
|
||||
func picoHome() string {
|
||||
return config.GetHome()
|
||||
}
|
||||
|
||||
func getGatewayStatus() gatewayStatus {
|
||||
data := ppid.ReadPidFileWithCheck(picoHome())
|
||||
if data == nil {
|
||||
return gatewayStatus{running: false}
|
||||
}
|
||||
return gatewayStatus{
|
||||
running: true,
|
||||
pid: data.PID,
|
||||
version: data.Version,
|
||||
}
|
||||
}
|
||||
|
||||
func startGateway() error {
|
||||
status := getGatewayStatus()
|
||||
if status.running {
|
||||
return fmt.Errorf("gateway is already running (PID: %d)", status.pid)
|
||||
}
|
||||
|
||||
var cmd *exec.Cmd
|
||||
|
||||
if runtime.GOOS == "windows" {
|
||||
cmd = exec.Command("cmd", "/C", "start /B picoclaw gateway > NUL 2>&1")
|
||||
} else {
|
||||
cmd = exec.Command("sh", "-c", "nohup picoclaw gateway > /dev/null 2>&1 &")
|
||||
}
|
||||
|
||||
err := cmd.Start()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
time.Sleep(1 * time.Second)
|
||||
|
||||
if runtime.GOOS == "windows" {
|
||||
cmd := exec.Command(
|
||||
"wmic",
|
||||
"process",
|
||||
"where",
|
||||
"name='picoclaw.exe' and commandline like '%gateway%'",
|
||||
"get",
|
||||
"processid",
|
||||
)
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get gateway PID: %w", err)
|
||||
}
|
||||
lines := strings.Split(string(output), "\n")
|
||||
for _, line := range lines[1:] {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
_, err := strconv.Atoi(line)
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
status = getGatewayStatus()
|
||||
if !status.running {
|
||||
return fmt.Errorf("failed to start gateway")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func stopGateway() error {
|
||||
status := getGatewayStatus()
|
||||
if !status.running {
|
||||
return fmt.Errorf("gateway is not running")
|
||||
}
|
||||
|
||||
var err error
|
||||
if runtime.GOOS == "windows" {
|
||||
err = exec.Command("taskkill", "/F", "/PID", strconv.Itoa(status.pid)).Run()
|
||||
} else {
|
||||
err = exec.Command("kill", strconv.Itoa(status.pid)).Run()
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Wait for process to stop (ReadPidFileWithCheck cleans up stale pid file)
|
||||
for i := 0; i < 5; i++ {
|
||||
if !getGatewayStatus().running {
|
||||
break
|
||||
}
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) newGatewayPage() tview.Primitive {
|
||||
flex := tview.NewFlex().SetDirection(tview.FlexRow)
|
||||
flex.SetBorder(true).
|
||||
SetTitle(" [#00f0ff::b] GATEWAY MANAGEMENT ").
|
||||
SetTitleColor(tcell.NewHexColor(0x00f0ff)).
|
||||
SetBorderColor(tcell.NewHexColor(0x00f0ff))
|
||||
flex.SetBackgroundColor(tcell.NewHexColor(0x050510))
|
||||
|
||||
statusTV := tview.NewTextView().
|
||||
SetDynamicColors(true).
|
||||
SetTextAlign(tview.AlignCenter).
|
||||
SetText("Checking status...")
|
||||
statusTV.SetBackgroundColor(tcell.NewHexColor(0x050510))
|
||||
|
||||
var updateStatus func()
|
||||
|
||||
// 使用List作为按钮,保证显示和交互正常
|
||||
buttons := tview.NewList()
|
||||
buttons.SetBackgroundColor(tcell.NewHexColor(0x050510))
|
||||
buttons.SetMainTextColor(tcell.ColorWhite)
|
||||
buttons.SetSelectedBackgroundColor(tcell.NewHexColor(0xff00ff))
|
||||
buttons.SetSelectedTextColor(tcell.ColorBlack)
|
||||
|
||||
buttons.AddItem(" [lime]START[white] ", "", 0, func() {
|
||||
if !getGatewayStatus().running {
|
||||
err := startGateway()
|
||||
if err != nil {
|
||||
a.showError(err.Error())
|
||||
}
|
||||
updateStatus()
|
||||
}
|
||||
})
|
||||
buttons.AddItem(" [red]STOP[white] ", "", 0, func() {
|
||||
if getGatewayStatus().running {
|
||||
err := stopGateway()
|
||||
if err != nil {
|
||||
a.showError(err.Error())
|
||||
}
|
||||
updateStatus()
|
||||
}
|
||||
})
|
||||
|
||||
buttonFlex := tview.NewFlex().SetDirection(tview.FlexColumn)
|
||||
buttonFlex.
|
||||
AddItem(tview.NewBox(), 0, 1, false).
|
||||
AddItem(buttons, 20, 1, true).
|
||||
AddItem(tview.NewBox(), 0, 1, false)
|
||||
|
||||
flex.
|
||||
AddItem(tview.NewBox(), 0, 1, false).
|
||||
AddItem(statusTV, 3, 1, false).
|
||||
AddItem(tview.NewBox(), 0, 1, false).
|
||||
AddItem(buttonFlex, 4, 1, true).
|
||||
AddItem(tview.NewBox(), 0, 1, false)
|
||||
|
||||
updateStatus = func() {
|
||||
status := getGatewayStatus()
|
||||
if status.running {
|
||||
versionInfo := ""
|
||||
if status.version != "" {
|
||||
versionInfo = fmt.Sprintf("\nVersion: %s", status.version)
|
||||
}
|
||||
statusTV.SetText(fmt.Sprintf("[#39ff14::b]GATEWAY RUNNING[-]\n\nPID: %d%s", status.pid, versionInfo))
|
||||
buttons.SetItemText(0, " [gray]START[white] ", "")
|
||||
buttons.SetItemText(1, " [red]STOP[white] ", "")
|
||||
} else {
|
||||
statusTV.SetText("[#ff2a2a::b]GATEWAY STOPPED[-]\n\nPID: N/A")
|
||||
buttons.SetItemText(0, " [lime]START[white] ", "")
|
||||
buttons.SetItemText(1, " [gray]STOP[white] ", "")
|
||||
}
|
||||
}
|
||||
|
||||
updateStatus()
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
ticker := time.NewTicker(2 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
a.tapp.QueueUpdateDraw(updateStatus)
|
||||
case <-done:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
originalInputCapture := flex.GetInputCapture()
|
||||
flex.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyEscape {
|
||||
close(done)
|
||||
return a.goBack()
|
||||
}
|
||||
if originalInputCapture != nil {
|
||||
return originalInputCapture(event)
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
a.pageRefreshFns["gateway"] = updateStatus
|
||||
|
||||
return a.buildShell("gateway", flex, " [#39ff14]Enter:[-] select [#ff2a2a]ESC:[-] back ")
|
||||
}
|
||||
|
|
@ -1,70 +0,0 @@
|
|||
// PicoClaw - Ultra-lightweight personal AI agent
|
||||
// License: MIT
|
||||
//
|
||||
// Copyright (c) 2026 PicoClaw contributors
|
||||
|
||||
package ui
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
|
||||
"github.com/gdamore/tcell/v2"
|
||||
"github.com/rivo/tview"
|
||||
)
|
||||
|
||||
func (a *App) newHomePage() tview.Primitive {
|
||||
list := tview.NewList()
|
||||
list.SetBorder(true).
|
||||
SetTitle(" [#00f0ff::b] ACTIVE CONFIGURATION ").
|
||||
SetTitleColor(tcell.NewHexColor(0x00f0ff)).
|
||||
SetBorderColor(tcell.NewHexColor(0x00f0ff))
|
||||
list.SetMainTextColor(tcell.NewHexColor(0xe0e0e0))
|
||||
list.SetSecondaryTextColor(tcell.NewHexColor(0x808080))
|
||||
list.SetSelectedStyle(
|
||||
tcell.StyleDefault.Background(tcell.NewHexColor(0x39ff14)).Foreground(tcell.NewHexColor(0x050510)),
|
||||
)
|
||||
list.SetHighlightFullLine(true)
|
||||
list.SetBackgroundColor(tcell.NewHexColor(0x050510))
|
||||
|
||||
rebuildList := func() {
|
||||
sel := list.GetCurrentItem()
|
||||
list.Clear()
|
||||
list.AddItem("MODEL: "+a.cfg.CurrentModelLabel(), "Select to configure AI model", 'm', func() {
|
||||
a.navigateTo("schemes", a.newSchemesPage())
|
||||
})
|
||||
list.AddItem(
|
||||
"CHANNELS: Configure communication channels",
|
||||
"Manage Telegram/Discord/WeChat channels",
|
||||
'n',
|
||||
func() {
|
||||
a.navigateTo("channels", a.newChannelsPage())
|
||||
},
|
||||
)
|
||||
list.AddItem("GATEWAY MANAGEMENT", "Manage PicoClaw gateway daemon", 'g', func() {
|
||||
a.navigateTo("gateway", a.newGatewayPage())
|
||||
})
|
||||
list.AddItem("CHAT: Start AI agent chat", "Launch interactive chat session", 'c', func() {
|
||||
a.tapp.Suspend(func() {
|
||||
cmd := exec.Command("picoclaw", "agent")
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
_ = cmd.Run()
|
||||
})
|
||||
})
|
||||
list.AddItem("QUIT SYSTEM", "Exit PicoClaw Launcher", 'q', func() { a.tapp.Stop() })
|
||||
if sel >= 0 && sel < list.GetItemCount() {
|
||||
list.SetCurrentItem(sel)
|
||||
}
|
||||
}
|
||||
rebuildList()
|
||||
|
||||
a.pageRefreshFns["home"] = rebuildList
|
||||
|
||||
return a.buildShell(
|
||||
"home",
|
||||
list,
|
||||
" [#00f0ff]m:[-] model [#00f0ff]n:[-] channels [#00f0ff]g:[-] gateway [#00f0ff]c:[-] chat [#ff2a2a]q:[-] quit ",
|
||||
)
|
||||
}
|
||||
|
|
@ -1,200 +0,0 @@
|
|||
// PicoClaw - Ultra-lightweight personal AI agent
|
||||
// License: MIT
|
||||
//
|
||||
// Copyright (c) 2026 PicoClaw contributors
|
||||
|
||||
package ui
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gdamore/tcell/v2"
|
||||
"github.com/rivo/tview"
|
||||
|
||||
tuicfg "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/config"
|
||||
)
|
||||
|
||||
type modelsAPIResponse struct {
|
||||
Data []modelEntry `json:"data"`
|
||||
}
|
||||
|
||||
type modelEntry struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
func (a *App) newModelsPage(schemeName, userName, baseURL string) tview.Primitive {
|
||||
table := tview.NewTable().
|
||||
SetBorders(false).
|
||||
SetSelectable(true, false).
|
||||
SetFixed(0, 0)
|
||||
table.SetBorder(true).
|
||||
SetTitle(fmt.Sprintf(" [#00f0ff::b] MODELS · %s / %s ", schemeName, userName)).
|
||||
SetTitleColor(tcell.NewHexColor(0x00f0ff)).
|
||||
SetBorderColor(tcell.NewHexColor(0x00f0ff))
|
||||
table.SetSelectedStyle(
|
||||
tcell.StyleDefault.Background(tcell.NewHexColor(0xff00ff)).Foreground(tcell.NewHexColor(0xffffff)),
|
||||
)
|
||||
table.SetBackgroundColor(tcell.NewHexColor(0x050510))
|
||||
|
||||
var modelIDs []string
|
||||
|
||||
status := tview.NewTextView().
|
||||
SetTextAlign(tview.AlignCenter).
|
||||
SetDynamicColors(true).
|
||||
SetText("[#ffff00]FETCHING MODELS...[-]")
|
||||
status.SetBackgroundColor(tcell.NewHexColor(0x050510))
|
||||
|
||||
flex := tview.NewFlex().
|
||||
SetDirection(tview.FlexRow).
|
||||
AddItem(status, 1, 0, false).
|
||||
AddItem(table, 0, 1, false)
|
||||
|
||||
apiKey := a.resolveKey(schemeName, userName)
|
||||
|
||||
go func() {
|
||||
var entries []modelEntry
|
||||
var err error
|
||||
if apiKey == "" {
|
||||
err = fmt.Errorf("key is required")
|
||||
} else {
|
||||
entries, err = fetchModels(baseURL, apiKey)
|
||||
}
|
||||
|
||||
a.modelCacheMu.Lock()
|
||||
if a.modelCache == nil {
|
||||
a.modelCache = make(map[string][]modelEntry)
|
||||
}
|
||||
if err == nil && len(entries) > 0 {
|
||||
a.modelCache[cacheKey(schemeName, userName)] = entries
|
||||
} else {
|
||||
a.modelCache[cacheKey(schemeName, userName)] = nil
|
||||
}
|
||||
a.modelCacheMu.Unlock()
|
||||
|
||||
a.tapp.QueueUpdateDraw(func() {
|
||||
if err != nil {
|
||||
status.SetText(fmt.Sprintf("[#ff2a2a]ERROR: %s[-]", err.Error()))
|
||||
table.SetCell(0, 0, tview.NewTableCell(" (failed to load models)"))
|
||||
a.tapp.SetFocus(table)
|
||||
return
|
||||
}
|
||||
if len(entries) == 0 {
|
||||
status.SetText("[#ff2a2a]NO MODELS RETURNED[-]")
|
||||
table.SetCell(0, 0, tview.NewTableCell(" (no models available)"))
|
||||
a.tapp.SetFocus(table)
|
||||
return
|
||||
}
|
||||
|
||||
status.SetText(fmt.Sprintf("[#39ff14]%d MODEL(S) LOADED[-]", len(entries)))
|
||||
for i, m := range entries {
|
||||
modelIDs = append(modelIDs, m.ID)
|
||||
table.SetCell(i, 0,
|
||||
tview.NewTableCell(fmt.Sprintf("%3d", i+1)).
|
||||
SetAlign(tview.AlignRight).
|
||||
SetTextColor(tcell.NewHexColor(0x808080)).
|
||||
SetSelectable(false),
|
||||
)
|
||||
table.SetCell(i, 1,
|
||||
tview.NewTableCell(" "+m.ID).
|
||||
SetAlign(tview.AlignLeft).
|
||||
SetExpansion(1).
|
||||
SetTextColor(tcell.NewHexColor(0xe0e0e0)),
|
||||
)
|
||||
}
|
||||
a.tapp.SetFocus(table)
|
||||
})
|
||||
}()
|
||||
|
||||
table.SetSelectedFunc(func(row, _ int) {
|
||||
if row < 0 || row >= len(modelIDs) {
|
||||
return
|
||||
}
|
||||
a.cfg.Provider.Current = tuicfg.ProviderCurrent{
|
||||
Scheme: schemeName,
|
||||
User: userName,
|
||||
Model: modelIDs[row],
|
||||
}
|
||||
a.save()
|
||||
|
||||
// Trigger model selected callback if set
|
||||
if a.OnModelSelected != nil && a.cfg.Model.Type == "provider" {
|
||||
scheme := a.cfg.Provider.SchemeByName(schemeName)
|
||||
if scheme == nil {
|
||||
a.goBack()
|
||||
return
|
||||
}
|
||||
var user tuicfg.User
|
||||
for _, u := range a.cfg.Provider.Users {
|
||||
if u.Scheme == schemeName && u.Name == userName {
|
||||
user = u
|
||||
break
|
||||
}
|
||||
}
|
||||
a.OnModelSelected(*scheme, user, modelIDs[row])
|
||||
}
|
||||
|
||||
a.goBack()
|
||||
})
|
||||
|
||||
return a.buildShell("models", flex, " [#39ff14]Enter:[-] select [#ff00ff]ESC:[-] back ")
|
||||
}
|
||||
|
||||
func (a *App) resolveKey(schemeName, userName string) string {
|
||||
for _, u := range a.cfg.Provider.Users {
|
||||
if u.Scheme == schemeName && u.Name == userName {
|
||||
return u.Key
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func fetchModels(baseURL, apiKey string) ([]modelEntry, error) {
|
||||
url := strings.TrimRight(baseURL, "/") + "/models"
|
||||
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build request: %w", err)
|
||||
}
|
||||
if apiKey != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read response: %w", err)
|
||||
}
|
||||
|
||||
var result modelsAPIResponse
|
||||
if err := json.Unmarshal(body, &result); err == nil && len(result.Data) > 0 {
|
||||
return result.Data, nil
|
||||
}
|
||||
|
||||
var arr []modelEntry
|
||||
if err := json.Unmarshal(body, &arr); err == nil {
|
||||
return arr, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf(
|
||||
"decode response: unrecognized shape: %s",
|
||||
strings.TrimSpace(string(body[:min(len(body), 256)])),
|
||||
)
|
||||
}
|
||||
|
|
@ -1,252 +0,0 @@
|
|||
// PicoClaw - Ultra-lightweight personal AI agent
|
||||
// License: MIT
|
||||
//
|
||||
// Copyright (c) 2026 PicoClaw contributors
|
||||
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/gdamore/tcell/v2"
|
||||
"github.com/rivo/tview"
|
||||
|
||||
tuicfg "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/config"
|
||||
)
|
||||
|
||||
func (a *App) newSchemesPage() tview.Primitive {
|
||||
table := tview.NewTable().
|
||||
SetBorders(false).
|
||||
SetSelectable(true, false)
|
||||
table.SetBorder(true).
|
||||
SetTitle(" [#00f0ff::b] PROVIDER SCHEMES ").
|
||||
SetTitleColor(tcell.NewHexColor(0x00f0ff)).
|
||||
SetBorderColor(tcell.NewHexColor(0x00f0ff))
|
||||
table.SetSelectedStyle(
|
||||
tcell.StyleDefault.Background(tcell.NewHexColor(0xff00ff)).Foreground(tcell.NewHexColor(0xffffff)),
|
||||
)
|
||||
table.SetBackgroundColor(tcell.NewHexColor(0x050510))
|
||||
|
||||
rowToIdx := func(row int) int { return row / 2 }
|
||||
|
||||
selectedSchemeName := func() string {
|
||||
row, _ := table.GetSelection()
|
||||
idx := rowToIdx(row)
|
||||
schemes := a.cfg.Provider.Schemes
|
||||
if idx >= 0 && idx < len(schemes) {
|
||||
return schemes[idx].Name
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
rebuild := func() {
|
||||
selName := selectedSchemeName()
|
||||
table.Clear()
|
||||
schemes := a.cfg.Provider.Schemes
|
||||
for i, s := range schemes {
|
||||
nameRow := i * 2
|
||||
detailRow := nameRow + 1
|
||||
|
||||
table.SetCell(nameRow, 0,
|
||||
tview.NewTableCell(" "+s.Name).
|
||||
SetTextColor(tcell.NewHexColor(0xe0e0e0)).
|
||||
SetExpansion(1).
|
||||
SetSelectable(true),
|
||||
)
|
||||
|
||||
users := a.cfg.Provider.UsersForScheme(s.Name)
|
||||
n := len(users)
|
||||
m := 0
|
||||
for _, u := range users {
|
||||
if models := a.cachedModels(s.Name, u.Name); len(models) > 0 {
|
||||
m++
|
||||
}
|
||||
}
|
||||
table.SetCell(detailRow, 0,
|
||||
tview.NewTableCell(fmt.Sprintf(" [#808080](%d/%d) %s", m, n, s.BaseURL)).
|
||||
SetTextColor(tcell.NewHexColor(0x808080)).
|
||||
SetExpansion(1).
|
||||
SetSelectable(false),
|
||||
)
|
||||
table.SetCell(detailRow, 1,
|
||||
tview.NewTableCell("[#00f0ff]"+s.Type+" ").
|
||||
SetAlign(tview.AlignRight).
|
||||
SetSelectable(false),
|
||||
)
|
||||
}
|
||||
if selName != "" {
|
||||
for i, s := range schemes {
|
||||
if s.Name == selName {
|
||||
table.Select(i*2, 0)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
if table.GetRowCount() > 0 {
|
||||
table.Select(0, 0)
|
||||
}
|
||||
}
|
||||
rebuild()
|
||||
|
||||
a.refreshModelCache(rebuild)
|
||||
a.pageRefreshFns["schemes"] = func() { a.refreshModelCache(rebuild) }
|
||||
|
||||
table.SetSelectedFunc(func(row, _ int) {
|
||||
idx := rowToIdx(row)
|
||||
schemes := a.cfg.Provider.Schemes
|
||||
if idx < 0 || idx >= len(schemes) {
|
||||
return
|
||||
}
|
||||
name := schemes[idx].Name
|
||||
a.navigateTo("users", a.newUsersPage(name))
|
||||
})
|
||||
|
||||
table.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
row, _ := table.GetSelection()
|
||||
idx := rowToIdx(row)
|
||||
schemes := a.cfg.Provider.Schemes
|
||||
switch event.Rune() {
|
||||
case 'a':
|
||||
a.showSchemeForm(nil, func(s tuicfg.Scheme) {
|
||||
a.cfg.Provider.Schemes = append(a.cfg.Provider.Schemes, s)
|
||||
a.save()
|
||||
a.refreshModelCache(rebuild)
|
||||
})
|
||||
return nil
|
||||
case 'e':
|
||||
if idx < 0 || idx >= len(schemes) {
|
||||
return nil
|
||||
}
|
||||
origName := schemes[idx].Name
|
||||
orig := schemes[idx]
|
||||
a.showSchemeForm(&orig, func(s tuicfg.Scheme) {
|
||||
current := a.cfg.Provider.Schemes
|
||||
for i, sc := range current {
|
||||
if sc.Name == origName {
|
||||
a.cfg.Provider.Schemes[i] = s
|
||||
break
|
||||
}
|
||||
}
|
||||
a.save()
|
||||
a.refreshModelCache(func() {
|
||||
rebuild()
|
||||
for i, sc := range a.cfg.Provider.Schemes {
|
||||
if sc.Name == s.Name {
|
||||
table.Select(i*2, 0)
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
return nil
|
||||
case 'd':
|
||||
if idx < 0 || idx >= len(schemes) {
|
||||
return nil
|
||||
}
|
||||
name := schemes[idx].Name
|
||||
a.confirmDelete(fmt.Sprintf("scheme %q", name), func() {
|
||||
current := a.cfg.Provider.Schemes
|
||||
newSchemes := make([]tuicfg.Scheme, 0, len(current))
|
||||
for _, sc := range current {
|
||||
if sc.Name != name {
|
||||
newSchemes = append(newSchemes, sc)
|
||||
}
|
||||
}
|
||||
a.cfg.Provider.Schemes = newSchemes
|
||||
|
||||
existing := a.cfg.Provider.Users
|
||||
filtered := make([]tuicfg.User, 0, len(existing))
|
||||
for _, u := range existing {
|
||||
if u.Scheme != name {
|
||||
filtered = append(filtered, u)
|
||||
}
|
||||
}
|
||||
a.cfg.Provider.Users = filtered
|
||||
|
||||
a.save()
|
||||
a.refreshModelCache(rebuild)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
return a.buildShell(
|
||||
"schemes",
|
||||
table,
|
||||
" [#00f0ff]a:[-] add [#00f0ff]e:[-] edit [#ff2a2a]d:[-] delete [#39ff14]Enter:[-] open [#ff00ff]ESC:[-] back ",
|
||||
)
|
||||
}
|
||||
|
||||
func (a *App) showSchemeForm(existing *tuicfg.Scheme, onSave func(tuicfg.Scheme)) {
|
||||
name := ""
|
||||
baseURL := ""
|
||||
schemeType := "openai-compatible"
|
||||
title := " ADD SCHEME "
|
||||
|
||||
if existing != nil {
|
||||
name = existing.Name
|
||||
baseURL = existing.BaseURL
|
||||
schemeType = existing.Type
|
||||
title = " EDIT SCHEME "
|
||||
}
|
||||
|
||||
typeOptions := []string{"openai-compatible", "anthropic"}
|
||||
typeIdx := 0
|
||||
for i, t := range typeOptions {
|
||||
if t == schemeType {
|
||||
typeIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
form := tview.NewForm()
|
||||
|
||||
form.
|
||||
AddInputField("Name", name, 20, nil, func(text string) { name = text }).
|
||||
AddInputField("Base URL", baseURL, 28, nil, func(text string) { baseURL = text }).
|
||||
AddDropDown("Type", typeOptions, typeIdx, func(option string, _ int) { schemeType = option }).
|
||||
AddButton("SAVE", func() {
|
||||
if name == "" {
|
||||
a.showError("Name is required")
|
||||
return
|
||||
}
|
||||
if baseURL == "" {
|
||||
a.showError("Base URL is required")
|
||||
return
|
||||
}
|
||||
if existing == nil {
|
||||
for _, s := range a.cfg.Provider.Schemes {
|
||||
if s.Name == name {
|
||||
a.showError(fmt.Sprintf("Scheme name %q already exists", name))
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
a.hideModal("scheme-form")
|
||||
onSave(tuicfg.Scheme{Name: name, BaseURL: baseURL, Type: schemeType})
|
||||
}).
|
||||
AddButton("CANCEL", func() {
|
||||
a.hideModal("scheme-form")
|
||||
})
|
||||
|
||||
form.SetBorder(true).
|
||||
SetTitle(" [::b]" + title + " ").
|
||||
SetTitleColor(tcell.NewHexColor(0x39ff14)).
|
||||
SetBorderColor(tcell.NewHexColor(0x00f0ff))
|
||||
form.SetBackgroundColor(tcell.NewHexColor(0x1a1a2e))
|
||||
form.SetFieldBackgroundColor(tcell.NewHexColor(0x050510))
|
||||
form.SetFieldTextColor(tcell.NewHexColor(0x00f0ff))
|
||||
form.SetLabelColor(tcell.NewHexColor(0xe0e0e0))
|
||||
form.SetButtonBackgroundColor(tcell.NewHexColor(0xff00ff))
|
||||
form.SetButtonTextColor(tcell.NewHexColor(0xffffff))
|
||||
form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyEscape {
|
||||
a.hideModal("scheme-form")
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
a.showModal("scheme-form", centeredForm(form, 4, 12))
|
||||
}
|
||||
|
|
@ -1,261 +0,0 @@
|
|||
// PicoClaw - Ultra-lightweight personal AI agent
|
||||
// License: MIT
|
||||
//
|
||||
// Copyright (c) 2026 PicoClaw contributors
|
||||
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/gdamore/tcell/v2"
|
||||
"github.com/rivo/tview"
|
||||
|
||||
tuicfg "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/config"
|
||||
)
|
||||
|
||||
func (a *App) newUsersPage(schemeName string) tview.Primitive {
|
||||
table := tview.NewTable().
|
||||
SetBorders(false).
|
||||
SetSelectable(true, false)
|
||||
table.SetBorder(true).
|
||||
SetTitle(fmt.Sprintf(" [#00f0ff::b] USERS · %s ", schemeName)).
|
||||
SetTitleColor(tcell.NewHexColor(0x00f0ff)).
|
||||
SetBorderColor(tcell.NewHexColor(0x00f0ff))
|
||||
table.SetSelectedStyle(
|
||||
tcell.StyleDefault.Background(tcell.NewHexColor(0xff00ff)).Foreground(tcell.NewHexColor(0xffffff)),
|
||||
)
|
||||
table.SetBackgroundColor(tcell.NewHexColor(0x050510))
|
||||
|
||||
visibleUsers := func() []tuicfg.User {
|
||||
var out []tuicfg.User
|
||||
for _, u := range a.cfg.Provider.Users {
|
||||
if u.Scheme == schemeName {
|
||||
out = append(out, u)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
findUserGlobalIdx := func(userName string) int {
|
||||
for i, u := range a.cfg.Provider.Users {
|
||||
if u.Scheme == schemeName && u.Name == userName {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
rowToVisIdx := func(row int) int { return row / 2 }
|
||||
|
||||
selectedUserName := func() string {
|
||||
row, _ := table.GetSelection()
|
||||
users := visibleUsers()
|
||||
visIdx := rowToVisIdx(row)
|
||||
if visIdx >= 0 && visIdx < len(users) {
|
||||
return users[visIdx].Name
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
rebuild := func() {
|
||||
selName := selectedUserName()
|
||||
table.Clear()
|
||||
users := visibleUsers()
|
||||
for i, u := range users {
|
||||
nameRow := i * 2
|
||||
detailRow := nameRow + 1
|
||||
|
||||
table.SetCell(nameRow, 0,
|
||||
tview.NewTableCell(" "+u.Name).
|
||||
SetTextColor(tcell.NewHexColor(0xe0e0e0)).
|
||||
SetExpansion(1).
|
||||
SetSelectable(true),
|
||||
)
|
||||
table.SetCell(nameRow, 1,
|
||||
tview.NewTableCell("").
|
||||
SetSelectable(false),
|
||||
)
|
||||
|
||||
models := a.cachedModels(schemeName, u.Name)
|
||||
var detailText string
|
||||
if len(models) > 0 {
|
||||
detailText = fmt.Sprintf(" [#39ff14]%d models available[-]", len(models))
|
||||
} else {
|
||||
detailText = " [#ff2a2a]Inactive / No Access[-]"
|
||||
}
|
||||
table.SetCell(detailRow, 0,
|
||||
tview.NewTableCell(detailText).
|
||||
SetTextColor(tcell.NewHexColor(0x808080)).
|
||||
SetExpansion(1).
|
||||
SetSelectable(false),
|
||||
)
|
||||
table.SetCell(detailRow, 1,
|
||||
tview.NewTableCell("[#00f0ff]"+u.Type+" ").
|
||||
SetAlign(tview.AlignRight).
|
||||
SetSelectable(false),
|
||||
)
|
||||
}
|
||||
if selName != "" {
|
||||
for i, u := range users {
|
||||
if u.Name == selName {
|
||||
table.Select(i*2, 0)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
if table.GetRowCount() > 0 {
|
||||
table.Select(0, 0)
|
||||
}
|
||||
}
|
||||
rebuild()
|
||||
|
||||
a.refreshModelCache(rebuild)
|
||||
a.pageRefreshFns["users"] = func() { a.refreshModelCache(rebuild) }
|
||||
|
||||
table.SetSelectedFunc(func(row, _ int) {
|
||||
visIdx := rowToVisIdx(row)
|
||||
users := visibleUsers()
|
||||
if visIdx < 0 || visIdx >= len(users) {
|
||||
return
|
||||
}
|
||||
uName := users[visIdx].Name
|
||||
scheme := a.cfg.Provider.SchemeByName(schemeName)
|
||||
if scheme == nil {
|
||||
a.showError(fmt.Sprintf("Scheme %q not found", schemeName))
|
||||
return
|
||||
}
|
||||
a.navigateTo("models", a.newModelsPage(schemeName, uName, scheme.BaseURL))
|
||||
})
|
||||
|
||||
table.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
row, _ := table.GetSelection()
|
||||
visIdx := rowToVisIdx(row)
|
||||
users := visibleUsers()
|
||||
switch event.Rune() {
|
||||
case 'a':
|
||||
a.showUserForm(schemeName, nil, func(u tuicfg.User) {
|
||||
a.cfg.Provider.Users = append(a.cfg.Provider.Users, u)
|
||||
a.save()
|
||||
a.refreshModelCache(rebuild)
|
||||
})
|
||||
return nil
|
||||
case 'e':
|
||||
if visIdx < 0 || visIdx >= len(users) {
|
||||
return nil
|
||||
}
|
||||
origName := users[visIdx].Name
|
||||
orig := a.cfg.Provider.Users[findUserGlobalIdx(origName)]
|
||||
a.showUserForm(schemeName, &orig, func(u tuicfg.User) {
|
||||
cfgIdx := findUserGlobalIdx(origName)
|
||||
if cfgIdx < 0 {
|
||||
a.showError(fmt.Sprintf("User %q no longer exists", origName))
|
||||
return
|
||||
}
|
||||
a.cfg.Provider.Users[cfgIdx] = u
|
||||
a.save()
|
||||
a.refreshModelCache(func() {
|
||||
rebuild()
|
||||
for i, usr := range visibleUsers() {
|
||||
if usr.Name == u.Name {
|
||||
table.Select(i*2, 0)
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
return nil
|
||||
case 'd':
|
||||
if visIdx < 0 || visIdx >= len(users) {
|
||||
return nil
|
||||
}
|
||||
uName := users[visIdx].Name
|
||||
a.confirmDelete(fmt.Sprintf("user %q", uName), func() {
|
||||
cfgIdx := findUserGlobalIdx(uName)
|
||||
if cfgIdx < 0 {
|
||||
return
|
||||
}
|
||||
all := a.cfg.Provider.Users
|
||||
a.cfg.Provider.Users = append(all[:cfgIdx], all[cfgIdx+1:]...)
|
||||
a.save()
|
||||
a.refreshModelCache(rebuild)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
return a.buildShell(
|
||||
"users",
|
||||
table,
|
||||
" [#00f0ff]a:[-] add [#00f0ff]e:[-] edit [#ff2a2a]d:[-] delete [#39ff14]Enter:[-] models [#ff00ff]ESC:[-] back ",
|
||||
)
|
||||
}
|
||||
|
||||
func (a *App) showUserForm(schemeName string, existing *tuicfg.User, onSave func(tuicfg.User)) {
|
||||
name := ""
|
||||
userType := "key"
|
||||
key := ""
|
||||
title := " ADD USER "
|
||||
|
||||
if existing != nil {
|
||||
name = existing.Name
|
||||
userType = existing.Type
|
||||
key = existing.Key
|
||||
title = " EDIT USER "
|
||||
}
|
||||
|
||||
typeOptions := []string{"key", "OAuth"}
|
||||
typeIdx := 0
|
||||
for i, t := range typeOptions {
|
||||
if t == userType {
|
||||
typeIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
form := tview.NewForm()
|
||||
form.
|
||||
AddInputField("Name", name, 20, nil, func(text string) { name = text }).
|
||||
AddDropDown("Type", typeOptions, typeIdx, func(option string, _ int) { userType = option }).
|
||||
AddPasswordField("Key", key, 28, '*', func(text string) { key = text }).
|
||||
AddButton("SAVE", func() {
|
||||
if name == "" {
|
||||
a.showError("Name is required")
|
||||
return
|
||||
}
|
||||
if existing == nil {
|
||||
for _, u := range a.cfg.Provider.Users {
|
||||
if u.Scheme == schemeName && u.Name == name {
|
||||
a.showError(fmt.Sprintf("User name %q already exists for this scheme", name))
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
a.hideModal("user-form")
|
||||
onSave(tuicfg.User{Name: name, Scheme: schemeName, Type: userType, Key: key})
|
||||
}).
|
||||
AddButton("CANCEL", func() {
|
||||
a.hideModal("user-form")
|
||||
})
|
||||
|
||||
form.SetBorder(true).
|
||||
SetTitle(" [::b]" + title + " ").
|
||||
SetTitleColor(tcell.NewHexColor(0x39ff14)).
|
||||
SetBorderColor(tcell.NewHexColor(0x00f0ff))
|
||||
form.SetBackgroundColor(tcell.NewHexColor(0x1a1a2e))
|
||||
form.SetFieldBackgroundColor(tcell.NewHexColor(0x050510))
|
||||
form.SetFieldTextColor(tcell.NewHexColor(0x00f0ff))
|
||||
form.SetLabelColor(tcell.NewHexColor(0xe0e0e0))
|
||||
form.SetButtonBackgroundColor(tcell.NewHexColor(0xff00ff))
|
||||
form.SetButtonTextColor(tcell.NewHexColor(0xffffff))
|
||||
form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyEscape {
|
||||
a.hideModal("user-form")
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
a.showModal("user-form", centeredForm(form, 4, 13))
|
||||
}
|
||||
200
cmd/picoclaw/internal/model/add.go
Normal file
200
cmd/picoclaw/internal/model/add.go
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
package model
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
const defaultAliasName = "custom-prefer"
|
||||
|
||||
func newAddCommand() *cobra.Command {
|
||||
var (
|
||||
apiBase string
|
||||
apiKey string
|
||||
modelID string
|
||||
alias string
|
||||
modelType string
|
||||
)
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "add",
|
||||
Short: "Add a model from an OpenAI-compatible endpoint",
|
||||
Long: `Add a model entry by querying an OpenAI-compatible endpoint exposing
|
||||
GET <api-base>/models, then setting it as the default model.
|
||||
|
||||
If --model is omitted, the available models are listed and you can pick one
|
||||
interactively. If --model is provided, the entry is written without contacting
|
||||
the server.
|
||||
|
||||
Sample interactive session (key shown masked):
|
||||
|
||||
$ picoclaw model add \
|
||||
-b https://ark.cn-beijing.volces.com/api/v3 \
|
||||
-k 7dff****-****-****-****-********e829
|
||||
|
||||
115 model(s) available:
|
||||
1) doubao-lite-128k-240428 (doubao-lite-128k)
|
||||
2) doubao-pro-128k-240515 (doubao-pro-128k)
|
||||
...
|
||||
48) deepseek-r1-250120 (deepseek-r1)
|
||||
78) kimi-k2-250711 (kimi-k2)
|
||||
...
|
||||
115) doubao-seed3d-2-0-260328 (doubao-seed3d-2-0)
|
||||
Pick a model (number or id): 48
|
||||
✓ Saved model 'custom-prefer' (deepseek-r1-250120) and set as default.`,
|
||||
Example: ` picoclaw model add --api-base https://api.openai.com/v1 --api-key sk-...
|
||||
picoclaw model add -b http://localhost:8000/v1 -k dummy -m my-model -n local`,
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
return runAdd(addOptions{
|
||||
apiBase: strings.TrimSpace(apiBase),
|
||||
apiKey: strings.TrimSpace(apiKey),
|
||||
modelID: strings.TrimSpace(modelID),
|
||||
alias: strings.TrimSpace(alias),
|
||||
modelType: strings.TrimSpace(modelType),
|
||||
stdin: cmd.InOrStdin(),
|
||||
stdout: cmd.OutOrStdout(),
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVarP(&apiBase, "api-base", "b", "",
|
||||
"API base URL (required), e.g. https://api.openai.com/v1")
|
||||
cmd.Flags().StringVarP(&apiKey, "api-key", "k", "", "API key (required)")
|
||||
cmd.Flags().StringVarP(&modelID, "model", "m", "",
|
||||
"Model id; when set, skips the interactive picker and the network call")
|
||||
cmd.Flags().StringVarP(&alias, "name", "n", defaultAliasName,
|
||||
"Local alias written to model_list and used as the default model name")
|
||||
cmd.Flags().StringVar(&modelType, "type", "openai-compatible",
|
||||
"Endpoint type (only 'openai-compatible' is supported today)")
|
||||
_ = cmd.MarkFlagRequired("api-base")
|
||||
_ = cmd.MarkFlagRequired("api-key")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
type addOptions struct {
|
||||
apiBase string
|
||||
apiKey string
|
||||
modelID string
|
||||
alias string
|
||||
modelType string
|
||||
stdin io.Reader
|
||||
stdout io.Writer
|
||||
}
|
||||
|
||||
func runAdd(opt addOptions) error {
|
||||
if opt.modelType != "" && opt.modelType != "openai-compatible" {
|
||||
return fmt.Errorf("unsupported --type %q (only 'openai-compatible' is supported)", opt.modelType)
|
||||
}
|
||||
if opt.alias == "" {
|
||||
opt.alias = defaultAliasName
|
||||
}
|
||||
|
||||
selected := opt.modelID
|
||||
if selected == "" {
|
||||
entries, err := fetchOpenAIModels(opt.apiBase, opt.apiKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("fetch models: %w", err)
|
||||
}
|
||||
if len(entries) == 0 {
|
||||
return fmt.Errorf("no models returned by %s", opt.apiBase)
|
||||
}
|
||||
selected, err = pickModel(opt.stdin, opt.stdout, entries)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return upsertModelDefault(opt.apiBase, opt.apiKey, opt.alias, selected, opt.stdout)
|
||||
}
|
||||
|
||||
func pickModel(stdin io.Reader, stdout io.Writer, entries []modelEntry) (string, error) {
|
||||
fmt.Fprintf(stdout, "\n%d model(s) available:\n", len(entries))
|
||||
for i, m := range entries {
|
||||
line := m.ID
|
||||
if m.Name != "" && m.Name != m.ID {
|
||||
line = fmt.Sprintf("%s (%s)", m.ID, m.Name)
|
||||
}
|
||||
fmt.Fprintf(stdout, " %3d) %s\n", i+1, line)
|
||||
}
|
||||
|
||||
scanner := bufio.NewScanner(stdin)
|
||||
for {
|
||||
fmt.Fprint(stdout, "Pick a model (number or id): ")
|
||||
if !scanner.Scan() {
|
||||
if err := scanner.Err(); err != nil {
|
||||
return "", fmt.Errorf("read input: %w", err)
|
||||
}
|
||||
return "", fmt.Errorf("no selection provided")
|
||||
}
|
||||
text := strings.TrimSpace(scanner.Text())
|
||||
if text == "" {
|
||||
continue
|
||||
}
|
||||
if idx, err := strconv.Atoi(text); err == nil {
|
||||
if idx < 1 || idx > len(entries) {
|
||||
fmt.Fprintf(stdout, "Out of range. Enter 1-%d.\n", len(entries))
|
||||
continue
|
||||
}
|
||||
return entries[idx-1].ID, nil
|
||||
}
|
||||
for _, m := range entries {
|
||||
if m.ID == text {
|
||||
return m.ID, nil
|
||||
}
|
||||
}
|
||||
fmt.Fprintln(stdout, "Not a valid number or model id; try again.")
|
||||
}
|
||||
}
|
||||
|
||||
func upsertModelDefault(apiBase, apiKey, alias, modelID string, stdout io.Writer) error {
|
||||
configPath := internal.GetConfigPath()
|
||||
cfg, err := config.LoadConfig(configPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load config: %w", err)
|
||||
}
|
||||
|
||||
secureKeys := config.SimpleSecureStrings(apiKey)
|
||||
|
||||
found := false
|
||||
for _, m := range cfg.ModelList {
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
if m.ModelName == alias {
|
||||
m.Model = modelID
|
||||
m.APIBase = apiBase
|
||||
m.APIKeys = secureKeys
|
||||
m.Enabled = true
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
cfg.ModelList = append(cfg.ModelList, &config.ModelConfig{
|
||||
ModelName: alias,
|
||||
Model: modelID,
|
||||
APIBase: apiBase,
|
||||
APIKeys: secureKeys,
|
||||
Enabled: true,
|
||||
})
|
||||
}
|
||||
|
||||
cfg.Agents.Defaults.ModelName = alias
|
||||
|
||||
if err := config.SaveConfig(configPath, cfg); err != nil {
|
||||
return fmt.Errorf("failed to save config: %w", err)
|
||||
}
|
||||
|
||||
fmt.Fprintf(stdout, "✓ Saved model '%s' (%s) and set as default.\n", alias, modelID)
|
||||
return nil
|
||||
}
|
||||
257
cmd/picoclaw/internal/model/add_test.go
Normal file
257
cmd/picoclaw/internal/model/add_test.go
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
package model
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
func TestFetchOpenAIModels_DataEnvelope(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/models", r.URL.Path)
|
||||
assert.Equal(t, "Bearer secret", r.Header.Get("Authorization"))
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"data":[{"id":"gpt-foo","name":"Foo"},{"id":"gpt-bar"}]}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
entries, err := fetchOpenAIModels(srv.URL, "secret")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, entries, 2)
|
||||
assert.Equal(t, "gpt-foo", entries[0].ID)
|
||||
assert.Equal(t, "Foo", entries[0].Name)
|
||||
assert.Equal(t, "gpt-bar", entries[1].ID)
|
||||
}
|
||||
|
||||
func TestFetchOpenAIModels_BareArray(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`[{"id":"a"},{"id":"b"}]`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
entries, err := fetchOpenAIModels(srv.URL, "secret")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, entries, 2)
|
||||
assert.Equal(t, "a", entries[0].ID)
|
||||
assert.Equal(t, "b", entries[1].ID)
|
||||
}
|
||||
|
||||
func TestFetchOpenAIModels_TrimsTrailingSlash(t *testing.T) {
|
||||
var gotPath string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPath = r.URL.Path
|
||||
_, _ = w.Write([]byte(`{"data":[{"id":"x"}]}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
_, err := fetchOpenAIModels(srv.URL+"/", "k")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "/models", gotPath)
|
||||
}
|
||||
|
||||
func TestFetchOpenAIModels_HTTPError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
http.Error(w, "nope", http.StatusUnauthorized)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
_, err := fetchOpenAIModels(srv.URL, "bad")
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "HTTP 401")
|
||||
}
|
||||
|
||||
func TestFetchOpenAIModels_EmptyDataEnvelope(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"data":[]}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
entries, err := fetchOpenAIModels(srv.URL, "k")
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, entries)
|
||||
}
|
||||
|
||||
func TestFetchOpenAIModels_EmptyBareArray(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte(`[]`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
entries, err := fetchOpenAIModels(srv.URL, "k")
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, entries)
|
||||
}
|
||||
|
||||
func TestFetchOpenAIModels_UnrecognizedShape(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"models":"not-supported"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
_, err := fetchOpenAIModels(srv.URL, "k")
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "unrecognized shape")
|
||||
}
|
||||
|
||||
func TestFetchOpenAIModels_RequiresInputs(t *testing.T) {
|
||||
_, err := fetchOpenAIModels("", "k")
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "api base")
|
||||
|
||||
_, err = fetchOpenAIModels("https://example.com", "")
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "api key")
|
||||
}
|
||||
|
||||
func TestPickModel_ByIndex(t *testing.T) {
|
||||
entries := []modelEntry{{ID: "a"}, {ID: "b"}, {ID: "c"}}
|
||||
out := &bytes.Buffer{}
|
||||
got, err := pickModel(strings.NewReader("2\n"), out, entries)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "b", got)
|
||||
assert.Contains(t, out.String(), "3 model(s) available")
|
||||
}
|
||||
|
||||
func TestPickModel_ByID(t *testing.T) {
|
||||
entries := []modelEntry{{ID: "alpha"}, {ID: "beta"}}
|
||||
out := &bytes.Buffer{}
|
||||
got, err := pickModel(strings.NewReader("beta\n"), out, entries)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "beta", got)
|
||||
}
|
||||
|
||||
func TestPickModel_RetriesOnInvalid(t *testing.T) {
|
||||
entries := []modelEntry{{ID: "x"}}
|
||||
out := &bytes.Buffer{}
|
||||
got, err := pickModel(strings.NewReader("\n9\nnot-a-model\nx\n"), out, entries)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "x", got)
|
||||
rendered := out.String()
|
||||
assert.Contains(t, rendered, "Out of range")
|
||||
assert.Contains(t, rendered, "Not a valid number")
|
||||
}
|
||||
|
||||
func TestRunAdd_WithExplicitModel_NoNetwork(t *testing.T) {
|
||||
initTest(t)
|
||||
|
||||
out := &bytes.Buffer{}
|
||||
err := runAdd(addOptions{
|
||||
apiBase: "https://invalid.invalid/v1",
|
||||
apiKey: "k",
|
||||
modelID: "explicit-model",
|
||||
alias: "myalias",
|
||||
modelType: "openai-compatible",
|
||||
stdout: out,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, out.String(), "Saved model 'myalias' (explicit-model)")
|
||||
|
||||
cfg, err := config.LoadConfig(configPath)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "myalias", cfg.Agents.Defaults.GetModelName())
|
||||
added := findModelByName(cfg, "myalias")
|
||||
require.NotNil(t, added, "expected model 'myalias' in model_list")
|
||||
assert.Equal(t, "explicit-model", added.Model)
|
||||
assert.Equal(t, "https://invalid.invalid/v1", added.APIBase)
|
||||
assert.True(t, added.Enabled)
|
||||
require.Len(t, added.APIKeys, 1)
|
||||
assert.Equal(t, "k", added.APIKeys[0].String())
|
||||
}
|
||||
|
||||
func findModelByName(cfg *config.Config, name string) *config.ModelConfig {
|
||||
for _, m := range cfg.ModelList {
|
||||
if m != nil && m.ModelName == name {
|
||||
return m
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestRunAdd_FetchAndPick(t *testing.T) {
|
||||
initTest(t)
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "Bearer my-key", r.Header.Get("Authorization"))
|
||||
_, _ = w.Write([]byte(`{"data":[{"id":"m1"},{"id":"m2"}]}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
out := &bytes.Buffer{}
|
||||
err := runAdd(addOptions{
|
||||
apiBase: srv.URL,
|
||||
apiKey: "my-key",
|
||||
alias: defaultAliasName,
|
||||
modelType: "openai-compatible",
|
||||
stdin: strings.NewReader("2\n"),
|
||||
stdout: out,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
cfg, err := config.LoadConfig(configPath)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, defaultAliasName, cfg.Agents.Defaults.GetModelName())
|
||||
added := findModelByName(cfg, defaultAliasName)
|
||||
require.NotNil(t, added)
|
||||
assert.Equal(t, "m2", added.Model)
|
||||
}
|
||||
|
||||
func TestRunAdd_UpsertsExistingAlias(t *testing.T) {
|
||||
initTest(t)
|
||||
|
||||
first := &bytes.Buffer{}
|
||||
require.NoError(t, runAdd(addOptions{
|
||||
apiBase: "https://a.example/v1",
|
||||
apiKey: "k1",
|
||||
modelID: "m1",
|
||||
alias: "shared",
|
||||
stdout: first,
|
||||
}))
|
||||
|
||||
second := &bytes.Buffer{}
|
||||
require.NoError(t, runAdd(addOptions{
|
||||
apiBase: "https://b.example/v1",
|
||||
apiKey: "k2",
|
||||
modelID: "m2",
|
||||
alias: "shared",
|
||||
stdout: second,
|
||||
}))
|
||||
|
||||
cfg, err := config.LoadConfig(configPath)
|
||||
require.NoError(t, err)
|
||||
matches := 0
|
||||
for _, m := range cfg.ModelList {
|
||||
if m != nil && m.ModelName == "shared" {
|
||||
matches++
|
||||
}
|
||||
}
|
||||
assert.Equal(t, 1, matches, "alias should be updated, not duplicated")
|
||||
|
||||
updated := findModelByName(cfg, "shared")
|
||||
require.NotNil(t, updated)
|
||||
assert.Equal(t, "m2", updated.Model)
|
||||
assert.Equal(t, "https://b.example/v1", updated.APIBase)
|
||||
assert.Equal(t, "k2", updated.APIKeys[0].String())
|
||||
}
|
||||
|
||||
func TestRunAdd_RejectsUnsupportedType(t *testing.T) {
|
||||
initTest(t)
|
||||
|
||||
err := runAdd(addOptions{
|
||||
apiBase: "https://x/v1",
|
||||
apiKey: "k",
|
||||
modelID: "m",
|
||||
alias: "a",
|
||||
modelType: "anthropic",
|
||||
stdout: &bytes.Buffer{},
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "unsupported --type")
|
||||
}
|
||||
|
|
@ -21,11 +21,17 @@ func NewModelCommand() *cobra.Command {
|
|||
If no argument is provided, shows the current default model.
|
||||
If a model name is provided, sets it as the default model.
|
||||
|
||||
To onboard a model from a custom OpenAI-compatible endpoint (fetch the
|
||||
available list online and pick one), use the 'add' subcommand:
|
||||
|
||||
picoclaw model add --help
|
||||
|
||||
Examples:
|
||||
picoclaw model # Show current default model
|
||||
picoclaw model gpt-5.2 # Set gpt-5.2 as default
|
||||
picoclaw model claude-sonnet-4.6 # Set claude-sonnet-4.6 as default
|
||||
picoclaw model local-model # Set local vLLM/OVMS server as default
|
||||
picoclaw model add -b URL -k KEY # Add a model from a custom endpoint
|
||||
|
||||
Note: 'local-model' is a special value for using a local OpenAI-compatible server
|
||||
(running at localhost:8000 by default) which does not require an API key.
|
||||
|
|
@ -52,6 +58,8 @@ It can be configured to point to vLLM, OVMS (OpenVINO Model Server), or other Op
|
|||
},
|
||||
}
|
||||
|
||||
cmd.AddCommand(newAddCommand())
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
|
|
@ -67,6 +75,9 @@ func showCurrentModel(cfg *config.Config) {
|
|||
fmt.Println("\nAvailable models in your config:")
|
||||
listAvailableModels(cfg)
|
||||
}
|
||||
|
||||
fmt.Println("\nTip: 'picoclaw model add -b URL -k KEY' adds a model from a custom")
|
||||
fmt.Println(" OpenAI-compatible endpoint (see 'picoclaw model add --help').")
|
||||
}
|
||||
|
||||
func listAvailableModels(cfg *config.Config) {
|
||||
|
|
|
|||
77
cmd/picoclaw/internal/model/online.go
Normal file
77
cmd/picoclaw/internal/model/online.go
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
package model
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type modelEntry struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
type modelsAPIResponse struct {
|
||||
Data []modelEntry `json:"data"`
|
||||
}
|
||||
|
||||
// fetchOpenAIModels GETs <baseURL>/models with Bearer auth and accepts both the
|
||||
// {data:[…]} envelope and a bare array shape used by various OpenAI-compatible servers.
|
||||
func fetchOpenAIModels(baseURL, apiKey string) ([]modelEntry, error) {
|
||||
if strings.TrimSpace(baseURL) == "" {
|
||||
return nil, fmt.Errorf("api base is required")
|
||||
}
|
||||
if strings.TrimSpace(apiKey) == "" {
|
||||
return nil, fmt.Errorf("api key is required")
|
||||
}
|
||||
|
||||
url := strings.TrimRight(baseURL, "/") + "/models"
|
||||
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build request: %w", err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read response: %w", err)
|
||||
}
|
||||
|
||||
// {"data": [...]} envelope. Distinguish "envelope shape with empty list"
|
||||
// from "object without a data key" via Data being non-nil after unmarshal:
|
||||
// json.Unmarshal sets Data to []modelEntry{} for `{"data":[]}` but leaves
|
||||
// it as nil when "data" is absent or null.
|
||||
var envelope modelsAPIResponse
|
||||
if err := json.Unmarshal(body, &envelope); err == nil && envelope.Data != nil {
|
||||
return envelope.Data, nil
|
||||
}
|
||||
|
||||
// Bare-array shape, including `[]`.
|
||||
var arr []modelEntry
|
||||
if err := json.Unmarshal(body, &arr); err == nil {
|
||||
return arr, nil
|
||||
}
|
||||
|
||||
preview := body
|
||||
if len(preview) > 256 {
|
||||
preview = preview[:256]
|
||||
}
|
||||
return nil, fmt.Errorf("decode response: unrecognized shape: %s", strings.TrimSpace(string(preview)))
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
# ============================================================
|
||||
# Stage 1: Build the picoclaw binary
|
||||
# ============================================================
|
||||
FROM golang:1.26.0-alpine AS builder
|
||||
FROM golang:1.25-alpine AS builder
|
||||
|
||||
RUN apk add --no-cache git make
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ RUN apk add --no-cache ca-certificates tzdata
|
|||
|
||||
COPY $TARGETPLATFORM/picoclaw /usr/local/bin/picoclaw
|
||||
COPY $TARGETPLATFORM/picoclaw-launcher /usr/local/bin/picoclaw-launcher
|
||||
COPY $TARGETPLATFORM/picoclaw-launcher-tui /usr/local/bin/picoclaw-launcher-tui
|
||||
|
||||
ENTRYPOINT ["picoclaw-launcher"]
|
||||
CMD ["-console", "-public", "-no-browser"]
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# ============================================================
|
||||
# Stage 1: Build the picoclaw binary
|
||||
# ============================================================
|
||||
FROM golang:1.26.0-alpine AS builder
|
||||
FROM golang:1.25-alpine AS builder
|
||||
|
||||
RUN apk add --no-cache git make
|
||||
|
||||
|
|
|
|||
65
docker/Dockerfile.launcher
Normal file
65
docker/Dockerfile.launcher
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
# ============================================================
|
||||
# Stage 1: Build frontend assets (Node.js + pnpm)
|
||||
# ============================================================
|
||||
FROM node:24-alpine3.23 AS frontend
|
||||
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
|
||||
WORKDIR /src/web/frontend
|
||||
|
||||
# Cache frontend dependencies
|
||||
COPY web/frontend/package.json web/frontend/pnpm-lock.yaml ./
|
||||
RUN CI=true pnpm install --frozen-lockfile
|
||||
|
||||
# Build frontend
|
||||
COPY web/frontend/ ./
|
||||
RUN pnpm build:backend
|
||||
|
||||
# ============================================================
|
||||
# Stage 2: Build Go binaries (picoclaw + picoclaw-launcher)
|
||||
# ============================================================
|
||||
FROM golang:1.25-alpine AS builder
|
||||
|
||||
RUN apk add --no-cache git make
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
# Cache Go dependencies
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
# Copy source
|
||||
COPY . .
|
||||
|
||||
# Copy pre-built frontend assets into the backend embed directory
|
||||
COPY --from=frontend /src/web/backend/dist web/backend/dist
|
||||
|
||||
# Build picoclaw binary (includes go generate)
|
||||
RUN make build
|
||||
|
||||
# Build picoclaw-launcher binary (frontend already built in stage 1)
|
||||
# Mirror ldflags from web/Makefile to inject version metadata
|
||||
RUN CONFIG_PKG=github.com/sipeed/picoclaw/pkg/config && \
|
||||
VERSION=$(git describe --tags --always --dirty 2>/dev/null || echo dev) && \
|
||||
GIT_COMMIT=$(git rev-parse --short=8 HEAD 2>/dev/null || echo dev) && \
|
||||
BUILD_TIME=$(date +%FT%T%z) && \
|
||||
GO_VERSION=$(go env GOVERSION) && \
|
||||
CGO_ENABLED=0 go build -v -tags goolm,stdjson \
|
||||
-ldflags "-X ${CONFIG_PKG}.Version=${VERSION} -X ${CONFIG_PKG}.GitCommit=${GIT_COMMIT} -X ${CONFIG_PKG}.BuildTime=${BUILD_TIME} -X ${CONFIG_PKG}.GoVersion=${GO_VERSION} -s -w" \
|
||||
-o build/picoclaw-launcher ./web/backend/
|
||||
|
||||
# ============================================================
|
||||
# Stage 3: Minimal runtime image
|
||||
# ============================================================
|
||||
FROM alpine:3.23
|
||||
|
||||
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 --from=builder /src/build/picoclaw /usr/local/bin/picoclaw
|
||||
COPY --from=builder /src/build/picoclaw-launcher /usr/local/bin/picoclaw-launcher
|
||||
|
||||
ENTRYPOINT ["picoclaw-launcher"]
|
||||
CMD ["-console", "-public", "-no-browser"]
|
||||
|
|
@ -4,6 +4,9 @@ services:
|
|||
# docker compose -f docker/docker-compose.yml run --rm picoclaw-agent -m "Hello"
|
||||
# ─────────────────────────────────────────────
|
||||
picoclaw-agent:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: docker/Dockerfile
|
||||
image: docker.io/sipeed/picoclaw:latest
|
||||
container_name: picoclaw-agent
|
||||
profiles:
|
||||
|
|
@ -22,6 +25,9 @@ services:
|
|||
# docker compose -f docker/docker-compose.yml --profile gateway up
|
||||
# ─────────────────────────────────────────────
|
||||
picoclaw-gateway:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: docker/Dockerfile
|
||||
image: docker.io/sipeed/picoclaw:latest
|
||||
container_name: picoclaw-gateway
|
||||
restart: unless-stopped
|
||||
|
|
@ -38,6 +44,9 @@ services:
|
|||
# docker compose -f docker/docker-compose.yml --profile launcher up
|
||||
# ─────────────────────────────────────────────
|
||||
picoclaw-launcher:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: docker/Dockerfile.launcher
|
||||
image: docker.io/sipeed/picoclaw:launcher
|
||||
container_name: picoclaw-launcher
|
||||
restart: unless-stopped
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ docker compose -f docker/docker-compose.yml --profile gateway down
|
|||
|
||||
### Mode Launcher (Console Web)
|
||||
|
||||
L'image `launcher` inclut les trois binaires (`picoclaw`, `picoclaw-launcher`, `picoclaw-launcher-tui`) et démarre la console web par défaut, qui fournit une interface navigateur pour la configuration et le chat.
|
||||
L'image `launcher` inclut les deux binaires (`picoclaw`, `picoclaw-launcher`) et démarre la console web par défaut, qui fournit une interface navigateur pour la configuration et le chat.
|
||||
|
||||
```bash
|
||||
docker compose -f docker/docker-compose.yml --profile launcher up -d
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ docker compose -f docker/docker-compose.yml --profile gateway down
|
|||
|
||||
### Launcher モード (Web コンソール)
|
||||
|
||||
`launcher` イメージには 3 つのバイナリ(`picoclaw`、`picoclaw-launcher`、`picoclaw-launcher-tui`)がすべて含まれており、デフォルトで Web コンソールを起動します。ブラウザベースの設定・チャット画面を提供します。
|
||||
`launcher` イメージには 2 つのバイナリ(`picoclaw`、`picoclaw-launcher`)が含まれており、デフォルトで Web コンソールを起動します。ブラウザベースの設定・チャット画面を提供します。
|
||||
|
||||
```bash
|
||||
docker compose -f docker/docker-compose.yml --profile launcher up -d
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ docker compose -f docker/docker-compose.yml --profile gateway down
|
|||
|
||||
### Launcher Mode (Web Console)
|
||||
|
||||
The `launcher` image includes all three binaries (`picoclaw`, `picoclaw-launcher`, `picoclaw-launcher-tui`) and starts the web console by default, which provides a browser-based UI for configuration and chat.
|
||||
The `launcher` image includes both binaries (`picoclaw`, `picoclaw-launcher`) and starts the web console by default, which provides a browser-based UI for configuration and chat.
|
||||
|
||||
```bash
|
||||
docker compose -f docker/docker-compose.yml --profile launcher up -d
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ docker compose -f docker/docker-compose.yml --profile gateway down
|
|||
|
||||
### Mod Launcher (Konsol Web)
|
||||
|
||||
Imej `launcher` merangkumi ketiga-tiga binari (`picoclaw`, `picoclaw-launcher`, `picoclaw-launcher-tui`) dan memulakan konsol web secara lalai, yang menyediakan UI berasaskan pelayar untuk konfigurasi dan sembang.
|
||||
Imej `launcher` merangkumi kedua-dua binari (`picoclaw`, `picoclaw-launcher`) dan memulakan konsol web secara lalai, yang menyediakan UI berasaskan pelayar untuk konfigurasi dan sembang.
|
||||
|
||||
```bash
|
||||
docker compose -f docker/docker-compose.yml --profile launcher up -d
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ docker compose -f docker/docker-compose.yml --profile gateway down
|
|||
|
||||
### Modo Launcher (Console Web)
|
||||
|
||||
A imagem `launcher` inclui os três binários (`picoclaw`, `picoclaw-launcher`, `picoclaw-launcher-tui`) e inicia o console web por padrão, que fornece uma interface baseada em navegador para configuração e chat.
|
||||
A imagem `launcher` inclui ambos os binários (`picoclaw`, `picoclaw-launcher`) e inicia o console web por padrão, que fornece uma interface baseada em navegador para configuração e chat.
|
||||
|
||||
```bash
|
||||
docker compose -f docker/docker-compose.yml --profile launcher up -d
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ docker compose -f docker/docker-compose.yml --profile gateway down
|
|||
|
||||
### Chế Độ Launcher (Web Console)
|
||||
|
||||
Image `launcher` bao gồm cả ba binary (`picoclaw`, `picoclaw-launcher`, `picoclaw-launcher-tui`) và khởi động web console mặc định, cung cấp giao diện trình duyệt để cấu hình và chat.
|
||||
Image `launcher` bao gồm cả hai binary (`picoclaw`, `picoclaw-launcher`) và khởi động web console mặc định, cung cấp giao diện trình duyệt để cấu hình và chat.
|
||||
|
||||
```bash
|
||||
docker compose -f docker/docker-compose.yml --profile launcher up -d
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ docker compose -f docker/docker-compose.yml --profile gateway down
|
|||
|
||||
### Launcher 模式 (Web 控制台)
|
||||
|
||||
`launcher` 镜像包含所有三个二进制文件(`picoclaw`、`picoclaw-launcher`、`picoclaw-launcher-tui`),默认启动 Web 控制台,提供基于浏览器的配置和聊天界面。
|
||||
`launcher` 镜像包含两个二进制文件(`picoclaw`、`picoclaw-launcher`),默认启动 Web 控制台,提供基于浏览器的配置和聊天界面。
|
||||
|
||||
```bash
|
||||
docker compose -f docker/docker-compose.yml --profile launcher up -d
|
||||
|
|
|
|||
|
|
@ -292,24 +292,6 @@ Après cette étape unique, `picoclaw-launcher` s'ouvrira normalement lors des l
|
|||
|
||||
</details>
|
||||
|
||||
### 💻 TUI Launcher (Recommandé pour les environnements sans interface / SSH)
|
||||
|
||||
Le TUI (Terminal UI) Launcher fournit une interface terminal complète pour la configuration et la gestion. Idéal pour les serveurs, Raspberry Pi et autres environnements sans interface graphique.
|
||||
|
||||
```bash
|
||||
picoclaw-launcher-tui
|
||||
```
|
||||
|
||||
<p align="center">
|
||||
<img src="../../assets/launcher-tui.jpg" alt="TUI Launcher" width="600">
|
||||
</p>
|
||||
|
||||
**Pour commencer :**
|
||||
|
||||
Utilisez les menus TUI pour : **1)** Configurer un Provider -> **2)** Configurer un Channel -> **3)** Démarrer le Gateway -> **4)** Chattez !
|
||||
|
||||
Pour la documentation détaillée du TUI, voir [docs.picoclaw.io](https://docs.picoclaw.io).
|
||||
|
||||
<a id="-run-on-old-android-phones"></a>
|
||||
### 📱 Android
|
||||
|
||||
|
|
|
|||
|
|
@ -289,24 +289,6 @@ Setelah langkah satu kali ini, `picoclaw-launcher` akan terbuka secara normal pa
|
|||
|
||||
</details>
|
||||
|
||||
### 💻 TUI Launcher (Direkomendasikan untuk Headless / SSH)
|
||||
|
||||
TUI (Terminal UI) Launcher menyediakan antarmuka terminal lengkap untuk konfigurasi dan manajemen. Ideal untuk server, Raspberry Pi, dan lingkungan headless lainnya.
|
||||
|
||||
```bash
|
||||
picoclaw-launcher-tui
|
||||
```
|
||||
|
||||
<p align="center">
|
||||
<img src="../../assets/launcher-tui.jpg" alt="TUI Launcher" width="600">
|
||||
</p>
|
||||
|
||||
**Memulai:**
|
||||
|
||||
Gunakan menu TUI untuk: **1)** Konfigurasi Provider -> **2)** Konfigurasi Channel -> **3)** Mulai Gateway -> **4)** Chat!
|
||||
|
||||
Untuk dokumentasi TUI lengkap, lihat [docs.picoclaw.io](https://docs.picoclaw.io).
|
||||
|
||||
### 📱 Android
|
||||
|
||||
Berikan kehidupan kedua untuk ponsel lama Anda! Ubah menjadi Asisten AI pintar dengan PicoClaw.
|
||||
|
|
|
|||
|
|
@ -289,24 +289,6 @@ Dopo questo passaggio una tantum, `picoclaw-launcher` si aprirà normalmente ai
|
|||
|
||||
</details>
|
||||
|
||||
### 💻 TUI Launcher (Consigliato per Headless / SSH)
|
||||
|
||||
Il TUI (Terminal UI) Launcher fornisce un'interfaccia terminale completa per la configurazione e la gestione. Ideale per server, Raspberry Pi e altri ambienti headless.
|
||||
|
||||
```bash
|
||||
picoclaw-launcher-tui
|
||||
```
|
||||
|
||||
<p align="center">
|
||||
<img src="../../assets/launcher-tui.jpg" alt="TUI Launcher" width="600">
|
||||
</p>
|
||||
|
||||
**Per iniziare:**
|
||||
|
||||
Usa i menu TUI per: **1)** Configurare un Provider -> **2)** Configurare un Channel -> **3)** Avviare il Gateway -> **4)** Chattare!
|
||||
|
||||
Per la documentazione dettagliata del TUI, vedi [docs.picoclaw.io](https://docs.picoclaw.io).
|
||||
|
||||
### 📱 Android
|
||||
|
||||
Dai una seconda vita al tuo telefono di dieci anni fa! Trasformalo in un assistente IA intelligente con PicoClaw.
|
||||
|
|
|
|||
|
|
@ -289,24 +289,6 @@ docker compose -f docker/docker-compose.yml --profile launcher up -d
|
|||
|
||||
</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)** Provider を設定 → **2)** Channel を設定 → **3)** Gateway を起動 → **4)** チャット!
|
||||
|
||||
TUI の詳細なドキュメントは [docs.picoclaw.io](https://docs.picoclaw.io) を参照してください。
|
||||
|
||||
<a id="-run-on-old-android-phones"></a>
|
||||
### 📱 Android
|
||||
|
||||
|
|
|
|||
|
|
@ -289,24 +289,6 @@ macOS에서는 인터넷에서 다운로드한 앱이고 Mac App Store 공증을
|
|||
|
||||
</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 어시스턴트로 바꿀 수 있습니다.
|
||||
|
|
|
|||
|
|
@ -286,24 +286,6 @@ Selepas langkah sekali ini, `picoclaw-launcher` akan dibuka secara normal pada p
|
|||
|
||||
</details>
|
||||
|
||||
### 💻 Pelancar TUI (Disyorkan untuk Headless / SSH)
|
||||
|
||||
Pelancar TUI menyediakan antara muka terminal lengkap untuk konfigurasi dan pengurusan. Sesuai untuk pelayan, Raspberry Pi, dan persekitaran tanpa kepala lain.
|
||||
|
||||
```bash
|
||||
picoclaw-launcher-tui
|
||||
```
|
||||
|
||||
<p align="center">
|
||||
<img src="../../assets/launcher-tui.jpg" alt="Pelancar TUI" width="600">
|
||||
</p>
|
||||
|
||||
**Memulakan:**
|
||||
|
||||
Gunakan menu TUI untuk: **1)** Konfigurasikan Penyedia -> **2)** Konfigurasikan Saluran -> **3)** Mulakan Gateway -> **4)** Sembang!
|
||||
|
||||
Untuk dokumentasi TUI terperinci, lihat [docs.picoclaw.io](https://docs.picoclaw.io).
|
||||
|
||||
### 📱 Android
|
||||
|
||||
Berikan telefon lama anda kehidupan baru! Jadikannya Pembantu AI pintar dengan PicoClaw.
|
||||
|
|
|
|||
|
|
@ -289,24 +289,6 @@ Após esta etapa única, o `picoclaw-launcher` abrirá normalmente nos lançamen
|
|||
|
||||
</details>
|
||||
|
||||
### 💻 TUI Launcher (Recomendado para Headless / SSH)
|
||||
|
||||
O TUI (Terminal UI) Launcher fornece uma interface de terminal completa para configuração e gerenciamento. Ideal para servidores, Raspberry Pi e outros ambientes headless.
|
||||
|
||||
```bash
|
||||
picoclaw-launcher-tui
|
||||
```
|
||||
|
||||
<p align="center">
|
||||
<img src="../../assets/launcher-tui.jpg" alt="TUI Launcher" width="600">
|
||||
</p>
|
||||
|
||||
**Primeiros passos:**
|
||||
|
||||
Use os menus do TUI para: **1)** Configurar um Provider -> **2)** Configurar um Channel -> **3)** Iniciar o Gateway -> **4)** Conversar!
|
||||
|
||||
Para documentação detalhada do TUI, veja [docs.picoclaw.io](https://docs.picoclaw.io).
|
||||
|
||||
<a id="-run-on-old-android-phones"></a>
|
||||
### 📱 Android
|
||||
|
||||
|
|
|
|||
|
|
@ -289,24 +289,6 @@ Sau bước này, `picoclaw-launcher` sẽ mở bình thường trong các lần
|
|||
|
||||
</details>
|
||||
|
||||
### 💻 TUI Launcher (Khuyến nghị cho Headless / SSH)
|
||||
|
||||
TUI (Terminal UI) Launcher cung cấp giao diện terminal đầy đủ tính năng để cấu hình và quản lý. Lý tưởng cho máy chủ, Raspberry Pi và các môi trường headless khác.
|
||||
|
||||
```bash
|
||||
picoclaw-launcher-tui
|
||||
```
|
||||
|
||||
<p align="center">
|
||||
<img src="../../assets/launcher-tui.jpg" alt="TUI Launcher" width="600">
|
||||
</p>
|
||||
|
||||
**Bắt đầu:**
|
||||
|
||||
Sử dụng menu TUI để: **1)** Cấu hình Provider -> **2)** Cấu hình Channel -> **3)** Khởi động Gateway -> **4)** Trò chuyện!
|
||||
|
||||
Để biết tài liệu TUI chi tiết, xem [docs.picoclaw.io](https://docs.picoclaw.io).
|
||||
|
||||
<a id="-run-on-old-android-phones"></a>
|
||||
### 📱 Android
|
||||
|
||||
|
|
|
|||
|
|
@ -289,24 +289,6 @@ macOS 可能会在首次启动时拦截 `picoclaw-launcher`,因为它从互联
|
|||
|
||||
</details>
|
||||
|
||||
### 💻 TUI Launcher(推荐无头环境 / SSH)
|
||||
|
||||
TUI(终端 UI)Launcher 提供功能完整的终端配置与管理界面,适合服务器、树莓派等无显示器环境。
|
||||
|
||||
```bash
|
||||
picoclaw-launcher-tui
|
||||
```
|
||||
|
||||
<p align="center">
|
||||
<img src="../../assets/launcher-tui.jpg" alt="TUI Launcher" width="600">
|
||||
</p>
|
||||
|
||||
**开始使用:**
|
||||
|
||||
通过 TUI 菜单:**1)** 配置 Provider -> **2)** 配置 Channel -> **3)** 启动 Gateway -> **4)** 开始聊天!
|
||||
|
||||
详细 TUI 文档请参阅 [docs.picoclaw.io](https://docs.picoclaw.io)。
|
||||
|
||||
<a id="-run-on-old-android-phones"></a>
|
||||
### 📱 Android
|
||||
|
||||
|
|
|
|||
6
go.mod
6
go.mod
|
|
@ -4,7 +4,6 @@ go 1.25.9
|
|||
|
||||
require (
|
||||
fyne.io/systray v1.12.0
|
||||
github.com/BurntSushi/toml v1.6.0
|
||||
github.com/SevereCloud/vksdk/v3 v3.3.1
|
||||
github.com/adhocore/gronx v1.19.6
|
||||
github.com/anthropics/anthropic-sdk-go v1.26.0
|
||||
|
|
@ -18,7 +17,6 @@ require (
|
|||
github.com/creack/pty v1.1.24
|
||||
github.com/ergochat/irc-go v0.6.0
|
||||
github.com/ergochat/readline v0.1.3
|
||||
github.com/gdamore/tcell/v2 v2.13.8
|
||||
github.com/gomarkdown/markdown v0.0.0-20260411013819-759bbc3e3207
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
|
|
@ -33,7 +31,6 @@ require (
|
|||
github.com/openai/openai-go/v3 v3.22.0
|
||||
github.com/pion/rtp v1.10.1
|
||||
github.com/pion/webrtc/v3 v3.3.6
|
||||
github.com/rivo/tview v0.42.0
|
||||
github.com/rs/zerolog v1.35.1
|
||||
github.com/slack-go/slack v0.17.3
|
||||
github.com/spf13/cobra v1.10.2
|
||||
|
|
@ -79,7 +76,6 @@ require (
|
|||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect
|
||||
github.com/gdamore/encoding v1.0.1 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
||||
|
|
@ -122,7 +118,7 @@ require (
|
|||
github.com/github/copilot-sdk/go v0.2.0
|
||||
github.com/go-resty/resty/v2 v2.17.1 // indirect
|
||||
github.com/gogo/protobuf v1.3.2 // indirect
|
||||
github.com/google/jsonschema-go v0.4.2 // indirect
|
||||
github.com/google/jsonschema-go v0.4.2
|
||||
github.com/grbit/go-json v0.11.0 // indirect
|
||||
github.com/klauspost/compress v1.18.4 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
|
|
|
|||
8
go.sum
8
go.sum
|
|
@ -5,8 +5,6 @@ filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
|||
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
||||
fyne.io/systray v1.12.0 h1:CA1Kk0e2zwFlxtc02L3QFSiIbxJ/P0n582YrZHT7aTM=
|
||||
fyne.io/systray v1.12.0/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
|
||||
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
|
||||
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
|
||||
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
|
||||
github.com/SevereCloud/vksdk/v3 v3.3.1 h1:O86zsp5LQnHE+O5acvuXM/s6S1LyxzVTkF6+Lup0Jyg=
|
||||
|
|
@ -105,10 +103,6 @@ github.com/ergochat/readline v0.1.3 h1:/DytGTmwdUJcLAe3k3VJgowh5vNnsdifYT6uVaf4p
|
|||
github.com/ergochat/readline v0.1.3/go.mod h1:o3ux9QLHLm77bq7hDB21UTm6HlV2++IPDMfIfKDuOgY=
|
||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
||||
github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw=
|
||||
github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo=
|
||||
github.com/gdamore/tcell/v2 v2.13.8 h1:Mys/Kl5wfC/GcC5Cx4C2BIQH9dbnhnkPgS9/wF3RlfU=
|
||||
github.com/gdamore/tcell/v2 v2.13.8/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo=
|
||||
github.com/github/copilot-sdk/go v0.2.0 h1:RnrIIirmtp4wGgqSQFJ2k9phbeveIxOtYZqDogoNEa0=
|
||||
github.com/github/copilot-sdk/go v0.2.0/go.mod h1:uGWkjVYcp2DV9DgtqYihh5tEoJjNqxIFaUNnrwY4FxM=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
|
|
@ -232,8 +226,6 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
|
|||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rivo/tview v0.42.0 h1:b/ftp+RxtDsHSaynXTbJb+/n/BxDEi+W3UfF5jILK6c=
|
||||
github.com/rivo/tview v0.42.0/go.mod h1:cSfIYfhpSGCjp3r/ECJb+GKS7cGJnqV8vfjQPwoXyfY=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
|
|
|
|||
|
|
@ -43,3 +43,9 @@ func (a *channelManagerAdapter) SendMedia(ctx context.Context, msg bus.OutboundM
|
|||
func (a *channelManagerAdapter) SendPlaceholder(ctx context.Context, channel, chatID string) bool {
|
||||
return a.inner.SendPlaceholder(ctx, channel, chatID)
|
||||
}
|
||||
|
||||
func (a *channelManagerAdapter) DismissToolFeedback(
|
||||
ctx context.Context, channel, chatID string, outboundCtx *bus.InboundContext,
|
||||
) {
|
||||
a.inner.DismissToolFeedback(ctx, channel, chatID, outboundCtx)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import (
|
|||
"encoding/base64"
|
||||
"io"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/h2non/filetype"
|
||||
|
|
@ -20,24 +21,59 @@ import (
|
|||
"github.com/sipeed/picoclaw/pkg/providers"
|
||||
)
|
||||
|
||||
// genericPlaceholderRegex matches generic media placeholders emitted by various
|
||||
// channels: [image], [image: photo], [image: filename.jpg] — but NOT path tags
|
||||
// like [image:/path/to/file] (path tags have no space after the colon).
|
||||
var (
|
||||
imagePlaceholderRegex = regexp.MustCompile(`\[image(:\s+[^\]]*)?\]`)
|
||||
audioPlaceholderRegex = regexp.MustCompile(`\[audio(:\s+[^\]]*)?\]`)
|
||||
videoPlaceholderRegex = regexp.MustCompile(`\[video(:\s+[^\]]*)?\]`)
|
||||
filePlaceholderRegex = regexp.MustCompile(`\[file(:\s+[^\]]*)?\]`)
|
||||
)
|
||||
|
||||
// resolveMediaRefs resolves media:// refs in messages.
|
||||
// Images are base64-encoded into the Media array for multimodal LLMs.
|
||||
// Non-image files (documents, audio, video) have their local path injected
|
||||
// into Content so the agent can access them via file tools like read_file.
|
||||
// For user messages: images get path tags only ([image:/path]) so the LLM
|
||||
// can decide whether to view them via load_image or operate on the file.
|
||||
// For tool messages: images are base64-encoded and appended as a synthetic
|
||||
// user message only after the contiguous tool-message block ends, so we don't
|
||||
// break the tool-results-must-immediately-follow-assistant constraint that
|
||||
// LLM APIs enforce.
|
||||
// Non-image files always get path tags regardless of role.
|
||||
// Returns a new slice; original messages are not mutated.
|
||||
func resolveMediaRefs(messages []providers.Message, store media.MediaStore, maxSize int) []providers.Message {
|
||||
if store == nil {
|
||||
return messages
|
||||
}
|
||||
|
||||
result := make([]providers.Message, len(messages))
|
||||
copy(result, messages)
|
||||
result := make([]providers.Message, 0, len(messages))
|
||||
var pendingToolImages []string
|
||||
|
||||
for idx, m := range messages {
|
||||
// When leaving a tool-message block, flush any accumulated images
|
||||
// as a synthetic user message.
|
||||
if m.Role != "tool" && len(pendingToolImages) > 0 {
|
||||
result = append(result, providers.Message{
|
||||
Role: "user",
|
||||
Content: "[Loaded image from tool result above]",
|
||||
Media: pendingToolImages,
|
||||
})
|
||||
pendingToolImages = nil
|
||||
}
|
||||
|
||||
for i, m := range result {
|
||||
if len(m.Media) == 0 {
|
||||
result = append(result, m)
|
||||
if idx == len(messages)-1 && len(pendingToolImages) > 0 {
|
||||
result = append(result, providers.Message{
|
||||
Role: "user",
|
||||
Content: "[Loaded image from tool result above]",
|
||||
Media: pendingToolImages,
|
||||
})
|
||||
pendingToolImages = nil
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
msg := m
|
||||
resolved := make([]string, 0, len(m.Media))
|
||||
var pathTags []string
|
||||
|
||||
|
|
@ -66,27 +102,77 @@ func resolveMediaRefs(messages []providers.Message, store media.MediaStore, maxS
|
|||
}
|
||||
|
||||
mime := detectMIME(localPath, meta)
|
||||
pathTags = append(pathTags, buildPathTag(mime, localPath))
|
||||
|
||||
if strings.HasPrefix(mime, "image/") {
|
||||
if m.Role == "tool" && strings.HasPrefix(mime, "image/") {
|
||||
dataURL := encodeImageToDataURL(localPath, mime, info, maxSize)
|
||||
if dataURL != "" {
|
||||
resolved = append(resolved, dataURL)
|
||||
pendingToolImages = append(pendingToolImages, dataURL)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
pathTags = append(pathTags, buildPathTag(mime, localPath))
|
||||
}
|
||||
|
||||
result[i].Media = resolved
|
||||
msg.Media = resolved
|
||||
if len(pathTags) > 0 {
|
||||
result[i].Content = injectPathTags(result[i].Content, pathTags)
|
||||
msg.Content = injectPathTags(msg.Content, pathTags)
|
||||
}
|
||||
result = append(result, msg)
|
||||
|
||||
// If this is the last message and we have pending images, flush them.
|
||||
if idx == len(messages)-1 && len(pendingToolImages) > 0 {
|
||||
result = append(result, providers.Message{
|
||||
Role: "user",
|
||||
Content: "[Loaded image from tool result above]",
|
||||
Media: pendingToolImages,
|
||||
})
|
||||
pendingToolImages = nil
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// encodeImageToDataURL base64-encodes an image file into a data URL.
|
||||
// Returns empty string if the file exceeds maxSize or encoding fails.
|
||||
func encodeImageToDataURL(localPath, mime string, info os.FileInfo, maxSize int) string {
|
||||
if info.Size() > int64(maxSize) {
|
||||
logger.WarnCF("agent", "Media file too large, skipping", map[string]any{
|
||||
"path": localPath,
|
||||
"size": info.Size(),
|
||||
"max_size": maxSize,
|
||||
})
|
||||
return ""
|
||||
}
|
||||
|
||||
f, err := os.Open(localPath)
|
||||
if err != nil {
|
||||
logger.WarnCF("agent", "Failed to open media file", map[string]any{
|
||||
"path": localPath,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return ""
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
prefix := "data:" + mime + ";base64,"
|
||||
encodedLen := base64.StdEncoding.EncodedLen(int(info.Size()))
|
||||
var buf bytes.Buffer
|
||||
buf.Grow(len(prefix) + encodedLen)
|
||||
buf.WriteString(prefix)
|
||||
|
||||
encoder := base64.NewEncoder(base64.StdEncoding, &buf)
|
||||
if _, err := io.Copy(encoder, f); err != nil {
|
||||
logger.WarnCF("agent", "Failed to encode media file", map[string]any{
|
||||
"path": localPath,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return ""
|
||||
}
|
||||
encoder.Close()
|
||||
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
func buildArtifactTags(store media.MediaStore, refs []string) []string {
|
||||
if store == nil || len(refs) == 0 {
|
||||
return nil
|
||||
|
|
@ -137,51 +223,12 @@ func detectMIME(localPath string, meta media.MediaMeta) string {
|
|||
return kind.MIME.Value
|
||||
}
|
||||
|
||||
// encodeImageToDataURL base64-encodes an image file into a data URL.
|
||||
// Returns empty string if the file exceeds maxSize or encoding fails.
|
||||
func encodeImageToDataURL(localPath, mime string, info os.FileInfo, maxSize int) string {
|
||||
if info.Size() > int64(maxSize) {
|
||||
logger.WarnCF("agent", "Media file too large, skipping", map[string]any{
|
||||
"path": localPath,
|
||||
"size": info.Size(),
|
||||
"max_size": maxSize,
|
||||
})
|
||||
return ""
|
||||
}
|
||||
|
||||
f, err := os.Open(localPath)
|
||||
if err != nil {
|
||||
logger.WarnCF("agent", "Failed to open media file", map[string]any{
|
||||
"path": localPath,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return ""
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
prefix := "data:" + mime + ";base64,"
|
||||
encodedLen := base64.StdEncoding.EncodedLen(int(info.Size()))
|
||||
var buf bytes.Buffer
|
||||
buf.Grow(len(prefix) + encodedLen)
|
||||
buf.WriteString(prefix)
|
||||
|
||||
encoder := base64.NewEncoder(base64.StdEncoding, &buf)
|
||||
if _, err := io.Copy(encoder, f); err != nil {
|
||||
logger.WarnCF("agent", "Failed to encode media file", map[string]any{
|
||||
"path": localPath,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return ""
|
||||
}
|
||||
encoder.Close()
|
||||
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
// buildPathTag creates a structured tag exposing the local file path.
|
||||
// Tag type is derived from MIME: [audio:/path], [video:/path], or [file:/path].
|
||||
// Tag type is derived from MIME: [image:/path], [audio:/path], [video:/path], or [file:/path].
|
||||
func buildPathTag(mime, localPath string) string {
|
||||
switch {
|
||||
case strings.HasPrefix(mime, "image/"):
|
||||
return "[image:" + localPath + "]"
|
||||
case strings.HasPrefix(mime, "audio/"):
|
||||
return "[audio:" + localPath + "]"
|
||||
case strings.HasPrefix(mime, "video/"):
|
||||
|
|
@ -192,22 +239,41 @@ func buildPathTag(mime, localPath string) string {
|
|||
}
|
||||
|
||||
// injectPathTags replaces generic media tags in content with path-bearing versions,
|
||||
// or appends if no matching generic tag is found.
|
||||
// or appends if no matching generic tag is found. Channels emit a few different
|
||||
// placeholder formats — [image], [image: photo], [image: filename.jpg] — so we
|
||||
// match all of them via regex while leaving path tags ([image:/path]) untouched.
|
||||
//
|
||||
// When content is structured data (e.g., JSON from Feishu interactive cards or
|
||||
// post messages), tags are only injected via placeholder replacement — never
|
||||
// appended — to avoid corrupting the payload.
|
||||
func injectPathTags(content string, tags []string) string {
|
||||
isStructured := looksLikeJSON(content)
|
||||
for _, tag := range tags {
|
||||
var generic string
|
||||
var pattern *regexp.Regexp
|
||||
switch {
|
||||
case strings.HasPrefix(tag, "[image:"):
|
||||
pattern = imagePlaceholderRegex
|
||||
case strings.HasPrefix(tag, "[audio:"):
|
||||
generic = "[audio]"
|
||||
pattern = audioPlaceholderRegex
|
||||
case strings.HasPrefix(tag, "[video:"):
|
||||
generic = "[video]"
|
||||
pattern = videoPlaceholderRegex
|
||||
case strings.HasPrefix(tag, "[file:"):
|
||||
generic = "[file]"
|
||||
pattern = filePlaceholderRegex
|
||||
}
|
||||
|
||||
if generic != "" && strings.Contains(content, generic) {
|
||||
content = strings.Replace(content, generic, tag, 1)
|
||||
} else if content == "" {
|
||||
if pattern != nil {
|
||||
if loc := pattern.FindStringIndex(content); loc != nil {
|
||||
content = content[:loc[0]] + tag + content[loc[1]:]
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if isStructured {
|
||||
content = tag + "\n" + content
|
||||
continue
|
||||
}
|
||||
|
||||
if content == "" {
|
||||
content = tag
|
||||
} else {
|
||||
content += " " + tag
|
||||
|
|
@ -215,3 +281,8 @@ func injectPathTags(content string, tags []string) string {
|
|||
}
|
||||
return content
|
||||
}
|
||||
|
||||
func looksLikeJSON(s string) bool {
|
||||
s = strings.TrimSpace(s)
|
||||
return len(s) > 1 && s[0] == '{'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1781,17 +1781,22 @@ func (m *artifactThenSendProvider) Chat(
|
|||
if messages[i].Role != "tool" {
|
||||
continue
|
||||
}
|
||||
start := strings.Index(messages[i].Content, "[file:")
|
||||
if start < 0 {
|
||||
continue
|
||||
for _, prefix := range []string{"[image:", "[file:", "[audio:", "[video:"} {
|
||||
start := strings.Index(messages[i].Content, prefix)
|
||||
if start < 0 {
|
||||
continue
|
||||
}
|
||||
rest := messages[i].Content[start+len(prefix):]
|
||||
end := strings.Index(rest, "]")
|
||||
if end < 0 {
|
||||
continue
|
||||
}
|
||||
artifactPath = rest[:end]
|
||||
break
|
||||
}
|
||||
rest := messages[i].Content[start+len("[file:"):]
|
||||
end := strings.Index(rest, "]")
|
||||
if end < 0 {
|
||||
continue
|
||||
if artifactPath != "" {
|
||||
break
|
||||
}
|
||||
artifactPath = rest[:end]
|
||||
break
|
||||
}
|
||||
if artifactPath == "" {
|
||||
return nil, fmt.Errorf("provider did not receive artifact path in tool result")
|
||||
|
|
@ -4656,7 +4661,7 @@ func TestRun_PicoToolFeedbackSuppressesDuplicateInterimAssistantContent(t *testi
|
|||
}
|
||||
}
|
||||
|
||||
func TestResolveMediaRefs_ResolvesToBase64(t *testing.T) {
|
||||
func TestResolveMediaRefs_ImageInjectsPathTag(t *testing.T) {
|
||||
store := media.NewFileMediaStore()
|
||||
dir := t.TempDir()
|
||||
|
||||
|
|
@ -4684,15 +4689,110 @@ func TestResolveMediaRefs_ResolvesToBase64(t *testing.T) {
|
|||
}
|
||||
result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize)
|
||||
|
||||
if len(result[0].Media) != 1 {
|
||||
t.Fatalf("expected 1 resolved media, got %d", len(result[0].Media))
|
||||
if len(result[0].Media) != 0 {
|
||||
t.Fatalf("expected 0 media (images use path tags), got %d", len(result[0].Media))
|
||||
}
|
||||
if !strings.HasPrefix(result[0].Media[0], "data:image/png;base64,") {
|
||||
t.Fatalf("expected data:image/png;base64, prefix, got %q", result[0].Media[0][:40])
|
||||
localPath, _, _ := store.ResolveWithMeta(ref)
|
||||
expectedContent := "describe this [image:" + localPath + "]"
|
||||
if result[0].Content != expectedContent {
|
||||
t.Fatalf("expected content %q, got %q", expectedContent, result[0].Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveMediaRefs_SkipsOversizedFile(t *testing.T) {
|
||||
func TestResolveMediaRefs_ToolRoleImageAppendedAsUserMessage(t *testing.T) {
|
||||
store := media.NewFileMediaStore()
|
||||
dir := t.TempDir()
|
||||
|
||||
pngPath := filepath.Join(dir, "tool-result.png")
|
||||
pngHeader := []byte{
|
||||
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // PNG signature
|
||||
0x00, 0x00, 0x00, 0x0D, // IHDR length
|
||||
0x49, 0x48, 0x44, 0x52, // "IHDR"
|
||||
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, // 1x1 RGB
|
||||
0x00, 0x00, 0x00, // no interlace
|
||||
0x90, 0x77, 0x53, 0xDE, // CRC
|
||||
}
|
||||
if err := os.WriteFile(pngPath, pngHeader, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ref, _ := store.Store(pngPath, media.MediaMeta{}, "test")
|
||||
|
||||
messages := []providers.Message{
|
||||
{Role: "tool", Content: "Image loaded", Media: []string{ref}},
|
||||
}
|
||||
result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize)
|
||||
|
||||
// Tool message should have path tag but no base64
|
||||
if len(result[0].Media) != 0 {
|
||||
t.Fatalf("expected 0 media in tool message, got %d", len(result[0].Media))
|
||||
}
|
||||
localPath, _, _ := store.ResolveWithMeta(ref)
|
||||
if !strings.Contains(result[0].Content, "[image:"+localPath+"]") {
|
||||
t.Fatalf("expected image path tag in tool content, got %q", result[0].Content)
|
||||
}
|
||||
|
||||
// A synthetic user message with base64 should follow
|
||||
if len(result) != 2 {
|
||||
t.Fatalf("expected 2 messages (tool + synthetic user), got %d", len(result))
|
||||
}
|
||||
if result[1].Role != "user" {
|
||||
t.Fatalf("expected synthetic message role=user, got %q", result[1].Role)
|
||||
}
|
||||
if len(result[1].Media) != 1 {
|
||||
t.Fatalf("expected 1 base64 media in synthetic user message, got %d", len(result[1].Media))
|
||||
}
|
||||
if !strings.HasPrefix(result[1].Media[0], "data:image/png;base64,") {
|
||||
t.Fatalf("expected data:image/png;base64, prefix, got %q", result[1].Media[0][:40])
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveMediaRefs_MultiToolCallPreservesOrdering(t *testing.T) {
|
||||
store := media.NewFileMediaStore()
|
||||
dir := t.TempDir()
|
||||
|
||||
// Create image for tool #1
|
||||
pngPath := filepath.Join(dir, "loaded.png")
|
||||
pngHeader := []byte{
|
||||
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // PNG signature
|
||||
0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, // IHDR
|
||||
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02,
|
||||
0x00, 0x00, 0x00, 0x90, 0x77, 0x53, 0xDE,
|
||||
}
|
||||
os.WriteFile(pngPath, pngHeader, 0o644)
|
||||
imgRef, _ := store.Store(pngPath, media.MediaMeta{}, "test")
|
||||
|
||||
// Simulate: assistant called load_image + read_file, two tool results follow
|
||||
messages := []providers.Message{
|
||||
{Role: "assistant", Content: "Let me load the image and read the file."},
|
||||
{Role: "tool", Content: "Image loaded [image: photo]", Media: []string{imgRef}},
|
||||
{Role: "tool", Content: "file contents here"},
|
||||
}
|
||||
result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize)
|
||||
|
||||
// assistant, tool#1, tool#2 must remain contiguous — no user in between
|
||||
if result[0].Role != "assistant" {
|
||||
t.Fatalf("result[0] expected assistant, got %q", result[0].Role)
|
||||
}
|
||||
if result[1].Role != "tool" {
|
||||
t.Fatalf("result[1] expected tool, got %q", result[1].Role)
|
||||
}
|
||||
if result[2].Role != "tool" {
|
||||
t.Fatalf("result[2] expected tool, got %q", result[2].Role)
|
||||
}
|
||||
|
||||
// Synthetic user message should come AFTER the tool block
|
||||
if len(result) != 4 {
|
||||
t.Fatalf("expected 4 messages (assistant + 2 tool + synthetic user), got %d", len(result))
|
||||
}
|
||||
if result[3].Role != "user" {
|
||||
t.Fatalf("result[3] expected user, got %q", result[3].Role)
|
||||
}
|
||||
if len(result[3].Media) != 1 || !strings.HasPrefix(result[3].Media[0], "data:image/png;base64,") {
|
||||
t.Fatal("expected synthetic user message to contain base64 image")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveMediaRefs_OversizedImageSkipsBase64KeepsPathTag(t *testing.T) {
|
||||
store := media.NewFileMediaStore()
|
||||
dir := t.TempDir()
|
||||
|
||||
|
|
@ -4714,6 +4814,11 @@ func TestResolveMediaRefs_SkipsOversizedFile(t *testing.T) {
|
|||
if len(result[0].Media) != 0 {
|
||||
t.Fatalf("expected 0 media (oversized), got %d", len(result[0].Media))
|
||||
}
|
||||
localPath, _, _ := store.ResolveWithMeta(ref)
|
||||
expected := "hi [image:" + localPath + "]"
|
||||
if result[0].Content != expected {
|
||||
t.Fatalf("expected content %q, got %q", expected, result[0].Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveMediaRefs_UnknownTypeInjectsPath(t *testing.T) {
|
||||
|
|
@ -4791,11 +4896,13 @@ func TestResolveMediaRefs_UsesMetaContentType(t *testing.T) {
|
|||
}
|
||||
result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize)
|
||||
|
||||
if len(result[0].Media) != 1 {
|
||||
t.Fatalf("expected 1 media, got %d", len(result[0].Media))
|
||||
if len(result[0].Media) != 0 {
|
||||
t.Fatalf("expected 0 media (images use path tags), got %d", len(result[0].Media))
|
||||
}
|
||||
if !strings.HasPrefix(result[0].Media[0], "data:image/jpeg;base64,") {
|
||||
t.Fatalf("expected jpeg prefix, got %q", result[0].Media[0][:30])
|
||||
localPath, _, _ := store.ResolveWithMeta(ref)
|
||||
expectedContent := "hi [image:" + localPath + "]"
|
||||
if result[0].Content != expectedContent {
|
||||
t.Fatalf("expected content %q, got %q", expectedContent, result[0].Content)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -4885,6 +4992,98 @@ func TestResolveMediaRefs_NoGenericTagAppendsPath(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestInjectPathTags_HandlesVariousChannelPlaceholders(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
content string
|
||||
tag string
|
||||
want string
|
||||
}{
|
||||
// Telegram / Feishu format
|
||||
{"image_photo", "[image: photo]", "[image:/tmp/p.png]", "[image:/tmp/p.png]"},
|
||||
// WeCom / WeChat / Line format
|
||||
{"bare_image", "[image]", "[image:/tmp/p.png]", "[image:/tmp/p.png]"},
|
||||
// QQ / Discord format with filename
|
||||
{"image_filename", "[image: pic.jpg]", "[image:/tmp/p.png]", "[image:/tmp/p.png]"},
|
||||
{"audio_with_filename", "[audio: voice.m4a]", "[audio:/tmp/a.m4a]", "[audio:/tmp/a.m4a]"},
|
||||
{"bare_audio", "[audio]", "[audio:/tmp/a.m4a]", "[audio:/tmp/a.m4a]"},
|
||||
{"bare_video", "[video]", "[video:/tmp/v.mp4]", "[video:/tmp/v.mp4]"},
|
||||
{"bare_file", "[file]", "[file:/tmp/f.pdf]", "[file:/tmp/f.pdf]"},
|
||||
// Mixed surrounding text
|
||||
{
|
||||
"with_text",
|
||||
"hello [image] world",
|
||||
"[image:/tmp/p.png]",
|
||||
"hello [image:/tmp/p.png] world",
|
||||
},
|
||||
// No placeholder — append
|
||||
{"no_placeholder", "hello world", "[image:/tmp/p.png]", "hello world [image:/tmp/p.png]"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := injectPathTags(tc.content, []string{tc.tag})
|
||||
if got != tc.want {
|
||||
t.Errorf("expected %q, got %q", tc.want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInjectPathTags_DoesNotReplacePathTag(t *testing.T) {
|
||||
// If content already contains a path tag, we must not touch it.
|
||||
content := "see [image:/already/placed.png] thanks"
|
||||
got := injectPathTags(content, []string{"[image:/new/path.png]"})
|
||||
want := "see [image:/already/placed.png] thanks [image:/new/path.png]"
|
||||
if got != want {
|
||||
t.Fatalf("expected %q, got %q", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInjectPathTags_PrependsForJSONContent(t *testing.T) {
|
||||
jsonContent := `{"schema":"2.0","body":{"elements":[{"tag":"img","img_key":"img_123"}]}}`
|
||||
got := injectPathTags(jsonContent, []string{"[image:/tmp/photo.png]"})
|
||||
want := "[image:/tmp/photo.png]\n" + jsonContent
|
||||
if got != want {
|
||||
t.Fatalf("expected tag prepended to JSON, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInjectPathTags_BracketTextNotTreatedAsJSON(t *testing.T) {
|
||||
content := "[update] see attached report"
|
||||
got := injectPathTags(content, []string{"[file:/tmp/report.pdf]"})
|
||||
want := "[update] see attached report [file:/tmp/report.pdf]"
|
||||
if got != want {
|
||||
t.Fatalf("expected tag appended to bracket text, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveMediaRefs_JSONContentPrependsPathTag(t *testing.T) {
|
||||
store := media.NewFileMediaStore()
|
||||
dir := t.TempDir()
|
||||
|
||||
pngPath := filepath.Join(dir, "card_img.png")
|
||||
pngHeader := []byte{
|
||||
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A,
|
||||
0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52,
|
||||
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02,
|
||||
0x00, 0x00, 0x00, 0x90, 0x77, 0x53, 0xDE,
|
||||
}
|
||||
os.WriteFile(pngPath, pngHeader, 0o644)
|
||||
ref, _ := store.Store(pngPath, media.MediaMeta{ContentType: "image/png"}, "test")
|
||||
|
||||
jsonContent := `{"schema":"2.0","body":{"elements":[{"tag":"img","img_key":"img_123"}]}}`
|
||||
messages := []providers.Message{
|
||||
{Role: "user", Content: jsonContent, Media: []string{ref}},
|
||||
}
|
||||
result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize)
|
||||
|
||||
want := "[image:" + pngPath + "]\n" + jsonContent
|
||||
if result[0].Content != want {
|
||||
t.Fatalf("expected path tag prepended to JSON content, got %q", result[0].Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveMediaRefs_EmptyContentGetsPathTag(t *testing.T) {
|
||||
store := media.NewFileMediaStore()
|
||||
dir := t.TempDir()
|
||||
|
|
@ -4928,13 +5127,12 @@ func TestResolveMediaRefs_MixedImageAndFile(t *testing.T) {
|
|||
}
|
||||
result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize)
|
||||
|
||||
if len(result[0].Media) != 1 {
|
||||
t.Fatalf("expected 1 media (image only), got %d", len(result[0].Media))
|
||||
if len(result[0].Media) != 0 {
|
||||
t.Fatalf("expected 0 media (all types use path tags), got %d", len(result[0].Media))
|
||||
}
|
||||
if !strings.HasPrefix(result[0].Media[0], "data:image/png;base64,") {
|
||||
t.Fatal("expected image to be base64 encoded")
|
||||
}
|
||||
expectedContent := "check these [file:" + pdfPath + "]"
|
||||
imgLocalPath, _, _ := store.ResolveWithMeta(imgRef)
|
||||
pdfLocalPath, _, _ := store.ResolveWithMeta(fileRef)
|
||||
expectedContent := "check these [file:" + pdfLocalPath + "] [image:" + imgLocalPath + "]"
|
||||
if result[0].Content != expectedContent {
|
||||
t.Fatalf("expected content %q, got %q", expectedContent, result[0].Content)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,4 +44,11 @@ type ChannelManager interface {
|
|||
|
||||
// SendPlaceholder sends a placeholder message (e.g., for audio transcription).
|
||||
SendPlaceholder(ctx context.Context, channel, chatID string) bool
|
||||
|
||||
// DismissToolFeedback clears any tracked tool feedback animation for the
|
||||
// given channel/chat. Call this when a turn ends without a final response
|
||||
// (e.g., ResponseHandled tools) to avoid orphaned animation goroutines.
|
||||
// outboundCtx carries topic/thread info needed for channels that use
|
||||
// scoped tracker keys (e.g., Telegram forum topics); may be nil.
|
||||
DismissToolFeedback(ctx context.Context, channel, chatID string, outboundCtx *bus.InboundContext)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -704,6 +704,9 @@ toolLoop:
|
|||
}
|
||||
ts.setPhase(TurnPhaseCompleted)
|
||||
ts.setFinalContent("")
|
||||
if al.channelManager != nil && ts.channel != "" {
|
||||
al.channelManager.DismissToolFeedback(ctx, ts.channel, ts.chatID, ts.opts.InboundContext)
|
||||
}
|
||||
logger.InfoCF("agent", "Tool output satisfied delivery; ending turn without follow-up LLM",
|
||||
map[string]any{
|
||||
"agent_id": ts.agent.ID,
|
||||
|
|
|
|||
|
|
@ -1051,16 +1051,16 @@ func TestAgentLoop_Continue_PreservesSteeringMedia(t *testing.T) {
|
|||
|
||||
foundResolvedMedia := false
|
||||
for _, msg := range msgs {
|
||||
if msg.Role != "user" || msg.Content != "describe this image" || len(msg.Media) != 1 {
|
||||
if msg.Role != "user" || !strings.Contains(msg.Content, "describe this image") {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(msg.Media[0], "data:image/png;base64,") {
|
||||
if strings.Contains(msg.Content, "[image:") {
|
||||
foundResolvedMedia = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundResolvedMedia {
|
||||
t.Fatal("expected continue path to inject steering media into the provider request")
|
||||
t.Fatal("expected continue path to inject image path tag into the provider request")
|
||||
}
|
||||
|
||||
defaultAgent := al.registry.GetDefaultAgent()
|
||||
|
|
|
|||
|
|
@ -64,6 +64,62 @@ func extractJSONStringField(content, field string) string {
|
|||
// Format: {"image_key": "img_xxx"}
|
||||
func extractImageKey(content string) string { return extractJSONStringField(content, "image_key") }
|
||||
|
||||
// extractPostImageKeys extracts all image_key values from a Feishu post (rich text)
|
||||
// message. Post messages have nested arrays of elements where images appear as
|
||||
// {"tag":"img","image_key":"img_xxx"}.
|
||||
func extractPostImageKeys(rawContent string) []string {
|
||||
if rawContent == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
var post map[string]json.RawMessage
|
||||
if err := json.Unmarshal([]byte(rawContent), &post); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var keys []string
|
||||
seen := make(map[string]struct{})
|
||||
|
||||
collectFromRows := func(contentRaw json.RawMessage) {
|
||||
var rows [][]map[string]any
|
||||
if err := json.Unmarshal(contentRaw, &rows); err != nil {
|
||||
return
|
||||
}
|
||||
for _, row := range rows {
|
||||
for _, elem := range row {
|
||||
if tag, _ := elem["tag"].(string); tag == "img" {
|
||||
if ik, _ := elem["image_key"].(string); ik != "" {
|
||||
if _, dup := seen[ik]; !dup {
|
||||
seen[ik] = struct{}{}
|
||||
keys = append(keys, ik)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Flat format: {"title":"...", "content":[[...]]}
|
||||
if contentRaw, ok := post["content"]; ok {
|
||||
collectFromRows(contentRaw)
|
||||
}
|
||||
|
||||
// Localized format: {"zh_cn": {"title":"...", "content":[[...]]}, ...}
|
||||
for _, raw := range post {
|
||||
var locale map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &locale); err != nil {
|
||||
continue
|
||||
}
|
||||
contentRaw, ok := locale["content"]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
collectFromRows(contentRaw)
|
||||
}
|
||||
|
||||
return keys
|
||||
}
|
||||
|
||||
// extractFileKey extracts the file_key from a Feishu file/audio message content JSON.
|
||||
// Format: {"file_key": "file_xxx", "file_name": "...", ...}
|
||||
func extractFileKey(content string) string { return extractJSONStringField(content, "file_key") }
|
||||
|
|
|
|||
|
|
@ -291,6 +291,100 @@ func TestStripMentionPlaceholders(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestExtractPostImageKeys(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
content string
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "empty content",
|
||||
content: "",
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "invalid JSON",
|
||||
content: "not json",
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "post with no images",
|
||||
content: `{"zh_cn":{"title":"Title","content":[[{"tag":"text","text":"hello"}]]}}`,
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "post with one image",
|
||||
content: `{"zh_cn":{"title":"","content":[[{"tag":"img","image_key":"img_v3_001"}]]}}`,
|
||||
want: []string{"img_v3_001"},
|
||||
},
|
||||
{
|
||||
name: "post with multiple images",
|
||||
content: `{"zh_cn":{"title":"","content":[[{"tag":"text","text":"see"},{"tag":"img","image_key":"img_001"}],[{"tag":"img","image_key":"img_002"}]]}}`,
|
||||
want: []string{"img_001", "img_002"},
|
||||
},
|
||||
{
|
||||
name: "post with text and image mixed in row",
|
||||
content: `{"zh_cn":{"title":"","content":[[{"tag":"text","text":"hi"},{"tag":"img","image_key":"img_mix"}]]}}`,
|
||||
want: []string{"img_mix"},
|
||||
},
|
||||
{
|
||||
name: "en_us locale",
|
||||
content: `{"en_us":{"title":"","content":[[{"tag":"img","image_key":"img_en"}]]}}`,
|
||||
want: []string{"img_en"},
|
||||
},
|
||||
{
|
||||
name: "multiple locales with distinct images",
|
||||
content: `{"zh_cn":{"title":"","content":[[{"tag":"img","image_key":"img_zh"}]]},"en_us":{"title":"","content":[[{"tag":"img","image_key":"img_en"}]]}}`,
|
||||
want: []string{"img_zh", "img_en"},
|
||||
},
|
||||
{
|
||||
name: "duplicate image_key across locales is deduplicated",
|
||||
content: `{"zh_cn":{"title":"","content":[[{"tag":"img","image_key":"img_same"}]]},"en_us":{"title":"","content":[[{"tag":"img","image_key":"img_same"}]]}}`,
|
||||
want: []string{"img_same"},
|
||||
},
|
||||
{
|
||||
name: "image with empty image_key",
|
||||
content: `{"zh_cn":{"title":"","content":[[{"tag":"img","image_key":""}]]}}`,
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "flat format without locale wrapper",
|
||||
content: `{"title":"","content":[[{"tag":"img","image_key":"img_v3_flat","width":1826,"height":338}],[{"tag":"text","text":" check this image","style":[]}]]}`,
|
||||
want: []string{"img_v3_flat"},
|
||||
},
|
||||
{
|
||||
name: "flat format multiple images",
|
||||
content: `{"title":"","content":[[{"tag":"img","image_key":"img_flat_1"}],[{"tag":"img","image_key":"img_flat_2"},{"tag":"text","text":"desc"}]]}`,
|
||||
want: []string{"img_flat_1", "img_flat_2"},
|
||||
},
|
||||
{
|
||||
name: "flat format no images",
|
||||
content: `{"title":"Test","content":[[{"tag":"text","text":"just text"}]]}`,
|
||||
want: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := extractPostImageKeys(tt.content)
|
||||
if len(got) != len(tt.want) {
|
||||
t.Errorf("extractPostImageKeys() = %v, want %v", got, tt.want)
|
||||
return
|
||||
}
|
||||
// Use set comparison to avoid map iteration order dependency
|
||||
gotSet := make(map[string]bool, len(got))
|
||||
for _, v := range got {
|
||||
gotSet[v] = true
|
||||
}
|
||||
for _, v := range tt.want {
|
||||
if !gotSet[v] {
|
||||
t.Errorf("extractPostImageKeys() missing expected key %q; got %v", v, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractCardImageKeys(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
|
|
|||
|
|
@ -803,6 +803,14 @@ func (c *FeishuChannel) downloadInboundMedia(
|
|||
refs = append(refs, ref)
|
||||
}
|
||||
|
||||
case larkim.MsgTypePost:
|
||||
for _, imageKey := range extractPostImageKeys(rawContent) {
|
||||
ref := c.downloadResource(ctx, messageID, imageKey, "image", ".jpg", store, scope)
|
||||
if ref != "" {
|
||||
refs = append(refs, ref)
|
||||
}
|
||||
}
|
||||
|
||||
case larkim.MsgTypeInteractive:
|
||||
// Extract and download images embedded in interactive cards
|
||||
feishuKeys, _ := extractCardImageKeys(rawContent)
|
||||
|
|
@ -842,12 +850,41 @@ func (c *FeishuChannel) downloadInboundMedia(
|
|||
// downloadResource downloads a message resource (image/file) from Feishu,
|
||||
// writes it to the project media directory, and stores the reference in MediaStore.
|
||||
// fallbackExt (e.g. ".jpg") is appended when the resolved filename has no extension.
|
||||
//
|
||||
// For image resources, if the primary MessageResource.Get API fails (which
|
||||
// requires im:message or im:message:readonly scope), a fallback to the
|
||||
// Image.Get API (which requires im:resource scope) is attempted. This ensures
|
||||
// image downloads succeed regardless of which permission the user has granted.
|
||||
func (c *FeishuChannel) downloadResource(
|
||||
ctx context.Context,
|
||||
messageID, fileKey, resourceType, fallbackExt string,
|
||||
store media.MediaStore,
|
||||
scope string,
|
||||
) string {
|
||||
file, filename := c.fetchResourceData(ctx, messageID, fileKey, resourceType)
|
||||
if file == nil {
|
||||
return ""
|
||||
}
|
||||
if closer, ok := file.(io.Closer); ok {
|
||||
defer closer.Close()
|
||||
}
|
||||
|
||||
if filename == "" {
|
||||
filename = fileKey
|
||||
}
|
||||
if filepath.Ext(filename) == "" && fallbackExt != "" {
|
||||
filename += fallbackExt
|
||||
}
|
||||
|
||||
return c.storeResourceFile(ctx, messageID, fileKey, filename, file, store, scope)
|
||||
}
|
||||
|
||||
// fetchResourceData tries to download a resource from Feishu, first via
|
||||
// MessageResource.Get, then falling back to Image.Get for image resources.
|
||||
func (c *FeishuChannel) fetchResourceData(
|
||||
ctx context.Context,
|
||||
messageID, fileKey, resourceType string,
|
||||
) (io.Reader, string) {
|
||||
req := larkim.NewGetMessageResourceReqBuilder().
|
||||
MessageId(messageID).
|
||||
FileKey(fileKey).
|
||||
|
|
@ -855,41 +892,80 @@ func (c *FeishuChannel) downloadResource(
|
|||
Build()
|
||||
|
||||
resp, err := c.client.Im.V1.MessageResource.Get(ctx, req)
|
||||
if err == nil && resp.Success() && resp.File != nil {
|
||||
return resp.File, resp.FileName
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
logger.ErrorCF("feishu", "Failed to download resource", map[string]any{
|
||||
logger.WarnCF("feishu", "MessageResource.Get failed", map[string]any{
|
||||
"message_id": messageID,
|
||||
"file_key": fileKey,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return ""
|
||||
} else if !resp.Success() {
|
||||
c.invalidateTokenOnAuthError(resp.Code)
|
||||
logger.WarnCF("feishu", "MessageResource.Get api error", map[string]any{
|
||||
"message_id": messageID,
|
||||
"file_key": fileKey,
|
||||
"code": resp.Code,
|
||||
"msg": resp.Msg,
|
||||
})
|
||||
} else {
|
||||
logger.WarnCF("feishu", "MessageResource.Get returned empty file body", map[string]any{
|
||||
"message_id": messageID,
|
||||
"file_key": fileKey,
|
||||
})
|
||||
}
|
||||
|
||||
if resourceType != "image" {
|
||||
return nil, ""
|
||||
}
|
||||
|
||||
return c.fetchImageDirect(ctx, fileKey)
|
||||
}
|
||||
|
||||
// fetchImageDirect downloads an image using the Image.Get API
|
||||
// (/open-apis/im/v1/images/:image_key), which requires the im:resource scope.
|
||||
func (c *FeishuChannel) fetchImageDirect(ctx context.Context, imageKey string) (io.Reader, string) {
|
||||
req := larkim.NewGetImageReqBuilder().
|
||||
ImageKey(imageKey).
|
||||
Build()
|
||||
|
||||
resp, err := c.client.Im.V1.Image.Get(ctx, req)
|
||||
if err != nil {
|
||||
logger.ErrorCF("feishu", "Image.Get fallback failed", map[string]any{
|
||||
"image_key": imageKey,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return nil, ""
|
||||
}
|
||||
if !resp.Success() {
|
||||
c.invalidateTokenOnAuthError(resp.Code)
|
||||
logger.ErrorCF("feishu", "Resource download api error", map[string]any{
|
||||
"code": resp.Code,
|
||||
"msg": resp.Msg,
|
||||
logger.ErrorCF("feishu", "Image.Get fallback api error", map[string]any{
|
||||
"image_key": imageKey,
|
||||
"code": resp.Code,
|
||||
"msg": resp.Msg,
|
||||
})
|
||||
return ""
|
||||
return nil, ""
|
||||
}
|
||||
|
||||
if resp.File == nil {
|
||||
return ""
|
||||
}
|
||||
// Safely close the underlying reader if it implements io.Closer (e.g. HTTP response body).
|
||||
if closer, ok := resp.File.(io.Closer); ok {
|
||||
defer closer.Close()
|
||||
return nil, ""
|
||||
}
|
||||
|
||||
filename := resp.FileName
|
||||
if filename == "" {
|
||||
filename = fileKey
|
||||
}
|
||||
// If filename still has no extension, append the fallback (like Telegram's ext parameter).
|
||||
if filepath.Ext(filename) == "" && fallbackExt != "" {
|
||||
filename += fallbackExt
|
||||
}
|
||||
logger.DebugCF("feishu", "Image downloaded via Image.Get fallback", map[string]any{
|
||||
"image_key": imageKey,
|
||||
})
|
||||
return resp.File, resp.FileName
|
||||
}
|
||||
|
||||
// Write to the shared picoclaw_media directory using a unique name to avoid collisions.
|
||||
// storeResourceFile writes downloaded resource data to disk and registers it in the MediaStore.
|
||||
func (c *FeishuChannel) storeResourceFile(
|
||||
ctx context.Context,
|
||||
messageID, fileKey, filename string,
|
||||
file io.Reader,
|
||||
store media.MediaStore,
|
||||
scope string,
|
||||
) string {
|
||||
mediaDir := media.TempDir()
|
||||
if mkdirErr := os.MkdirAll(mediaDir, 0o700); mkdirErr != nil {
|
||||
logger.ErrorCF("feishu", "Failed to create media directory", map[string]any{
|
||||
|
|
@ -908,7 +984,7 @@ func (c *FeishuChannel) downloadResource(
|
|||
return ""
|
||||
}
|
||||
|
||||
if _, copyErr := io.Copy(out, resp.File); copyErr != nil {
|
||||
if _, copyErr := io.Copy(out, file); copyErr != nil {
|
||||
out.Close()
|
||||
os.Remove(localPath)
|
||||
logger.ErrorCF("feishu", "Failed to write resource to file", map[string]any{
|
||||
|
|
@ -943,8 +1019,8 @@ func appendMediaTags(content, messageType string, mediaRefs []string) string {
|
|||
return content
|
||||
}
|
||||
|
||||
// Don't append tags to JSON content (interactive cards) - would produce invalid JSON
|
||||
if messageType == larkim.MsgTypeInteractive {
|
||||
// Don't append tags to JSON content - would produce invalid JSON
|
||||
if messageType == larkim.MsgTypeInteractive || messageType == larkim.MsgTypePost {
|
||||
return content
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -180,6 +180,13 @@ func TestAppendMediaTags(t *testing.T) {
|
|||
mediaRefs: []string{"ref1"},
|
||||
want: `{"schema":"2.0","body":{"elements":[{"tag":"img","img_key":"img_123"}]}}`,
|
||||
},
|
||||
{
|
||||
name: "post message with images returns content unchanged",
|
||||
content: `{"zh_cn":{"title":"","content":[[{"tag":"img","image_key":"img_001"}]]}}`,
|
||||
messageType: "post",
|
||||
mediaRefs: []string{"ref1"},
|
||||
want: `{"zh_cn":{"title":"","content":[[{"tag":"img","image_key":"img_001"}]]}}`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
|
|
|||
|
|
@ -192,6 +192,21 @@ func clearTrackedToolFeedbackMessage(
|
|||
}
|
||||
}
|
||||
|
||||
// DismissToolFeedback clears any tracked tool feedback animation for the
|
||||
// given channel/chat. This is called when a turn ends without a final
|
||||
// response (e.g., ResponseHandled tools) to stop orphaned animation goroutines.
|
||||
// outboundCtx carries topic/thread info for channels that use scoped tracker
|
||||
// keys (e.g., Telegram forum topics); may be nil for non-topic channels.
|
||||
func (m *Manager) DismissToolFeedback(
|
||||
ctx context.Context, channelName, chatID string, outboundCtx *bus.InboundContext,
|
||||
) {
|
||||
ch, ok := m.GetChannel(channelName)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
dismissTrackedToolFeedbackMessage(ctx, ch, chatID, outboundCtx)
|
||||
}
|
||||
|
||||
func prepareToolFeedbackMessageContent(ch Channel, content string) string {
|
||||
prepared := strings.TrimSpace(content)
|
||||
if prepared == "" {
|
||||
|
|
|
|||
|
|
@ -270,6 +270,10 @@ func filterDeepSeekReasoningTurn(messages []Message) []Message {
|
|||
}
|
||||
|
||||
cloned := msg
|
||||
// DeepSeek thinking-mode replay only requires reasoning_content for
|
||||
// turns that participate in a tool interaction round. For plain
|
||||
// assistant turns between two user messages, the docs say the API will
|
||||
// ignore reasoning_content on replay, so we strip it here.
|
||||
if cloned.Role == "assistant" && strings.TrimSpace(cloned.ReasoningContent) != "" && !hasToolInteraction {
|
||||
cloned.ReasoningContent = ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -526,6 +526,112 @@ func TestProviderChat_HistoryCanonicalizationMatrix(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
func TestProviderChat_DeepSeekDocsReplayRequirements(t *testing.T) {
|
||||
// DeepSeek's thinking-mode and multi-round chat docs distinguish two cases:
|
||||
// - for a plain assistant turn between two user messages without tool calls,
|
||||
// reasoning_content does not need to be replayed and the API ignores it if sent;
|
||||
// - for a turn that participates in a tool-interaction round, assistant
|
||||
// reasoning_content must be replayed on subsequent requests.
|
||||
//
|
||||
// Keep this behavior explicit here so future changes do not "fix" the
|
||||
// non-tool stripping based on issue reports that are broader than the
|
||||
// vendor documentation.
|
||||
var requestBody map[string]any
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
resp := map[string]any{
|
||||
"choices": []map[string]any{
|
||||
{
|
||||
"message": map[string]any{"content": "ok"},
|
||||
"finish_reason": "stop",
|
||||
},
|
||||
},
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
p := NewProvider("key", server.URL, "")
|
||||
p.SetProviderName("deepseek")
|
||||
|
||||
messages := []Message{
|
||||
{Role: "user", Content: "Who wrote The Hobbit?"},
|
||||
{Role: "assistant", Content: "J.R.R. Tolkien.", ReasoningContent: "I know this from general knowledge."},
|
||||
{Role: "user", Content: "What's the weather tomorrow?"},
|
||||
{
|
||||
Role: "assistant",
|
||||
Content: "Let me check the date first.",
|
||||
ReasoningContent: "I need tomorrow's date before checking the weather.",
|
||||
ToolCalls: []ToolCall{{
|
||||
ID: "call_date",
|
||||
Type: "function",
|
||||
Function: &FunctionCall{
|
||||
Name: "get_date",
|
||||
Arguments: "{}",
|
||||
},
|
||||
}},
|
||||
},
|
||||
{Role: "tool", ToolCallID: "call_date", Content: "2026-04-29"},
|
||||
{
|
||||
Role: "assistant",
|
||||
Content: "Tomorrow is 2026-04-30.",
|
||||
ReasoningContent: "Now I can continue with the weather request.",
|
||||
},
|
||||
{Role: "user", Content: "What about Guangzhou?"},
|
||||
}
|
||||
|
||||
_, err := p.Chat(t.Context(), messages, nil, "deepseek-v4-flash", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Chat() error = %v", err)
|
||||
}
|
||||
|
||||
reqMessages, ok := requestBody["messages"].([]any)
|
||||
if !ok {
|
||||
t.Fatalf("messages is not []any: %T", requestBody["messages"])
|
||||
}
|
||||
if len(reqMessages) != len(messages) {
|
||||
t.Fatalf("len(messages) = %d, want %d", len(reqMessages), len(messages))
|
||||
}
|
||||
|
||||
plainAssistant, ok := reqMessages[1].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("plain assistant message is not map[string]any: %T", reqMessages[1])
|
||||
}
|
||||
if _, exists := plainAssistant["reasoning_content"]; exists {
|
||||
t.Fatalf(
|
||||
"plain DeepSeek turn should omit reasoning_content on replay, got %v",
|
||||
plainAssistant["reasoning_content"],
|
||||
)
|
||||
}
|
||||
|
||||
toolAssistant, ok := reqMessages[3].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("tool assistant message is not map[string]any: %T", reqMessages[3])
|
||||
}
|
||||
if toolAssistant["reasoning_content"] != "I need tomorrow's date before checking the weather." {
|
||||
t.Fatalf(
|
||||
"tool assistant reasoning_content = %v, want preserved",
|
||||
toolAssistant["reasoning_content"],
|
||||
)
|
||||
}
|
||||
|
||||
finalAssistant, ok := reqMessages[5].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("final assistant message is not map[string]any: %T", reqMessages[5])
|
||||
}
|
||||
if finalAssistant["reasoning_content"] != "Now I can continue with the weather request." {
|
||||
t.Fatalf(
|
||||
"final assistant reasoning_content = %v, want preserved",
|
||||
finalAssistant["reasoning_content"],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderChat_HTTPError(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ func runSchema(db *sql.DB) error {
|
|||
conversation_id INTEGER NOT NULL REFERENCES conversations(conversation_id),
|
||||
role TEXT NOT NULL,
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
reasoning_content TEXT NOT NULL DEFAULT '',
|
||||
token_count INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)`,
|
||||
|
|
@ -157,9 +158,57 @@ func runSchema(db *sql.DB) error {
|
|||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := ensureMessagesReasoningContentColumn(db); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ensureMessagesReasoningContentColumn(db *sql.DB) error {
|
||||
hasColumn, err := tableHasColumn(db, "messages", "reasoning_content")
|
||||
if err != nil {
|
||||
return fmt.Errorf("check messages.reasoning_content: %w", err)
|
||||
}
|
||||
if hasColumn {
|
||||
return nil
|
||||
}
|
||||
|
||||
if _, err := db.Exec(`ALTER TABLE messages ADD COLUMN reasoning_content TEXT NOT NULL DEFAULT ''`); err != nil {
|
||||
return fmt.Errorf("add messages.reasoning_content: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func tableHasColumn(db *sql.DB, tableName, columnName string) (bool, error) {
|
||||
rows, err := db.Query(fmt.Sprintf(`PRAGMA table_info(%s)`, tableName))
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var (
|
||||
cid int
|
||||
name string
|
||||
columnType string
|
||||
notNull int
|
||||
defaultVal sql.NullString
|
||||
pk int
|
||||
)
|
||||
if err := rows.Scan(&cid, &name, &columnType, ¬Null, &defaultVal, &pk); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if name == columnName {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// checkFTS5Support verifies that SQLite has FTS5 with trigram tokenizer enabled.
|
||||
// This is required for full-text search with CJK (Chinese, Japanese, Korean) support.
|
||||
func checkFTS5Support(db *sql.DB) error {
|
||||
|
|
|
|||
|
|
@ -91,6 +91,53 @@ func TestRunMigrationsIdempotent(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestRunSchemaAddsMessagesReasoningContentColumn(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
|
||||
_, err := db.Exec(`CREATE TABLE messages (
|
||||
message_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
conversation_id INTEGER NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
token_count INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)`)
|
||||
if err != nil {
|
||||
t.Fatalf("create legacy messages table: %v", err)
|
||||
}
|
||||
|
||||
err = runSchema(db)
|
||||
if err != nil {
|
||||
t.Fatalf("runSchema: %v", err)
|
||||
}
|
||||
|
||||
var count int
|
||||
err = db.QueryRow(`SELECT count(*) FROM pragma_table_info('messages') WHERE name = 'reasoning_content'`).
|
||||
Scan(&count)
|
||||
if err != nil {
|
||||
t.Fatalf("query pragma_table_info: %v", err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("reasoning_content column count = %d, want 1", count)
|
||||
}
|
||||
|
||||
_, err = db.Exec(
|
||||
`INSERT INTO conversations (session_key, created_at, updated_at) VALUES (?, datetime('now'), datetime('now'))`,
|
||||
"reasoning-column-test",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("insert conversation: %v", err)
|
||||
}
|
||||
|
||||
_, err = db.Exec(
|
||||
`INSERT INTO messages (conversation_id, role, content, reasoning_content, token_count)
|
||||
VALUES (1, 'assistant', 'answer', 'thinking', 1)`,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("insert message with reasoning_content: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrationConversationUnique(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
if err := runSchema(db); err != nil {
|
||||
|
|
|
|||
|
|
@ -253,9 +253,23 @@ func (e *Engine) Ingest(ctx context.Context, sessionKey string, messages []Messa
|
|||
var added *Message
|
||||
var err error
|
||||
if len(msg.Parts) > 0 {
|
||||
added, err = e.store.AddMessageWithParts(ctx, conv.ConversationID, msg.Role, msg.Parts, msg.TokenCount)
|
||||
added, err = e.store.AddMessageWithPartsAndReasoning(
|
||||
ctx,
|
||||
conv.ConversationID,
|
||||
msg.Role,
|
||||
msg.Parts,
|
||||
msg.ReasoningContent,
|
||||
msg.TokenCount,
|
||||
)
|
||||
} else {
|
||||
added, err = e.store.AddMessage(ctx, conv.ConversationID, msg.Role, msg.Content, msg.TokenCount)
|
||||
added, err = e.store.AddMessageWithReasoning(
|
||||
ctx,
|
||||
conv.ConversationID,
|
||||
msg.Role,
|
||||
msg.Content,
|
||||
msg.ReasoningContent,
|
||||
msg.TokenCount,
|
||||
)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("add message: %w", err)
|
||||
|
|
@ -420,7 +434,7 @@ func (e *Engine) Bootstrap(ctx context.Context, sessionKey string, messages []Me
|
|||
// Fast path: DB has same count and exact match → no-op
|
||||
if len(dbMsgs) == len(messages) {
|
||||
matched := true
|
||||
for i := 0; i < len(messages); i++ {
|
||||
for i := range messages {
|
||||
if !messageMatches(dbMsgs[i], messages[i]) {
|
||||
matched = false
|
||||
break
|
||||
|
|
@ -431,14 +445,21 @@ func (e *Engine) Bootstrap(ctx context.Context, sessionKey string, messages []Me
|
|||
}
|
||||
}
|
||||
|
||||
// Find longest matching prefix from the start
|
||||
anchor := -1
|
||||
compareLen := len(dbMsgs)
|
||||
if compareLen > len(messages) {
|
||||
compareLen = len(messages)
|
||||
// Migration repair path: old SeaHorse rows may be missing reasoning_content
|
||||
// even though the canonical JSONL history already has it. Backfill those
|
||||
// rows in place so we do not treat this as edited history and leave stale
|
||||
// summaries/context behind after a partial raw-message rebuild.
|
||||
if repaired, err := e.repairBootstrapReasoningContent(ctx, dbMsgs, messages); err != nil {
|
||||
return fmt.Errorf("bootstrap: repair reasoning_content: %w", err)
|
||||
} else if repaired && len(dbMsgs) == len(messages) {
|
||||
return nil
|
||||
}
|
||||
|
||||
for i := 0; i < compareLen; i++ {
|
||||
// Find longest matching prefix from the start
|
||||
anchor := -1
|
||||
compareLen := min(len(dbMsgs), len(messages))
|
||||
|
||||
for i := range compareLen {
|
||||
if messageMatches(dbMsgs[i], messages[i]) {
|
||||
anchor = i
|
||||
} else {
|
||||
|
|
@ -524,6 +545,57 @@ func (e *Engine) Bootstrap(ctx context.Context, sessionKey string, messages []Me
|
|||
return nil
|
||||
}
|
||||
|
||||
func (e *Engine) repairBootstrapReasoningContent(ctx context.Context, dbMsgs, messages []Message) (bool, error) {
|
||||
if len(dbMsgs) == 0 || len(messages) == 0 {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
overlap := min(len(messages), len(dbMsgs))
|
||||
|
||||
var updates []struct {
|
||||
index int
|
||||
messageID int64
|
||||
reasoningContent string
|
||||
}
|
||||
|
||||
for i := range overlap {
|
||||
if !messageMatchesIgnoringReasoning(dbMsgs[i], messages[i]) {
|
||||
return false, nil
|
||||
}
|
||||
if dbMsgs[i].ReasoningContent == messages[i].ReasoningContent {
|
||||
continue
|
||||
}
|
||||
if dbMsgs[i].ReasoningContent != "" || messages[i].ReasoningContent == "" {
|
||||
return false, nil
|
||||
}
|
||||
updates = append(updates, struct {
|
||||
index int
|
||||
messageID int64
|
||||
reasoningContent string
|
||||
}{
|
||||
index: i,
|
||||
messageID: dbMsgs[i].ID,
|
||||
reasoningContent: messages[i].ReasoningContent,
|
||||
})
|
||||
}
|
||||
|
||||
if len(updates) == 0 {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
for _, update := range updates {
|
||||
if err := e.store.UpdateMessageReasoningContent(ctx, update.messageID, update.reasoningContent); err != nil {
|
||||
return false, err
|
||||
}
|
||||
dbMsgs[update.index].ReasoningContent = update.reasoningContent
|
||||
}
|
||||
|
||||
logger.InfoCF("seahorse", "bootstrap: repaired missing reasoning_content", map[string]any{
|
||||
"messages": len(updates),
|
||||
})
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// truncate shortens a string for logging.
|
||||
func truncate(s string, maxLen int) string {
|
||||
if len(s) <= maxLen {
|
||||
|
|
@ -532,12 +604,19 @@ func truncate(s string, maxLen int) string {
|
|||
return s[:maxLen] + "..."
|
||||
}
|
||||
|
||||
// messageMatches compares two messages using (role, content) or (role, parts).
|
||||
// TokenCount is NOT compared because it may be re-estimated differently
|
||||
// during bootstrap (e.g., via tokenizer.EstimateMessageTokens).
|
||||
// messageMatches compares two messages using role + reasoning_content and then
|
||||
// either content or parts. TokenCount is NOT compared because it may be
|
||||
// re-estimated differently during bootstrap (e.g., via tokenizer.EstimateMessageTokens).
|
||||
// For messages with Parts (tool_use, tool_result), compare Parts instead of Content
|
||||
// since AddMessageWithParts stores empty Content in DB.
|
||||
// because structured messages are matched by their parts payload.
|
||||
func messageMatches(a, b Message) bool {
|
||||
if a.Role != b.Role || a.ReasoningContent != b.ReasoningContent {
|
||||
return false
|
||||
}
|
||||
return messageMatchesIgnoringReasoning(a, b)
|
||||
}
|
||||
|
||||
func messageMatchesIgnoringReasoning(a, b Message) bool {
|
||||
if a.Role != b.Role {
|
||||
return false
|
||||
}
|
||||
|
|
|
|||
|
|
@ -320,6 +320,108 @@ func TestEngineIngestWithParts(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestEngineIngestPreservesReasoningContent(t *testing.T) {
|
||||
eng := newTestEngine(t)
|
||||
ctx := context.Background()
|
||||
|
||||
msgs := []Message{
|
||||
{
|
||||
Role: "assistant",
|
||||
Content: "world",
|
||||
ReasoningContent: "let me think this through",
|
||||
TokenCount: 4,
|
||||
},
|
||||
}
|
||||
|
||||
_, err := eng.Ingest(ctx, "agent:reasoning", msgs)
|
||||
if err != nil {
|
||||
t.Fatalf("Ingest: %v", err)
|
||||
}
|
||||
|
||||
conv, _ := eng.store.GetOrCreateConversation(ctx, "agent:reasoning")
|
||||
stored, err := eng.store.GetMessages(ctx, conv.ConversationID, 10, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("GetMessages: %v", err)
|
||||
}
|
||||
if len(stored) != 1 {
|
||||
t.Fatalf("stored messages = %d, want 1", len(stored))
|
||||
}
|
||||
if stored[0].ReasoningContent != "let me think this through" {
|
||||
t.Errorf(
|
||||
"stored[0].ReasoningContent = %q, want %q",
|
||||
stored[0].ReasoningContent,
|
||||
"let me think this through",
|
||||
)
|
||||
}
|
||||
|
||||
result, err := eng.Assemble(ctx, "agent:reasoning", AssembleInput{Budget: 1000})
|
||||
if err != nil {
|
||||
t.Fatalf("Assemble: %v", err)
|
||||
}
|
||||
if len(result.Messages) != 1 {
|
||||
t.Fatalf("assembled messages = %d, want 1", len(result.Messages))
|
||||
}
|
||||
if result.Messages[0].ReasoningContent != "let me think this through" {
|
||||
t.Errorf(
|
||||
"assembled reasoning = %q, want %q",
|
||||
result.Messages[0].ReasoningContent,
|
||||
"let me think this through",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEngineIngestWithPartsPreservesReasoningContent(t *testing.T) {
|
||||
eng := newTestEngine(t)
|
||||
ctx := context.Background()
|
||||
|
||||
msgs := []Message{
|
||||
{
|
||||
Role: "assistant",
|
||||
ReasoningContent: "I need to inspect the file first",
|
||||
TokenCount: 10,
|
||||
Parts: []MessagePart{
|
||||
{Type: "tool_use", Name: "read_file", Arguments: `{"path":"/tmp/test"}`, ToolCallID: "tc_123"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
_, err := eng.Ingest(ctx, "agent:parts-reasoning", msgs)
|
||||
if err != nil {
|
||||
t.Fatalf("Ingest: %v", err)
|
||||
}
|
||||
|
||||
conv, _ := eng.store.GetOrCreateConversation(ctx, "agent:parts-reasoning")
|
||||
stored, err := eng.store.GetMessages(ctx, conv.ConversationID, 10, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("GetMessages: %v", err)
|
||||
}
|
||||
if len(stored) != 1 {
|
||||
t.Fatalf("stored messages = %d, want 1", len(stored))
|
||||
}
|
||||
if stored[0].ReasoningContent != "I need to inspect the file first" {
|
||||
t.Errorf(
|
||||
"stored reasoning = %q, want %q",
|
||||
stored[0].ReasoningContent,
|
||||
"I need to inspect the file first",
|
||||
)
|
||||
}
|
||||
|
||||
result, err := eng.Assemble(ctx, "agent:parts-reasoning", AssembleInput{Budget: 1000})
|
||||
if err != nil {
|
||||
t.Fatalf("Assemble: %v", err)
|
||||
}
|
||||
if len(result.Messages) != 1 {
|
||||
t.Fatalf("assembled messages = %d, want 1", len(result.Messages))
|
||||
}
|
||||
if result.Messages[0].ReasoningContent != "I need to inspect the file first" {
|
||||
t.Errorf(
|
||||
"assembled reasoning = %q, want %q",
|
||||
result.Messages[0].ReasoningContent,
|
||||
"I need to inspect the file first",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEngineIngestAssemblePreservesParts(t *testing.T) {
|
||||
eng := newTestEngine(t)
|
||||
ctx := context.Background()
|
||||
|
|
@ -514,6 +616,216 @@ func TestEngineBootstrapIdempotent(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestBootstrapRepairsMissingReasoningContent(t *testing.T) {
|
||||
eng := newTestEngine(t)
|
||||
ctx := context.Background()
|
||||
sessionKey := "agent:repair-reasoning"
|
||||
|
||||
conv, err := eng.store.GetOrCreateConversation(ctx, sessionKey)
|
||||
if err != nil {
|
||||
t.Fatalf("GetOrCreateConversation: %v", err)
|
||||
}
|
||||
|
||||
userMsg, err := eng.store.AddMessage(ctx, conv.ConversationID, "user", "hello", 3)
|
||||
if err != nil {
|
||||
t.Fatalf("AddMessage user: %v", err)
|
||||
}
|
||||
|
||||
assistantMsg, err := eng.store.AddMessage(ctx, conv.ConversationID, "assistant", "world", 3)
|
||||
if err != nil {
|
||||
t.Fatalf("AddMessage assistant: %v", err)
|
||||
}
|
||||
|
||||
err = eng.store.AppendContextMessages(
|
||||
ctx,
|
||||
conv.ConversationID,
|
||||
[]int64{userMsg.ID, assistantMsg.ID},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("AppendContextMessages: %v", err)
|
||||
}
|
||||
|
||||
err = eng.Bootstrap(ctx, sessionKey, []Message{
|
||||
{Role: "user", Content: "hello", TokenCount: 3},
|
||||
{Role: "assistant", Content: "world", ReasoningContent: "let me think this through", TokenCount: 3},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Bootstrap: %v", err)
|
||||
}
|
||||
|
||||
stored, err := eng.store.GetMessages(ctx, conv.ConversationID, 10, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("GetMessages: %v", err)
|
||||
}
|
||||
if len(stored) != 2 {
|
||||
t.Fatalf("stored messages = %d, want 2", len(stored))
|
||||
}
|
||||
if stored[1].ReasoningContent != "let me think this through" {
|
||||
t.Errorf(
|
||||
"stored[1].ReasoningContent = %q, want %q",
|
||||
stored[1].ReasoningContent,
|
||||
"let me think this through",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootstrapRepairsMissingReasoningContentWithoutDroppingSummaries(t *testing.T) {
|
||||
eng := newTestEngine(t)
|
||||
ctx := context.Background()
|
||||
sessionKey := "agent:repair-reasoning-summary"
|
||||
|
||||
conv, err := eng.store.GetOrCreateConversation(ctx, sessionKey)
|
||||
if err != nil {
|
||||
t.Fatalf("GetOrCreateConversation: %v", err)
|
||||
}
|
||||
|
||||
userMsg, err := eng.store.AddMessage(ctx, conv.ConversationID, "user", "hello", 3)
|
||||
if err != nil {
|
||||
t.Fatalf("AddMessage user: %v", err)
|
||||
}
|
||||
assistantMsg, err := eng.store.AddMessage(ctx, conv.ConversationID, "assistant", "world", 3)
|
||||
if err != nil {
|
||||
t.Fatalf("AddMessage assistant: %v", err)
|
||||
}
|
||||
|
||||
err = eng.store.AppendContextMessages(
|
||||
ctx,
|
||||
conv.ConversationID,
|
||||
[]int64{userMsg.ID, assistantMsg.ID},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("AppendContextMessages: %v", err)
|
||||
}
|
||||
|
||||
summary, err := eng.store.CreateSummary(ctx, CreateSummaryInput{
|
||||
ConversationID: conv.ConversationID,
|
||||
Kind: SummaryKindLeaf,
|
||||
Depth: 0,
|
||||
Content: "summary before repair",
|
||||
TokenCount: 10,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSummary: %v", err)
|
||||
}
|
||||
|
||||
err = eng.store.AppendContextSummary(ctx, conv.ConversationID, summary.SummaryID)
|
||||
if err != nil {
|
||||
t.Fatalf("AppendContextSummary: %v", err)
|
||||
}
|
||||
|
||||
err = eng.Bootstrap(ctx, sessionKey, []Message{
|
||||
{Role: "user", Content: "hello", TokenCount: 3},
|
||||
{Role: "assistant", Content: "world", ReasoningContent: "let me think this through", TokenCount: 3},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Bootstrap: %v", err)
|
||||
}
|
||||
|
||||
stored, err := eng.store.GetMessages(ctx, conv.ConversationID, 10, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("GetMessages: %v", err)
|
||||
}
|
||||
if len(stored) != 2 {
|
||||
t.Fatalf("stored messages = %d, want 2", len(stored))
|
||||
}
|
||||
if stored[1].ReasoningContent != "let me think this through" {
|
||||
t.Errorf(
|
||||
"stored[1].ReasoningContent = %q, want %q",
|
||||
stored[1].ReasoningContent,
|
||||
"let me think this through",
|
||||
)
|
||||
}
|
||||
|
||||
summaries, err := eng.store.GetSummariesByConversation(ctx, conv.ConversationID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetSummariesByConversation: %v", err)
|
||||
}
|
||||
if len(summaries) != 1 {
|
||||
t.Fatalf("summaries = %d, want 1", len(summaries))
|
||||
}
|
||||
if summaries[0].SummaryID != summary.SummaryID {
|
||||
t.Errorf("SummaryID = %q, want %q", summaries[0].SummaryID, summary.SummaryID)
|
||||
}
|
||||
|
||||
items, err := eng.store.GetContextItems(ctx, conv.ConversationID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetContextItems: %v", err)
|
||||
}
|
||||
if len(items) != 3 {
|
||||
t.Fatalf("context items = %d, want 3", len(items))
|
||||
}
|
||||
if items[2].ItemType != "summary" || items[2].SummaryID != summary.SummaryID {
|
||||
t.Errorf("summary context item = %+v, want summary %q", items[2], summary.SummaryID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootstrapRepairsMissingReasoningContentOnPrefixBeforeAppendingDelta(t *testing.T) {
|
||||
eng := newTestEngine(t)
|
||||
ctx := context.Background()
|
||||
sessionKey := "agent:repair-reasoning-prefix"
|
||||
|
||||
conv, err := eng.store.GetOrCreateConversation(ctx, sessionKey)
|
||||
if err != nil {
|
||||
t.Fatalf("GetOrCreateConversation: %v", err)
|
||||
}
|
||||
|
||||
userMsg, err := eng.store.AddMessage(ctx, conv.ConversationID, "user", "hello", 3)
|
||||
if err != nil {
|
||||
t.Fatalf("AddMessage user: %v", err)
|
||||
}
|
||||
assistantMsg, err := eng.store.AddMessage(ctx, conv.ConversationID, "assistant", "world", 3)
|
||||
if err != nil {
|
||||
t.Fatalf("AddMessage assistant: %v", err)
|
||||
}
|
||||
|
||||
err = eng.store.AppendContextMessages(
|
||||
ctx,
|
||||
conv.ConversationID,
|
||||
[]int64{userMsg.ID, assistantMsg.ID},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("AppendContextMessages: %v", err)
|
||||
}
|
||||
|
||||
err = eng.Bootstrap(ctx, sessionKey, []Message{
|
||||
{Role: "user", Content: "hello", TokenCount: 3},
|
||||
{Role: "assistant", Content: "world", ReasoningContent: "let me think this through", TokenCount: 3},
|
||||
{Role: "user", Content: "follow-up", TokenCount: 2},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Bootstrap: %v", err)
|
||||
}
|
||||
|
||||
stored, err := eng.store.GetMessages(ctx, conv.ConversationID, 10, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("GetMessages: %v", err)
|
||||
}
|
||||
if len(stored) != 3 {
|
||||
t.Fatalf("stored messages = %d, want 3", len(stored))
|
||||
}
|
||||
if stored[1].ReasoningContent != "let me think this through" {
|
||||
t.Errorf(
|
||||
"stored[1].ReasoningContent = %q, want %q",
|
||||
stored[1].ReasoningContent,
|
||||
"let me think this through",
|
||||
)
|
||||
}
|
||||
if stored[2].Content != "follow-up" {
|
||||
t.Errorf("stored[2].Content = %q, want %q", stored[2].Content, "follow-up")
|
||||
}
|
||||
|
||||
items, err := eng.store.GetContextItems(ctx, conv.ConversationID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetContextItems: %v", err)
|
||||
}
|
||||
if len(items) != 3 {
|
||||
t.Fatalf("context items = %d, want 3", len(items))
|
||||
}
|
||||
if items[2].ItemType != "message" || items[2].MessageID != stored[2].ID {
|
||||
t.Errorf("last context item = %+v, want appended message %d", items[2], stored[2].ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEngineBootstrapDelta(t *testing.T) {
|
||||
eng := newTestEngine(t)
|
||||
ctx := context.Background()
|
||||
|
|
|
|||
|
|
@ -162,20 +162,31 @@ func (s *Store) getMessageTimeRange(ctx context.Context, convID int64) (time.Tim
|
|||
|
||||
// AddMessage appends a message to a conversation.
|
||||
func (s *Store) AddMessage(ctx context.Context, convID int64, role, content string, tokenCount int) (*Message, error) {
|
||||
return s.AddMessageWithReasoning(ctx, convID, role, content, "", tokenCount)
|
||||
}
|
||||
|
||||
// AddMessageWithReasoning appends a message with reasoning content to a conversation.
|
||||
func (s *Store) AddMessageWithReasoning(
|
||||
ctx context.Context,
|
||||
convID int64,
|
||||
role, content, reasoningContent string,
|
||||
tokenCount int,
|
||||
) (*Message, error) {
|
||||
result, err := s.db.ExecContext(ctx,
|
||||
"INSERT INTO messages (conversation_id, role, content, token_count) VALUES (?, ?, ?, ?)",
|
||||
convID, role, content, tokenCount,
|
||||
"INSERT INTO messages (conversation_id, role, content, reasoning_content, token_count) VALUES (?, ?, ?, ?, ?)",
|
||||
convID, role, content, reasoningContent, tokenCount,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("add message: %w", err)
|
||||
}
|
||||
id, _ := result.LastInsertId()
|
||||
return &Message{
|
||||
ID: id,
|
||||
ConversationID: convID,
|
||||
Role: role,
|
||||
Content: content,
|
||||
TokenCount: tokenCount,
|
||||
ID: id,
|
||||
ConversationID: convID,
|
||||
Role: role,
|
||||
Content: content,
|
||||
ReasoningContent: reasoningContent,
|
||||
TokenCount: tokenCount,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
|
@ -212,6 +223,18 @@ func (s *Store) AddMessageWithParts(
|
|||
role string,
|
||||
parts []MessagePart,
|
||||
tokenCount int,
|
||||
) (*Message, error) {
|
||||
return s.AddMessageWithPartsAndReasoning(ctx, convID, role, parts, "", tokenCount)
|
||||
}
|
||||
|
||||
// AddMessageWithPartsAndReasoning adds a message with structured parts and reasoning content.
|
||||
func (s *Store) AddMessageWithPartsAndReasoning(
|
||||
ctx context.Context,
|
||||
convID int64,
|
||||
role string,
|
||||
parts []MessagePart,
|
||||
reasoningContent string,
|
||||
tokenCount int,
|
||||
) (*Message, error) {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
|
|
@ -223,8 +246,8 @@ func (s *Store) AddMessageWithParts(
|
|||
readableContent := partsToReadableContent(parts)
|
||||
|
||||
result, err := tx.ExecContext(ctx,
|
||||
"INSERT INTO messages (conversation_id, role, content, token_count) VALUES (?, ?, ?, ?)",
|
||||
convID, role, readableContent, tokenCount,
|
||||
"INSERT INTO messages (conversation_id, role, content, reasoning_content, token_count) VALUES (?, ?, ?, ?, ?)",
|
||||
convID, role, readableContent, reasoningContent, tokenCount,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("add message: %w", err)
|
||||
|
|
@ -256,11 +279,12 @@ func (s *Store) AddMessageWithParts(
|
|||
|
||||
// Return message with parts
|
||||
msg := &Message{
|
||||
ID: msgID,
|
||||
ConversationID: convID,
|
||||
Role: role,
|
||||
TokenCount: tokenCount,
|
||||
Parts: make([]MessagePart, len(parts)),
|
||||
ID: msgID,
|
||||
ConversationID: convID,
|
||||
Role: role,
|
||||
ReasoningContent: reasoningContent,
|
||||
TokenCount: tokenCount,
|
||||
Parts: make([]MessagePart, len(parts)),
|
||||
}
|
||||
for i, p := range parts {
|
||||
p.MessageID = msgID
|
||||
|
|
@ -271,7 +295,7 @@ func (s *Store) AddMessageWithParts(
|
|||
|
||||
// GetMessages retrieves messages for a conversation.
|
||||
func (s *Store) GetMessages(ctx context.Context, convID int64, limit int, beforeID int64) ([]Message, error) {
|
||||
query := "SELECT message_id, conversation_id, role, content, token_count, created_at FROM messages WHERE conversation_id = ?"
|
||||
query := "SELECT message_id, conversation_id, role, content, reasoning_content, token_count, created_at FROM messages WHERE conversation_id = ?"
|
||||
args := []any{convID}
|
||||
if beforeID > 0 {
|
||||
query += " AND message_id < ?"
|
||||
|
|
@ -298,6 +322,7 @@ func (s *Store) GetMessages(ctx context.Context, convID int64, limit int, before
|
|||
&msg.ConversationID,
|
||||
&msg.Role,
|
||||
&msg.Content,
|
||||
&msg.ReasoningContent,
|
||||
&msg.TokenCount,
|
||||
&createdAt,
|
||||
); err != nil {
|
||||
|
|
@ -335,10 +360,11 @@ func (s *Store) GetMessageCount(ctx context.Context, convID int64) (int, error)
|
|||
func (s *Store) GetMessageByID(ctx context.Context, messageID int64) (*Message, error) {
|
||||
var msg Message
|
||||
var createdAt string
|
||||
err := s.db.QueryRowContext(ctx,
|
||||
"SELECT message_id, conversation_id, role, content, token_count, created_at FROM messages WHERE message_id = ?",
|
||||
err := s.db.QueryRowContext(
|
||||
ctx,
|
||||
"SELECT message_id, conversation_id, role, content, reasoning_content, token_count, created_at FROM messages WHERE message_id = ?",
|
||||
messageID,
|
||||
).Scan(&msg.ID, &msg.ConversationID, &msg.Role, &msg.Content, &msg.TokenCount, &createdAt)
|
||||
).Scan(&msg.ID, &msg.ConversationID, &msg.Role, &msg.Content, &msg.ReasoningContent, &msg.TokenCount, &createdAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, fmt.Errorf("message %d not found", messageID)
|
||||
}
|
||||
|
|
@ -350,6 +376,28 @@ func (s *Store) GetMessageByID(ctx context.Context, messageID int64) (*Message,
|
|||
return &msg, nil
|
||||
}
|
||||
|
||||
// UpdateMessageReasoningContent updates reasoning_content for an existing message.
|
||||
func (s *Store) UpdateMessageReasoningContent(ctx context.Context, messageID int64, reasoningContent string) error {
|
||||
result, err := s.db.ExecContext(
|
||||
ctx,
|
||||
"UPDATE messages SET reasoning_content = ? WHERE message_id = ?",
|
||||
reasoningContent,
|
||||
messageID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update message reasoning_content: %w", err)
|
||||
}
|
||||
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("update message reasoning_content rows affected: %w", err)
|
||||
}
|
||||
if rowsAffected == 0 {
|
||||
return fmt.Errorf("message %d not found", messageID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) loadMessageParts(ctx context.Context, msgID int64) ([]MessagePart, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT part_id, message_id, type, text, name, arguments, tool_call_id, media_uri, mime_type
|
||||
|
|
@ -534,7 +582,7 @@ func (s *Store) LinkSummaryToMessages(ctx context.Context, summaryID string, mes
|
|||
// GetSummarySourceMessages retrieves source messages for a summary.
|
||||
func (s *Store) GetSummarySourceMessages(ctx context.Context, summaryID string) ([]Message, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT m.message_id, m.conversation_id, m.role, m.content, m.token_count, m.created_at
|
||||
`SELECT m.message_id, m.conversation_id, m.role, m.content, m.reasoning_content, m.token_count, m.created_at
|
||||
FROM summary_messages sm
|
||||
JOIN messages m ON m.message_id = sm.message_id
|
||||
WHERE sm.summary_id = ?
|
||||
|
|
@ -555,6 +603,7 @@ func (s *Store) GetSummarySourceMessages(ctx context.Context, summaryID string)
|
|||
&msg.ConversationID,
|
||||
&msg.Role,
|
||||
&msg.Content,
|
||||
&msg.ReasoningContent,
|
||||
&msg.TokenCount,
|
||||
&createdAt,
|
||||
); err != nil {
|
||||
|
|
|
|||
|
|
@ -199,6 +199,47 @@ func TestStoreAddAndGetMessages(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestStoreAddAndGetMessagesWithReasoningContent(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
conv, _ := s.GetOrCreateConversation(ctx, "agent:reasoning")
|
||||
|
||||
msg, err := s.AddMessageWithReasoning(
|
||||
ctx,
|
||||
conv.ConversationID,
|
||||
"assistant",
|
||||
"hello world",
|
||||
"let me think",
|
||||
5,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("AddMessageWithReasoning: %v", err)
|
||||
}
|
||||
if msg.ReasoningContent != "let me think" {
|
||||
t.Fatalf("ReasoningContent = %q, want %q", msg.ReasoningContent, "let me think")
|
||||
}
|
||||
|
||||
msgs, err := s.GetMessages(ctx, conv.ConversationID, 10, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("GetMessages: %v", err)
|
||||
}
|
||||
if len(msgs) != 1 {
|
||||
t.Fatalf("got %d messages, want 1", len(msgs))
|
||||
}
|
||||
if msgs[0].ReasoningContent != "let me think" {
|
||||
t.Errorf("ReasoningContent = %q, want %q", msgs[0].ReasoningContent, "let me think")
|
||||
}
|
||||
|
||||
found, err := s.GetMessageByID(ctx, msg.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetMessageByID: %v", err)
|
||||
}
|
||||
if found.ReasoningContent != "let me think" {
|
||||
t.Errorf("GetMessageByID ReasoningContent = %q, want %q", found.ReasoningContent, "let me think")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreAddMessageWithParts(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
|
@ -233,6 +274,43 @@ func TestStoreAddMessageWithParts(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestStoreAddMessageWithPartsAndReasoningContent(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
conv, _ := s.GetOrCreateConversation(ctx, "agent:parts-reasoning")
|
||||
|
||||
parts := []MessagePart{
|
||||
{Type: "tool_use", Name: "read_file", Arguments: `{"path":"/tmp/test"}`, ToolCallID: "tc_123"},
|
||||
}
|
||||
_, err := s.AddMessageWithPartsAndReasoning(
|
||||
ctx,
|
||||
conv.ConversationID,
|
||||
"assistant",
|
||||
parts,
|
||||
"need to inspect the file first",
|
||||
10,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("AddMessageWithPartsAndReasoning: %v", err)
|
||||
}
|
||||
|
||||
msgs, err := s.GetMessages(ctx, conv.ConversationID, 10, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("GetMessages: %v", err)
|
||||
}
|
||||
if len(msgs) != 1 {
|
||||
t.Fatalf("expected 1 message, got %d", len(msgs))
|
||||
}
|
||||
if msgs[0].ReasoningContent != "need to inspect the file first" {
|
||||
t.Errorf(
|
||||
"ReasoningContent = %q, want %q",
|
||||
msgs[0].ReasoningContent,
|
||||
"need to inspect the file first",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreGetMessageCount(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
|
@ -275,6 +353,31 @@ func TestStoreGetMessageByID(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestStoreUpdateMessageReasoningContent(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
conv, _ := s.GetOrCreateConversation(ctx, "agent:update-reasoning")
|
||||
|
||||
msg, err := s.AddMessage(ctx, conv.ConversationID, "assistant", "answer", 3)
|
||||
if err != nil {
|
||||
t.Fatalf("AddMessage: %v", err)
|
||||
}
|
||||
|
||||
err = s.UpdateMessageReasoningContent(ctx, msg.ID, "thinking")
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateMessageReasoningContent: %v", err)
|
||||
}
|
||||
|
||||
found, err := s.GetMessageByID(ctx, msg.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetMessageByID: %v", err)
|
||||
}
|
||||
if found.ReasoningContent != "thinking" {
|
||||
t.Errorf("ReasoningContent = %q, want %q", found.ReasoningContent, "thinking")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Summary Operations ---
|
||||
|
||||
func TestStoreCreateAndGetSummary(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -147,10 +147,10 @@ func (t *LoadImageTool) Execute(ctx context.Context, args map[string]any) *ToolR
|
|||
return ErrorResult(fmt.Sprintf("failed to register image in media store: %v", err))
|
||||
}
|
||||
|
||||
// Build the tool result text. The media:// ref will be picked up by
|
||||
// resolveMediaRefs in loop_media.go and converted to a base64 data URL
|
||||
// before the next LLM call, exactly like channel-received images.
|
||||
msg := fmt.Sprintf("Image loaded: %s\n[image: %s]", filename, ref)
|
||||
// Build the tool result text. The media:// ref in Media will be picked
|
||||
// up by resolveMediaRefs in agent_media.go and base64-encoded for tool
|
||||
// result messages (role="tool"), so the LLM can see the image content.
|
||||
msg := fmt.Sprintf("Image loaded: %s\n[image: photo]", filename)
|
||||
|
||||
return &ToolResult{
|
||||
ForLLM: msg,
|
||||
|
|
|
|||
|
|
@ -135,9 +135,10 @@ func TestLoadImage_SuccessPath(t *testing.T) {
|
|||
t.Errorf("expected ForLLM to contain '[image:' marker, got: %s", result.ForLLM)
|
||||
}
|
||||
|
||||
// 4. ForLLM should also contain the media:// ref
|
||||
if !strings.Contains(result.ForLLM, result.Media[0]) {
|
||||
t.Errorf("expected ForLLM to contain media ref %q, got: %s", result.Media[0], result.ForLLM)
|
||||
// 4. ForLLM should contain the generic [image: photo] placeholder
|
||||
// (resolveMediaRefs will replace it with the actual path later)
|
||||
if !strings.Contains(result.ForLLM, "[image: photo]") {
|
||||
t.Errorf("expected ForLLM to contain '[image: photo]' placeholder, got: %s", result.ForLLM)
|
||||
}
|
||||
|
||||
// 5. Verify the ref is resolvable in the store
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue