merge: sync dev_features with origin/main

Brings in MCP tool discovery (BM25/Regex search tools, RegisterHidden),
binary file read protection (64KB limit, offset/length pagination),
modular web frontend/backend, minimax provider, macOS notarization,
linux/s390x+mipsle builds, and various other upstream improvements.

Conflict resolutions:
- pkg/commands/{runtime,cmd_clear,builtin}.go: keep dev_features versions
  (richer Runtime with ClearSession/CompactSession/cmd-mode callbacks)
- pkg/tools/filesystem.go: adopt remote binary protection + pagination,
  keep exported ValidatePath (referenced by loop.go, shell.go, send_file.go)
- pkg/agent/loop.go: adopt remote *processOptions signature for
  handleCommand/buildCommandsRuntime; preserve cmd-mode dispatch,
  WorkingDir injection, and session-mode constants

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
seagochen 2026-03-10 10:31:30 +08:00
commit 704a27c279
188 changed files with 26311 additions and 4504 deletions

View file

@ -9,67 +9,78 @@ permissions:
contents: read contents: read
jobs: jobs:
generate-version: create-tag:
name: Generate Version name: Create Git Tag
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: write
outputs: outputs:
version: ${{ steps.version.outputs.version }} version: ${{ steps.version.outputs.version }}
tag: ${{ steps.version.outputs.tag }} tag: ${{ steps.version.outputs.tag }}
changelog: ${{ steps.version.outputs.changelog }}
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v6 uses: actions/checkout@v6
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Generate version - name: Generate and push tag
id: version id: version
run: | run: |
DATE=$(date -u +%Y%m%d) DATE=$(date -u +%Y%m%d)
SHA=$(git rev-parse --short=8 HEAD) SHA=$(git rev-parse --short=8 HEAD)
BASE_VERSION=$(git describe --tags --match "v*" --exclude "*nightly*" --abbrev=0 2>/dev/null || true) 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 if [ -z "$BASE_VERSION" ] || [ "$BASE_VERSION" = "v0.0.0" ]; then
VERSION="nightly-${DATE}-${SHA}" TAG="v0.0.0-nightly.${DATE}.${SHA}"
else else
VERSION="${BASE_VERSION}-nightly-${DATE}-${SHA}" TAG="${BASE_VERSION}-nightly.${DATE}.${SHA}"
fi fi
TAG="nightly-${DATE}-${SHA}" VERSION=$TAG
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
if git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then
echo "Tag $TAG already exists, reusing existing tag"
else
git tag -a "$TAG" -m "Nightly build $VERSION"
fi
git push origin "$TAG"
COMPARE_URL="https://github.com/${{ github.repository }}/commits/${TAG}"
if [ -n "$BASE_VERSION" ] && [ "$BASE_VERSION" != "v0.0.0" ]; then
COMPARE_URL="https://github.com/${{ github.repository }}/compare/${BASE_VERSION}...${TAG}"
fi
echo "changelog=**Full Changelog**: $COMPARE_URL" >> "$GITHUB_OUTPUT"
echo "version=${VERSION}" >> "$GITHUB_OUTPUT" echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "tag=${TAG}" >> "$GITHUB_OUTPUT" echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
build: release:
name: Build name: GoReleaser Release
needs: create-tag
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: generate-version permissions:
contents: write
packages: write
steps: steps:
- name: Checkout - name: Checkout tag
uses: actions/checkout@v6 uses: actions/checkout@v6
with:
fetch-depth: 0
ref: ${{ needs.create-tag.outputs.tag }}
- name: Setup Go - name: Setup Go from go.mod
id: setup-go
uses: actions/setup-go@v6 uses: actions/setup-go@v6
with: with:
go-version-file: go.mod go-version-file: go.mod
- name: Build - name: Setup Node.js
env: uses: actions/setup-node@v4
VERSION: ${{ needs.generate-version.outputs.version }}
run: make build-all VERSION="$VERSION"
- name: Upload artifacts
uses: actions/upload-artifact@v6
with: with:
name: picoclaw-binaries node-version: 22
path: build
build-docker: - name: Setup pnpm
name: Build Docker run: corepack enable && corepack prepare pnpm@latest --activate
runs-on: ubuntu-latest
needs: generate-version
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v3 uses: docker/setup-qemu-action@v3
@ -77,80 +88,79 @@ jobs:
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
- name: Log in to GHCR - name: Login to GitHub Container Registry
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract Docker metadata - name: Run GoReleaser
id: meta uses: goreleaser/goreleaser-action@v6
uses: docker/metadata-action@v5
with: with:
images: ghcr.io/${{ github.repository }} distribution: goreleaser
tags: | version: ~> v2
type=raw,value=${{ needs.generate-version.outputs.tag }} args: release --clean
type=raw,value=${{ needs.generate-version.outputs.version }} env:
type=raw,value=nightly GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_REPOSITORY_OWNER: ${{ github.repository_owner }}
DOCKERHUB_IMAGE_NAME: ${{ vars.DOCKERHUB_REPOSITORY }}
GOVERSION: ${{ steps.setup-go.outputs.go-version }}
NIGHTLY_BUILD: "true"
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 }}
- name: Build and push update-rolling:
uses: docker/build-push-action@v6 name: Update Rolling Nightly
with: needs: [create-tag, release]
context: .
file: ./docker/Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
platforms: linux/amd64,linux/arm64,linux/riscv64
provenance: false
release:
name: Release
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [generate-version, build, build-docker]
permissions: permissions:
contents: write contents: write
packages: write
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v6 uses: actions/checkout@v6
- name: Download artifacts - name: Update nightly release
uses: actions/download-artifact@v6
with:
name: picoclaw-binaries
path: ./build
- name: Create release
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ needs.create-tag.outputs.tag }}
TITLE: ${{ needs.create-tag.outputs.version }}
run: | run: |
TAG="${{ needs.generate-version.outputs.tag }}" CHANGELOG='${{ needs.create-tag.outputs.changelog }}'
TITLE="${{ needs.generate-version.outputs.version }}" NOTES=$(cat <<EOF
NOTES=$'Nightly build for **${{ needs.generate-version.outputs.version }}**\n\nThis is an automated build and may be unstable. Use with caution.' Nightly build for **${TITLE}**
This is an automated build and may be unstable. Use with caution.
${CHANGELOG}
EOF
)
# Download assets from the newly created release if it exists,
# otherwise fall back to using locally built dist/ artifacts.
mkdir -p build
if gh release view "$TAG" >/dev/null 2>&1; then if gh release view "$TAG" >/dev/null 2>&1; then
echo "Release $TAG already exists, updating metadata and assets..." echo "Downloading assets from GitHub release for $TAG..."
gh release edit "$TAG" \ gh release download "$TAG" --dir build
--title "$TITLE" \
--notes "$NOTES" \
--prerelease
gh release upload "$TAG" build/* --clobber
else else
echo "Creating new release $TAG..." echo "GitHub release for $TAG not found; falling back to local dist/ artifacts..."
gh release create "$TAG" \ if [ -d "dist" ]; then
--title "$TITLE" \ cp -R dist/* build/
--notes "$NOTES" \ else
--target "${{ github.sha }}" \ echo "Error: no GitHub release for $TAG and no local dist/ directory found." >&2
--prerelease \ exit 1
build/* fi
fi fi
echo "Updating rolling 'nightly' release..." # Delete existing nightly release and tag to avoid conflicts
gh release delete nightly --cleanup-tag -y >/dev/null 2>&1 || true echo "Deleting existing nightly release and tag..."
sleep 2 gh release delete nightly --cleanup-tag -y || true
git push origin :refs/tags/nightly || true
gh release create nightly \ gh release create nightly \
--title "Nightly Build" \ --title "Nightly Build" \
--notes "$NOTES" \ --notes "$NOTES" \
@ -159,9 +169,36 @@ jobs:
build/* build/*
echo "Cleaning up old nightly releases (keeping only the most recent)..." echo "Cleaning up old nightly releases (keeping only the most recent)..."
gh release list --limit 100 --json tagName -q '.[].tagName | select(startswith("nightly-"))' | tail -n +2 | while read -r old_tag; do gh release list --limit 100 --json tagName -q '.[].tagName | select(contains("-nightly."))' | tail -n +2 | while read -r old_tag; do
if [ -n "$old_tag" ] && [ "$old_tag" != "$TAG" ]; then if [ -n "$old_tag" ] && [ "$old_tag" != "$TAG" ]; then
echo "Deleting old nightly release: $old_tag" echo "Deleting old nightly release: $old_tag"
gh release delete "$old_tag" --cleanup-tag -y || true gh release delete "$old_tag" --cleanup-tag -y || true
fi fi
done done
echo "Cleaning up old 'vX.X.X-nightly...' Docker images on GHCR..."
OWNER="${{ github.repository_owner }}"
PACKAGE_NAME="${{ github.event.repository.name }}"
# Check if owner is an organization or user
ORG_TEST=$(gh api -H "Accept: application/vnd.github+json" /orgs/$OWNER 2>/dev/null || true)
if echo "$ORG_TEST" | grep -q '"login"'; then
ACCOUNT_TYPE="orgs"
else
ACCOUNT_TYPE="users"
fi
PACKAGE_URL="/${ACCOUNT_TYPE}/${OWNER}/packages/container/${PACKAGE_NAME}/versions"
OLD_NIGHTLY_VERSIONS=$(gh api --paginate -H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2022-11-28" \
"$PACKAGE_URL" \
--jq ". | map(select(any(.metadata.container.tags[]; contains(\"-nightly.\") and (. != \"nightly\") and (. != \"$TAG\")))) | .[].id" 2>/dev/null || true)
for version_id in $OLD_NIGHTLY_VERSIONS; do
if [ -n "$version_id" ]; then
echo "Deleting Docker image version ID: $version_id"
gh api -X DELETE -H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2022-11-28" \
"/${ACCOUNT_TYPE}/${OWNER}/packages/container/${PACKAGE_NAME}/versions/$version_id" || true
fi
done

View file

@ -65,6 +65,14 @@ jobs:
with: with:
go-version-file: go.mod go-version-file: go.mod
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
- name: Setup pnpm
run: corepack enable && corepack prepare pnpm@latest --activate
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v3 uses: docker/setup-qemu-action@v3
@ -96,6 +104,11 @@ jobs:
GITHUB_REPOSITORY_OWNER: ${{ github.repository_owner }} GITHUB_REPOSITORY_OWNER: ${{ github.repository_owner }}
DOCKERHUB_IMAGE_NAME: ${{ vars.DOCKERHUB_REPOSITORY }} DOCKERHUB_IMAGE_NAME: ${{ vars.DOCKERHUB_REPOSITORY }}
GOVERSION: ${{ steps.setup-go.outputs.go-version }} GOVERSION: ${{ steps.setup-go.outputs.go-version }}
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 }}
- name: Apply release flags - name: Apply release flags
shell: bash shell: bash

6
.gitignore vendored
View file

@ -51,6 +51,12 @@ docs/plans/
# Added by goreleaser init: # Added by goreleaser init:
dist/ dist/
*.vite/
# Windows Application Icon/Resource # Windows Application Icon/Resource
*.syso *.syso
# Keep embedded backend dist directory placeholder in VCS
!web/backend/dist/
web/backend/dist/*
!web/backend/dist/.gitkeep

View file

@ -6,8 +6,9 @@ before:
hooks: hooks:
- go mod tidy - go mod tidy
- go generate ./... - go generate ./...
- sh -c 'cd web/frontend && pnpm install && pnpm build:backend'
- go install github.com/tc-hib/go-winres@latest - go install github.com/tc-hib/go-winres@latest
- go-winres make --in cmd/picoclaw-launcher/winres/winres.json --out cmd/picoclaw-launcher/rsrc --product-version={{ .Version }} --file-version={{ .Version }} - go-winres make --in web/backend/winres/winres.json --out web/backend/rsrc --product-version={{ .Version }} --file-version={{ .Version }}
builds: builds:
- id: picoclaw - id: picoclaw
@ -70,7 +71,7 @@ builds:
- "7" - "7"
gomips: gomips:
- softfloat - softfloat
main: ./cmd/picoclaw-launcher main: ./web/backend
ignore: ignore:
- goos: windows - goos: windows
goarch: arm goarch: arm
@ -115,15 +116,32 @@ dockers_v2:
- picoclaw - picoclaw
images: images:
- "ghcr.io/{{ .Env.GITHUB_REPOSITORY_OWNER }}/picoclaw" - "ghcr.io/{{ .Env.GITHUB_REPOSITORY_OWNER }}/picoclaw"
- "docker.io/{{ .Env.DOCKERHUB_IMAGE_NAME }}" - '{{ if not (isEnvSet "NIGHTLY_BUILD") }}docker.io/{{ .Env.DOCKERHUB_IMAGE_NAME }}{{ end }}'
tags: tags:
- "{{ .Tag }}" - "{{ .Tag }}"
- "latest" - '{{ if isEnvSet "NIGHTLY_BUILD" }}nightly{{ else }}latest{{ end }}'
platforms: platforms:
- linux/amd64 - linux/amd64
- linux/arm64 - linux/arm64
- linux/riscv64 - linux/riscv64
notarize:
macos:
- enabled: '{{ isEnvSet "MACOS_SIGN_P12" }}'
ids:
- picoclaw
- picoclaw-launcher
- picoclaw-launcher-tui
sign:
certificate: "{{.Env.MACOS_SIGN_P12}}"
password: "{{.Env.MACOS_SIGN_PASSWORD}}"
notarize:
issuer_id: "{{.Env.MACOS_NOTARY_ISSUER_ID}}"
key_id: "{{.Env.MACOS_NOTARY_KEY_ID}}"
key: "{{.Env.MACOS_NOTARY_KEY}}"
wait: true
timeout: 20m
archives: archives:
- formats: [tar.gz] - formats: [tar.gz]
# this name template makes the OS and Arch compatible with the results of `uname`. # this name template makes the OS and Arch compatible with the results of `uname`.
@ -141,7 +159,7 @@ archives:
nfpms: nfpms:
- id: picoclaw - id: picoclaw
builds: ids:
- picoclaw - picoclaw
- picoclaw-launcher - picoclaw-launcher
- picoclaw-launcher-tui - picoclaw-launcher-tui
@ -161,6 +179,11 @@ nfpms:
- rpm - rpm
- deb - deb
bindir: /usr/bin bindir: /usr/bin
contents:
- src: web/picoclaw-launcher.desktop
dst: /usr/share/applications/picoclaw-launcher.desktop
- src: web/picoclaw-launcher.png
dst: /usr/share/icons/hicolor/512x512/apps/picoclaw-launcher.png
changelog: changelog:
sort: asc sort: asc

View file

@ -111,6 +111,18 @@ build: generate
@echo "Build complete: $(BINARY_PATH)" @echo "Build complete: $(BINARY_PATH)"
@ln -sf $(BINARY_NAME)-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/$(BINARY_NAME) @ln -sf $(BINARY_NAME)-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/$(BINARY_NAME)
## build-launcher: Build the picoclaw-launcher (web console) binary
build-launcher:
@echo "Building picoclaw-launcher for $(PLATFORM)/$(ARCH)..."
@mkdir -p $(BUILD_DIR)
@if [ ! -f web/backend/dist/index.html ]; then \
echo "Building frontend..."; \
cd web/frontend && pnpm install && pnpm build:backend; \
fi
@$(GO) build $(GOFLAGS) -o $(BUILD_DIR)/picoclaw-launcher-$(PLATFORM)-$(ARCH) ./web/backend
@ln -sf picoclaw-launcher-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/picoclaw-launcher
@echo "Build complete: $(BUILD_DIR)/picoclaw-launcher"
## build-whatsapp-native: Build with WhatsApp native (whatsmeow) support; larger binary ## build-whatsapp-native: Build with WhatsApp native (whatsmeow) support; larger binary
build-whatsapp-native: generate build-whatsapp-native: generate
## @echo "Building $(BINARY_NAME) with WhatsApp native for $(PLATFORM)/$(ARCH)..." ## @echo "Building $(BINARY_NAME) with WhatsApp native for $(PLATFORM)/$(ARCH)..."

Binary file not shown.

Before

Width:  |  Height:  |  Size: 386 KiB

After

Width:  |  Height:  |  Size: 348 KiB

View file

@ -1,6 +1,7 @@
package ui package ui
import ( import (
"fmt"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
@ -67,6 +68,7 @@ func Run() error {
root := tview.NewFlex().SetDirection(tview.FlexRow) root := tview.NewFlex().SetDirection(tview.FlexRow)
root.AddItem(bannerView(), 6, 0, false) root.AddItem(bannerView(), 6, 0, false)
root.AddItem(state.pages, 0, 1, true) root.AddItem(state.pages, 0, 1, true)
root.AddItem(footerView(), 1, 0, false)
if err := state.app.SetRoot(root, true).EnableMouse(false).Run(); err != nil { if err := state.app.SetRoot(root, true).EnableMouse(false).Run(); err != nil {
return err return err
@ -102,7 +104,7 @@ func (s *appState) pop() {
} }
func (s *appState) mainMenu() tview.Primitive { func (s *appState) mainMenu() tview.Primitive {
menu := NewMenu("Config Menu", nil) menu := NewMenu("Menu", nil)
refreshMainMenu(menu, s) refreshMainMenu(menu, s)
menu.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { menu.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
switch event.Key() { switch event.Key() {
@ -110,10 +112,7 @@ func (s *appState) mainMenu() tview.Primitive {
s.requestExit() s.requestExit()
return nil return nil
} }
if event.Rune() == 'q' {
s.requestExit()
return nil
}
return event return event
}) })
@ -131,6 +130,32 @@ func (s *appState) refreshMenu(name string, menu *Menu) {
} }
} }
func (s *appState) countChannels() (enabled int, total int) {
c := s.config.Channels
entries := []bool{
c.Telegram.Enabled,
c.Discord.Enabled,
c.QQ.Enabled,
c.MaixCam.Enabled,
c.WhatsApp.Enabled,
c.Feishu.Enabled,
c.DingTalk.Enabled,
c.Slack.Enabled,
c.Matrix.Enabled,
c.LINE.Enabled,
c.OneBot.Enabled,
c.WeCom.Enabled,
c.WeComApp.Enabled,
}
total = len(entries)
for _, v := range entries {
if v {
enabled++
}
}
return enabled, total
}
func refreshMainMenuIfPresent(s *appState) { func refreshMainMenuIfPresent(s *appState) {
if menu, ok := s.menus["main"]; ok { if menu, ok := s.menus["main"]; ok {
refreshMainMenu(menu, s) refreshMainMenu(menu, s)
@ -141,6 +166,7 @@ func refreshMainMenu(menu *Menu, s *appState) {
selectedModel := s.selectedModelName() selectedModel := s.selectedModelName()
modelReady := selectedModel != "" modelReady := selectedModel != ""
channelReady := s.hasEnabledChannel() channelReady := s.hasEnabledChannel()
enabledCount, totalChannels := s.countChannels()
gatewayRunning := s.gatewayCmd != nil || s.isGatewayRunning() gatewayRunning := s.gatewayCmd != nil || s.isGatewayRunning()
gatewayLabel := "Start Gateway" gatewayLabel := "Start Gateway"
@ -153,7 +179,7 @@ func refreshMainMenu(menu *Menu, s *appState) {
items := []MenuItem{ items := []MenuItem{
{ {
Label: rootModelLabel(selectedModel), Label: rootModelLabel(selectedModel),
Description: rootModelDescription(selectedModel), Description: rootModelDescription(),
Action: func() { Action: func() {
s.push("model", s.modelMenu()) s.push("model", s.modelMenu())
}, },
@ -167,7 +193,7 @@ func refreshMainMenu(menu *Menu, s *appState) {
}, },
{ {
Label: rootChannelLabel(channelReady), Label: rootChannelLabel(channelReady),
Description: rootChannelDescription(channelReady), Description: fmt.Sprintf("%d/%d enabled", enabledCount, totalChannels),
Action: func() { Action: func() {
s.push("channel", s.channelMenu()) s.push("channel", s.channelMenu())
}, },
@ -311,16 +337,13 @@ func (s *appState) selectedModelName() string {
func rootModelLabel(selected string) string { func rootModelLabel(selected string) string {
if selected == "" { if selected == "" {
return "Model (no model selected)" return "Model (None)"
} }
return "Model (" + selected + ")" return "Model (" + selected + ")"
} }
func rootModelDescription(selected string) string { func rootModelDescription() string {
if selected == "" { return "Using SPACE to choose your model"
return "no model selected"
}
return "selected"
} }
func rootChannelLabel(valid bool) string { func rootChannelLabel(valid bool) string {
@ -330,13 +353,6 @@ func rootChannelLabel(valid bool) string {
return "Channel" return "Channel"
} }
func rootChannelDescription(valid bool) string {
if !valid {
return "no channel enabled"
}
return "enabled"
}
func (s *appState) startTalk() { func (s *appState) startTalk() {
if !s.isActiveModelValid() { if !s.isActiveModelValid() {
s.showMessage("Model required", "Select a valid model before starting talk") s.showMessage("Model required", "Select a valid model before starting talk")

View file

@ -12,7 +12,6 @@ import (
func (s *appState) buildChannelMenuItems() []MenuItem { func (s *appState) buildChannelMenuItems() []MenuItem {
return []MenuItem{ return []MenuItem{
{Label: "Back", Description: "Return to main menu", Action: func() { s.pop() }},
channelItem( channelItem(
"Telegram", "Telegram",
"Telegram bot settings", "Telegram bot settings",
@ -101,10 +100,6 @@ func (s *appState) channelMenu() tview.Primitive {
s.pop() s.pop()
return nil return nil
} }
if event.Rune() == 'q' {
s.pop()
return nil
}
return event return event
}) })
return menu return menu

View file

@ -14,23 +14,7 @@ import (
) )
func (s *appState) modelMenu() tview.Primitive { func (s *appState) modelMenu() tview.Primitive {
items := make([]MenuItem, 0, 2+len(s.config.ModelList)) items := make([]MenuItem, 0, 1+len(s.config.ModelList))
items = append(items,
MenuItem{Label: "Back", Description: "Return to main menu", Action: func() { s.pop() }},
MenuItem{
Label: "Add model",
Description: "Append a new model entry",
Action: func() {
s.addModel(
picoclawconfig.ModelConfig{ModelName: "new-model", Model: "openai/gpt-5.2"},
)
s.push(
fmt.Sprintf("model-%d", len(s.config.ModelList)-1),
s.modelForm(len(s.config.ModelList)-1),
)
},
},
)
currentModel := strings.TrimSpace(s.config.Agents.Defaults.Model) currentModel := strings.TrimSpace(s.config.Agents.Defaults.Model)
for i := range s.config.ModelList { for i := range s.config.ModelList {
index := i index := i
@ -57,6 +41,23 @@ func (s *appState) modelMenu() tview.Primitive {
}, },
}) })
} }
// Add model entry appended at the end so the models map to rows 1..N
items = append(items,
MenuItem{
Label: "**Add model**",
Description: "Append a new model entry",
Action: func() {
newName := s.nextAvailableModelName("new-model")
s.addModel(
picoclawconfig.ModelConfig{ModelName: newName, Model: "openai/gpt-5.2"},
)
s.push(
fmt.Sprintf("model-%d", len(s.config.ModelList)-1),
s.modelForm(len(s.config.ModelList)-1),
)
},
},
)
menu := NewMenu("Models", items) menu := NewMenu("Models", items)
menu.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { menu.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
@ -64,14 +65,11 @@ func (s *appState) modelMenu() tview.Primitive {
s.pop() s.pop()
return nil return nil
} }
if event.Rune() == 'q' {
s.pop()
return nil
}
if event.Rune() == ' ' { if event.Rune() == ' ' {
row, _ := menu.GetSelection() row, _ := menu.GetSelection()
if row > 0 && row <= len(s.config.ModelList) { if row >= 0 && row < len(s.config.ModelList) {
model := s.config.ModelList[row-1] model := s.config.ModelList[row]
if !isModelValid(model) { if !isModelValid(model) {
s.showMessage( s.showMessage(
"Invalid model", "Invalid model",
@ -95,12 +93,23 @@ func (s *appState) modelForm(index int) tview.Primitive {
model := &s.config.ModelList[index] model := &s.config.ModelList[index]
form := tview.NewForm() form := tview.NewForm()
form.SetBorder(true).SetTitle(fmt.Sprintf("Model: %s", model.ModelName)) form.SetBorder(true).SetTitle(fmt.Sprintf("Model: %s", model.ModelName))
form.SetButtonBackgroundColor(tcell.NewRGBColor(80, 250, 123))
form.SetButtonTextColor(tcell.NewRGBColor(12, 13, 22))
addInput(form, "Model Name", model.ModelName, func(value string) { addInput(form, "Model Name", model.ModelName, func(value string) {
if value == "" {
s.showMessage("Invalid model name", "Model Name cannot be empty")
return
}
if s.modelNameExists(value, index) {
s.showMessage("Duplicate model name", fmt.Sprintf("Model Name '%s' already exists", value))
return
}
oldName := model.ModelName
model.ModelName = value model.ModelName = value
if s.config.Agents.Defaults.Model == oldName {
s.config.Agents.Defaults.Model = value
}
s.dirty = true s.dirty = true
form.SetTitle(fmt.Sprintf("Model: %s", model.ModelName))
refreshMainMenuIfPresent(s) refreshMainMenuIfPresent(s)
if menu, ok := s.menus["model"]; ok { if menu, ok := s.menus["model"]; ok {
refreshModelMenuFromState(menu, s) refreshModelMenuFromState(menu, s)
@ -158,7 +167,21 @@ func (s *appState) modelForm(index int) tview.Primitive {
}) })
form.AddButton("Delete", func() { form.AddButton("Delete", func() {
s.deleteModel(index) pageName := "confirm-delete-model"
if s.pages.HasPage(pageName) {
return
}
modal := tview.NewModal().
SetText("Are you sure you want to delete this model?").
AddButtons([]string{"Cancel", "Delete"}).
SetDoneFunc(func(buttonIndex int, buttonLabel string) {
s.pages.RemovePage(pageName)
if buttonLabel == "Delete" {
s.deleteModel(index)
}
})
modal.SetTitle("Confirm Delete").SetBorder(true)
s.pages.AddPage(pageName, modal, true, true)
}) })
form.AddButton("Test", func() { form.AddButton("Test", func() {
s.testModel(model) s.testModel(model)
@ -215,7 +238,7 @@ func modelStatusColor(valid bool, selected bool) *tcell.Color {
func refreshModelMenu(menu *Menu, currentModel string, models []picoclawconfig.ModelConfig) { func refreshModelMenu(menu *Menu, currentModel string, models []picoclawconfig.ModelConfig) {
for i, model := range models { for i, model := range models {
row := i + 1 row := i
label := fmt.Sprintf("%s (%s)", model.ModelName, model.Model) label := fmt.Sprintf("%s (%s)", model.ModelName, model.Model)
isValid := isModelValid(model) isValid := isModelValid(model)
if model.ModelName == currentModel && currentModel != "" { if model.ModelName == currentModel && currentModel != "" {
@ -234,23 +257,7 @@ func refreshModelMenu(menu *Menu, currentModel string, models []picoclawconfig.M
} }
func refreshModelMenuFromState(menu *Menu, s *appState) { func refreshModelMenuFromState(menu *Menu, s *appState) {
items := make([]MenuItem, 0, 2+len(s.config.ModelList)) items := make([]MenuItem, 0, 1+len(s.config.ModelList))
items = append(items,
MenuItem{Label: "Back", Description: "Return to main menu", Action: func() { s.pop() }},
MenuItem{
Label: "Add model",
Description: "Append a new model entry",
Action: func() {
s.addModel(
picoclawconfig.ModelConfig{ModelName: "new-model", Model: "openai/gpt-5.2"},
)
s.push(
fmt.Sprintf("model-%d", len(s.config.ModelList)-1),
s.modelForm(len(s.config.ModelList)-1),
)
},
},
)
currentModel := strings.TrimSpace(s.config.Agents.Defaults.Model) currentModel := strings.TrimSpace(s.config.Agents.Defaults.Model)
for i := range s.config.ModelList { for i := range s.config.ModelList {
index := i index := i
@ -277,6 +284,19 @@ func refreshModelMenuFromState(menu *Menu, s *appState) {
}, },
}) })
} }
items = append(items,
MenuItem{
Label: "**Add Model**",
Description: "Append a new model entry",
Action: func() {
newName := s.nextAvailableModelName("new-model")
s.addModel(
picoclawconfig.ModelConfig{ModelName: newName, Model: "openai/gpt-5.2"},
)
s.push(fmt.Sprintf("model-%d", len(s.config.ModelList)-1), s.modelForm(len(s.config.ModelList)-1))
},
},
)
menu.applyItems(items) menu.applyItems(items)
} }
@ -287,6 +307,38 @@ func isModelValid(model picoclawconfig.ModelConfig) bool {
return hasKey && hasModel return hasKey && hasModel
} }
func (s *appState) modelNameExists(name string, excludeIndex int) bool {
target := strings.TrimSpace(name)
if target == "" {
return false
}
for i := range s.config.ModelList {
if i == excludeIndex {
continue
}
if strings.TrimSpace(s.config.ModelList[i].ModelName) == target {
return true
}
}
return false
}
func (s *appState) nextAvailableModelName(base string) string {
name := strings.TrimSpace(base)
if name == "" {
name = "new-model"
}
if !s.modelNameExists(name, -1) {
return name
}
for i := 2; ; i++ {
candidate := fmt.Sprintf("%s-%d", name, i)
if !s.modelNameExists(candidate, -1) {
return candidate
}
}
}
func (s *appState) testModel(model *picoclawconfig.ModelConfig) { func (s *appState) testModel(model *picoclawconfig.ModelConfig) {
if model == nil { if model == nil {
return return

View file

@ -41,3 +41,15 @@ func bannerView() *tview.TextView {
text.SetBorder(false) text.SetBorder(false)
return text return text
} }
const footerText = "Esc: Back/Exit | Enter: Enter | ←↓↑→ : Move | Space: Select | Tab/Shift+Tab: Switch"
func footerView() *tview.TextView {
text := tview.NewTextView()
text.SetTextAlign(tview.AlignCenter)
text.SetText(footerText)
text.SetBackgroundColor(tview.Styles.MoreContrastBackgroundColor)
text.SetTextColor(tview.Styles.PrimaryTextColor)
text.SetBorder(false)
return text
}

View file

@ -1,290 +0,0 @@
# PicoClaw Launcher
> [!WARNING]
> This project is a temporary solution and will be refactored in the future to provide a complete web service. Therefore, the APIs in this directory are not stable.
A standalone launcher for PicoClaw, providing visual JSON editing and OAuth provider authentication management.
## Features
- 📝 **Config Editor** — Sidebar-based settings UI with model management, channel configuration forms, and a raw JSON editor
- 🤖 **Model Management** — Model card grid with availability status (grayed out without API key), primary model selection, add/edit/delete with required/optional field separation
- 📡 **Channel Configuration** — Form-based settings for 13 channel types (Telegram, Discord, Slack, Matrix, WeCom, DingTalk, Feishu, LINE, WhatsApp, QQ, OneBot, MaixCAM, etc.) with documentation links
- 🔐 **Provider Auth** — Login to OpenAI (Device Code), Anthropic (API Token), Google Antigravity (Browser OAuth)
- 🌐 **Embedded Frontend** — Compiles to a single binary with no external dependencies
- 🌍 **i18n** — Chinese/English language switching with browser auto-detection
- 🎨 **Theme** — Light / Dark / System theme toggle with localStorage persistence
## Quick Start
```bash
# Build
go build -o picoclaw-launcher ./cmd/picoclaw-launcher/
# Run with default config path (~/.picoclaw/config.json)
./picoclaw-launcher
# Specify a config file
./picoclaw-launcher ./config.json
# Allow LAN access
./picoclaw-launcher -public
```
Open `http://localhost:18800` in your browser.
## CLI Options
```
Usage: picoclaw-config [options] [config.json]
Arguments:
config.json Path to the configuration file (default: ~/.picoclaw/config.json)
Options:
-public Listen on all interfaces (0.0.0.0), allowing access from other devices
```
## API Reference
Base URL: `http://localhost:18800`
---
### Static Files
#### GET /
Serves the embedded frontend (`index.html`).
---
### Config API
#### GET /api/config
Reads the current configuration file.
**Response** `200 OK`
```json
{
"config": { ... },
"path": "/Users/xiao/.picoclaw/config.json"
}
```
---
#### PUT /api/config
Saves the configuration. The request body must be a complete Config JSON object.
**Request Body** — `application/json`
```json
{
"agents": { "defaults": { "model_name": "gpt-5.2" } },
"model_list": [
{
"model_name": "gpt-5.2",
"model": "openai/gpt-5.2",
"auth_method": "oauth"
}
]
}
```
**Response** `200 OK`
```json
{ "status": "ok" }
```
**Error** `400 Bad Request` — Invalid JSON
---
### Auth API
#### GET /api/auth/status
Returns the authentication status of all providers and any in-progress device code login.
**Response** `200 OK`
```json
{
"providers": [
{
"provider": "openai",
"auth_method": "oauth",
"status": "active",
"account_id": "user-xxx",
"expires_at": "2026-03-01T00:00:00Z"
}
],
"pending_device": {
"provider": "openai",
"status": "pending",
"device_url": "https://auth.openai.com/activate",
"user_code": "ABCD-1234"
}
}
```
`status` values: `active` | `expired` | `needs_refresh`
`pending_device` is only present when a device code login is in progress.
---
#### POST /api/auth/login
Initiates a provider login.
**Request Body** — `application/json`
```json
{ "provider": "openai" }
```
Supported `provider` values: `openai` | `anthropic` | `google-antigravity`
##### OpenAI (Device Code Flow)
Returns device code info. The server polls for completion in the background.
```json
{
"status": "pending",
"device_url": "https://auth.openai.com/activate",
"user_code": "ABCD-1234",
"message": "Open the URL and enter the code to authenticate."
}
```
The user opens `device_url` in a browser and enters `user_code`. Once authenticated, `GET /api/auth/status` will show `pending_device.status` as `success`.
##### Anthropic (API Token)
Requires a `token` field in the request:
```json
{ "provider": "anthropic", "token": "sk-ant-xxx" }
```
**Response:**
```json
{ "status": "success", "message": "Anthropic token saved" }
```
##### Google Antigravity (Browser OAuth)
Returns an authorization URL for the frontend to open in a new tab:
```json
{
"status": "redirect",
"auth_url": "https://accounts.google.com/o/oauth2/auth?...",
"message": "Open the URL to authenticate with Google."
}
```
After authentication, Google redirects to `GET /auth/callback`, which saves the credentials and redirects back to the picoclaw-config UI.
---
#### POST /api/auth/logout
Logs out from a provider.
**Request Body** — `application/json`
```json
{ "provider": "openai" }
```
Omit or leave `provider` empty to log out from all providers.
**Response** `200 OK`
```json
{ "status": "ok" }
```
---
#### GET /auth/callback
OAuth browser callback endpoint (used by Google Antigravity). Called by the OAuth provider's redirect — **not invoked directly by the frontend**.
**Query Parameters:**
- `state` — OAuth state for CSRF validation
- `code` — Authorization code
On success, redirects to `/#auth`.
### Process API
#### GET /api/process/status
Gets the running status of the `picoclaw gateway` process.
**Response** `200 OK` (Running)
```json
{
"process_status": "running",
"status": "ok",
"uptime": "1.010814s"
}
```
**Response** `200 OK` (Stopped)
```json
{
"process_status": "stopped",
"error": "Get \"http://localhost:18790/health\": dial tcp [::1]:18790: connect: connection refused"
}
```
---
#### POST /api/process/start
Starts the `picoclaw gateway` process in the background.
**Response** `200 OK`
```json
{
"status": "ok",
"pid": 12345
}
```
---
#### POST /api/process/stop
Stops the running `picoclaw gateway` process.
**Response** `200 OK`
```json
{
"status": "ok"
}
```
---
## Testing
```bash
go test -v ./cmd/picoclaw-launcher/
```

View file

@ -1,287 +0,0 @@
# PicoClaw Launcher
> [!WARNING]
> 该项目属于临时解决方案,后续会重构并提供完整的 Web 服务,因此该目录下的接口并不稳定。
PicoClaw 的独立启动器,提供可视化 JSON 配置编辑和 OAuth Provider 认证管理。
## 功能
- 📝 **配置编辑** — 侧边栏式设置 UI支持模型管理、通道配置表单和原始 JSON 编辑器
- 🤖 **模型管理** — 模型卡片网格,可用性状态显示(无 API Key 时灰色),主模型选择,增删改查,必填/选填字段分离
- 📡 **通道配置** — 12 种通道类型Telegram、Discord、Slack、企业微信、钉钉、飞书、LINE、WhatsApp、QQ、OneBot、MaixCAM 等)的表单化配置,附带文档链接
- 🔐 **Provider 认证** — 支持 OpenAI (Device Code)、Anthropic (API Token)、Google Antigravity (Browser OAuth) 登录
- 🌐 **嵌入式前端** — 编译为单一二进制文件,无需额外依赖
- 🌍 **国际化** — 中英文切换,首次访问自动检测浏览器语言
- 🎨 **主题** — 亮色 / 暗色 / 跟随系统,偏好保存在 localStorage
## 快速开始
```bash
# 编译
go build -o picoclaw-launcher ./cmd/picoclaw-launcher/
# 运行(使用默认配置路径 ~/.picoclaw/config.json
./picoclaw-launcher
# 指定配置文件
./picoclaw-launcher ./config.json
# 允许局域网访问
./picoclaw-launcher -public
```
启动后在浏览器中打开 `http://localhost:18800`
## 命令行参数
```
Usage: picoclaw-launcher [options] [config.json]
Arguments:
config.json 配置文件路径(默认: ~/.picoclaw/config.json
Options:
-public 监听所有网络接口0.0.0.0),允许局域网设备访问
```
## API 文档
Base URL: `http://localhost:18800`
### 静态文件
#### GET /
提供嵌入式前端页面(`index.html`)。
---
### Config API
#### GET /api/config
读取当前配置文件内容。
**Response** `200 OK`
```json
{
"config": { ... },
"path": "/Users/xiao/.picoclaw/config.json"
}
```
---
#### PUT /api/config
保存配置。请求体为完整的 Config JSON。
**Request Body** — `application/json`
```json
{
"agents": { "defaults": { "model_name": "gpt-5.2" } },
"model_list": [
{
"model_name": "gpt-5.2",
"model": "openai/gpt-5.2",
"auth_method": "oauth"
}
]
}
```
**Response** `200 OK`
```json
{ "status": "ok" }
```
**Error** `400 Bad Request` — 无效 JSON
---
### Auth API
#### GET /api/auth/status
获取所有 Provider 的认证状态和进行中的 Device Code 登录信息。
**Response** `200 OK`
```json
{
"providers": [
{
"provider": "openai",
"auth_method": "oauth",
"status": "active",
"account_id": "user-xxx",
"expires_at": "2026-03-01T00:00:00Z"
}
],
"pending_device": {
"provider": "openai",
"status": "pending",
"device_url": "https://auth.openai.com/activate",
"user_code": "ABCD-1234"
}
}
```
`status` 可选值: `active` | `expired` | `needs_refresh`
`pending_device` 仅在有进行中的 Device Code 登录时返回。
---
#### POST /api/auth/login
发起 Provider 登录。
**Request Body** — `application/json`
```json
{ "provider": "openai" }
```
支持的 `provider` 值: `openai` | `anthropic` | `google-antigravity`
##### OpenAI (Device Code Flow)
返回 Device Code 信息,后台自动轮询认证结果:
```json
{
"status": "pending",
"device_url": "https://auth.openai.com/activate",
"user_code": "ABCD-1234",
"message": "Open the URL and enter the code to authenticate."
}
```
用户在浏览器中打开 `device_url` 并输入 `user_code`。认证完成后通过 `GET /api/auth/status``pending_device.status` 变为 `success` 通知前端。
##### Anthropic (API Token)
需在请求中附带 token
```json
{ "provider": "anthropic", "token": "sk-ant-xxx" }
```
**Response:**
```json
{ "status": "success", "message": "Anthropic token saved" }
```
##### Google Antigravity (Browser OAuth)
返回授权 URL前端打开新标签页
```json
{
"status": "redirect",
"auth_url": "https://accounts.google.com/o/oauth2/auth?...",
"message": "Open the URL to authenticate with Google."
}
```
认证完成后 Google 回调至 `GET /auth/callback`,自动保存凭据并重定向回 picoclaw-config 页面。
---
#### POST /api/auth/logout
登出 Provider。
**Request Body** — `application/json`
```json
{ "provider": "openai" }
```
传空字符串或省略 `provider` 则登出所有 Provider。
**Response** `200 OK`
```json
{ "status": "ok" }
```
---
#### GET /auth/callback
OAuth Browser 回调端点Google Antigravity 专用),由 OAuth Provider 重定向调用,**非前端直接使用**。
**Query Parameters:**
- `state` — OAuth state 校验
- `code` — 授权码
认证成功后重定向到 `/#auth`
### Process API
#### GET /api/process/status
获取 `picoclaw gateway` 进程的运行状态。
**Response** `200 OK` (运行中)
```json
{
"process_status": "running",
"status": "ok",
"uptime": "1.010814s"
}
```
**Response** `200 OK` (未运行)
```json
{
"process_status": "stopped",
"error": "Get \"http://localhost:18790/health\": dial tcp [::1]:18790: connect: connection refused"
}
```
---
#### POST /api/process/start
在后台启动 `picoclaw gateway` 进程。
**Response** `200 OK`
```json
{
"status": "ok",
"pid": 12345
}
```
---
#### POST /api/process/stop
停止正在运行的 `picoclaw gateway` 进程。
**Response** `200 OK`
```json
{
"status": "ok"
}
```
---
## 测试
```bash
go test -v ./cmd/picoclaw-launcher/
```

View file

@ -1,147 +0,0 @@
package server
import (
"log"
"strings"
"github.com/sipeed/picoclaw/pkg/auth"
"github.com/sipeed/picoclaw/pkg/config"
)
// updateConfigAfterLogin updates config.json after a successful provider login.
func updateConfigAfterLogin(configPath, provider string, cred *auth.AuthCredential) {
cfg, err := config.LoadConfig(configPath)
if err != nil {
log.Printf("Warning: could not load config to update auth_method: %v", err)
return
}
switch provider {
case "openai":
cfg.Providers.OpenAI.AuthMethod = "oauth"
found := false
for i := range cfg.ModelList {
if isOpenAIModel(cfg.ModelList[i].Model) {
cfg.ModelList[i].AuthMethod = "oauth"
found = true
break
}
}
if !found {
cfg.ModelList = append(cfg.ModelList, config.ModelConfig{
ModelName: "gpt-5.2",
Model: "openai/gpt-5.2",
AuthMethod: "oauth",
})
}
cfg.Agents.Defaults.ModelName = "gpt-5.2"
case "anthropic":
cfg.Providers.Anthropic.AuthMethod = "token"
found := false
for i := range cfg.ModelList {
if isAnthropicModel(cfg.ModelList[i].Model) {
cfg.ModelList[i].AuthMethod = "token"
found = true
break
}
}
if !found {
cfg.ModelList = append(cfg.ModelList, config.ModelConfig{
ModelName: "claude-sonnet-4.6",
Model: "anthropic/claude-sonnet-4.6",
AuthMethod: "token",
})
}
cfg.Agents.Defaults.ModelName = "claude-sonnet-4.6"
case "google-antigravity":
cfg.Providers.Antigravity.AuthMethod = "oauth"
found := false
for i := range cfg.ModelList {
if isAntigravityModel(cfg.ModelList[i].Model) {
cfg.ModelList[i].AuthMethod = "oauth"
found = true
break
}
}
if !found {
cfg.ModelList = append(cfg.ModelList, config.ModelConfig{
ModelName: "gemini-flash",
Model: "antigravity/gemini-3-flash",
AuthMethod: "oauth",
})
}
cfg.Agents.Defaults.ModelName = "gemini-flash"
}
if err := config.SaveConfig(configPath, cfg); err != nil {
log.Printf("Warning: could not update config: %v", err)
}
}
// clearAuthMethodInConfig clears auth_method for a specific provider in config.json.
func clearAuthMethodInConfig(configPath, provider string) {
cfg, err := config.LoadConfig(configPath)
if err != nil {
return
}
for i := range cfg.ModelList {
switch provider {
case "openai":
if isOpenAIModel(cfg.ModelList[i].Model) {
cfg.ModelList[i].AuthMethod = ""
}
case "anthropic":
if isAnthropicModel(cfg.ModelList[i].Model) {
cfg.ModelList[i].AuthMethod = ""
}
case "google-antigravity", "antigravity":
if isAntigravityModel(cfg.ModelList[i].Model) {
cfg.ModelList[i].AuthMethod = ""
}
}
}
switch provider {
case "openai":
cfg.Providers.OpenAI.AuthMethod = ""
case "anthropic":
cfg.Providers.Anthropic.AuthMethod = ""
case "google-antigravity", "antigravity":
cfg.Providers.Antigravity.AuthMethod = ""
}
config.SaveConfig(configPath, cfg)
}
// clearAllAuthMethodsInConfig clears auth_method for all providers in config.json.
func clearAllAuthMethodsInConfig(configPath string) {
cfg, err := config.LoadConfig(configPath)
if err != nil {
return
}
for i := range cfg.ModelList {
cfg.ModelList[i].AuthMethod = ""
}
cfg.Providers.OpenAI.AuthMethod = ""
cfg.Providers.Anthropic.AuthMethod = ""
cfg.Providers.Antigravity.AuthMethod = ""
config.SaveConfig(configPath, cfg)
}
// ── Model identification helpers ─────────────────────────────────
func isOpenAIModel(model string) bool {
return model == "openai" || strings.HasPrefix(model, "openai/")
}
func isAnthropicModel(model string) bool {
return model == "anthropic" || strings.HasPrefix(model, "anthropic/")
}
func isAntigravityModel(model string) bool {
return model == "antigravity" || model == "google-antigravity" ||
strings.HasPrefix(model, "antigravity/") || strings.HasPrefix(model, "google-antigravity/")
}

View file

@ -1,222 +0,0 @@
package server
import (
"path/filepath"
"testing"
"github.com/sipeed/picoclaw/pkg/auth"
"github.com/sipeed/picoclaw/pkg/config"
)
// ── Model identification helpers ─────────────────────────────────
func TestIsOpenAIModel(t *testing.T) {
tests := []struct {
model string
want bool
}{
{"openai", true},
{"openai/gpt-4o", true},
{"openai/gpt-5.2", true},
{"anthropic", false},
{"anthropic/claude-sonnet-4.6", false},
{"openai-compatible", false},
{"", false},
}
for _, tt := range tests {
if got := isOpenAIModel(tt.model); got != tt.want {
t.Errorf("isOpenAIModel(%q) = %v, want %v", tt.model, got, tt.want)
}
}
}
func TestIsAnthropicModel(t *testing.T) {
tests := []struct {
model string
want bool
}{
{"anthropic", true},
{"anthropic/claude-sonnet-4.6", true},
{"openai", false},
{"openai/gpt-4o", false},
{"", false},
}
for _, tt := range tests {
if got := isAnthropicModel(tt.model); got != tt.want {
t.Errorf("isAnthropicModel(%q) = %v, want %v", tt.model, got, tt.want)
}
}
}
func TestIsAntigravityModel(t *testing.T) {
tests := []struct {
model string
want bool
}{
{"antigravity", true},
{"google-antigravity", true},
{"antigravity/gemini-3-flash", true},
{"google-antigravity/gemini-3-flash", true},
{"openai", false},
{"antigravity-custom", false},
{"", false},
}
for _, tt := range tests {
if got := isAntigravityModel(tt.model); got != tt.want {
t.Errorf("isAntigravityModel(%q) = %v, want %v", tt.model, got, tt.want)
}
}
}
// ── Config update helpers ────────────────────────────────────────
func writeTempConfigViaSave(t *testing.T, cfg *config.Config) string {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, "config.json")
if err := config.SaveConfig(path, cfg); err != nil {
t.Fatalf("save config: %v", err)
}
return path
}
func loadTempConfig(t *testing.T, path string) *config.Config {
t.Helper()
cfg, err := config.LoadConfig(path)
if err != nil {
t.Fatalf("load config: %v", err)
}
return cfg
}
func TestUpdateConfigAfterLogin_OpenAI_ExistingModel(t *testing.T) {
cfg := &config.Config{
ModelList: []config.ModelConfig{
{ModelName: "gpt-4o", Model: "openai/gpt-4o"},
},
}
path := writeTempConfigViaSave(t, cfg)
cred := &auth.AuthCredential{AuthMethod: "oauth"}
updateConfigAfterLogin(path, "openai", cred)
result := loadTempConfig(t, path)
// Model-level auth_method persists through serialization
if len(result.ModelList) != 1 {
t.Fatalf("expected 1 model, got %d", len(result.ModelList))
}
if result.ModelList[0].AuthMethod != "oauth" {
t.Errorf("expected model auth_method=oauth, got %q", result.ModelList[0].AuthMethod)
}
}
func TestUpdateConfigAfterLogin_OpenAI_NoExistingModel(t *testing.T) {
cfg := &config.Config{
ModelList: []config.ModelConfig{
{ModelName: "claude", Model: "anthropic/claude-sonnet-4.6"},
},
}
path := writeTempConfigViaSave(t, cfg)
cred := &auth.AuthCredential{AuthMethod: "oauth"}
updateConfigAfterLogin(path, "openai", cred)
result := loadTempConfig(t, path)
if len(result.ModelList) != 2 {
t.Fatalf("expected 2 models (original + added), got %d", len(result.ModelList))
}
if result.ModelList[1].Model != "openai/gpt-5.2" {
t.Errorf("expected added model openai/gpt-5.2, got %q", result.ModelList[1].Model)
}
if result.Agents.Defaults.ModelName != "gpt-5.2" {
t.Errorf("expected default model_name=gpt-5.2, got %q", result.Agents.Defaults.ModelName)
}
}
func TestUpdateConfigAfterLogin_Anthropic(t *testing.T) {
cfg := &config.Config{}
path := writeTempConfigViaSave(t, cfg)
cred := &auth.AuthCredential{AuthMethod: "token"}
updateConfigAfterLogin(path, "anthropic", cred)
result := loadTempConfig(t, path)
// Model should be added with correct auth_method
if len(result.ModelList) != 1 {
t.Fatalf("expected 1 model added, got %d", len(result.ModelList))
}
if result.ModelList[0].Model != "anthropic/claude-sonnet-4.6" {
t.Errorf("expected model anthropic/claude-sonnet-4.6, got %q", result.ModelList[0].Model)
}
if result.ModelList[0].AuthMethod != "token" {
t.Errorf("expected model auth_method=token, got %q", result.ModelList[0].AuthMethod)
}
}
func TestUpdateConfigAfterLogin_GoogleAntigravity(t *testing.T) {
cfg := &config.Config{}
path := writeTempConfigViaSave(t, cfg)
cred := &auth.AuthCredential{AuthMethod: "oauth"}
updateConfigAfterLogin(path, "google-antigravity", cred)
result := loadTempConfig(t, path)
// Model should be added with correct auth_method
if len(result.ModelList) != 1 {
t.Fatalf("expected 1 model added, got %d", len(result.ModelList))
}
if result.ModelList[0].Model != "antigravity/gemini-3-flash" {
t.Errorf("expected model antigravity/gemini-3-flash, got %q", result.ModelList[0].Model)
}
if result.ModelList[0].AuthMethod != "oauth" {
t.Errorf("expected model auth_method=oauth, got %q", result.ModelList[0].AuthMethod)
}
}
func TestClearAuthMethodInConfig(t *testing.T) {
cfg := &config.Config{
ModelList: []config.ModelConfig{
{ModelName: "gpt-4o", Model: "openai/gpt-4o", AuthMethod: "oauth"},
{ModelName: "claude", Model: "anthropic/claude-sonnet-4.6", AuthMethod: "token"},
},
}
path := writeTempConfigViaSave(t, cfg)
clearAuthMethodInConfig(path, "openai")
result := loadTempConfig(t, path)
// Openai model auth_method should be cleared
if result.ModelList[0].AuthMethod != "" {
t.Errorf("expected openai model auth_method cleared, got %q", result.ModelList[0].AuthMethod)
}
// Anthropic model should be unchanged
if result.ModelList[1].AuthMethod != "token" {
t.Errorf("expected anthropic model auth_method unchanged, got %q", result.ModelList[1].AuthMethod)
}
}
func TestClearAllAuthMethodsInConfig(t *testing.T) {
cfg := &config.Config{
ModelList: []config.ModelConfig{
{ModelName: "gpt-4o", Model: "openai/gpt-4o", AuthMethod: "oauth"},
{ModelName: "claude", Model: "anthropic/claude-sonnet-4.6", AuthMethod: "token"},
{ModelName: "gemini", Model: "antigravity/gemini-3-flash", AuthMethod: "oauth"},
},
}
path := writeTempConfigViaSave(t, cfg)
clearAllAuthMethodsInConfig(path)
result := loadTempConfig(t, path)
for i, m := range result.ModelList {
if m.AuthMethod != "" {
t.Errorf("model[%d] auth_method not cleared, got %q", i, m.AuthMethod)
}
}
}

View file

@ -1,315 +0,0 @@
package server
import (
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"sync"
"time"
"github.com/sipeed/picoclaw/pkg/auth"
"github.com/sipeed/picoclaw/pkg/providers"
)
// oauthSession stores in-flight OAuth state for browser-based flows.
type oauthSession struct {
Provider string
PKCE auth.PKCECodes
State string
RedirectURI string
OAuthCfg auth.OAuthProviderConfig
ConfigPath string
}
// deviceCodeSession stores in-flight device code flow state.
type deviceCodeSession struct {
mu sync.Mutex
Provider string
Info *auth.DeviceCodeInfo
OAuthCfg auth.OAuthProviderConfig
ConfigPath string
Status string // "pending", "success", "error"
Error string
Done bool
}
var (
oauthSessions = map[string]*oauthSession{} // keyed by state
oauthSessionsMu sync.Mutex
activeDeviceSession *deviceCodeSession
activeDeviceSessionMu sync.Mutex
)
// handleOpenAILogin starts the OpenAI device code flow and returns device code info to the frontend.
func handleOpenAILogin(w http.ResponseWriter, configPath string) {
// Check if there's already a pending device code session
activeDeviceSessionMu.Lock()
if activeDeviceSession != nil {
activeDeviceSession.mu.Lock()
if !activeDeviceSession.Done {
resp := map[string]any{
"status": "pending",
"device_url": activeDeviceSession.Info.VerifyURL,
"user_code": activeDeviceSession.Info.UserCode,
"message": "Device code flow already in progress. Enter the code in your browser.",
}
activeDeviceSession.mu.Unlock()
activeDeviceSessionMu.Unlock()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
return
}
activeDeviceSession.mu.Unlock()
}
activeDeviceSessionMu.Unlock()
// Request a device code
oauthCfg := auth.OpenAIOAuthConfig()
info, err := auth.RequestDeviceCode(oauthCfg)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to request device code: %v", err), http.StatusInternalServerError)
return
}
session := &deviceCodeSession{
Provider: "openai",
Info: info,
OAuthCfg: oauthCfg,
ConfigPath: configPath,
Status: "pending",
}
activeDeviceSessionMu.Lock()
activeDeviceSession = session
activeDeviceSessionMu.Unlock()
// Start background polling
go func() {
deadline := time.After(15 * time.Minute)
ticker := time.NewTicker(time.Duration(info.Interval) * time.Second)
defer ticker.Stop()
for {
select {
case <-deadline:
session.mu.Lock()
session.Status = "error"
session.Error = "Authentication timed out after 15 minutes"
session.Done = true
session.mu.Unlock()
return
case <-ticker.C:
cred, err := auth.PollDeviceCodeOnce(oauthCfg, info.DeviceAuthID, info.UserCode)
if err != nil {
continue // Still pending
}
if cred != nil {
if saveErr := auth.SetCredential("openai", cred); saveErr != nil {
session.mu.Lock()
session.Status = "error"
session.Error = saveErr.Error()
session.Done = true
session.mu.Unlock()
return
}
updateConfigAfterLogin(configPath, "openai", cred)
session.mu.Lock()
session.Status = "success"
session.Done = true
session.mu.Unlock()
log.Printf("OpenAI device code login successful (account: %s)", cred.AccountID)
return
}
}
}
}()
// Return device code info to frontend
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"status": "pending",
"device_url": info.VerifyURL,
"user_code": info.UserCode,
"message": "Open the URL and enter the code to authenticate.",
})
}
// handleAnthropicLogin saves a pasted API token for Anthropic.
func handleAnthropicLogin(w http.ResponseWriter, token, configPath string) {
if token == "" {
http.Error(w, "Token is required for Anthropic login", http.StatusBadRequest)
return
}
cred := &auth.AuthCredential{
AccessToken: token,
Provider: "anthropic",
AuthMethod: "token",
}
if err := auth.SetCredential("anthropic", cred); err != nil {
http.Error(w, fmt.Sprintf("Failed to save credentials: %v", err), http.StatusInternalServerError)
return
}
updateConfigAfterLogin(configPath, "anthropic", cred)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"status": "success",
"message": "Anthropic token saved",
})
}
// handleGoogleAntigravityLogin generates a PKCE + auth URL and returns it to the frontend.
func handleGoogleAntigravityLogin(w http.ResponseWriter, r *http.Request, configPath string) {
oauthCfg := auth.GoogleAntigravityOAuthConfig()
pkce, err := auth.GeneratePKCE()
if err != nil {
http.Error(w, fmt.Sprintf("Failed to generate PKCE: %v", err), http.StatusInternalServerError)
return
}
state, err := auth.GenerateState()
if err != nil {
http.Error(w, fmt.Sprintf("Failed to generate state: %v", err), http.StatusInternalServerError)
return
}
// Build redirect URI pointing to picoclaw-launcher's own callback
scheme := "http"
redirectURI := fmt.Sprintf("%s://%s/auth/callback", scheme, r.Host)
authURL := auth.BuildAuthorizeURL(oauthCfg, pkce, state, redirectURI)
// Store session for callback
oauthSessionsMu.Lock()
oauthSessions[state] = &oauthSession{
Provider: "google-antigravity",
PKCE: pkce,
State: state,
RedirectURI: redirectURI,
OAuthCfg: oauthCfg,
ConfigPath: configPath,
}
oauthSessionsMu.Unlock()
// Clean up stale sessions after 10 minutes
go func() {
time.Sleep(10 * time.Minute)
oauthSessionsMu.Lock()
delete(oauthSessions, state)
oauthSessionsMu.Unlock()
}()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"status": "redirect",
"auth_url": authURL,
"message": "Open the URL to authenticate with Google.",
})
}
// handleOAuthCallback processes the OAuth callback from Google Antigravity.
func handleOAuthCallback(w http.ResponseWriter, r *http.Request) {
state := r.URL.Query().Get("state")
code := r.URL.Query().Get("code")
oauthSessionsMu.Lock()
session, ok := oauthSessions[state]
if ok {
delete(oauthSessions, state)
}
oauthSessionsMu.Unlock()
if !ok {
http.Error(w, "Invalid or expired OAuth state", http.StatusBadRequest)
return
}
if code == "" {
errMsg := r.URL.Query().Get("error")
w.Header().Set("Content-Type", "text/html")
fmt.Fprintf(
w,
`<html><body><h2>Authentication failed</h2><p>%s</p><p>You can close this window.</p></body></html>`,
errMsg,
)
return
}
cred, err := auth.ExchangeCodeForTokens(session.OAuthCfg, code, session.PKCE.CodeVerifier, session.RedirectURI)
if err != nil {
w.Header().Set("Content-Type", "text/html")
fmt.Fprintf(
w,
`<html><body><h2>Authentication failed</h2><p>%s</p><p>You can close this window.</p></body></html>`,
err.Error(),
)
return
}
cred.Provider = session.Provider
// Fetch user info for Google Antigravity
if session.Provider == "google-antigravity" {
if email, err := fetchGoogleUserEmail(cred.AccessToken); err == nil {
cred.Email = email
}
if projectID, err := providers.FetchAntigravityProjectID(cred.AccessToken); err == nil {
cred.ProjectID = projectID
}
}
if err := auth.SetCredential(session.Provider, cred); err != nil {
w.Header().Set("Content-Type", "text/html")
fmt.Fprintf(w, `<html><body><h2>Failed to save credentials</h2><p>%s</p></body></html>`, err.Error())
return
}
updateConfigAfterLogin(session.ConfigPath, session.Provider, cred)
// Redirect back to picoclaw-launcher UI
w.Header().Set("Content-Type", "text/html")
fmt.Fprintf(w, `<html><body>
<h2>Authentication successful!</h2>
<p>Redirecting back to Config Editor...</p>
<script>setTimeout(function(){ window.location.href = '/#auth'; }, 1000);</script>
</body></html>`)
}
// fetchGoogleUserEmail retrieves the user's email from Google's userinfo endpoint.
func fetchGoogleUserEmail(accessToken string) (string, error) {
req, err := http.NewRequest("GET", "https://www.googleapis.com/oauth2/v2/userinfo", nil)
if err != nil {
return "", err
}
req.Header.Set("Authorization", "Bearer "+accessToken)
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("reading userinfo response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("userinfo request failed: %s", string(body))
}
var userInfo struct {
Email string `json:"email"`
}
if err := json.Unmarshal(body, &userInfo); err != nil {
return "", err
}
return userInfo.Email, nil
}

View file

@ -1,116 +0,0 @@
package server
import (
"fmt"
"sync"
"testing"
"github.com/stretchr/testify/assert"
)
func TestLogBuffer_Basic(t *testing.T) {
buf := NewLogBuffer(5)
// Empty buffer
lines, total, runID := buf.LinesSince(0)
assert.Nil(t, lines)
assert.Equal(t, 0, total)
assert.Equal(t, 0, runID)
// Append some lines
buf.Append("line1")
buf.Append("line2")
buf.Append("line3")
lines, total, runID = buf.LinesSince(0)
assert.Equal(t, []string{"line1", "line2", "line3"}, lines)
assert.Equal(t, 3, total)
assert.Equal(t, 0, runID)
// Incremental read
lines, total, _ = buf.LinesSince(2)
assert.Equal(t, []string{"line3"}, lines)
assert.Equal(t, 3, total)
// No new lines
lines, total, _ = buf.LinesSince(3)
assert.Nil(t, lines)
assert.Equal(t, 3, total)
}
func TestLogBuffer_Wrap(t *testing.T) {
buf := NewLogBuffer(3)
buf.Append("a")
buf.Append("b")
buf.Append("c")
buf.Append("d") // evicts "a"
buf.Append("e") // evicts "b"
lines, total, _ := buf.LinesSince(0)
assert.Equal(t, []string{"c", "d", "e"}, lines)
assert.Equal(t, 5, total)
// Incremental after wrap
lines, total, _ = buf.LinesSince(3)
assert.Equal(t, []string{"d", "e"}, lines)
assert.Equal(t, 5, total)
// Offset too old (before buffer start), get all buffered
lines, total, _ = buf.LinesSince(1)
assert.Equal(t, []string{"c", "d", "e"}, lines)
assert.Equal(t, 5, total)
}
func TestLogBuffer_Reset(t *testing.T) {
buf := NewLogBuffer(5)
buf.Append("before")
assert.Equal(t, 0, buf.RunID())
buf.Reset()
assert.Equal(t, 1, buf.RunID())
assert.Equal(t, 0, buf.Total())
lines, total, runID := buf.LinesSince(0)
assert.Nil(t, lines)
assert.Equal(t, 0, total)
assert.Equal(t, 1, runID)
buf.Append("after")
lines, total, runID = buf.LinesSince(0)
assert.Equal(t, []string{"after"}, lines)
assert.Equal(t, 1, total)
assert.Equal(t, 1, runID)
}
func TestLogBuffer_Concurrent(t *testing.T) {
buf := NewLogBuffer(100)
var wg sync.WaitGroup
// 10 writers
for i := range 10 {
wg.Add(1)
go func(id int) {
defer wg.Done()
for j := range 50 {
buf.Append(fmt.Sprintf("writer-%d-line-%d", id, j))
}
}(i)
}
// 5 readers
for range 5 {
wg.Add(1)
go func() {
defer wg.Done()
for range 100 {
buf.LinesSince(0)
}
}()
}
wg.Wait()
assert.Equal(t, 500, buf.Total())
}

View file

@ -1,232 +0,0 @@
package server
import (
"bufio"
"encoding/json"
"fmt"
"io"
"log"
"net"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"time"
"github.com/sipeed/picoclaw/pkg/config"
)
// gatewayLogs stores captured stdout/stderr from the gateway process launched by the launcher.
var gatewayLogs = NewLogBuffer(200)
// RegisterProcessAPI registers endpoints to start, stop and check status of the picoclaw gateway.
func RegisterProcessAPI(mux *http.ServeMux, absPath string) {
mux.HandleFunc("GET /api/process/status", func(w http.ResponseWriter, r *http.Request) {
handleStatusGateway(w, r, absPath)
})
mux.HandleFunc("POST /api/process/start", handleStartGateway)
mux.HandleFunc("POST /api/process/stop", handleStopGateway)
}
func handleStartGateway(w http.ResponseWriter, r *http.Request) {
// Locate picoclaw executable:
// 1. Try same directory as current executable
// 2. Fallback to just "picoclaw" (relies on $PATH)
execPath := "picoclaw"
if exe, err := os.Executable(); err == nil {
dir := filepath.Dir(exe)
candidate := filepath.Join(dir, "picoclaw")
if runtime.GOOS == "windows" {
candidate += ".exe"
}
if info, err := os.Stat(candidate); err == nil && !info.IsDir() {
execPath = candidate
}
}
cmd := exec.Command(execPath, "gateway")
stdoutPipe, err := cmd.StdoutPipe()
if err != nil {
log.Printf("Failed to create stdout pipe: %v\n", err)
http.Error(w, fmt.Sprintf("Failed to start gateway: %v", err), http.StatusInternalServerError)
return
}
stderrPipe, err := cmd.StderrPipe()
if err != nil {
log.Printf("Failed to create stderr pipe: %v\n", err)
http.Error(w, fmt.Sprintf("Failed to start gateway: %v", err), http.StatusInternalServerError)
return
}
// Clear old logs and increment runID before starting
gatewayLogs.Reset()
if err := cmd.Start(); err != nil {
log.Printf("Failed to start picoclaw gateway: %v\n", err)
http.Error(w, fmt.Sprintf("Failed to start gateway: %v", err), http.StatusInternalServerError)
return
}
// Read stdout and stderr into the log buffer
go scanPipe(stdoutPipe, gatewayLogs)
go scanPipe(stderrPipe, gatewayLogs)
// Wait for the process to exit in the background to avoid zombies
go func() {
if err := cmd.Wait(); err != nil {
log.Printf("Gateway process exited: %v\n", err)
}
}()
log.Printf("Started picoclaw gateway (PID: %d) from %s\n", cmd.Process.Pid, execPath)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"status": "ok",
"pid": cmd.Process.Pid,
})
}
// scanPipe reads lines from r and appends them to buf. It returns when r reaches EOF.
func scanPipe(r io.Reader, buf *LogBuffer) {
scanner := bufio.NewScanner(r)
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) // up to 1MB per line
for scanner.Scan() {
buf.Append(scanner.Text())
}
}
func handleStopGateway(w http.ResponseWriter, r *http.Request) {
var err error
if runtime.GOOS == "windows" {
// Kill via taskkill finding picoclaw.exe (though it might kill this config tool if it's named picoclaw-launcher.exe...? No, /IM does exact match usually, but just to be safe let's stop exactly picoclaw.exe)
// Alternatively, we use powershell to kill processes with commandline containing 'gateway'
psCmd := `Get-WmiObject Win32_Process | Where-Object { $_.CommandLine -match 'picoclaw.*gateway' } | ForEach-Object { Stop-Process $_.ProcessId -Force }`
err = exec.Command("powershell", "-Command", psCmd).Run()
} else {
// Linux/macOS
err = exec.Command("pkill", "-f", "picoclaw gateway").Run()
}
if err != nil {
log.Printf("Warning: Failed to stop gateway (perhaps not running?): %v\n", err)
// We still return 200 OK because pkill returns an error if no process was found
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"status": "ok", // or "not_found"
"msg": "Stop command executed, but returned error (process might not be running).",
"error": err.Error(),
})
return
}
log.Printf("Stopped picoclaw gateway processes.\n")
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"status": "ok",
})
}
func handleStatusGateway(w http.ResponseWriter, r *http.Request, absPath string) {
cfg, cfgErr := config.LoadConfig(absPath)
host := "127.0.0.1"
port := 18790
if cfgErr == nil && cfg != nil {
if cfg.Gateway.Host != "" && cfg.Gateway.Host != "0.0.0.0" {
host = cfg.Gateway.Host
}
if cfg.Gateway.Port != 0 {
port = cfg.Gateway.Port
}
}
url := fmt.Sprintf("http://%s/health", net.JoinHostPort(host, strconv.Itoa(port)))
client := http.Client{Timeout: 2 * time.Second}
resp, err := client.Get(url)
// Build the response data map
data := map[string]any{}
if err != nil {
data["process_status"] = "stopped"
data["error"] = err.Error()
} else {
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
data["process_status"] = "error"
data["status_code"] = resp.StatusCode
} else {
var healthData map[string]any
if decErr := json.NewDecoder(resp.Body).Decode(&healthData); decErr != nil {
data["process_status"] = "error"
data["error"] = "invalid response from gateway"
} else {
// Gateway is running and responded properly — merge health data
for k, v := range healthData {
data[k] = v
}
data["process_status"] = "running"
}
}
}
// Append log data from the buffer
appendLogData(r, data)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(data)
}
// appendLogData reads log_offset and log_run_id query params from the request and
// populates the response data map with incremental log lines.
func appendLogData(r *http.Request, data map[string]any) {
clientOffset := 0
clientRunID := -1
if v := r.URL.Query().Get("log_offset"); v != "" {
if n, err := strconv.Atoi(v); err == nil {
clientOffset = n
}
}
if v := r.URL.Query().Get("log_run_id"); v != "" {
if n, err := strconv.Atoi(v); err == nil {
clientRunID = n
}
}
runID := gatewayLogs.RunID()
// If runID is 0 (never reset = never launched from this launcher), report no source
if runID == 0 {
data["logs"] = []string{}
data["log_total"] = 0
data["log_run_id"] = 0
data["log_source"] = "none"
return
}
// If the client's runID doesn't match, send all buffered lines (gateway restarted)
offset := clientOffset
if clientRunID != runID {
offset = 0
}
lines, total, runID := gatewayLogs.LinesSince(offset)
if lines == nil {
lines = []string{}
}
data["logs"] = lines
data["log_total"] = total
data["log_run_id"] = runID
data["log_source"] = "launcher"
}

View file

@ -1,196 +0,0 @@
package server
import (
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"time"
"github.com/sipeed/picoclaw/pkg/auth"
"github.com/sipeed/picoclaw/pkg/config"
)
const DefaultPort = "18800"
// providerStatus represents the auth status of a single provider in API responses.
type providerStatus struct {
Provider string `json:"provider"`
AuthMethod string `json:"auth_method"`
Status string `json:"status"`
AccountID string `json:"account_id,omitempty"`
Email string `json:"email,omitempty"`
ProjectID string `json:"project_id,omitempty"`
ExpiresAt string `json:"expires_at,omitempty"`
}
// ── Route registration ───────────────────────────────────────────
func RegisterConfigAPI(mux *http.ServeMux, absPath string) {
// GET /api/config — read config
mux.HandleFunc("GET /api/config", func(w http.ResponseWriter, r *http.Request) {
cfg, err := config.LoadConfig(absPath)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
resp := map[string]any{
"config": cfg,
"path": absPath,
}
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
if err := enc.Encode(resp); err != nil {
log.Printf("Failed to encode response: %v", err)
}
})
// PUT /api/config — save config
mux.HandleFunc("PUT /api/config", func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
if err != nil {
http.Error(w, "Failed to read request body", http.StatusBadRequest)
return
}
defer r.Body.Close()
var cfg config.Config
if err := json.Unmarshal(body, &cfg); err != nil {
http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest)
return
}
if err := config.SaveConfig(absPath, &cfg); err != nil {
http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
})
}
func RegisterAuthAPI(mux *http.ServeMux, absPath string) {
// GET /api/auth/status — all authenticated providers + pending login state
mux.HandleFunc("GET /api/auth/status", func(w http.ResponseWriter, r *http.Request) {
store, err := auth.LoadStore()
if err != nil {
http.Error(w, fmt.Sprintf("Failed to load auth store: %v", err), http.StatusInternalServerError)
return
}
result := []providerStatus{}
for name, cred := range store.Credentials {
status := "active"
if cred.IsExpired() {
status = "expired"
} else if cred.NeedsRefresh() {
status = "needs_refresh"
}
ps := providerStatus{
Provider: name,
AuthMethod: cred.AuthMethod,
Status: status,
AccountID: cred.AccountID,
Email: cred.Email,
ProjectID: cred.ProjectID,
}
if !cred.ExpiresAt.IsZero() {
ps.ExpiresAt = cred.ExpiresAt.Format(time.RFC3339)
}
result = append(result, ps)
}
// Include pending device code state
var pendingDevice map[string]any
activeDeviceSessionMu.Lock()
if activeDeviceSession != nil {
activeDeviceSession.mu.Lock()
pendingDevice = map[string]any{
"provider": activeDeviceSession.Provider,
"status": activeDeviceSession.Status,
"device_url": activeDeviceSession.Info.VerifyURL,
"user_code": activeDeviceSession.Info.UserCode,
}
if activeDeviceSession.Error != "" {
pendingDevice["error"] = activeDeviceSession.Error
}
if activeDeviceSession.Done {
activeDeviceSession.mu.Unlock()
activeDeviceSession = nil
} else {
activeDeviceSession.mu.Unlock()
}
}
activeDeviceSessionMu.Unlock()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"providers": result,
"pending_device": pendingDevice,
})
})
// POST /api/auth/login — initiate provider login
mux.HandleFunc("POST /api/auth/login", func(w http.ResponseWriter, r *http.Request) {
var req struct {
Provider string `json:"provider"`
Token string `json:"token,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
switch req.Provider {
case "openai":
handleOpenAILogin(w, absPath)
case "anthropic":
handleAnthropicLogin(w, req.Token, absPath)
case "google-antigravity", "antigravity":
handleGoogleAntigravityLogin(w, r, absPath)
default:
http.Error(
w,
fmt.Sprintf(
"Unsupported provider: %s (supported: openai, anthropic, google-antigravity)",
req.Provider,
),
http.StatusBadRequest,
)
}
})
// POST /api/auth/logout — logout a provider
mux.HandleFunc("POST /api/auth/logout", func(w http.ResponseWriter, r *http.Request) {
var req struct {
Provider string `json:"provider"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
if req.Provider == "" {
if err := auth.DeleteAllCredentials(); err != nil {
http.Error(w, fmt.Sprintf("Failed to logout: %v", err), http.StatusInternalServerError)
return
}
clearAllAuthMethodsInConfig(absPath)
} else {
if err := auth.DeleteCredential(req.Provider); err != nil {
http.Error(w, fmt.Sprintf("Failed to logout: %v", err), http.StatusInternalServerError)
return
}
clearAuthMethodInConfig(absPath, req.Provider)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
})
// GET /auth/callback — OAuth browser callback for Google Antigravity
mux.HandleFunc("GET /auth/callback", handleOAuthCallback)
}

View file

@ -1,247 +0,0 @@
package server
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/sipeed/picoclaw/pkg/config"
)
// ── Config API tests ─────────────────────────────────────────────
func setupConfigMux(t *testing.T, cfg *config.Config) (*http.ServeMux, string) {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, "config.json")
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
t.Fatalf("marshal config: %v", err)
}
if err := os.WriteFile(path, data, 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
mux := http.NewServeMux()
RegisterConfigAPI(mux, path)
RegisterAuthAPI(mux, path)
return mux, path
}
func TestGetConfig(t *testing.T) {
cfg := &config.Config{
ModelList: []config.ModelConfig{
{ModelName: "gpt-4o", Model: "openai/gpt-4o"},
},
}
mux, path := setupConfigMux(t, cfg)
req := httptest.NewRequest("GET", "/api/config", nil)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("GET /api/config: expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp struct {
Config config.Config `json:"config"`
Path string `json:"path"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if resp.Path != path {
t.Errorf("expected path %q, got %q", path, resp.Path)
}
if len(resp.Config.ModelList) != 1 {
t.Errorf("expected 1 model, got %d", len(resp.Config.ModelList))
}
}
func TestGetConfig_MissingFile_ReturnsDefault(t *testing.T) {
mux := http.NewServeMux()
RegisterConfigAPI(mux, "/tmp/nonexistent-picoclaw-launcher-test/config.json")
req := httptest.NewRequest("GET", "/api/config", nil)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
// LoadConfig returns a default empty config when file is missing
if w.Code != http.StatusOK {
t.Errorf("expected 200 for missing file (default config), got %d", w.Code)
}
}
func TestPutConfig(t *testing.T) {
cfg := &config.Config{}
mux, path := setupConfigMux(t, cfg)
newCfg := config.Config{
ModelList: []config.ModelConfig{
{ModelName: "claude", Model: "anthropic/claude-sonnet-4.6", AuthMethod: "token"},
},
}
body, _ := json.Marshal(newCfg)
req := httptest.NewRequest("PUT", "/api/config", strings.NewReader(string(body)))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("PUT /api/config: expected 200, got %d: %s", w.Code, w.Body.String())
}
saved, err := config.LoadConfig(path)
if err != nil {
t.Fatalf("load saved config: %v", err)
}
if len(saved.ModelList) != 1 {
t.Fatalf("expected 1 model saved, got %d", len(saved.ModelList))
}
if saved.ModelList[0].Model != "anthropic/claude-sonnet-4.6" {
t.Errorf("expected model anthropic/claude-sonnet-4.6, got %q", saved.ModelList[0].Model)
}
}
func TestPutConfig_InvalidJSON(t *testing.T) {
cfg := &config.Config{}
mux, _ := setupConfigMux(t, cfg)
req := httptest.NewRequest("PUT", "/api/config", strings.NewReader("{invalid"))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400 for invalid JSON, got %d", w.Code)
}
}
// ── Auth API tests ───────────────────────────────────────────────
func TestAuthStatus(t *testing.T) {
cfg := &config.Config{}
mux, _ := setupConfigMux(t, cfg)
req := httptest.NewRequest("GET", "/api/auth/status", nil)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("GET /api/auth/status: expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp struct {
Providers []providerStatus `json:"providers"`
PendingDevice map[string]any `json:"pending_device"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode response: %v", err)
}
// providers should be a non-nil list (could be empty)
if resp.Providers == nil {
t.Error("providers should not be nil")
}
}
func TestAuthLogin_UnsupportedProvider(t *testing.T) {
cfg := &config.Config{}
mux, _ := setupConfigMux(t, cfg)
body := `{"provider": "unsupported"}`
req := httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400 for unsupported provider, got %d", w.Code)
}
}
func TestAuthLogin_AnthropicNoToken(t *testing.T) {
cfg := &config.Config{}
mux, _ := setupConfigMux(t, cfg)
body := `{"provider": "anthropic"}`
req := httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400 for anthropic without token, got %d", w.Code)
}
}
func TestAuthLogin_InvalidBody(t *testing.T) {
cfg := &config.Config{}
mux, _ := setupConfigMux(t, cfg)
req := httptest.NewRequest("POST", "/api/auth/login", strings.NewReader("{bad"))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400 for invalid JSON body, got %d", w.Code)
}
}
func TestAuthLogout_InvalidBody(t *testing.T) {
cfg := &config.Config{}
mux, _ := setupConfigMux(t, cfg)
req := httptest.NewRequest("POST", "/api/auth/logout", strings.NewReader("{bad"))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400 for invalid body, got %d", w.Code)
}
}
func TestOAuthCallback_InvalidState(t *testing.T) {
cfg := &config.Config{}
mux, _ := setupConfigMux(t, cfg)
req := httptest.NewRequest("GET", "/auth/callback?state=invalid&code=test", nil)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400 for invalid state, got %d", w.Code)
}
}
// ── Utility tests ────────────────────────────────────────────────
func TestDefaultConfigPath(t *testing.T) {
path := DefaultConfigPath()
if path == "" {
t.Error("defaultConfigPath should not return empty")
}
if !strings.HasSuffix(path, filepath.Join(".picoclaw", "config.json")) {
t.Errorf("expected path ending with .picoclaw/config.json, got %q", path)
}
}
func TestGetLocalIP(t *testing.T) {
// Just ensure it doesn't panic; IP may or may not be available
ip := GetLocalIP()
if ip != "" {
// If returned, should look like an IP
if !strings.Contains(ip, ".") {
t.Errorf("getLocalIP returned non-IPv4 looking string: %q", ip)
}
}
}

View file

@ -1,28 +0,0 @@
package server
import (
"net"
"os"
"path/filepath"
)
func DefaultConfigPath() string {
home, err := os.UserHomeDir()
if err != nil {
return "config.json"
}
return filepath.Join(home, ".picoclaw", "config.json")
}
func GetLocalIP() string {
addrs, err := net.InterfaceAddrs()
if err != nil {
return ""
}
for _, a := range addrs {
if ipnet, ok := a.(*net.IPNet); ok && !ipnet.IP.IsLoopback() && ipnet.IP.To4() != nil {
return ipnet.IP.String()
}
}
return ""
}

File diff suppressed because it is too large Load diff

View file

@ -1,127 +0,0 @@
// PicoClaw Launcher - Standalone HTTP service
//
// Provides a web-based JSON editor for picoclaw config files,
// with OAuth provider authentication support.
//
// Usage:
//
// go build -o picoclaw-launcher ./cmd/picoclaw-launcher/
// ./picoclaw-launcher [config.json]
// ./picoclaw-launcher -public config.json
package main
import (
"embed"
"flag"
"fmt"
"io/fs"
"log"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"time"
"github.com/sipeed/picoclaw/cmd/picoclaw-launcher/internal/server"
)
//go:embed internal/ui/index.html
var staticFiles embed.FS
func main() {
public := flag.Bool("public", false, "Listen on all interfaces (0.0.0.0) instead of localhost only")
flag.Usage = func() {
fmt.Fprintf(os.Stderr, "PicoClaw Launcher - A web-based configuration editor\n\n")
fmt.Fprintf(os.Stderr, "Usage: %s [options] [config.json]\n\n", os.Args[0])
fmt.Fprintf(os.Stderr, "Arguments:\n")
fmt.Fprintf(os.Stderr, " config.json Path to the configuration file (default: ~/.picoclaw/config.json)\n\n")
fmt.Fprintf(os.Stderr, "Options:\n")
flag.PrintDefaults()
fmt.Fprintf(os.Stderr, "\nExamples:\n")
fmt.Fprintf(os.Stderr, " %s Use default config path\n", os.Args[0])
fmt.Fprintf(os.Stderr, " %s ./config.json Specify a config file\n", os.Args[0])
fmt.Fprintf(
os.Stderr,
" %s -public ./config.json Allow access from other devices on the network\n",
os.Args[0],
)
}
flag.Parse()
configPath := server.DefaultConfigPath()
if flag.NArg() > 0 {
configPath = flag.Arg(0)
}
absPath, err := filepath.Abs(configPath)
if err != nil {
log.Fatalf("Failed to resolve config path: %v", err)
}
var addr string
if *public {
addr = "0.0.0.0:" + server.DefaultPort
} else {
addr = "127.0.0.1:" + server.DefaultPort
}
mux := http.NewServeMux()
server.RegisterConfigAPI(mux, absPath)
server.RegisterAuthAPI(mux, absPath)
server.RegisterProcessAPI(mux, absPath)
staticFS, err := fs.Sub(staticFiles, "internal/ui")
if err != nil {
log.Fatalf("Failed to create sub filesystem: %v", err)
}
mux.Handle("/", http.FileServer(http.FS(staticFS)))
// Print startup banner
fmt.Println("=============================================")
fmt.Println(" PicoClaw Launcher")
fmt.Println("=============================================")
fmt.Printf(" Config file : %s\n", absPath)
fmt.Printf(" Listen addr : %s\n\n", addr)
fmt.Println(" Open the following URL in your browser")
fmt.Println(" to view and edit the configuration:")
fmt.Println()
fmt.Printf(" >> http://localhost:%s <<\n", server.DefaultPort)
if *public {
if ip := server.GetLocalIP(); ip != "" {
fmt.Printf(" >> http://%s:%s <<\n", ip, server.DefaultPort)
}
}
fmt.Println()
// fmt.Println("=============================================")
go func() {
// Wait briefly to ensure the server is ready before opening the browser
time.Sleep(500 * time.Millisecond)
url := "http://localhost:" + server.DefaultPort
if err := openBrowser(url); err != nil {
log.Printf("Warning: Failed to auto-open browser: %v\n", err)
}
}()
if err := http.ListenAndServe(addr, mux); err != nil {
log.Fatalf("Server failed: %v", err)
}
}
// openBrowser automatically opens the given URL in the default browser.
func openBrowser(url string) error {
var err error
switch runtime.GOOS {
case "linux":
err = exec.Command("xdg-open", url).Start()
case "windows":
err = exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start()
case "darwin":
err = exec.Command("open", url).Start()
default:
err = fmt.Errorf("unsupported platform")
}
return err
}

View file

@ -194,8 +194,13 @@
"nickserv_password": "", "nickserv_password": "",
"sasl_user": "", "sasl_user": "",
"sasl_password": "", "sasl_password": "",
"channels": ["#mychannel"], "channels": [
"request_caps": ["server-time", "message-tags"], "#mychannel"
],
"request_caps": [
"server-time",
"message-tags"
],
"allow_from": [], "allow_from": [],
"group_trigger": { "group_trigger": {
"mention_only": true "mention_only": true
@ -316,6 +321,13 @@
}, },
"mcp": { "mcp": {
"enabled": false, "enabled": false,
"discovery": {
"enabled": false,
"ttl": 5,
"max_search_results": 5,
"use_bm25": true,
"use_regex": false
},
"servers": { "servers": {
"context7": { "context7": {
"enabled": false, "enabled": false,

View file

@ -7,11 +7,21 @@ PicoClaw's tools configuration is located in the `tools` field of `config.json`.
```json ```json
{ {
"tools": { "tools": {
"web": { ... }, "web": {
"mcp": { ... }, ...
"exec": { ... }, },
"cron": { ... }, "mcp": {
"skills": { ... } ...
},
"exec": {
...
},
"cron": {
...
},
"skills": {
...
}
} }
} }
``` ```
@ -23,7 +33,7 @@ Web tools are used for web search and fetching.
### Brave ### Brave
| Config | Type | Default | Description | | Config | Type | Default | Description |
| ------------- | ------ | ------- | ------------------------- | |---------------|--------|---------|---------------------------|
| `enabled` | bool | false | Enable Brave search | | `enabled` | bool | false | Enable Brave search |
| `api_key` | string | - | Brave Search API key | | `api_key` | string | - | Brave Search API key |
| `max_results` | int | 5 | Maximum number of results | | `max_results` | int | 5 | Maximum number of results |
@ -31,14 +41,14 @@ Web tools are used for web search and fetching.
### DuckDuckGo ### DuckDuckGo
| Config | Type | Default | Description | | Config | Type | Default | Description |
| ------------- | ---- | ------- | ------------------------- | |---------------|------|---------|---------------------------|
| `enabled` | bool | true | Enable DuckDuckGo search | | `enabled` | bool | true | Enable DuckDuckGo search |
| `max_results` | int | 5 | Maximum number of results | | `max_results` | int | 5 | Maximum number of results |
### Perplexity ### Perplexity
| Config | Type | Default | Description | | Config | Type | Default | Description |
| ------------- | ------ | ------- | ------------------------- | |---------------|--------|---------|---------------------------|
| `enabled` | bool | false | Enable Perplexity search | | `enabled` | bool | false | Enable Perplexity search |
| `api_key` | string | - | Perplexity API key | | `api_key` | string | - | Perplexity API key |
| `max_results` | int | 5 | Maximum number of results | | `max_results` | int | 5 | Maximum number of results |
@ -48,7 +58,7 @@ Web tools are used for web search and fetching.
The exec tool is used to execute shell commands. The exec tool is used to execute shell commands.
| Config | Type | Default | Description | | Config | Type | Default | Description |
| ---------------------- | ----- | ------- | ------------------------------------------ | |------------------------|-------|---------|--------------------------------------------|
| `enable_deny_patterns` | bool | true | Enable default dangerous command blocking | | `enable_deny_patterns` | bool | true | Enable default dangerous command blocking |
| `custom_deny_patterns` | array | [] | Custom deny patterns (regular expressions) | | `custom_deny_patterns` | array | [] | Custom deny patterns (regular expressions) |
@ -81,7 +91,10 @@ By default, PicoClaw blocks the following dangerous commands:
"tools": { "tools": {
"exec": { "exec": {
"enable_deny_patterns": true, "enable_deny_patterns": true,
"custom_deny_patterns": ["\\brm\\s+-r\\b", "\\bkillall\\s+python"] "custom_deny_patterns": [
"\\brm\\s+-r\\b",
"\\bkillall\\s+python"
]
} }
} }
} }
@ -92,24 +105,47 @@ By default, PicoClaw blocks the following dangerous commands:
The cron tool is used for scheduling periodic tasks. The cron tool is used for scheduling periodic tasks.
| Config | Type | Default | Description | | Config | Type | Default | Description |
| ---------------------- | ---- | ------- | ---------------------------------------------- | |------------------------|------|---------|------------------------------------------------|
| `exec_timeout_minutes` | int | 5 | Execution timeout in minutes, 0 means no limit | | `exec_timeout_minutes` | int | 5 | Execution timeout in minutes, 0 means no limit |
## MCP Tool ## MCP Tool
The MCP tool enables integration with external Model Context Protocol servers. The MCP tool enables integration with external Model Context Protocol servers.
### Tool Discovery (Lazy Loading)
When connecting to multiple MCP servers, exposing hundreds of tools simultaneously can exhaust the LLM's context window
and increase API costs. The **Discovery** feature solves this by keeping MCP tools *hidden* by default.
Instead of loading all tools, the LLM is provided with a lightweight search tool (using BM25 keyword matching or Regex).
When the LLM needs a specific capability, it searches the hidden library. Matching tools are then temporarily "unlocked"
and injected into the context for a configured number of turns (`ttl`).
### Global Config ### Global Config
| Config | Type | Default | Description | | Config | Type | Default | Description |
| --------- | ------ | ------- | ----------------------------------- | |-------------|--------|---------|----------------------------------------------|
| `enabled` | bool | false | Enable MCP integration globally | | `enabled` | bool | false | Enable MCP integration globally |
| `servers` | object | `{}` | Map of server name to server config | | `discovery` | object | `{}` | Configuration for Tool Discovery (see below) |
| `servers` | object | `{}` | Map of server name to server config |
### Discovery Config (`discovery`)
| Config | Type | Default | Description |
|----------------------|------|---------|-----------------------------------------------------------------------------------------------------------------------------------|
| `enabled` | bool | false | If true, MCP tools are hidden and loaded on-demand via search. If false, all tools are loaded |
| `ttl` | int | 5 | Number of conversational turns a discovered tool remains unlocked |
| `max_search_results` | int | 5 | Maximum number of tools returned per search query |
| `use_bm25` | bool | true | Enable the natural language/keyword search tool (`tool_search_tool_bm25`). **Warning**: consumes more resources than regex search |
| `use_regex` | bool | false | Enable the regex pattern search tool (`tool_search_tool_regex`) |
> **Note:** If `discovery.enabled` is `true`, you MUST enable at least one search engine (`use_bm25` or `use_regex`),
> otherwise the application will fail to start.
### Per-Server Config ### Per-Server Config
| Config | Type | Required | Description | | Config | Type | Required | Description |
| ---------- | ------ | -------- | ------------------------------------------ | |------------|--------|----------|--------------------------------------------|
| `enabled` | bool | yes | Enable this MCP server | | `enabled` | bool | yes | Enable this MCP server |
| `type` | string | no | Transport type: `stdio`, `sse`, `http` | | `type` | string | no | Transport type: `stdio`, `sse`, `http` |
| `command` | string | stdio | Executable command for stdio transport | | `command` | string | stdio | Executable command for stdio transport |
@ -122,8 +158,8 @@ The MCP tool enables integration with external Model Context Protocol servers.
### Transport Behavior ### Transport Behavior
- If `type` is omitted, transport is auto-detected: - If `type` is omitted, transport is auto-detected:
- `url` is set → `sse` - `url` is set → `sse`
- `command` is set → `stdio` - `command` is set → `stdio`
- `http` and `sse` both use `url` + optional `headers`. - `http` and `sse` both use `url` + optional `headers`.
- `env` and `env_file` are only applied to `stdio` servers. - `env` and `env_file` are only applied to `stdio` servers.
@ -140,7 +176,11 @@ The MCP tool enables integration with external Model Context Protocol servers.
"filesystem": { "filesystem": {
"enabled": true, "enabled": true,
"command": "npx", "command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"] "args": [
"-y",
"@modelcontextprotocol/server-filesystem",
"/tmp"
]
} }
} }
} }
@ -170,20 +210,76 @@ The MCP tool enables integration with external Model Context Protocol servers.
} }
``` ```
#### 3) Massive MCP setup with Tool Discovery enabled
*In this example, the LLM will only see the `tool_search_tool_bm25`. It will search and unlock Github or Postgres tools
dynamically only when requested by the user.*
```json
{
"tools": {
"mcp": {
"enabled": true,
"discovery": {
"enabled": true,
"ttl": 5,
"max_search_results": 5,
"use_bm25": true,
"use_regex": false
},
"servers": {
"github": {
"enabled": true,
"command": "npx",
"args": [
"-y",
"@modelcontextprotocol/server-github"
],
"env": {
"GITHUB_PERSONAL_ACCESS_TOKEN": "YOUR_GITHUB_TOKEN"
}
},
"postgres": {
"enabled": true,
"command": "npx",
"args": [
"-y",
"@modelcontextprotocol/server-postgres",
"postgresql://user:password@localhost/dbname"
]
},
"slack": {
"enabled": true,
"command": "npx",
"args": [
"-y",
"@modelcontextprotocol/server-slack"
],
"env": {
"SLACK_BOT_TOKEN": "YOUR_SLACK_BOT_TOKEN",
"SLACK_TEAM_ID": "YOUR_SLACK_TEAM_ID"
}
}
}
}
}
}
```
## Skills Tool ## Skills Tool
The skills tool configures skill discovery and installation via registries like ClawHub. The skills tool configures skill discovery and installation via registries like ClawHub.
### Registries ### Registries
| Config | Type | Default | Description | | Config | Type | Default | Description |
| ---------------------------------- | ------ | -------------------- | ----------------------- | |------------------------------------|--------|----------------------|----------------------------------------------|
| `registries.clawhub.enabled` | bool | true | Enable ClawHub registry | | `registries.clawhub.enabled` | bool | true | Enable ClawHub registry |
| `registries.clawhub.base_url` | string | `https://clawhub.ai` | ClawHub base URL | | `registries.clawhub.base_url` | string | `https://clawhub.ai` | ClawHub base URL |
| `registries.clawhub.auth_token` | string | `""` | Optional Bearer token for higher rate limits | | `registries.clawhub.auth_token` | string | `""` | Optional Bearer token for higher rate limits |
| `registries.clawhub.search_path` | string | `/api/v1/search` | Search API path | | `registries.clawhub.search_path` | string | `/api/v1/search` | Search API path |
| `registries.clawhub.skills_path` | string | `/api/v1/skills` | Skills API path | | `registries.clawhub.skills_path` | string | `/api/v1/skills` | Skills API path |
| `registries.clawhub.download_path` | string | `/api/v1/download` | Download API path | | `registries.clawhub.download_path` | string | `/api/v1/download` | Download API path |
### Configuration Example ### Configuration Example
@ -217,4 +313,5 @@ For example:
- `PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES=10` - `PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES=10`
- `PICOCLAW_TOOLS_MCP_ENABLED=true` - `PICOCLAW_TOOLS_MCP_ENABLED=true`
Note: Nested map-style config (for example `tools.mcp.servers.<name>.*`) is configured in `config.json` rather than environment variables. Note: Nested map-style config (for example `tools.mcp.servers.<name>.*`) is configured in `config.json` rather than
environment variables.

View file

@ -18,9 +18,11 @@ import (
) )
type ContextBuilder struct { type ContextBuilder struct {
workspace string workspace string
skillsLoader *skills.SkillsLoader skillsLoader *skills.SkillsLoader
memory *MemoryStore memory *MemoryStore
toolDiscoveryBM25 bool
toolDiscoveryRegex bool
// Cache for system prompt to avoid rebuilding on every call. // Cache for system prompt to avoid rebuilding on every call.
// This fixes issue #607: repeated reprocessing of the entire context. // This fixes issue #607: repeated reprocessing of the entire context.
@ -41,6 +43,12 @@ type ContextBuilder struct {
skillFilesAtCache map[string]time.Time skillFilesAtCache map[string]time.Time
} }
func (cb *ContextBuilder) WithToolDiscovery(useBM25, useRegex bool) *ContextBuilder {
cb.toolDiscoveryBM25 = useBM25
cb.toolDiscoveryRegex = useRegex
return cb
}
func getGlobalConfigDir() string { func getGlobalConfigDir() string {
if home := os.Getenv("PICOCLAW_HOME"); home != "" { if home := os.Getenv("PICOCLAW_HOME"); home != "" {
return home return home
@ -71,6 +79,7 @@ func NewContextBuilder(workspace string) *ContextBuilder {
func (cb *ContextBuilder) getIdentity() string { func (cb *ContextBuilder) getIdentity() string {
workspacePath, _ := filepath.Abs(filepath.Join(cb.workspace)) workspacePath, _ := filepath.Abs(filepath.Join(cb.workspace))
toolDiscovery := cb.getDiscoveryRule()
return fmt.Sprintf(`# picoclaw 🦞 return fmt.Sprintf(`# picoclaw 🦞
@ -90,8 +99,29 @@ Your workspace is at: %s
3. **Memory** - When interacting with me if something seems memorable, update %s/memory/MEMORY.md 3. **Memory** - When interacting with me if something seems memorable, update %s/memory/MEMORY.md
4. **Context summaries** - Conversation summaries provided as context are approximate references only. They may be incomplete or outdated. Always defer to explicit user instructions over summary content.`, 4. **Context summaries** - Conversation summaries provided as context are approximate references only. They may be incomplete or outdated. Always defer to explicit user instructions over summary content.
workspacePath, workspacePath, workspacePath, workspacePath, workspacePath)
%s`,
workspacePath, workspacePath, workspacePath, workspacePath, workspacePath, toolDiscovery)
}
func (cb *ContextBuilder) getDiscoveryRule() string {
if !cb.toolDiscoveryBM25 && !cb.toolDiscoveryRegex {
return ""
}
var toolNames []string
if cb.toolDiscoveryBM25 {
toolNames = append(toolNames, `"tool_search_tool_bm25"`)
}
if cb.toolDiscoveryRegex {
toolNames = append(toolNames, `"tool_search_tool_regex"`)
}
return fmt.Sprintf(
`5. **Tool Discovery** - Your visible tools are limited to save memory, but a vast hidden library exists. If you lack the right tool for a task, BEFORE giving up, you MUST search using the %s tool. Do not refuse a request unless the search returns nothing. Found tools will temporarily unlock for your next turn.`,
strings.Join(toolNames, " or "),
)
} }
func (cb *ContextBuilder) BuildSystemPrompt() string { func (cb *ContextBuilder) BuildSystemPrompt() string {

View file

@ -86,7 +86,8 @@ func NewAgentInstance(
toolsRegistry := tools.NewToolRegistry() toolsRegistry := tools.NewToolRegistry()
if cfg.Tools.IsToolEnabled("read_file") { if cfg.Tools.IsToolEnabled("read_file") {
toolsRegistry.Register(tools.NewReadFileTool(workspace, readRestrict, allowReadPaths)) maxReadFileSize := cfg.Tools.ReadFile.MaxReadFileSize
toolsRegistry.Register(tools.NewReadFileTool(workspace, readRestrict, maxReadFileSize, allowReadPaths))
} }
if cfg.Tools.IsToolEnabled("write_file") { if cfg.Tools.IsToolEnabled("write_file") {
toolsRegistry.Register(tools.NewWriteFileTool(workspace, restrict, allowWritePaths)) toolsRegistry.Register(tools.NewWriteFileTool(workspace, restrict, allowWritePaths))
@ -112,7 +113,11 @@ func NewAgentInstance(
sessionsDir := filepath.Join(workspace, "sessions") sessionsDir := filepath.Join(workspace, "sessions")
sessionsManager := session.NewSessionManager(sessionsDir) sessionsManager := session.NewSessionManager(sessionsDir)
contextBuilder := NewContextBuilder(workspace) mcpDiscoveryActive := cfg.Tools.MCP.Enabled && cfg.Tools.MCP.Discovery.Enabled
contextBuilder := NewContextBuilder(workspace).WithToolDiscovery(
mcpDiscoveryActive && cfg.Tools.MCP.Discovery.UseBM25,
mcpDiscoveryActive && cfg.Tools.MCP.Discovery.UseRegex,
)
agentID := routing.DefaultAgentID agentID := routing.DefaultAgentID
agentName := "" agentName := ""

View file

@ -318,7 +318,13 @@ func (al *AgentLoop) Run(ctx context.Context) error {
} }
mcpTool := tools.NewMCPTool(mcpManager, serverName, tool) mcpTool := tools.NewMCPTool(mcpManager, serverName, tool)
agent.Tools.Register(mcpTool)
if al.cfg.Tools.MCP.Discovery.Enabled {
agent.Tools.RegisterHidden(mcpTool)
} else {
agent.Tools.Register(mcpTool)
}
totalRegistrations++ totalRegistrations++
logger.DebugCF("agent", "Registered MCP tool", logger.DebugCF("agent", "Registered MCP tool",
map[string]any{ map[string]any{
@ -337,6 +343,47 @@ func (al *AgentLoop) Run(ctx context.Context) error {
"total_registrations": totalRegistrations, "total_registrations": totalRegistrations,
"agent_count": agentCount, "agent_count": agentCount,
}) })
// Initializes Discovery Tools only if enabled by configuration
if al.cfg.Tools.MCP.Enabled && al.cfg.Tools.MCP.Discovery.Enabled {
useBM25 := al.cfg.Tools.MCP.Discovery.UseBM25
useRegex := al.cfg.Tools.MCP.Discovery.UseRegex
// Fail fast: If discovery is enabled but no search method is turned on
if !useBM25 && !useRegex {
return fmt.Errorf(
"tool discovery is enabled but neither 'use_bm25' nor 'use_regex' is set to true in the configuration",
)
}
ttl := al.cfg.Tools.MCP.Discovery.TTL
if ttl <= 0 {
ttl = 5 // Default value
}
maxSearchResults := al.cfg.Tools.MCP.Discovery.MaxSearchResults
if maxSearchResults <= 0 {
maxSearchResults = 5 // Default value
}
logger.InfoCF("agent", "Initializing tool discovery", map[string]any{
"bm25": useBM25, "regex": useRegex, "ttl": ttl, "max_results": maxSearchResults,
})
for _, agentID := range agentIDs {
agent, ok := al.registry.GetAgent(agentID)
if !ok {
continue
}
if useRegex {
agent.Tools.Register(tools.NewRegexSearchTool(agent.Tools, ttl, maxSearchResults))
}
if useBM25 {
agent.Tools.Register(tools.NewBM25SearchTool(agent.Tools, ttl, maxSearchResults))
}
}
}
} }
} }
@ -634,19 +681,9 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
route, agent, routeErr := al.resolveMessageRoute(msg) route, agent, routeErr := al.resolveMessageRoute(msg)
// Compute session key upfront so session-scoped commands (/cmd, /pico, /clear…) // Resolve session key from route, while preserving explicit agent-scoped keys.
// get the same key that the agent loop will use. Falls back to msg.SessionKey when
// routing fails, which is fine — context-dependent commands guard against nil agent.
sessionKey := resolveScopeKey(route, msg.SessionKey) sessionKey := resolveScopeKey(route, msg.SessionKey)
// Commands are checked before requiring a successful route.
// Global commands (/help, /show, /switch) work even when routing fails;
// context-dependent commands check their own Runtime fields and report
// "unavailable" when the required capability is nil.
if response, handled := al.handleCommand(ctx, msg, agent, sessionKey); handled {
return response, nil
}
if routeErr != nil { if routeErr != nil {
return "", routeErr return "", routeErr
} }
@ -668,24 +705,31 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
"route_channel": route.Channel, "route_channel": route.Channel,
}) })
opts := processOptions{
SessionKey: sessionKey,
Channel: msg.Channel,
ChatID: msg.ChatID,
UserMessage: msg.Content,
Media: msg.Media,
DefaultResponse: defaultResponse,
EnableSummary: true,
SendResponse: false,
WorkingDir: msg.Metadata["work_dir"],
}
// context-dependent commands check their own Runtime fields and report
// "unavailable" when the required capability is nil.
if response, handled := al.handleCommand(ctx, msg, agent, &opts); handled {
return response, nil
}
// Dispatch based on current session mode // Dispatch based on current session mode
content := strings.TrimSpace(msg.Content) content := strings.TrimSpace(msg.Content)
switch al.getSessionMode(sessionKey) { switch al.getSessionMode(sessionKey) {
case modeCmd: case modeCmd:
return al.executeCmdMode(ctx, agent, content, sessionKey, msg.Channel, msg.ChatID) return al.executeCmdMode(ctx, agent, content, sessionKey, msg.Channel, msg.ChatID)
default: // modePico default: // modePico
return al.runAgentLoop(ctx, agent, processOptions{ return al.runAgentLoop(ctx, agent, opts)
SessionKey: sessionKey,
Channel: msg.Channel,
ChatID: msg.ChatID,
UserMessage: msg.Content,
Media: msg.Media,
DefaultResponse: defaultResponse,
EnableSummary: true,
SendResponse: false,
WorkingDir: msg.Metadata["work_dir"],
})
} }
} }
@ -1329,6 +1373,17 @@ func (al *AgentLoop) runLLMIteration(
// Save tool result message to session // Save tool result message to session
agent.Sessions.AddFullMessage(opts.SessionKey, toolResultMsg) agent.Sessions.AddFullMessage(opts.SessionKey, toolResultMsg)
} }
// Tick down TTL of discovered tools after processing tool results.
// Only reached when tool calls were made (the loop continues);
// the break on no-tool-call responses skips this.
// NOTE: This is safe because processMessage is sequential per agent.
// If per-agent concurrency is added, TTL consistency between
// ToProviderDefs and Get must be re-evaluated.
agent.Tools.TickTTL()
logger.DebugCF("agent", "TTL tick after tool execution", map[string]any{
"agent_id": agent.ID, "iteration": iteration,
})
} }
return finalContent, iteration, nil return finalContent, iteration, nil
@ -1775,7 +1830,7 @@ func (al *AgentLoop) handleCommand(
ctx context.Context, ctx context.Context,
msg bus.InboundMessage, msg bus.InboundMessage,
agent *AgentInstance, agent *AgentInstance,
sessionKey string, opts *processOptions,
) (string, bool) { ) (string, bool) {
if !commands.HasCommandPrefix(msg.Content) { if !commands.HasCommandPrefix(msg.Content) {
return "", false return "", false
@ -1785,7 +1840,7 @@ func (al *AgentLoop) handleCommand(
return "", false return "", false
} }
rt := al.buildCommandsRuntime(agent, sessionKey, msg) rt := al.buildCommandsRuntime(agent, opts)
executor := commands.NewExecutor(al.cmdRegistry, rt) executor := commands.NewExecutor(al.cmdRegistry, rt)
var commandReply string var commandReply string
@ -1814,7 +1869,7 @@ func (al *AgentLoop) handleCommand(
} }
} }
func (al *AgentLoop) buildCommandsRuntime(agent *AgentInstance, sessionKey string, msg bus.InboundMessage) *commands.Runtime { func (al *AgentLoop) buildCommandsRuntime(agent *AgentInstance, opts *processOptions) *commands.Runtime {
rt := &commands.Runtime{ rt := &commands.Runtime{
Config: al.cfg, Config: al.cfg,
ListAgentIDs: al.registry.ListAgentIDs, ListAgentIDs: al.registry.ListAgentIDs,
@ -1837,7 +1892,7 @@ func (al *AgentLoop) buildCommandsRuntime(agent *AgentInstance, sessionKey strin
// Session mode (session-scoped) // Session mode (session-scoped)
GetSessionMode: func() string { GetSessionMode: func() string {
if al.getSessionMode(sessionKey) == modeCmd { if al.getSessionMode(opts.SessionKey) == modeCmd {
return "cmd" return "cmd"
} }
return "pico" return "pico"
@ -1846,23 +1901,23 @@ func (al *AgentLoop) buildCommandsRuntime(agent *AgentInstance, sessionKey strin
if agent == nil { if agent == nil {
return "Command unavailable in current context." return "Command unavailable in current context."
} }
al.setSessionMode(sessionKey, modeCmd) al.setSessionMode(opts.SessionKey, modeCmd)
workDir := al.getSessionWorkDir(sessionKey) workDir := al.getSessionWorkDir(opts.SessionKey)
if workDir == "" { if workDir == "" {
workDir = agent.Workspace workDir = agent.Workspace
al.setSessionWorkDir(sessionKey, workDir) al.setSessionWorkDir(opts.SessionKey, workDir)
} }
return fmt.Sprintf("```\n%s$\n```\nType `/pico` to return to chat mode.", shortenHomePath(workDir)) return fmt.Sprintf("```\n%s$\n```\nType `/pico` to return to chat mode.", shortenHomePath(workDir))
}, },
SetModePico: func() string { SetModePico: func() string {
al.setSessionMode(sessionKey, modePico) al.setSessionMode(opts.SessionKey, modePico)
return "Switched to chat mode. Type /cmd to enter command mode." return "Switched to chat mode. Type /cmd to enter command mode."
}, },
GetWorkDir: func() string { GetWorkDir: func() string {
if agent == nil { if agent == nil {
return "" return ""
} }
workDir := al.getSessionWorkDir(sessionKey) workDir := al.getSessionWorkDir(opts.SessionKey)
if workDir == "" { if workDir == "" {
return agent.Workspace return agent.Workspace
} }
@ -1880,7 +1935,7 @@ func (al *AgentLoop) buildCommandsRuntime(agent *AgentInstance, sessionKey strin
if agent == nil { if agent == nil {
return "Command unavailable in current context." return "Command unavailable in current context."
} }
workDir := al.getSessionWorkDir(sessionKey) workDir := al.getSessionWorkDir(opts.SessionKey)
if workDir == "" { if workDir == "" {
workDir = agent.Workspace workDir = agent.Workspace
} }
@ -1900,16 +1955,16 @@ func (al *AgentLoop) buildCommandsRuntime(agent *AgentInstance, sessionKey strin
if agent == nil { if agent == nil {
return "", fmt.Errorf("no agent available") return "", fmt.Errorf("no agent available")
} }
workDir := al.getSessionWorkDir(sessionKey) workDir := al.getSessionWorkDir(opts.SessionKey)
if workDir == "" { if workDir == "" {
workDir = agent.Workspace workDir = agent.Workspace
} }
return al.runAgentLoop(ctx, agent, processOptions{ return al.runAgentLoop(ctx, agent, processOptions{
SessionKey: sessionKey + ":hipico", SessionKey: opts.SessionKey + ":hipico",
Channel: msg.Channel, Channel: opts.Channel,
ChatID: msg.ChatID, ChatID: opts.ChatID,
UserMessage: message, UserMessage: message,
Media: msg.Media, Media: opts.Media,
DefaultResponse: defaultResponse, DefaultResponse: defaultResponse,
EnableSummary: false, EnableSummary: false,
SendResponse: false, SendResponse: false,
@ -1922,15 +1977,15 @@ func (al *AgentLoop) buildCommandsRuntime(agent *AgentInstance, sessionKey strin
if agent == nil { if agent == nil {
return fmt.Errorf("no agent available") return fmt.Errorf("no agent available")
} }
agent.Sessions.TruncateHistory(sessionKey, 0) agent.Sessions.TruncateHistory(opts.SessionKey, 0)
agent.Sessions.SetSummary(sessionKey, "") agent.Sessions.SetSummary(opts.SessionKey, "")
return agent.Sessions.Save(sessionKey) return agent.Sessions.Save(opts.SessionKey)
}, },
CompactSession: func() error { CompactSession: func() error {
if agent == nil { if agent == nil {
return fmt.Errorf("no agent available") return fmt.Errorf("no agent available")
} }
al.summarizeSession(agent, sessionKey) al.summarizeSession(agent, opts.SessionKey)
return nil return nil
}, },
} }
@ -1944,6 +1999,7 @@ func (al *AgentLoop) buildCommandsRuntime(agent *AgentInstance, sessionKey strin
agent.Model = value agent.Model = value
return oldModel, nil return oldModel, nil
} }
} }
return rt return rt
} }

View file

@ -4,11 +4,10 @@ package feishu
import ( import (
"context" "context"
"crypto/rand"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"math/big" "math/rand"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
@ -201,18 +200,13 @@ func (c *FeishuChannel) SendPlaceholder(ctx context.Context, chatID string) (str
func (c *FeishuChannel) ReactToMessage(ctx context.Context, chatID, messageID string) (func(), error) { func (c *FeishuChannel) ReactToMessage(ctx context.Context, chatID, messageID string) (func(), error) {
// Get emoji list from config // Get emoji list from config
emojiList := c.config.RandomReactionEmoji emojiList := c.config.RandomReactionEmoji
var chosenEmoji string
if len(emojiList) == 0 { if len(emojiList) == 0 {
// Default to "Pin" if no config // Default to "Pin" if no config
emojiList = []string{"Pin"} chosenEmoji = "Pin"
}
// Randomly choose one from the list using crypto/rand for better distribution
idx, err := rand.Int(rand.Reader, big.NewInt(int64(len(emojiList))))
var chosenEmoji string
if err != nil {
chosenEmoji = emojiList[0]
} else { } else {
chosenEmoji = emojiList[idx.Int64()] idx := rand.Intn(len(emojiList))
chosenEmoji = emojiList[idx]
} }
req := larkim.NewCreateMessageReactionReqBuilder(). req := larkim.NewCreateMessageReactionReqBuilder().

View file

@ -481,6 +481,7 @@ type ProvidersConfig struct {
Qwen ProviderConfig `json:"qwen"` Qwen ProviderConfig `json:"qwen"`
Mistral ProviderConfig `json:"mistral"` Mistral ProviderConfig `json:"mistral"`
Avian ProviderConfig `json:"avian"` Avian ProviderConfig `json:"avian"`
Minimax ProviderConfig `json:"minimax"`
} }
// IsEmpty checks if all provider configs are empty (no API keys or API bases set) // IsEmpty checks if all provider configs are empty (no API keys or API bases set)
@ -506,7 +507,8 @@ func (p ProvidersConfig) IsEmpty() bool {
p.Antigravity.APIKey == "" && p.Antigravity.APIBase == "" && p.Antigravity.APIKey == "" && p.Antigravity.APIBase == "" &&
p.Qwen.APIKey == "" && p.Qwen.APIBase == "" && p.Qwen.APIKey == "" && p.Qwen.APIBase == "" &&
p.Mistral.APIKey == "" && p.Mistral.APIBase == "" && p.Mistral.APIKey == "" && p.Mistral.APIBase == "" &&
p.Avian.APIKey == "" && p.Avian.APIBase == "" p.Avian.APIKey == "" && p.Avian.APIBase == "" &&
p.Minimax.APIKey == "" && p.Minimax.APIBase == ""
} }
// MarshalJSON implements custom JSON marshaling for ProvidersConfig // MarshalJSON implements custom JSON marshaling for ProvidersConfig
@ -576,6 +578,14 @@ type GatewayConfig struct {
Port int `json:"port" env:"PICOCLAW_GATEWAY_PORT"` Port int `json:"port" env:"PICOCLAW_GATEWAY_PORT"`
} }
type ToolDiscoveryConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_DISCOVERY_ENABLED"`
TTL int `json:"ttl" env:"PICOCLAW_TOOLS_DISCOVERY_TTL"`
MaxSearchResults int `json:"max_search_results" env:"PICOCLAW_MAX_SEARCH_RESULTS"`
UseBM25 bool `json:"use_bm25" env:"PICOCLAW_TOOLS_DISCOVERY_USE_BM25"`
UseRegex bool `json:"use_regex" env:"PICOCLAW_TOOLS_DISCOVERY_USE_REGEX"`
}
type ToolConfig struct { type ToolConfig struct {
Enabled bool `json:"enabled" env:"ENABLED"` Enabled bool `json:"enabled" env:"ENABLED"`
} }
@ -660,6 +670,11 @@ type MediaCleanupConfig struct {
Interval int ` env:"PICOCLAW_MEDIA_CLEANUP_INTERVAL" json:"interval_minutes"` Interval int ` env:"PICOCLAW_MEDIA_CLEANUP_INTERVAL" json:"interval_minutes"`
} }
type ReadFileToolConfig struct {
Enabled bool `json:"enabled"`
MaxReadFileSize int `json:"max_read_file_size"`
}
type ToolsConfig struct { type ToolsConfig struct {
AllowReadPaths []string `json:"allow_read_paths" env:"PICOCLAW_TOOLS_ALLOW_READ_PATHS"` AllowReadPaths []string `json:"allow_read_paths" env:"PICOCLAW_TOOLS_ALLOW_READ_PATHS"`
AllowWritePaths []string `json:"allow_write_paths" env:"PICOCLAW_TOOLS_ALLOW_WRITE_PATHS"` AllowWritePaths []string `json:"allow_write_paths" env:"PICOCLAW_TOOLS_ALLOW_WRITE_PATHS"`
@ -676,7 +691,7 @@ type ToolsConfig struct {
InstallSkill ToolConfig `json:"install_skill" envPrefix:"PICOCLAW_TOOLS_INSTALL_SKILL_"` InstallSkill ToolConfig `json:"install_skill" envPrefix:"PICOCLAW_TOOLS_INSTALL_SKILL_"`
ListDir ToolConfig `json:"list_dir" envPrefix:"PICOCLAW_TOOLS_LIST_DIR_"` ListDir ToolConfig `json:"list_dir" envPrefix:"PICOCLAW_TOOLS_LIST_DIR_"`
Message ToolConfig `json:"message" envPrefix:"PICOCLAW_TOOLS_MESSAGE_"` Message ToolConfig `json:"message" envPrefix:"PICOCLAW_TOOLS_MESSAGE_"`
ReadFile ToolConfig `json:"read_file" envPrefix:"PICOCLAW_TOOLS_READ_FILE_"` ReadFile ReadFileToolConfig `json:"read_file" envPrefix:"PICOCLAW_TOOLS_READ_FILE_"`
SendFile ToolConfig `json:"send_file" envPrefix:"PICOCLAW_TOOLS_SEND_FILE_"` SendFile ToolConfig `json:"send_file" envPrefix:"PICOCLAW_TOOLS_SEND_FILE_"`
Spawn ToolConfig `json:"spawn" envPrefix:"PICOCLAW_TOOLS_SPAWN_"` Spawn ToolConfig `json:"spawn" envPrefix:"PICOCLAW_TOOLS_SPAWN_"`
SPI ToolConfig `json:"spi" envPrefix:"PICOCLAW_TOOLS_SPI_"` SPI ToolConfig `json:"spi" envPrefix:"PICOCLAW_TOOLS_SPI_"`
@ -728,7 +743,8 @@ type MCPServerConfig struct {
// MCPConfig defines configuration for all MCP servers // MCPConfig defines configuration for all MCP servers
type MCPConfig struct { type MCPConfig struct {
ToolConfig `envPrefix:"PICOCLAW_TOOLS_MCP_"` ToolConfig ` envPrefix:"PICOCLAW_TOOLS_MCP_"`
Discovery ToolDiscoveryConfig ` json:"discovery"`
// Servers is a map of server name to server configuration // Servers is a map of server name to server configuration
Servers map[string]MCPServerConfig `json:"servers,omitempty"` Servers map[string]MCPServerConfig `json:"servers,omitempty"`
} }

View file

@ -346,6 +346,14 @@ func DefaultConfig() *Config {
APIKey: "", APIKey: "",
}, },
// Minimax - https://api.minimaxi.com/
{
ModelName: "MiniMax-M2.5",
Model: "minimax/MiniMax-M2.5",
APIBase: "https://api.minimaxi.com/v1",
APIKey: "",
},
// VLLM (local) - http://localhost:8000 // VLLM (local) - http://localhost:8000
{ {
ModelName: "local-model", ModelName: "local-model",
@ -435,6 +443,13 @@ func DefaultConfig() *Config {
ToolConfig: ToolConfig{ ToolConfig: ToolConfig{
Enabled: false, Enabled: false,
}, },
Discovery: ToolDiscoveryConfig{
Enabled: false,
TTL: 5,
MaxSearchResults: 5,
UseBM25: true,
UseRegex: false,
},
Servers: map[string]MCPServerConfig{}, Servers: map[string]MCPServerConfig{},
}, },
AppendFile: ToolConfig{ AppendFile: ToolConfig{
@ -458,8 +473,9 @@ func DefaultConfig() *Config {
Message: ToolConfig{ Message: ToolConfig{
Enabled: true, Enabled: true,
}, },
ReadFile: ToolConfig{ ReadFile: ReadFileToolConfig{
Enabled: true, Enabled: true,
MaxReadFileSize: 64 * 1024, // 64KB
}, },
Spawn: ToolConfig{ Spawn: ToolConfig{
Enabled: true, Enabled: true,

View file

@ -208,6 +208,15 @@ func resolveProviderSelection(cfg *config.Config) (providerSelection, error) {
sel.apiBase = "https://api.mistral.ai/v1" sel.apiBase = "https://api.mistral.ai/v1"
} }
} }
case "minimax":
if cfg.Providers.Minimax.APIKey != "" {
sel.apiKey = cfg.Providers.Minimax.APIKey
sel.apiBase = cfg.Providers.Minimax.APIBase
sel.proxy = cfg.Providers.Minimax.Proxy
if sel.apiBase == "" {
sel.apiBase = "https://api.minimaxi.com/v1"
}
}
case "github_copilot", "copilot": case "github_copilot", "copilot":
sel.providerType = providerTypeGitHubCopilot sel.providerType = providerTypeGitHubCopilot
if cfg.Providers.GitHubCopilot.APIBase != "" { if cfg.Providers.GitHubCopilot.APIBase != "" {
@ -325,6 +334,13 @@ func resolveProviderSelection(cfg *config.Config) (providerSelection, error) {
if sel.apiBase == "" { if sel.apiBase == "" {
sel.apiBase = "https://api.mistral.ai/v1" sel.apiBase = "https://api.mistral.ai/v1"
} }
case (strings.Contains(lowerModel, "minimax") || strings.HasPrefix(model, "minimax/")) && cfg.Providers.Minimax.APIKey != "":
sel.apiKey = cfg.Providers.Minimax.APIKey
sel.apiBase = cfg.Providers.Minimax.APIBase
sel.proxy = cfg.Providers.Minimax.Proxy
if sel.apiBase == "" {
sel.apiBase = "https://api.minimaxi.com/v1"
}
case strings.HasPrefix(model, "avian/") && cfg.Providers.Avian.APIKey != "": case strings.HasPrefix(model, "avian/") && cfg.Providers.Avian.APIKey != "":
sel.apiKey = cfg.Providers.Avian.APIKey sel.apiKey = cfg.Providers.Avian.APIKey
sel.apiBase = cfg.Providers.Avian.APIBase sel.apiBase = cfg.Providers.Avian.APIBase

View file

@ -94,7 +94,8 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
case "litellm", "openrouter", "groq", "zhipu", "gemini", "nvidia", case "litellm", "openrouter", "groq", "zhipu", "gemini", "nvidia",
"ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras", "ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras",
"vivgrid", "volcengine", "vllm", "qwen", "mistral", "avian": "vivgrid", "volcengine", "vllm", "qwen", "mistral", "avian",
"minimax":
// All other OpenAI-compatible HTTP providers // All other OpenAI-compatible HTTP providers
if cfg.APIKey == "" && cfg.APIBase == "" { if cfg.APIKey == "" && cfg.APIBase == "" {
return nil, "", fmt.Errorf("api_key or api_base is required for HTTP-based protocol %q", protocol) return nil, "", fmt.Errorf("api_key or api_base is required for HTTP-based protocol %q", protocol)
@ -212,6 +213,8 @@ func getDefaultAPIBase(protocol string) string {
return "https://api.mistral.ai/v1" return "https://api.mistral.ai/v1"
case "avian": case "avian":
return "https://api.avian.io/v1" return "https://api.avian.io/v1"
case "minimax":
return "https://api.minimaxi.com/v1"
default: default:
return "" return ""
} }

View file

@ -440,7 +440,7 @@ func normalizeModel(model, apiBase string) string {
prefix := strings.ToLower(before) prefix := strings.ToLower(before)
switch prefix { switch prefix {
case "litellm", "moonshot", "nvidia", "groq", "ollama", "deepseek", "google", case "litellm", "moonshot", "nvidia", "groq", "ollama", "deepseek", "google",
"openrouter", "zhipu", "mistral", "vivgrid": "openrouter", "zhipu", "mistral", "vivgrid", "minimax":
return after return after
default: default:
return model return model

View file

@ -2,17 +2,24 @@ package tools
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"io"
"io/fs" "io/fs"
"math"
"os" "os"
"path/filepath" "path/filepath"
"regexp" "regexp"
"strconv"
"strings" "strings"
"time" "time"
"github.com/sipeed/picoclaw/pkg/fileutil" "github.com/sipeed/picoclaw/pkg/fileutil"
"github.com/sipeed/picoclaw/pkg/logger"
) )
const MaxReadFileSize = 64 * 1024 // 64KB limit to avoid context overflow
// ValidatePath ensures the given path is within the workspace if restrict is true. // ValidatePath ensures the given path is within the workspace if restrict is true.
func ValidatePath(path, workspace string, restrict bool) (string, error) { func ValidatePath(path, workspace string, restrict bool) (string, error) {
if workspace == "" { if workspace == "" {
@ -85,15 +92,30 @@ func isWithinWorkspace(candidate, workspace string) bool {
} }
type ReadFileTool struct { type ReadFileTool struct {
fs fileSystem fs fileSystem
maxSize int64
} }
func NewReadFileTool(workspace string, restrict bool, allowPaths ...[]*regexp.Regexp) *ReadFileTool { func NewReadFileTool(
workspace string,
restrict bool,
maxReadFileSize int,
allowPaths ...[]*regexp.Regexp,
) *ReadFileTool {
var patterns []*regexp.Regexp var patterns []*regexp.Regexp
if len(allowPaths) > 0 { if len(allowPaths) > 0 {
patterns = allowPaths[0] patterns = allowPaths[0]
} }
return &ReadFileTool{fs: buildFs(workspace, restrict, patterns)}
maxSize := int64(maxReadFileSize)
if maxSize <= 0 {
maxSize = MaxReadFileSize
}
return &ReadFileTool{
fs: buildFs(workspace, restrict, patterns),
maxSize: maxSize,
}
} }
func (t *ReadFileTool) Name() string { func (t *ReadFileTool) Name() string {
@ -101,7 +123,7 @@ func (t *ReadFileTool) Name() string {
} }
func (t *ReadFileTool) Description() string { func (t *ReadFileTool) Description() string {
return "Read the contents of a file" return "Read the contents of a file. Supports pagination via `offset` and `length`."
} }
func (t *ReadFileTool) Parameters() map[string]any { func (t *ReadFileTool) Parameters() map[string]any {
@ -110,7 +132,17 @@ func (t *ReadFileTool) Parameters() map[string]any {
"properties": map[string]any{ "properties": map[string]any{
"path": map[string]any{ "path": map[string]any{
"type": "string", "type": "string",
"description": "Path to the file to read", "description": "Path to the file to read.",
},
"offset": map[string]any{
"type": "integer",
"description": "Byte offset to start reading from.",
"default": 0,
},
"length": map[string]any{
"type": "integer",
"description": "Maximum number of bytes to read.",
"default": t.maxSize,
}, },
}, },
"required": []string{"path"}, "required": []string{"path"},
@ -123,11 +155,171 @@ func (t *ReadFileTool) Execute(ctx context.Context, args map[string]any) *ToolRe
return ErrorResult("path is required") return ErrorResult("path is required")
} }
content, err := t.fs.ReadFile(path) // offset (optional, default 0)
offset, err := getInt64Arg(args, "offset", 0)
if err != nil { if err != nil {
return ErrorResult(err.Error()) return ErrorResult(err.Error())
} }
return NewToolResult(string(content)) if offset < 0 {
return ErrorResult("offset must be >= 0")
}
// length (optional, capped at MaxReadFileSize)
length, err := getInt64Arg(args, "length", t.maxSize)
if err != nil {
return ErrorResult(err.Error())
}
if length <= 0 {
return ErrorResult("length must be > 0")
}
if length > t.maxSize {
length = t.maxSize
}
file, err := t.fs.Open(path)
if err != nil {
return ErrorResult(err.Error())
}
defer file.Close()
// measure total size
totalSize := int64(-1) // -1 means unknown
if info, statErr := file.Stat(); statErr == nil {
totalSize = info.Size()
}
// sniff the first 512 bytes to detect binary content before loading
// it into the LLM context. Seeking back to 0 afterwards restores state.
sniff := make([]byte, 512)
sniffN, _ := file.Read(sniff)
// Reset read position to beginning before applying the caller's offset.
if seeker, ok := file.(io.Seeker); ok {
_, err = seeker.Seek(0, io.SeekStart)
if err != nil {
return ErrorResult(fmt.Sprintf("failed to reset file position after sniff: %v", err))
}
} else {
// Non-seekable: we consumed sniffN bytes above; account for them when
// discarding to reach the requested offset below.
// If offset < sniffN the data we already read covers it, which we
// cannot replay on a non-seekable stream — return a clear error.
if offset < int64(sniffN) && offset > 0 {
return ErrorResult(
"non-seekable file: cannot seek to an offset within the first 512 bytes after binary detection",
)
}
}
// Seek to the requested offset.
if seeker, ok := file.(io.Seeker); ok {
_, err = seeker.Seek(offset, io.SeekStart)
if err != nil {
return ErrorResult(fmt.Sprintf("failed to seek to offset %d: %v", offset, err))
}
} else if offset > 0 {
// Fallback for non-seekable streams: discard leading bytes.
// sniffN bytes were already consumed above, so subtract them.
remaining := offset - int64(sniffN)
if remaining > 0 {
_, err = io.CopyN(io.Discard, file, remaining)
if err != nil {
return ErrorResult(fmt.Sprintf("failed to advance to offset %d: %v", offset, err))
}
}
}
// read length+1 bytes to reliably detect whether more content exists
// without relying on totalSize (which may be -1 for non-seekable streams).
// This avoids the false-positive TRUNCATED message on the last page.
probe := make([]byte, length+1)
n, err := io.ReadFull(file, probe)
// FIX: io.ReadFull returns io.ErrUnexpectedEOF for partial reads (0 < n < len),
// and io.EOF only when n == 0. Both are normal terminal conditions — only
// other errors are genuine failures.
if err != nil && err != io.EOF && !errors.Is(err, io.ErrUnexpectedEOF) {
return ErrorResult(fmt.Sprintf("failed to read file content: %v", err))
}
// hasMore is true only when we actually got the extra probe byte.
hasMore := int64(n) > length
data := probe[:min(int64(n), length)]
if len(data) == 0 {
return NewToolResult("[END OF FILE - no content at this offset]")
}
// Build metadata header.
// use filepath.Base(path) instead of the raw path to avoid leaking
// internal filesystem structure into the LLM context.
readEnd := offset + int64(len(data))
// use ASCII hyphen-minus instead of en-dash (U+2013) to keep the
// header parseable by downstream tools and log processors.
readRange := fmt.Sprintf("bytes %d-%d", offset, readEnd-1)
displayPath := filepath.Base(path)
var header string
if totalSize >= 0 {
header = fmt.Sprintf(
"[file: %s | total: %d bytes | read: %s]",
displayPath, totalSize, readRange,
)
} else {
header = fmt.Sprintf(
"[file: %s | read: %s | total size unknown]",
displayPath, readRange,
)
}
if hasMore {
header += fmt.Sprintf(
"\n[TRUNCATED - file has more content. Call read_file again with offset=%d to continue.]",
readEnd,
)
} else {
header += "\n[END OF FILE - no further content.]"
}
logger.DebugCF("tool", "ReadFileTool execution completed successfully",
map[string]any{
"path": path,
"bytes_read": len(data),
"has_more": hasMore,
})
return NewToolResult(header + "\n\n" + string(data))
}
// getInt64Arg extracts an integer argument from the args map, returning the
// provided default if the key is absent.
func getInt64Arg(args map[string]any, key string, defaultVal int64) (int64, error) {
raw, exists := args[key]
if !exists {
return defaultVal, nil
}
switch v := raw.(type) {
case float64:
if v != math.Trunc(v) {
return 0, fmt.Errorf("%s must be an integer, got float %v", key, v)
}
if v > math.MaxInt64 || v < math.MinInt64 {
return 0, fmt.Errorf("%s value %v overflows int64", key, v)
}
return int64(v), nil
case int:
return int64(v), nil
case int64:
return v, nil
case string:
parsed, err := strconv.ParseInt(v, 10, 64)
if err != nil {
return 0, fmt.Errorf("invalid integer format for %s parameter: %w", key, err)
}
return parsed, nil
default:
return 0, fmt.Errorf("unsupported type %T for %s parameter", raw, key)
}
} }
type WriteFileTool struct { type WriteFileTool struct {
@ -249,6 +441,7 @@ type fileSystem interface {
ReadFile(path string) ([]byte, error) ReadFile(path string) ([]byte, error)
WriteFile(path string, data []byte) error WriteFile(path string, data []byte) error
ReadDir(path string) ([]os.DirEntry, error) ReadDir(path string) ([]os.DirEntry, error)
Open(path string) (fs.File, error)
} }
// hostFs is an unrestricted fileReadWriter that operates directly on the host filesystem. // hostFs is an unrestricted fileReadWriter that operates directly on the host filesystem.
@ -278,6 +471,20 @@ func (h *hostFs) WriteFile(path string, data []byte) error {
return fileutil.WriteFileAtomic(path, data, 0o600) return fileutil.WriteFileAtomic(path, data, 0o600)
} }
func (h *hostFs) Open(path string) (fs.File, error) {
f, err := os.Open(path)
if err != nil {
if os.IsNotExist(err) {
return nil, fmt.Errorf("failed to open file: file not found: %w", err)
}
if os.IsPermission(err) {
return nil, fmt.Errorf("failed to open file: access denied: %w", err)
}
return nil, fmt.Errorf("failed to open file: %w", err)
}
return f, nil
}
// sandboxFs is a sandboxed fileSystem that operates within a strictly defined workspace using os.Root. // sandboxFs is a sandboxed fileSystem that operates within a strictly defined workspace using os.Root.
type sandboxFs struct { type sandboxFs struct {
workspace string workspace string
@ -389,6 +596,26 @@ func (r *sandboxFs) ReadDir(path string) ([]os.DirEntry, error) {
return entries, err return entries, err
} }
func (r *sandboxFs) Open(path string) (fs.File, error) {
var f fs.File
err := r.execute(path, func(root *os.Root, relPath string) error {
file, err := root.Open(relPath)
if err != nil {
if os.IsNotExist(err) {
return fmt.Errorf("failed to open file: file not found: %w", err)
}
if os.IsPermission(err) || strings.Contains(err.Error(), "escapes from parent") ||
strings.Contains(err.Error(), "permission denied") {
return fmt.Errorf("failed to open file: access denied: %w", err)
}
return fmt.Errorf("failed to open file: %w", err)
}
f = file
return nil
})
return f, err
}
// whitelistFs wraps a sandboxFs and allows access to specific paths outside // whitelistFs wraps a sandboxFs and allows access to specific paths outside
// the workspace when they match any of the provided patterns. // the workspace when they match any of the provided patterns.
type whitelistFs struct { type whitelistFs struct {
@ -427,6 +654,13 @@ func (w *whitelistFs) ReadDir(path string) ([]os.DirEntry, error) {
return w.sandbox.ReadDir(path) return w.sandbox.ReadDir(path)
} }
func (w *whitelistFs) Open(path string) (fs.File, error) {
if w.matches(path) {
return w.host.Open(path)
}
return w.sandbox.Open(path)
}
// buildFs returns the appropriate fileSystem implementation based on restriction // buildFs returns the appropriate fileSystem implementation based on restriction
// settings and optional path whitelist patterns. // settings and optional path whitelist patterns.
func buildFs(workspace string, restrict bool, patterns []*regexp.Regexp) fileSystem { func buildFs(workspace string, restrict bool, patterns []*regexp.Regexp) fileSystem {

View file

@ -18,7 +18,7 @@ func TestFilesystemTool_ReadFile_Success(t *testing.T) {
testFile := filepath.Join(tmpDir, "test.txt") testFile := filepath.Join(tmpDir, "test.txt")
os.WriteFile(testFile, []byte("test content"), 0o644) os.WriteFile(testFile, []byte("test content"), 0o644)
tool := NewReadFileTool("", false) tool := NewReadFileTool("", false, MaxReadFileSize)
ctx := context.Background() ctx := context.Background()
args := map[string]any{ args := map[string]any{
"path": testFile, "path": testFile,
@ -45,7 +45,7 @@ func TestFilesystemTool_ReadFile_Success(t *testing.T) {
// TestFilesystemTool_ReadFile_NotFound verifies error handling for missing file // TestFilesystemTool_ReadFile_NotFound verifies error handling for missing file
func TestFilesystemTool_ReadFile_NotFound(t *testing.T) { func TestFilesystemTool_ReadFile_NotFound(t *testing.T) {
tool := NewReadFileTool("", false) tool := NewReadFileTool("", false, MaxReadFileSize)
ctx := context.Background() ctx := context.Background()
args := map[string]any{ args := map[string]any{
"path": "/nonexistent_file_12345.txt", "path": "/nonexistent_file_12345.txt",
@ -59,7 +59,7 @@ func TestFilesystemTool_ReadFile_NotFound(t *testing.T) {
} }
// Should contain error message // Should contain error message
if !strings.Contains(result.ForLLM, "failed to read") && !strings.Contains(result.ForUser, "failed to read") { if !strings.Contains(result.ForLLM, "failed to open file") && !strings.Contains(result.ForUser, "failed to read") {
t.Errorf("Expected error message, got ForLLM: %s, ForUser: %s", result.ForLLM, result.ForUser) t.Errorf("Expected error message, got ForLLM: %s, ForUser: %s", result.ForLLM, result.ForUser)
} }
} }
@ -271,7 +271,7 @@ func TestFilesystemTool_ReadFile_RejectsSymlinkEscape(t *testing.T) {
t.Skipf("symlink not supported in this environment: %v", err) t.Skipf("symlink not supported in this environment: %v", err)
} }
tool := NewReadFileTool(workspace, true) tool := NewReadFileTool(workspace, true, MaxReadFileSize)
result := tool.Execute(context.Background(), map[string]any{ result := tool.Execute(context.Background(), map[string]any{
"path": link, "path": link,
}) })
@ -289,7 +289,7 @@ func TestFilesystemTool_ReadFile_RejectsSymlinkEscape(t *testing.T) {
} }
func TestFilesystemTool_EmptyWorkspace_AccessDenied(t *testing.T) { func TestFilesystemTool_EmptyWorkspace_AccessDenied(t *testing.T) {
tool := NewReadFileTool("", true) // restrict=true but workspace="" tool := NewReadFileTool("", true, MaxReadFileSize) // restrict=true but workspace=""
// Try to read a sensitive file (simulated by a temp file outside workspace) // Try to read a sensitive file (simulated by a temp file outside workspace)
tmpDir := t.TempDir() tmpDir := t.TempDir()
@ -499,7 +499,7 @@ func TestWhitelistFs_AllowsMatchingPaths(t *testing.T) {
// Pattern allows access to the outsideDir. // Pattern allows access to the outsideDir.
patterns := []*regexp.Regexp{regexp.MustCompile(`^` + regexp.QuoteMeta(outsideDir))} patterns := []*regexp.Regexp{regexp.MustCompile(`^` + regexp.QuoteMeta(outsideDir))}
tool := NewReadFileTool(workspace, true, patterns) tool := NewReadFileTool(workspace, true, MaxReadFileSize, patterns)
// Read from whitelisted path should succeed. // Read from whitelisted path should succeed.
result := tool.Execute(context.Background(), map[string]any{"path": outsideFile}) result := tool.Execute(context.Background(), map[string]any{"path": outsideFile})
@ -520,3 +520,127 @@ func TestWhitelistFs_AllowsMatchingPaths(t *testing.T) {
t.Errorf("expected non-whitelisted path to be blocked, got: %s", result.ForLLM) t.Errorf("expected non-whitelisted path to be blocked, got: %s", result.ForLLM)
} }
} }
// TestReadFileTool_ChunkedReading verifies the pagination logic of the tool
// by reading a file in multiple chunks using 'offset' and 'length'.
func TestReadFileTool_ChunkedReading(t *testing.T) {
tmpDir := t.TempDir()
testFile := filepath.Join(tmpDir, "pagination_test.txt")
// Create a test file with exactly 26 bytes of content
fullContent := "abcdefghijklmnopqrstuvwxyz"
err := os.WriteFile(testFile, []byte(fullContent), 0o644)
if err != nil {
t.Fatalf("Failed to write test file: %v", err)
}
tool := NewReadFileTool(tmpDir, false, MaxReadFileSize)
ctx := context.Background()
// --- Step 1: Read the first chunk (10 bytes) ---
args1 := map[string]any{
"path": testFile,
"offset": 0,
"length": 10,
}
result1 := tool.Execute(ctx, args1)
if result1.IsError {
t.Fatalf("Chunk 1 failed: %s", result1.ForLLM)
}
// Expect the first 10 characters
if !strings.Contains(result1.ForLLM, "abcdefghij") {
t.Errorf("Chunk 1 should contain 'abcdefghij', got: %s", result1.ForLLM)
}
// Expect the header to indicate the file is truncated
if !strings.Contains(result1.ForLLM, "[TRUNCATED") {
t.Errorf("Chunk 1 header should indicate truncation, got: %s", result1.ForLLM)
}
// Expect the header to suggest the next offset (10)
if !strings.Contains(result1.ForLLM, "offset=10") {
t.Errorf("Chunk 1 header should suggest next offset=10, got: %s", result1.ForLLM)
}
// Step 2: Read the second chunk (10 bytes) ---
args2 := map[string]any{
"path": testFile,
"offset": 10,
"length": 10,
}
result2 := tool.Execute(ctx, args2)
if result2.IsError {
t.Fatalf("Chunk 2 failed: %s", result2.ForLLM)
}
// Expect the next 10 characters
if !strings.Contains(result2.ForLLM, "klmnopqrst") {
t.Errorf("Chunk 2 should contain 'klmnopqrst', got: %s", result2.ForLLM)
}
// Expect the header to suggest the next offset (20)
if !strings.Contains(result2.ForLLM, "offset=20") {
t.Errorf("Chunk 2 header should suggest next offset=20, got: %s", result2.ForLLM)
}
// Step 3: Read the final chunk (remaining 6 bytes) ---
// We ask for 10 bytes, but only 6 are left in the file
args3 := map[string]any{
"path": testFile,
"offset": 20,
"length": 10,
}
result3 := tool.Execute(ctx, args3)
if result3.IsError {
t.Fatalf("Chunk 3 failed: %s", result3.ForLLM)
}
// Expect the last 6 characters
if !strings.Contains(result3.ForLLM, "uvwxyz") {
t.Errorf("Chunk 3 should contain 'uvwxyz', got: %s", result3.ForLLM)
}
// Expect the header to indicate the end of the file
if !strings.Contains(result3.ForLLM, "[END OF FILE") {
t.Errorf("Chunk 3 header should indicate end of file, got: %s", result3.ForLLM)
}
// Ensure no TRUNCATED message is present in the final chunk
if strings.Contains(result3.ForLLM, "[TRUNCATED") {
t.Errorf("Chunk 3 header should NOT indicate truncation, got: %s", result3.ForLLM)
}
}
// TestReadFileTool_OffsetBeyondEOF checks the behavior when requesting
// An offset that exceeds the total file size.
func TestReadFileTool_OffsetBeyondEOF(t *testing.T) {
tmpDir := t.TempDir()
testFile := filepath.Join(tmpDir, "short.txt")
// create a file of only 5 bytes
err := os.WriteFile(testFile, []byte("12345"), 0o644)
if err != nil {
t.Fatalf("Failed to write test file: %v", err)
}
tool := NewReadFileTool(tmpDir, false, MaxReadFileSize)
ctx := context.Background()
args := map[string]any{
"path": testFile,
"offset": int64(100), // Offset beyond the end of the file
}
result := tool.Execute(ctx, args)
// It should not be classified as a tool execution error
if result.IsError {
t.Errorf("A mistake was not expected, obtained IsError=true: %s", result.ForLLM)
}
// Must return EXACTLY the string provided in the code
expectedMsg := "[END OF FILE - no content at this offset]"
if result.ForLLM != expectedMsg {
t.Errorf("The message %q was expected, obtained: %q", expectedMsg, result.ForLLM)
}
}

View file

@ -5,20 +5,28 @@ import (
"fmt" "fmt"
"sort" "sort"
"sync" "sync"
"sync/atomic"
"time" "time"
"github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/providers" "github.com/sipeed/picoclaw/pkg/providers"
) )
type ToolEntry struct {
Tool Tool
IsCore bool
TTL int
}
type ToolRegistry struct { type ToolRegistry struct {
tools map[string]Tool tools map[string]*ToolEntry
mu sync.RWMutex mu sync.RWMutex
version atomic.Uint64 // incremented on Register/RegisterHidden for cache invalidation
} }
func NewToolRegistry() *ToolRegistry { func NewToolRegistry() *ToolRegistry {
return &ToolRegistry{ return &ToolRegistry{
tools: make(map[string]Tool), tools: make(map[string]*ToolEntry),
} }
} }
@ -30,14 +38,116 @@ func (r *ToolRegistry) Register(tool Tool) {
logger.WarnCF("tools", "Tool registration overwrites existing tool", logger.WarnCF("tools", "Tool registration overwrites existing tool",
map[string]any{"name": name}) map[string]any{"name": name})
} }
r.tools[name] = tool r.tools[name] = &ToolEntry{
Tool: tool,
IsCore: true,
TTL: 0, // Core tools do not use TTL
}
r.version.Add(1)
logger.DebugCF("tools", "Registered core tool", map[string]any{"name": name})
}
// RegisterHidden saves hidden tools (visible only via TTL)
func (r *ToolRegistry) RegisterHidden(tool Tool) {
r.mu.Lock()
defer r.mu.Unlock()
name := tool.Name()
if _, exists := r.tools[name]; exists {
logger.WarnCF("tools", "Hidden tool registration overwrites existing tool",
map[string]any{"name": name})
}
r.tools[name] = &ToolEntry{
Tool: tool,
IsCore: false,
TTL: 0,
}
r.version.Add(1)
logger.DebugCF("tools", "Registered hidden tool", map[string]any{"name": name})
}
// PromoteTools atomically sets the TTL for multiple non-core tools.
// This prevents a concurrent TickTTL from decrementing between promotions.
func (r *ToolRegistry) PromoteTools(names []string, ttl int) {
r.mu.Lock()
defer r.mu.Unlock()
promoted := 0
for _, name := range names {
if entry, exists := r.tools[name]; exists {
if !entry.IsCore {
entry.TTL = ttl
promoted++
}
}
}
logger.DebugCF(
"tools",
"PromoteTools completed",
map[string]any{"requested": len(names), "promoted": promoted, "ttl": ttl},
)
}
// TickTTL decreases TTL only for non-core tools
func (r *ToolRegistry) TickTTL() {
r.mu.Lock()
defer r.mu.Unlock()
for _, entry := range r.tools {
if !entry.IsCore && entry.TTL > 0 {
entry.TTL--
}
}
}
// Version returns the current registry version (atomically).
func (r *ToolRegistry) Version() uint64 {
return r.version.Load()
}
// HiddenToolSnapshot holds a consistent snapshot of hidden tools and the
// registry version at which it was taken. Used by BM25SearchTool cache.
type HiddenToolSnapshot struct {
Docs []HiddenToolDoc
Version uint64
}
// HiddenToolDoc is a lightweight representation of a hidden tool for search indexing.
type HiddenToolDoc struct {
Name string
Description string
}
// SnapshotHiddenTools returns all non-core tools and the current registry
// version under a single read-lock, guaranteeing consistency between the
// two values.
func (r *ToolRegistry) SnapshotHiddenTools() HiddenToolSnapshot {
r.mu.RLock()
defer r.mu.RUnlock()
docs := make([]HiddenToolDoc, 0, len(r.tools))
for name, entry := range r.tools {
if !entry.IsCore {
docs = append(docs, HiddenToolDoc{
Name: name,
Description: entry.Tool.Description(),
})
}
}
return HiddenToolSnapshot{
Docs: docs,
Version: r.version.Load(),
}
} }
func (r *ToolRegistry) Get(name string) (Tool, bool) { func (r *ToolRegistry) Get(name string) (Tool, bool) {
r.mu.RLock() r.mu.RLock()
defer r.mu.RUnlock() defer r.mu.RUnlock()
tool, ok := r.tools[name] entry, ok := r.tools[name]
return tool, ok if !ok {
return nil, false
}
// Hidden tools with expired TTL are not callable.
if !entry.IsCore && entry.TTL <= 0 {
return nil, false
}
return entry.Tool, true
} }
func (r *ToolRegistry) Execute(ctx context.Context, name string, args map[string]any) *ToolResult { func (r *ToolRegistry) Execute(ctx context.Context, name string, args map[string]any) *ToolResult {
@ -135,7 +245,13 @@ func (r *ToolRegistry) GetDefinitions() []map[string]any {
sorted := r.sortedToolNames() sorted := r.sortedToolNames()
definitions := make([]map[string]any, 0, len(sorted)) definitions := make([]map[string]any, 0, len(sorted))
for _, name := range sorted { for _, name := range sorted {
definitions = append(definitions, ToolToSchema(r.tools[name])) entry := r.tools[name]
if !entry.IsCore && entry.TTL <= 0 {
continue
}
definitions = append(definitions, ToolToSchema(r.tools[name].Tool))
} }
return definitions return definitions
} }
@ -149,8 +265,13 @@ func (r *ToolRegistry) ToProviderDefs() []providers.ToolDefinition {
sorted := r.sortedToolNames() sorted := r.sortedToolNames()
definitions := make([]providers.ToolDefinition, 0, len(sorted)) definitions := make([]providers.ToolDefinition, 0, len(sorted))
for _, name := range sorted { for _, name := range sorted {
tool := r.tools[name] entry := r.tools[name]
schema := ToolToSchema(tool)
if !entry.IsCore && entry.TTL <= 0 {
continue
}
schema := ToolToSchema(entry.Tool)
// Safely extract nested values with type checks // Safely extract nested values with type checks
fn, ok := schema["function"].(map[string]any) fn, ok := schema["function"].(map[string]any)
@ -198,8 +319,13 @@ func (r *ToolRegistry) GetSummaries() []string {
sorted := r.sortedToolNames() sorted := r.sortedToolNames()
summaries := make([]string, 0, len(sorted)) summaries := make([]string, 0, len(sorted))
for _, name := range sorted { for _, name := range sorted {
tool := r.tools[name] entry := r.tools[name]
summaries = append(summaries, fmt.Sprintf("- `%s` - %s", tool.Name(), tool.Description()))
if !entry.IsCore && entry.TTL <= 0 {
continue
}
summaries = append(summaries, fmt.Sprintf("- `%s` - %s", entry.Tool.Name(), entry.Tool.Description()))
} }
return summaries return summaries
} }

304
pkg/tools/search_tool.go Normal file
View file

@ -0,0 +1,304 @@
package tools
import (
"context"
"encoding/json"
"fmt"
"regexp"
"strings"
"sync"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/utils"
)
const (
MaxRegexPatternLength = 200
)
type RegexSearchTool struct {
registry *ToolRegistry
ttl int
maxSearchResults int
}
func NewRegexSearchTool(r *ToolRegistry, ttl int, maxSearchResults int) *RegexSearchTool {
return &RegexSearchTool{registry: r, ttl: ttl, maxSearchResults: maxSearchResults}
}
func (t *RegexSearchTool) Name() string {
return "tool_search_tool_regex"
}
func (t *RegexSearchTool) Description() string {
return "Search available hidden tools on-demand using a regex pattern. Returns JSON schemas of discovered tools."
}
func (t *RegexSearchTool) Parameters() map[string]any {
return map[string]any{
"type": "object",
"properties": map[string]any{
"pattern": map[string]any{
"type": "string",
"description": "Regex pattern to match tool name or description",
},
},
"required": []string{"pattern"},
}
}
func (t *RegexSearchTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
pattern, ok := args["pattern"].(string)
if !ok || strings.TrimSpace(pattern) == "" {
// An empty string regex (?i) will match every hidden tool,
// dumping massive payloads into the context and burning tokens.
return ErrorResult("Missing or invalid 'pattern' argument. Must be a non-empty string.")
}
if len(pattern) > MaxRegexPatternLength {
logger.WarnCF("discovery", "Regex pattern rejected (too long)", map[string]any{"len": len(pattern)})
return ErrorResult(fmt.Sprintf("Pattern too long: max %d characters allowed", MaxRegexPatternLength))
}
logger.DebugCF("discovery", "Regex search", map[string]any{"pattern": pattern})
res, err := t.registry.SearchRegex(pattern, t.maxSearchResults)
if err != nil {
logger.WarnCF("discovery", "Invalid regex pattern", map[string]any{"pattern": pattern, "error": err.Error()})
return ErrorResult(fmt.Sprintf("Invalid regex pattern syntax: %v. Please fix your regex and try again.", err))
}
logger.InfoCF("discovery", "Regex search completed", map[string]any{"pattern": pattern, "results": len(res)})
return formatDiscoveryResponse(t.registry, res, t.ttl)
}
type BM25SearchTool struct {
registry *ToolRegistry
ttl int
maxSearchResults int
// Cache: rebuilt only when the registry version changes.
cacheMu sync.Mutex
cachedEngine *bm25CachedEngine
cacheVersion uint64
}
func NewBM25SearchTool(r *ToolRegistry, ttl int, maxSearchResults int) *BM25SearchTool {
return &BM25SearchTool{registry: r, ttl: ttl, maxSearchResults: maxSearchResults}
}
func (t *BM25SearchTool) Name() string {
return "tool_search_tool_bm25"
}
func (t *BM25SearchTool) Description() string {
return "Search available hidden tools on-demand using natural language query describing the action you need to perform. Returns JSON schemas of discovered tools."
}
func (t *BM25SearchTool) Parameters() map[string]any {
return map[string]any{
"type": "object",
"properties": map[string]any{
"query": map[string]any{
"type": "string",
"description": "Search query",
},
},
"required": []string{"query"},
}
}
func (t *BM25SearchTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
query, ok := args["query"].(string)
if !ok || strings.TrimSpace(query) == "" {
// An empty string query will match every hidden tool,
// dumping massive payloads into the context and burning tokens.
return ErrorResult("Missing or invalid 'query' argument. Must be a non-empty string.")
}
logger.DebugCF("discovery", "BM25 search", map[string]any{"query": query})
cached := t.getOrBuildEngine()
if cached == nil {
logger.DebugCF("discovery", "BM25 search: no hidden tools available", nil)
return SilentResult("No tools found matching the query.")
}
ranked := cached.engine.Search(query, t.maxSearchResults)
if len(ranked) == 0 {
logger.DebugCF("discovery", "BM25 search: no matches", map[string]any{"query": query})
return SilentResult("No tools found matching the query.")
}
results := make([]ToolSearchResult, len(ranked))
for i, r := range ranked {
results[i] = ToolSearchResult{
Name: r.Document.Name,
Description: r.Document.Description,
}
}
logger.InfoCF("discovery", "BM25 search completed", map[string]any{"query": query, "results": len(results)})
return formatDiscoveryResponse(t.registry, results, t.ttl)
}
// ToolSearchResult represents the result returned to the LLM.
// Parameters are omitted from the JSON response to save context tokens;
// the LLM will see full schemas via ToProviderDefs after promotion.
type ToolSearchResult struct {
Name string `json:"name"`
Description string `json:"description"`
}
func (r *ToolRegistry) SearchRegex(pattern string, maxSearchResults int) ([]ToolSearchResult, error) {
if maxSearchResults <= 0 {
return nil, nil
}
regex, err := regexp.Compile("(?i)" + pattern)
if err != nil {
return nil, fmt.Errorf("failed to compile regex pattern %q: %w", pattern, err)
}
r.mu.RLock()
defer r.mu.RUnlock()
var results []ToolSearchResult
// Iterate in sorted order for deterministic results across calls.
for _, name := range r.sortedToolNames() {
entry := r.tools[name]
// Search only among the hidden tools (Core tools are already visible)
if !entry.IsCore {
// Directly call interface methods! No reflection/unmarshalling needed.
desc := entry.Tool.Description()
if regex.MatchString(name) || regex.MatchString(desc) {
results = append(results, ToolSearchResult{
Name: name,
Description: desc,
})
if len(results) >= maxSearchResults {
break // Stop searching once we hit the max! Saves CPU.
}
}
}
}
return results, nil
}
func formatDiscoveryResponse(registry *ToolRegistry, results []ToolSearchResult, ttl int) *ToolResult {
if len(results) == 0 {
return SilentResult("No tools found matching the query.")
}
names := make([]string, len(results))
for i, r := range results {
names[i] = r.Name
}
registry.PromoteTools(names, ttl)
logger.InfoCF("discovery", "Promoted tools", map[string]any{"tools": names, "ttl": ttl})
b, err := json.Marshal(results)
if err != nil {
return ErrorResult("Failed to format search results: " + err.Error())
}
msg := fmt.Sprintf(
"Found %d tools:\n%s\n\nSUCCESS: These tools have been temporarily UNLOCKED as native tools! In your next response, you can call them directly just like any normal tool",
len(results),
string(b),
)
return SilentResult(msg)
}
// Lightweight internal type used as corpus document for BM25.
type searchDoc struct {
Name string
Description string
}
// bm25CachedEngine wraps a BM25Engine with its corpus snapshot.
type bm25CachedEngine struct {
engine *utils.BM25Engine[searchDoc]
}
// snapshotToSearchDocs converts a HiddenToolSnapshot to BM25 searchDoc slice.
func snapshotToSearchDocs(snap HiddenToolSnapshot) []searchDoc {
docs := make([]searchDoc, len(snap.Docs))
for i, d := range snap.Docs {
docs[i] = searchDoc{Name: d.Name, Description: d.Description}
}
return docs
}
// buildBM25Engine creates a BM25Engine from a slice of searchDocs.
func buildBM25Engine(docs []searchDoc) *utils.BM25Engine[searchDoc] {
return utils.NewBM25Engine(
docs,
func(doc searchDoc) string {
return doc.Name + " " + doc.Description
},
)
}
// getOrBuildEngine returns a cached BM25 engine, rebuilding it only when
// the registry version has changed (new tools registered).
func (t *BM25SearchTool) getOrBuildEngine() *bm25CachedEngine {
// Fast path: optimistic check without locking.
if t.cachedEngine != nil && t.cacheVersion == t.registry.Version() {
return t.cachedEngine
}
t.cacheMu.Lock()
defer t.cacheMu.Unlock()
// Snapshot + version are read under a single registry RLock,
// guaranteeing consistency (no TOCTOU).
snap := t.registry.SnapshotHiddenTools()
// Re-check: another goroutine may have rebuilt while we waited for cacheMu.
if t.cachedEngine != nil && t.cacheVersion == snap.Version {
return t.cachedEngine
}
docs := snapshotToSearchDocs(snap)
if len(docs) == 0 {
t.cachedEngine = nil
t.cacheVersion = snap.Version
return nil
}
cached := &bm25CachedEngine{engine: buildBM25Engine(docs)}
t.cachedEngine = cached
t.cacheVersion = snap.Version
logger.DebugCF("discovery", "BM25 engine rebuilt", map[string]any{"docs": len(docs), "version": snap.Version})
return cached
}
// SearchBM25 ranks hidden tools against query using BM25 via utils.BM25Engine.
// This non-cached variant rebuilds the engine on every call. Used by tests
// and any code that doesn't hold a BM25SearchTool instance.
func (r *ToolRegistry) SearchBM25(query string, maxSearchResults int) []ToolSearchResult {
snap := r.SnapshotHiddenTools()
docs := snapshotToSearchDocs(snap)
if len(docs) == 0 {
return nil
}
ranked := buildBM25Engine(docs).Search(query, maxSearchResults)
if len(ranked) == 0 {
return nil
}
out := make([]ToolSearchResult, len(ranked))
for i, r := range ranked {
out[i] = ToolSearchResult{
Name: r.Document.Name,
Description: r.Document.Description,
}
}
return out
}

View file

@ -0,0 +1,339 @@
package tools
import (
"context"
"fmt"
"strings"
"testing"
)
// Dummy tool to fill the registry in our tests.
type mockSearchableTool struct {
name string
desc string
}
func (m *mockSearchableTool) Name() string { return m.name }
func (m *mockSearchableTool) Description() string { return m.desc }
func (m *mockSearchableTool) Parameters() map[string]any {
return map[string]any{"type": "object"}
}
func (m *mockSearchableTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
return SilentResult("mock executed: " + m.name)
}
// Helper to initialize a populated ToolRegistry
func setupPopulatedRegistry() *ToolRegistry {
reg := NewToolRegistry()
// A core tool (NOT to be found by searches)
reg.Register(&mockSearchableTool{
name: "core_search",
desc: "I am a visible core tool for searching files",
})
// Hidden tools (must be found by searches)
reg.RegisterHidden(&mockSearchableTool{
name: "mcp_read_file",
desc: "Read the contents of a system file",
})
reg.RegisterHidden(&mockSearchableTool{
name: "mcp_list_dir",
desc: "List directories and files in the system",
})
reg.RegisterHidden(&mockSearchableTool{
name: "mcp_fetch_net",
desc: "Fetch data from a network database",
})
return reg
}
func TestRegexSearchTool_Execute(t *testing.T) {
reg := setupPopulatedRegistry()
tool := NewRegexSearchTool(reg, 5, 10)
ctx := context.Background()
t.Run("Empty Pattern Error", func(t *testing.T) {
res := tool.Execute(ctx, map[string]any{})
if !res.IsError || !strings.Contains(res.ForLLM, "Missing or invalid 'pattern'") {
t.Errorf("Expected missing pattern error, got: %v", res.ForLLM)
}
})
t.Run("Invalid Regex Syntax", func(t *testing.T) {
res := tool.Execute(ctx, map[string]any{"pattern": "[unclosed"})
if !res.IsError || !strings.Contains(res.ForLLM, "Invalid regex pattern syntax") {
t.Errorf("Expected regex syntax error, got: %v", res.ForLLM)
}
})
t.Run("No Match Found", func(t *testing.T) {
res := tool.Execute(ctx, map[string]any{"pattern": "alien"})
if res.IsError || !strings.Contains(res.ForLLM, "No tools found matching") {
t.Errorf("Expected 'no tools found' message, got: %v", res.ForLLM)
}
})
t.Run("Successful Match & Promotion", func(t *testing.T) {
res := tool.Execute(ctx, map[string]any{"pattern": "system"})
if res.IsError {
t.Fatalf("Unexpected error: %v", res.ForLLM)
}
if !strings.Contains(res.ForLLM, "SUCCESS: These tools have been temporarily UNLOCKED") {
t.Errorf("Expected success string, got: %v", res.ForLLM)
}
if !strings.Contains(res.ForLLM, "mcp_read_file") {
t.Errorf("Expected 'mcp_read_file' in results")
}
// Verify that the TTL has been updated for the tools found
reg.mu.RLock()
defer reg.mu.RUnlock()
if reg.tools["mcp_read_file"].TTL != 5 {
t.Errorf("Expected TTL of 'mcp_read_file' to be promoted to 5, got %d", reg.tools["mcp_read_file"].TTL)
}
if reg.tools["mcp_fetch_net"].TTL != 0 {
t.Errorf("Expected 'mcp_fetch_net' to NOT be promoted (TTL=0)")
}
})
}
func TestBM25SearchTool_Execute(t *testing.T) {
reg := setupPopulatedRegistry()
tool := NewBM25SearchTool(reg, 3, 10)
ctx := context.Background()
t.Run("Empty Query Error", func(t *testing.T) {
res := tool.Execute(ctx, map[string]any{"query": " "})
if !res.IsError || !strings.Contains(res.ForLLM, "Missing or invalid 'query'") {
t.Errorf("Expected missing query error, got: %v", res.ForLLM)
}
})
t.Run("No Match Found", func(t *testing.T) {
res := tool.Execute(ctx, map[string]any{"query": "aliens spaceships"})
if res.IsError || !strings.Contains(res.ForLLM, "No tools found matching") {
t.Errorf("Expected 'no tools found', got: %v", res.ForLLM)
}
})
t.Run("Successful Match & Promotion", func(t *testing.T) {
res := tool.Execute(ctx, map[string]any{"query": "read files"})
if res.IsError {
t.Fatalf("Unexpected error: %v", res.ForLLM)
}
if !strings.Contains(res.ForLLM, "mcp_read_file") {
t.Errorf("Expected 'mcp_read_file' in BM25 results")
}
reg.mu.RLock()
defer reg.mu.RUnlock()
if reg.tools["mcp_read_file"].TTL != 3 {
t.Errorf("Expected TTL of 'mcp_read_file' to be promoted to 3")
}
})
}
func TestRegexSearchTool_PatternTooLong(t *testing.T) {
reg := setupPopulatedRegistry()
tool := NewRegexSearchTool(reg, 5, 10)
ctx := context.Background()
longPattern := strings.Repeat("a", MaxRegexPatternLength+1)
res := tool.Execute(ctx, map[string]any{"pattern": longPattern})
if !res.IsError || !strings.Contains(res.ForLLM, "Pattern too long") {
t.Errorf("Expected pattern too long error, got: %v", res.ForLLM)
}
}
func TestSearchRegex_ZeroMaxResults(t *testing.T) {
reg := setupPopulatedRegistry()
res, err := reg.SearchRegex("mcp", 0)
if err != nil {
t.Fatalf("SearchRegex failed: %v", err)
}
if len(res) != 0 {
t.Errorf("Expected 0 results with maxSearchResults=0, got %d", len(res))
}
}
func TestSearchBM25_ZeroMaxResults(t *testing.T) {
reg := setupPopulatedRegistry()
res := reg.SearchBM25("read file", 0)
if len(res) != 0 {
t.Errorf("Expected 0 results with maxSearchResults=0, got %d", len(res))
}
}
func TestSearchRegex_DeterministicOrder(t *testing.T) {
reg := NewToolRegistry()
for i := 0; i < 20; i++ {
reg.RegisterHidden(&mockSearchableTool{
name: fmt.Sprintf("tool_%02d", i),
desc: "searchable tool",
})
}
// Run the same search multiple times and verify order is stable
var firstRun []string
for attempt := 0; attempt < 10; attempt++ {
res, err := reg.SearchRegex("searchable", 20)
if err != nil {
t.Fatalf("SearchRegex failed: %v", err)
}
names := make([]string, len(res))
for i, r := range res {
names[i] = r.Name
}
if attempt == 0 {
firstRun = names
} else {
for i, name := range names {
if name != firstRun[i] {
t.Fatalf("Non-deterministic order at attempt %d, index %d: got %q, want %q",
attempt, i, name, firstRun[i])
}
}
}
}
}
func TestToolRegistry_SearchLimitsAndCoreFiltering(t *testing.T) {
reg := NewToolRegistry()
// Add 1 Core and 10 Hidden, all containing the word "match"
reg.Register(&mockSearchableTool{"core_match", "I am core with match"})
for i := 0; i < 10; i++ {
reg.RegisterHidden(&mockSearchableTool{
name: fmt.Sprintf("hidden_match_%d", i),
desc: "this has a match",
})
}
t.Run("Regex limits and core filtering", func(t *testing.T) {
// Search with Regex and a limit of maxSearchResults = 4
res, err := reg.SearchRegex("match", 4)
if err != nil {
t.Fatalf("SearchRegex failed: %v", err)
}
if len(res) != 4 {
t.Errorf("Expected exactly 4 results due to limit, got %d", len(res))
}
for _, r := range res {
if r.Name == "core_match" {
t.Errorf("SearchRegex returned a Core tool, which should be excluded")
}
}
})
t.Run("BM25 limits and core filtering", func(t *testing.T) {
// Search with BM25 and a limit of maxSearchResults = 3
res := reg.SearchBM25("match", 3)
if len(res) != 3 {
t.Errorf("Expected exactly 3 results due to limit, got %d", len(res))
}
for _, r := range res {
if r.Name == "core_match" {
t.Errorf("SearchBM25 returned a Core tool, which should be excluded")
}
}
})
}
func TestGet_HiddenToolTTLLifecycle(t *testing.T) {
reg := NewToolRegistry()
reg.RegisterHidden(&mockSearchableTool{name: "hidden_tool", desc: "test"})
// TTL=0 at registration → not gettable
_, ok := reg.Get("hidden_tool")
if ok {
t.Error("Expected hidden tool with TTL=0 to NOT be gettable")
}
// Promote → gettable
reg.PromoteTools([]string{"hidden_tool"}, 3)
_, ok = reg.Get("hidden_tool")
if !ok {
t.Error("Expected promoted hidden tool to be gettable")
}
// Tick down to 0 → not gettable again
reg.TickTTL() // 3→2
reg.TickTTL() // 2→1
reg.TickTTL() // 1→0
_, ok = reg.Get("hidden_tool")
if ok {
t.Error("Expected hidden tool with TTL ticked to 0 to NOT be gettable")
}
// Core tools remain always gettable
reg.Register(&mockSearchableTool{name: "core_tool", desc: "core"})
_, ok = reg.Get("core_tool")
if !ok {
t.Error("Expected core tool to always be gettable")
}
}
func TestBM25CacheInvalidation(t *testing.T) {
reg := NewToolRegistry()
reg.RegisterHidden(&mockSearchableTool{name: "tool_alpha", desc: "alpha functionality"})
tool := NewBM25SearchTool(reg, 5, 10)
ctx := context.Background()
// First search should find tool_alpha
res := tool.Execute(ctx, map[string]any{"query": "alpha"})
if !strings.Contains(res.ForLLM, "tool_alpha") {
t.Fatalf("Expected 'tool_alpha' in first search, got: %v", res.ForLLM)
}
// Register a new hidden tool
reg.RegisterHidden(&mockSearchableTool{name: "tool_beta", desc: "beta functionality"})
// Cache should be invalidated; new tool should be findable
res = tool.Execute(ctx, map[string]any{"query": "beta"})
if !strings.Contains(res.ForLLM, "tool_beta") {
t.Errorf("Expected 'tool_beta' after cache invalidation, got: %v", res.ForLLM)
}
}
func TestPromoteTools_ConcurrentWithTickTTL(t *testing.T) {
reg := NewToolRegistry()
for i := 0; i < 20; i++ {
reg.RegisterHidden(&mockSearchableTool{
name: fmt.Sprintf("concurrent_tool_%d", i),
desc: "concurrent test tool",
})
}
names := make([]string, 20)
for i := 0; i < 20; i++ {
names[i] = fmt.Sprintf("concurrent_tool_%d", i)
}
// Hammer PromoteTools and TickTTL concurrently to detect races
done := make(chan struct{})
go func() {
for i := 0; i < 1000; i++ {
reg.PromoteTools(names, 5)
}
close(done)
}()
for i := 0; i < 1000; i++ {
reg.TickTTL()
}
<-done
}

272
pkg/utils/bm25.go Normal file
View file

@ -0,0 +1,272 @@
// Package utils provides shared, reusable algorithms.
// This file implements a generic BM25 search engine.
//
// Usage:
//
// type MyDoc struct { ID string; Body string }
//
// corpus := []MyDoc{...}
// engine := bm25.New(corpus, func(d MyDoc) string {
// return d.ID + " " + d.Body
// })
// results := engine.Search("my query", 5)
package utils
import (
"math"
"sort"
"strings"
)
// ── Tuning defaults ───────────────────────────────────────────────────────────
const (
// DefaultBM25K1 is the term-frequency saturation factor (typical range 1.22.0).
// Higher values give more weight to repeated terms.
DefaultBM25K1 = 1.2
// DefaultBM25B is the document-length normalization factor (0 = none, 1 = full).
DefaultBM25B = 0.75
)
// BM25Engine is a query-time BM25 search engine over a generic corpus.
// T is the document type; the caller supplies a TextFunc that extracts the
// searchable text from each document.
//
// The engine is stateless between queries: no caching, no invalidation logic.
// All indexing work is performed inside Search() on every call, making it
// safe to use on corpora that change frequently.
type BM25Engine[T any] struct {
corpus []T
textFunc func(T) string
k1 float64
b float64
}
// BM25Option is a functional option to configure a BM25Engine.
type BM25Option func(*bm25Config)
type bm25Config struct {
k1 float64
b float64
}
// WithK1 overrides the term-frequency saturation constant (default 1.2).
func WithK1(k1 float64) BM25Option {
return func(c *bm25Config) { c.k1 = k1 }
}
// WithB overrides the document-length normalization factor (default 0.75).
func WithB(b float64) BM25Option {
return func(c *bm25Config) { c.b = b }
}
// NewBM25Engine creates a BM25Engine for the given corpus.
//
// - corpus : slice of documents of any type T.
// - textFunc : function that returns the searchable text for a document.
// - opts : optional tuning (WithK1, WithB).
//
// The corpus slice is referenced, not copied. Callers must not mutate it
// concurrently with Search().
func NewBM25Engine[T any](corpus []T, textFunc func(T) string, opts ...BM25Option) *BM25Engine[T] {
cfg := bm25Config{k1: DefaultBM25K1, b: DefaultBM25B}
for _, o := range opts {
o(&cfg)
}
return &BM25Engine[T]{
corpus: corpus,
textFunc: textFunc,
k1: cfg.k1,
b: cfg.b,
}
}
// BM25Result is a single ranked result from a Search call.
type BM25Result[T any] struct {
Document T
Score float32
}
// Search ranks the corpus against query and returns the top-k results.
// Returns an empty slice (not nil) when there are no matches.
//
// Complexity: O(N×L) for indexing + O(|Q|×avgPostingLen) for scoring,
// where N = corpus size, L = average document length, Q = query terms.
// Top-k extraction uses a fixed-size min-heap: O(candidates × log k).
func (e *BM25Engine[T]) Search(query string, topK int) []BM25Result[T] {
if topK <= 0 {
return []BM25Result[T]{}
}
queryTerms := bm25Tokenize(query)
if len(queryTerms) == 0 {
return []BM25Result[T]{}
}
N := len(e.corpus)
if N == 0 {
return []BM25Result[T]{}
}
// Step 1: build per-document tf + raw doc lengths
type docEntry struct {
tf map[string]uint32
rawLen int
}
entries := make([]docEntry, N)
df := make(map[string]int, 64)
totalLen := 0
for i, doc := range e.corpus {
tokens := bm25Tokenize(e.textFunc(doc))
totalLen += len(tokens)
tf := make(map[string]uint32, len(tokens))
for _, t := range tokens {
tf[t]++
}
// df: each term counts once per document (iterate the map, keys are unique)
for t := range tf {
df[t]++
}
entries[i] = docEntry{tf: tf, rawLen: len(tokens)}
}
avgDocLen := float64(totalLen) / float64(N)
// Step 2: pre-compute IDF and per-doc length normalization
// IDF (Robertson smoothing): log( (N - df(t) + 0.5) / (df(t) + 0.5) + 1 )
idf := make(map[string]float32, len(df))
for term, freq := range df {
idf[term] = float32(math.Log(
(float64(N)-float64(freq)+0.5)/(float64(freq)+0.5) + 1,
))
}
// docLenNorm[i] = k1 * (1 - b + b * |doc_i| / avgDocLen)
// Stored as float32 — sufficient precision for ranking.
docLenNorm := make([]float32, N)
for i, entry := range entries {
docLenNorm[i] = float32(e.k1 * (1 - e.b + e.b*float64(entry.rawLen)/avgDocLen))
}
// Step 3: build inverted index (posting lists)
// Iterate the tf map directly — map keys are already unique, no seen-set needed.
posting := make(map[string][]int32, len(df))
for i, entry := range entries {
for term := range entry.tf {
posting[term] = append(posting[term], int32(i))
}
}
// Step 4: score via posting lists
// Deduplicate query terms to avoid double-weighting the same term.
unique := bm25Dedupe(queryTerms)
scores := make(map[int32]float32)
for _, term := range unique {
termIDF, ok := idf[term]
if !ok {
continue // term not in vocabulary → zero contribution
}
for _, docID := range posting[term] {
freq := float32(entries[docID].tf[term])
// TF_norm = freq * (k1+1) / (freq + docLenNorm)
tfNorm := freq * float32(e.k1+1) / (freq + docLenNorm[docID])
scores[docID] += termIDF * tfNorm
}
}
if len(scores) == 0 {
return []BM25Result[T]{}
}
// Step 5: top-K via fixed-size min-heap
heap := make([]bm25ScoredDoc, 0, topK)
for docID, sc := range scores {
switch {
case len(heap) < topK:
heap = append(heap, bm25ScoredDoc{docID: docID, score: sc})
if len(heap) == topK {
bm25MinHeapify(heap)
}
case sc > heap[0].score:
heap[0] = bm25ScoredDoc{docID: docID, score: sc}
bm25SiftDown(heap, 0)
}
}
sort.Slice(heap, func(i, j int) bool { return heap[i].score > heap[j].score })
out := make([]BM25Result[T], len(heap))
for i, h := range heap {
out[i] = BM25Result[T]{
Document: e.corpus[h.docID],
Score: h.score,
}
}
return out
}
// bm25Tokenize splits s into lowercase tokens, stripping edge punctuation.
func bm25Tokenize(s string) []string {
raw := strings.Fields(strings.ToLower(s))
out := raw[:0] // reuse backing array to avoid extra allocation
for _, t := range raw {
t = strings.Trim(t, ".,;:!?\"'()/\\-_")
if t != "" {
out = append(out, t)
}
}
return out
}
// bm25Dedupe returns a new slice with duplicate tokens removed,
// preserving first-occurrence order.
func bm25Dedupe(tokens []string) []string {
seen := make(map[string]struct{}, len(tokens))
out := make([]string, 0, len(tokens))
for _, t := range tokens {
if _, ok := seen[t]; !ok {
seen[t] = struct{}{}
out = append(out, t)
}
}
return out
}
type bm25ScoredDoc struct {
docID int32
score float32
}
// bm25MinHeapify builds a min-heap in-place using Floyd's algorithm: O(k).
func bm25MinHeapify(h []bm25ScoredDoc) {
for i := len(h)/2 - 1; i >= 0; i-- {
bm25SiftDown(h, i)
}
}
// bm25SiftDown restores the min-heap property starting at node i: O(log k).
func bm25SiftDown(h []bm25ScoredDoc, i int) {
n := len(h)
for {
smallest := i
l, r := 2*i+1, 2*i+2
if l < n && h[l].score < h[smallest].score {
smallest = l
}
if r < n && h[r].score < h[smallest].score {
smallest = r
}
if smallest == i {
break
}
h[i], h[smallest] = h[smallest], h[i]
i = smallest
}
}

175
pkg/utils/bm25_test.go Normal file
View file

@ -0,0 +1,175 @@
package utils
import (
"reflect"
"testing"
)
// testDoc is a generic structure for use in tests.
type testDoc struct {
ID int
Text string
}
func extractText(d testDoc) string {
return d.Text
}
func TestBM25Search_EdgeCases(t *testing.T) {
corpus := []testDoc{
{1, "hello world"},
{2, "foo bar"},
}
engine := NewBM25Engine(corpus, extractText)
tests := []struct {
name string
query string
topK int
}{
{"Zero topK", "hello", 0},
{"Negative topK", "hello", -1},
{"Empty query", "", 5},
{"Query with only punctuation", "...,,,!!!", 5},
{"No matches found", "golang", 5},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
results := engine.Search(tt.query, tt.topK)
if len(results) != 0 {
t.Errorf("expected 0 results, got %d", len(results))
}
// Check that it never returns nil, but an empty slice
if results == nil {
t.Errorf("expected empty slice, got nil")
}
})
}
}
func TestBM25Search_EmptyCorpus(t *testing.T) {
engine := NewBM25Engine([]testDoc{}, extractText)
results := engine.Search("hello", 5)
if len(results) != 0 || results == nil {
t.Errorf("expected empty slice from empty corpus, got %v", results)
}
}
func TestBM25Search_RankingLogic(t *testing.T) {
corpus := []testDoc{
{1, "the quick brown fox jumps over the lazy dog"},
{2, "quick fox"},
{3, "quick quick quick fox"}, // High Term Frequency (TF)
{4, "completely irrelevant document here"},
}
engine := NewBM25Engine(corpus, extractText)
t.Run("Term Frequency (TF) boosts score", func(t *testing.T) {
results := engine.Search("quick", 5)
if len(results) < 3 {
t.Fatalf("expected at least 3 results, got %d", len(results))
}
// Doc 3 has the word "quick" repeated 3 times, it should beat Doc 2
if results[0].Document.ID != 3 {
t.Errorf("expected doc 3 to rank first due to high TF, got doc %d", results[0].Document.ID)
}
})
t.Run("Document Length penalty", func(t *testing.T) {
results := engine.Search("fox", 5)
if len(results) < 3 {
t.Fatalf("expected at least 3 results, got %d", len(results))
}
// Doc 2 ("quick fox") is much shorter than Doc 1 ("the quick brown fox..."),
// so, with equal Term Frequency for the word "fox" (1 time), Doc 2 wins.
if results[0].Document.ID != 2 {
t.Errorf("expected doc 2 to rank first due to shorter length, got doc %d", results[0].Document.ID)
}
})
t.Run("TopK limits results", func(t *testing.T) {
results := engine.Search("quick", 2)
if len(results) != 2 {
t.Errorf("expected exactly 2 results, got %d", len(results))
}
})
}
func TestBM25Tokenize(t *testing.T) {
tests := []struct {
input string
expected []string
}{
{"Hello World", []string{"hello", "world"}},
{" spaces everywhere ", []string{"spaces", "everywhere"}},
{"punctuation... test!!!", []string{"punctuation", "test"}},
{"(parentheses) and-hyphens", []string{"parentheses", "and-hyphens"}}, // hyphens trimmed from edges
{"internal-hyphen is kept", []string{"internal-hyphen", "is", "kept"}},
{".,;?!", []string{}}, // Becomes empty after trim
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got := bm25Tokenize(tt.input)
if len(got) == 0 && len(tt.expected) == 0 {
return // Both empty
}
if !reflect.DeepEqual(got, tt.expected) {
t.Errorf("bm25Tokenize(%q) = %v, want %v", tt.input, got, tt.expected)
}
})
}
}
func TestBM25Dedupe(t *testing.T) {
input := []string{"apple", "banana", "apple", "orange", "banana"}
expected := []string{"apple", "banana", "orange"}
got := bm25Dedupe(input)
if !reflect.DeepEqual(got, expected) {
t.Errorf("bm25Dedupe() = %v, want %v", got, expected)
}
}
func TestBM25Options(t *testing.T) {
corpus := []testDoc{{1, "test"}}
engine := NewBM25Engine(
corpus,
extractText,
WithK1(2.5),
WithB(0.9),
)
if engine.k1 != 2.5 {
t.Errorf("expected k1 to be 2.5, got %v", engine.k1)
}
if engine.b != 0.9 {
t.Errorf("expected b to be 0.9, got %v", engine.b)
}
}
func TestBM25Search_SortingStability(t *testing.T) {
// Ensure that sorting by heap returns in correct descending order
corpus := []testDoc{
{1, "golang is good"},
{2, "golang golang"},
{3, "golang golang golang"},
{4, "golang golang golang golang"},
}
engine := NewBM25Engine(corpus, extractText)
results := engine.Search("golang", 10)
if len(results) != 4 {
t.Fatalf("expected 4 results, got %d", len(results))
}
// Score should be strictly decreasing
for i := 1; i < len(results); i++ {
if results[i].Score > results[i-1].Score {
t.Errorf("results not sorted correctly: result %d score (%v) > result %d score (%v)",
i, results[i].Score, i-1, results[i-1].Score)
}
}
}

38
web/Makefile Normal file
View file

@ -0,0 +1,38 @@
.PHONY: dev dev-frontend dev-backend build test lint clean
# Run both frontend and backend dev servers
dev:
@if [ ! -f backend/picoclaw-web ] || [ ! -d backend/dist ]; then \
echo "Build artifacts not found, building..."; \
$(MAKE) build; \
fi
@echo "Starting backend and frontend dev servers..."
@$(MAKE) dev-backend & $(MAKE) dev-frontend
# Start frontend dev server (Vite, with proxy to backend)
dev-frontend:
cd frontend && pnpm dev
# Start backend dev server
dev-backend:
cd backend && go run .
# Build frontend and embed into Go binary
build:
cd frontend && pnpm build:backend
cd backend && go build -o picoclaw-web .
# Run all tests
test:
cd backend && go test ./...
cd frontend && pnpm lint
# Lint and format
lint:
cd backend && go vet ./...
cd frontend && pnpm check
# Clean build artifacts
clean:
rm -rf frontend/dist backend/dist backend/picoclaw-web
mkdir -p backend/dist && touch backend/dist/.gitkeep

51
web/README.md Normal file
View file

@ -0,0 +1,51 @@
# Picoclaw Web
This directory contains the standalone web service for `picoclaw`.
It provides a complete unified web interface, acting as a dashboard, configuration center, and interactive console (channel client) for the core `picoclaw` engine.
## Architecture
The service is structured as a monorepo containing both the backend and frontend code to ensure high cohesion and simplify deployment.
* **`backend/`**: The Go-based web server. It provides RESTful APIs, manages WebSocket connections for chat, and handles the lifecycle of the `picoclaw` process. It eventually embeds the compiled frontend assets into a single executable.
* **`frontend/`**: The Vite + React + TanStack Router single-page application (SPA). It provides the interactive user interface.
## Getting Started
### Prerequisites
* Go 1.25+
* Node.js 20+ with pnpm
### Development
Run both the frontend dev server and the Go backend simultaneously:
```bash
make dev
```
Or run them separately:
```bash
make dev-frontend # Vite dev server
make dev-backend # Go backend
```
### Build
Build the frontend and embed it into a single Go binary:
```bash
make build
```
The output binary is `backend/picoclaw-web`.
### Other Commands
```bash
make test # Run backend tests and frontend lint
make lint # Run go vet and prettier/eslint
make clean # Remove all build artifacts
```

19
web/backend/.gitignore vendored Normal file
View file

@ -0,0 +1,19 @@
# Go build output
*.exe
*.dll
*.so
*.dylib
*.test
*.out
picoclaw-web
# Frontend build artifacts (embedded by Go)
dist/*
!dist/.gitkeep
# OS
.DS_Store
# Editors
.vscode/
.idea/

View file

@ -0,0 +1,47 @@
package api
import (
"encoding/json"
"net/http"
)
type channelCatalogItem struct {
Name string `json:"name"`
ConfigKey string `json:"config_key"`
Variant string `json:"variant,omitempty"`
}
var channelCatalog = []channelCatalogItem{
{Name: "telegram", ConfigKey: "telegram"},
{Name: "discord", ConfigKey: "discord"},
{Name: "slack", ConfigKey: "slack"},
{Name: "feishu", ConfigKey: "feishu"},
{Name: "dingtalk", ConfigKey: "dingtalk"},
{Name: "line", ConfigKey: "line"},
{Name: "qq", ConfigKey: "qq"},
{Name: "onebot", ConfigKey: "onebot"},
{Name: "wecom", ConfigKey: "wecom"},
{Name: "wecom_app", ConfigKey: "wecom_app"},
{Name: "wecom_aibot", ConfigKey: "wecom_aibot"},
{Name: "whatsapp", ConfigKey: "whatsapp", Variant: "bridge"},
{Name: "whatsapp_native", ConfigKey: "whatsapp", Variant: "native"},
{Name: "pico", ConfigKey: "pico"},
{Name: "maixcam", ConfigKey: "maixcam"},
{Name: "matrix", ConfigKey: "matrix"},
{Name: "irc", ConfigKey: "irc"},
}
// registerChannelRoutes binds read-only channel catalog endpoints to the ServeMux.
func (h *Handler) registerChannelRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/channels/catalog", h.handleListChannelCatalog)
}
// handleListChannelCatalog returns the channels supported by backend.
//
// GET /api/channels/catalog
func (h *Handler) handleListChannelCatalog(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"channels": channelCatalog,
})
}

221
web/backend/api/config.go Normal file
View file

@ -0,0 +1,221 @@
package api
import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"github.com/sipeed/picoclaw/pkg/config"
)
// registerConfigRoutes binds configuration management endpoints to the ServeMux.
func (h *Handler) registerConfigRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/config", h.handleGetConfig)
mux.HandleFunc("PUT /api/config", h.handleUpdateConfig)
mux.HandleFunc("PATCH /api/config", h.handlePatchConfig)
}
// loadFilteredConfig loads the configuration and filters out default placeholder credentials
// (like API limits/keys) if the configuration file has not been created yet by the user.
func (h *Handler) loadFilteredConfig() (*config.Config, error) {
cfg, err := config.LoadConfig(h.configPath)
if err != nil {
return nil, err
}
configExists := false
if h.configPath != "" {
if _, err := os.Stat(h.configPath); err == nil {
configExists = true
}
}
if !configExists {
for i := range cfg.ModelList {
cfg.ModelList[i].APIKey = ""
cfg.ModelList[i].AuthMethod = ""
}
}
return cfg, nil
}
// handleGetConfig returns the complete system configuration.
//
// GET /api/config
func (h *Handler) handleGetConfig(w http.ResponseWriter, r *http.Request) {
cfg, err := h.loadFilteredConfig()
if err != nil {
http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(cfg); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
}
}
// handleUpdateConfig updates the complete system configuration.
//
// PUT /api/config
func (h *Handler) handleUpdateConfig(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
if err != nil {
http.Error(w, "Failed to read request body", http.StatusBadRequest)
return
}
defer r.Body.Close()
var cfg config.Config
if err := json.Unmarshal(body, &cfg); err != nil {
http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest)
return
}
if errs := validateConfig(&cfg); len(errs) > 0 {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]any{
"status": "validation_error",
"errors": errs,
})
return
}
if err := config.SaveConfig(h.configPath, &cfg); err != nil {
http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}
// handlePatchConfig partially updates the system configuration using JSON Merge Patch (RFC 7396).
// Only the fields present in the request body will be updated; all other fields remain unchanged.
//
// PATCH /api/config
func (h *Handler) handlePatchConfig(w http.ResponseWriter, r *http.Request) {
patchBody, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
if err != nil {
http.Error(w, "Failed to read request body", http.StatusBadRequest)
return
}
defer r.Body.Close()
// Validate the patch is valid JSON
var patch map[string]any
if err = json.Unmarshal(patchBody, &patch); err != nil {
http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest)
return
}
// Load existing config and marshal to a map for merging
cfg, err := config.LoadConfig(h.configPath)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError)
return
}
existing, err := json.Marshal(cfg)
if err != nil {
http.Error(w, "Failed to serialize current config", http.StatusInternalServerError)
return
}
var base map[string]any
if err = json.Unmarshal(existing, &base); err != nil {
http.Error(w, "Failed to parse current config", http.StatusInternalServerError)
return
}
// Recursively merge patch into base
mergeMap(base, patch)
// Convert merged map back to Config struct
merged, err := json.Marshal(base)
if err != nil {
http.Error(w, "Failed to serialize merged config", http.StatusInternalServerError)
return
}
var newCfg config.Config
if err := json.Unmarshal(merged, &newCfg); err != nil {
http.Error(w, fmt.Sprintf("Merged config is invalid: %v", err), http.StatusBadRequest)
return
}
if errs := validateConfig(&newCfg); len(errs) > 0 {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]any{
"status": "validation_error",
"errors": errs,
})
return
}
if err := config.SaveConfig(h.configPath, &newCfg); err != nil {
http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}
// validateConfig checks the config for common errors before saving.
// Returns a list of human-readable error strings; empty means valid.
func validateConfig(cfg *config.Config) []string {
var errs []string
// Validate model_list entries
if err := cfg.ValidateModelList(); err != nil {
errs = append(errs, err.Error())
}
// Gateway port range
if cfg.Gateway.Port != 0 && (cfg.Gateway.Port < 1 || cfg.Gateway.Port > 65535) {
errs = append(errs, fmt.Sprintf("gateway.port %d is out of valid range (1-65535)", cfg.Gateway.Port))
}
// Pico channel: token required when enabled
if cfg.Channels.Pico.Enabled && cfg.Channels.Pico.Token == "" {
errs = append(errs, "channels.pico.token is required when pico channel is enabled")
}
// Telegram: token required when enabled
if cfg.Channels.Telegram.Enabled && cfg.Channels.Telegram.Token == "" {
errs = append(errs, "channels.telegram.token is required when telegram channel is enabled")
}
// Discord: token required when enabled
if cfg.Channels.Discord.Enabled && cfg.Channels.Discord.Token == "" {
errs = append(errs, "channels.discord.token is required when discord channel is enabled")
}
return errs
}
// mergeMap recursively merges src into dst (JSON Merge Patch semantics).
// - If a key in src has a null value, it is deleted from dst.
// - If both dst and src have a nested object for the same key, merge recursively.
// - Otherwise the value from src overwrites dst.
func mergeMap(dst, src map[string]any) {
for key, srcVal := range src {
if srcVal == nil {
delete(dst, key)
continue
}
srcMap, srcIsMap := srcVal.(map[string]any)
dstMap, dstIsMap := dst[key].(map[string]any)
if srcIsMap && dstIsMap {
mergeMap(dstMap, srcMap)
} else {
dst[key] = srcVal
}
}
}

62
web/backend/api/events.go Normal file
View file

@ -0,0 +1,62 @@
package api
import (
"encoding/json"
"sync"
)
// GatewayEvent represents a state change event for the gateway process.
type GatewayEvent struct {
Status string `json:"gateway_status"` // "running", "starting", "stopped", "error"
PID int `json:"pid,omitempty"`
}
// EventBroadcaster manages SSE client subscriptions and broadcasts events.
type EventBroadcaster struct {
mu sync.RWMutex
clients map[chan string]struct{}
}
// NewEventBroadcaster creates a new broadcaster.
func NewEventBroadcaster() *EventBroadcaster {
return &EventBroadcaster{
clients: make(map[chan string]struct{}),
}
}
// Subscribe adds a new listener channel and returns it.
// The caller must call Unsubscribe when done.
func (b *EventBroadcaster) Subscribe() chan string {
ch := make(chan string, 8)
b.mu.Lock()
b.clients[ch] = struct{}{}
b.mu.Unlock()
return ch
}
// Unsubscribe removes a listener channel and closes it.
func (b *EventBroadcaster) Unsubscribe(ch chan string) {
b.mu.Lock()
delete(b.clients, ch)
b.mu.Unlock()
close(ch)
}
// Broadcast sends a GatewayEvent to all connected SSE clients.
func (b *EventBroadcaster) Broadcast(event GatewayEvent) {
data, err := json.Marshal(event)
if err != nil {
return
}
b.mu.RLock()
defer b.mu.RUnlock()
for ch := range b.clients {
// Non-blocking send; drop event if client is slow
select {
case ch <- string(data):
default:
}
}
}

555
web/backend/api/gateway.go Normal file
View file

@ -0,0 +1,555 @@
package api
import (
"bufio"
"encoding/json"
"fmt"
"io"
"log"
"net"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
"sync"
"syscall"
"time"
"github.com/sipeed/picoclaw/pkg/config"
)
// gateway holds the state for the managed gateway process.
var gateway = struct {
mu sync.Mutex
cmd *exec.Cmd
logs *LogBuffer
events *EventBroadcaster
}{
logs: NewLogBuffer(200),
events: NewEventBroadcaster(),
}
// registerGatewayRoutes binds gateway lifecycle endpoints to the ServeMux.
func (h *Handler) registerGatewayRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/gateway/status", h.handleGatewayStatus)
mux.HandleFunc("GET /api/gateway/events", h.handleGatewayEvents)
mux.HandleFunc("POST /api/gateway/start", h.handleGatewayStart)
mux.HandleFunc("POST /api/gateway/stop", h.handleGatewayStop)
mux.HandleFunc("POST /api/gateway/restart", h.handleGatewayRestart)
}
// TryAutoStartGateway checks whether gateway start preconditions are met and
// starts it when possible. Intended to be called by the backend at startup.
func (h *Handler) TryAutoStartGateway() {
gateway.mu.Lock()
defer gateway.mu.Unlock()
if isGatewayProcessAliveLocked() {
return
}
if gateway.cmd != nil && gateway.cmd.Process != nil {
gateway.cmd = nil
}
ready, reason, err := h.gatewayStartReady()
if err != nil {
log.Printf("Skip auto-starting gateway: %v", err)
return
}
if !ready {
log.Printf("Skip auto-starting gateway: %s", reason)
return
}
pid, err := h.startGatewayLocked()
if err != nil {
log.Printf("Failed to auto-start gateway: %v", err)
return
}
log.Printf("Gateway auto-started (PID: %d)", pid)
}
// gatewayStartReady validates whether current config can start the gateway.
func (h *Handler) gatewayStartReady() (bool, string, error) {
cfg, err := config.LoadConfig(h.configPath)
if err != nil {
return false, "", fmt.Errorf("failed to load config: %w", err)
}
modelName := strings.TrimSpace(cfg.Agents.Defaults.GetModelName())
if modelName == "" {
return false, "no default model configured", nil
}
modelCfg := lookupModelConfig(cfg, modelName)
if modelCfg == nil {
return false, fmt.Sprintf("default model %q is invalid", modelName), nil
}
hasCredential := strings.TrimSpace(modelCfg.APIKey) != "" ||
strings.TrimSpace(modelCfg.AuthMethod) != ""
if !hasCredential {
return false, fmt.Sprintf("default model %q has no credentials configured", modelName), nil
}
return true, "", nil
}
func lookupModelConfig(cfg *config.Config, modelName string) *config.ModelConfig {
modelCfg, err := cfg.GetModelConfig(modelName)
if err != nil {
return nil
}
return modelCfg
}
func isGatewayProcessAliveLocked() bool {
return isCmdProcessAliveLocked(gateway.cmd)
}
func isCmdProcessAliveLocked(cmd *exec.Cmd) bool {
if cmd == nil || cmd.Process == nil {
return false
}
// Wait() sets ProcessState when the process exits; use it when available.
if cmd.ProcessState != nil && cmd.ProcessState.Exited() {
return false
}
// Windows does not support Signal(0) probing. If we still own cmd and it
// has not reported exit, treat it as alive.
if runtime.GOOS == "windows" {
return true
}
return cmd.Process.Signal(syscall.Signal(0)) == nil
}
func (h *Handler) startGatewayLocked() (int, error) {
// Locate the picoclaw executable
execPath := findPicoclawBinary()
cmd := exec.Command(execPath, "gateway")
stdoutPipe, err := cmd.StdoutPipe()
if err != nil {
return 0, fmt.Errorf("failed to create stdout pipe: %w", err)
}
stderrPipe, err := cmd.StderrPipe()
if err != nil {
return 0, fmt.Errorf("failed to create stderr pipe: %w", err)
}
// Clear old logs for this new run
gateway.logs.Reset()
// Ensure Pico Channel is configured before starting gateway
if _, err := h.ensurePicoChannel(); err != nil {
log.Printf("Warning: failed to ensure pico channel: %v", err)
// Non-fatal: gateway can still start without pico channel
}
if err := cmd.Start(); err != nil {
return 0, fmt.Errorf("failed to start gateway: %w", err)
}
gateway.cmd = cmd
pid := cmd.Process.Pid
log.Printf("Started picoclaw gateway (PID: %d) from %s", pid, execPath)
// Broadcast starting event
gateway.events.Broadcast(GatewayEvent{Status: "starting", PID: pid})
// Capture stdout/stderr in background
go scanPipe(stdoutPipe, gateway.logs)
go scanPipe(stderrPipe, gateway.logs)
// Wait for exit in background and clean up
go func() {
if err := cmd.Wait(); err != nil {
log.Printf("Gateway process exited: %v", err)
} else {
log.Printf("Gateway process exited normally")
}
gateway.mu.Lock()
if gateway.cmd == cmd {
gateway.cmd = nil
}
gateway.mu.Unlock()
// Broadcast stopped event
gateway.events.Broadcast(GatewayEvent{Status: "stopped"})
}()
// Start a goroutine to probe health and broadcast "running" once ready
go func() {
for i := 0; i < 30; i++ { // try for up to 15 seconds
time.Sleep(500 * time.Millisecond)
gateway.mu.Lock()
stillOurs := gateway.cmd == cmd
gateway.mu.Unlock()
if !stillOurs {
return
}
cfg, err := config.LoadConfig(h.configPath)
if err != nil {
continue
}
healthHost := "127.0.0.1"
if cfg.Gateway.Host != "" && cfg.Gateway.Host != "0.0.0.0" {
healthHost = cfg.Gateway.Host
}
healthPort := cfg.Gateway.Port
if healthPort == 0 {
healthPort = 18790
}
healthURL := fmt.Sprintf("http://%s/health", net.JoinHostPort(healthHost, strconv.Itoa(healthPort)))
client := http.Client{Timeout: 1 * time.Second}
resp, err := client.Get(healthURL)
if err == nil {
resp.Body.Close()
if resp.StatusCode == http.StatusOK {
gateway.events.Broadcast(GatewayEvent{Status: "running", PID: pid})
return
}
}
}
}()
return pid, nil
}
// handleGatewayStart starts the picoclaw gateway subprocess.
//
// POST /api/gateway/start
func (h *Handler) handleGatewayStart(w http.ResponseWriter, r *http.Request) {
gateway.mu.Lock()
defer gateway.mu.Unlock()
// Prevent duplicate starts
if isGatewayProcessAliveLocked() {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusConflict)
json.NewEncoder(w).Encode(map[string]any{
"status": "already_running",
"pid": gateway.cmd.Process.Pid,
})
return
}
if gateway.cmd != nil && gateway.cmd.Process != nil {
gateway.cmd = nil
}
ready, reason, err := h.gatewayStartReady()
if err != nil {
http.Error(
w,
fmt.Sprintf("Failed to validate gateway start conditions: %v", err),
http.StatusInternalServerError,
)
return
}
if !ready {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]any{
"status": "precondition_failed",
"message": reason,
})
return
}
pid, err := h.startGatewayLocked()
if err != nil {
http.Error(w, fmt.Sprintf("Failed to start gateway: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"status": "ok",
"pid": pid,
})
}
// handleGatewayStop stops the running gateway subprocess gracefully.
//
// POST /api/gateway/stop
func (h *Handler) handleGatewayStop(w http.ResponseWriter, r *http.Request) {
gateway.mu.Lock()
defer gateway.mu.Unlock()
if gateway.cmd == nil || gateway.cmd.Process == nil {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"status": "not_running",
})
return
}
pid := gateway.cmd.Process.Pid
// Send SIGTERM for graceful shutdown (SIGKILL on Windows)
var sigErr error
if runtime.GOOS == "windows" {
sigErr = gateway.cmd.Process.Kill()
} else {
sigErr = gateway.cmd.Process.Signal(syscall.SIGTERM)
}
if sigErr != nil {
http.Error(w, fmt.Sprintf("Failed to stop gateway (PID %d): %v", pid, sigErr), http.StatusInternalServerError)
return
}
log.Printf("Sent stop signal to gateway (PID: %d)", pid)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"status": "ok",
"pid": pid,
})
}
// handleGatewayRestart stops the gateway (if running) and starts a new instance.
//
// POST /api/gateway/restart
func (h *Handler) handleGatewayRestart(w http.ResponseWriter, r *http.Request) {
gateway.mu.Lock()
// Stop existing process if running
if gateway.cmd != nil && gateway.cmd.Process != nil {
if isCmdProcessAliveLocked(gateway.cmd) {
// Process is alive, send SIGTERM
if runtime.GOOS == "windows" {
gateway.cmd.Process.Kill()
} else {
gateway.cmd.Process.Signal(syscall.SIGTERM)
}
// Wait briefly for it to exit
gateway.mu.Unlock()
time.Sleep(2 * time.Second)
gateway.mu.Lock()
}
gateway.cmd = nil
}
gateway.mu.Unlock()
// Start fresh via the existing handler
h.handleGatewayStart(w, r)
}
// handleGatewayStatus returns the gateway run status, health info, and logs.
//
// GET /api/gateway/status
func (h *Handler) handleGatewayStatus(w http.ResponseWriter, r *http.Request) {
data := map[string]any{}
// Check process state
gateway.mu.Lock()
processAlive := isGatewayProcessAliveLocked()
if processAlive {
data["pid"] = gateway.cmd.Process.Pid
}
gateway.mu.Unlock()
if !processAlive {
data["gateway_status"] = "stopped"
} else {
// Process is alive — probe its health endpoint
cfg, err := config.LoadConfig(h.configPath)
host := "127.0.0.1"
port := 18790
if err == nil && cfg != nil {
if cfg.Gateway.Host != "" && cfg.Gateway.Host != "0.0.0.0" {
host = cfg.Gateway.Host
}
if cfg.Gateway.Port != 0 {
port = cfg.Gateway.Port
}
}
url := fmt.Sprintf("http://%s/health", net.JoinHostPort(host, strconv.Itoa(port)))
client := http.Client{Timeout: 2 * time.Second}
resp, err := client.Get(url)
if err != nil {
data["gateway_status"] = "starting"
} else {
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
data["gateway_status"] = "error"
data["status_code"] = resp.StatusCode
} else {
var healthData map[string]any
if decErr := json.NewDecoder(resp.Body).Decode(&healthData); decErr != nil {
data["gateway_status"] = "error"
} else {
for k, v := range healthData {
data[k] = v
}
data["gateway_status"] = "running"
}
}
}
}
ready, reason, readyErr := h.gatewayStartReady()
if readyErr != nil {
data["gateway_start_allowed"] = false
data["gateway_start_reason"] = readyErr.Error()
} else {
data["gateway_start_allowed"] = ready
if !ready {
data["gateway_start_reason"] = reason
}
}
// Append incremental log data
appendGatewayLogs(r, data)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(data)
}
// appendGatewayLogs reads log_offset and log_run_id query params from the request
// and populates the response data map with incremental log lines.
func appendGatewayLogs(r *http.Request, data map[string]any) {
clientOffset := 0
clientRunID := -1
if v := r.URL.Query().Get("log_offset"); v != "" {
if n, err := strconv.Atoi(v); err == nil {
clientOffset = n
}
}
if v := r.URL.Query().Get("log_run_id"); v != "" {
if n, err := strconv.Atoi(v); err == nil {
clientRunID = n
}
}
runID := gateway.logs.RunID()
if runID == 0 {
data["logs"] = []string{}
data["log_total"] = 0
data["log_run_id"] = 0
return
}
// If runID changed, reset offset to get all logs from new run
offset := clientOffset
if clientRunID != runID {
offset = 0
}
lines, total, runID := gateway.logs.LinesSince(offset)
if lines == nil {
lines = []string{}
}
data["logs"] = lines
data["log_total"] = total
data["log_run_id"] = runID
}
// handleGatewayEvents serves an SSE stream of gateway state change events.
//
// GET /api/gateway/events
func (h *Handler) handleGatewayEvents(w http.ResponseWriter, r *http.Request) {
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "SSE not supported", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("Access-Control-Allow-Origin", "*")
// Subscribe to gateway events
ch := gateway.events.Subscribe()
defer gateway.events.Unsubscribe(ch)
// Send initial status so the client doesn't start blank
initial := h.currentGatewayStatus()
fmt.Fprintf(w, "data: %s\n\n", initial)
flusher.Flush()
for {
select {
case <-r.Context().Done():
return
case data, ok := <-ch:
if !ok {
return
}
fmt.Fprintf(w, "data: %s\n\n", data)
flusher.Flush()
}
}
}
// currentGatewayStatus returns the current gateway status as a JSON string.
func (h *Handler) currentGatewayStatus() string {
gateway.mu.Lock()
defer gateway.mu.Unlock()
data := map[string]any{
"gateway_status": "stopped",
}
if isGatewayProcessAliveLocked() {
data["gateway_status"] = "running"
data["pid"] = gateway.cmd.Process.Pid
}
ready, reason, readyErr := h.gatewayStartReady()
if readyErr != nil {
data["gateway_start_allowed"] = false
data["gateway_start_reason"] = readyErr.Error()
} else {
data["gateway_start_allowed"] = ready
if !ready {
data["gateway_start_reason"] = reason
}
}
encoded, _ := json.Marshal(data)
return string(encoded)
}
// findPicoclawBinary locates the picoclaw executable.
// Tries the same directory as the current executable first, then falls back to $PATH.
func findPicoclawBinary() string {
if exe, err := os.Executable(); err == nil {
dir := filepath.Dir(exe)
candidate := filepath.Join(dir, "picoclaw")
if runtime.GOOS == "windows" {
candidate += ".exe"
}
if info, err := os.Stat(candidate); err == nil && !info.IsDir() {
return candidate
}
}
return "picoclaw"
}
// scanPipe reads lines from r and appends them to buf. Returns when r reaches EOF.
func scanPipe(r io.Reader, buf *LogBuffer) {
scanner := bufio.NewScanner(r)
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for scanner.Scan() {
buf.Append(scanner.Text())
}
}

View file

@ -0,0 +1,122 @@
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"github.com/sipeed/picoclaw/pkg/config"
)
func TestGatewayStartReady_NoDefaultModel(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
h := NewHandler(configPath)
ready, reason, err := h.gatewayStartReady()
if err != nil {
t.Fatalf("gatewayStartReady() error = %v", err)
}
if ready {
t.Fatalf("gatewayStartReady() ready = true, want false")
}
if reason != "no default model configured" {
t.Fatalf("gatewayStartReady() reason = %q, want %q", reason, "no default model configured")
}
}
func TestGatewayStartReady_InvalidDefaultModel(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
cfg := config.DefaultConfig()
cfg.Agents.Defaults.Model = "missing-model"
if err := config.SaveConfig(configPath, cfg); err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
h := NewHandler(configPath)
ready, reason, err := h.gatewayStartReady()
if err != nil {
t.Fatalf("gatewayStartReady() error = %v", err)
}
if ready {
t.Fatalf("gatewayStartReady() ready = true, want false")
}
if reason == "" {
t.Fatalf("gatewayStartReady() reason is empty")
}
}
func TestGatewayStartReady_ValidDefaultModel(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
cfg := config.DefaultConfig()
cfg.Agents.Defaults.ModelName = cfg.ModelList[0].ModelName
cfg.ModelList[0].APIKey = "test-key"
if err := config.SaveConfig(configPath, cfg); err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
h := NewHandler(configPath)
ready, reason, err := h.gatewayStartReady()
if err != nil {
t.Fatalf("gatewayStartReady() error = %v", err)
}
if !ready {
t.Fatalf("gatewayStartReady() ready = false, want true (reason=%q)", reason)
}
}
func TestGatewayStartReady_DefaultModelWithoutCredential(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
cfg := config.DefaultConfig()
cfg.Agents.Defaults.ModelName = cfg.ModelList[0].ModelName
cfg.ModelList[0].APIKey = ""
cfg.ModelList[0].AuthMethod = ""
if err := config.SaveConfig(configPath, cfg); err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
h := NewHandler(configPath)
ready, reason, err := h.gatewayStartReady()
if err != nil {
t.Fatalf("gatewayStartReady() error = %v", err)
}
if ready {
t.Fatalf("gatewayStartReady() ready = true, want false")
}
if !strings.Contains(reason, "no credentials configured") {
t.Fatalf("gatewayStartReady() reason = %q, want contains %q", reason, "no credentials configured")
}
}
func TestGatewayStatusIncludesStartConditionWhenNotReady(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/gateway/status", nil)
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
}
var body map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("unmarshal response: %v", err)
}
allowed, ok := body["gateway_start_allowed"].(bool)
if !ok {
t.Fatalf("gateway_start_allowed missing or not bool: %#v", body["gateway_start_allowed"])
}
if allowed {
t.Fatalf("gateway_start_allowed = true, want false")
}
if _, ok := body["gateway_start_reason"].(string); !ok {
t.Fatalf("gateway_start_reason missing or not string: %#v", body["gateway_start_reason"])
}
}

View file

@ -0,0 +1,85 @@
package api
import (
"encoding/json"
"fmt"
"net/http"
"github.com/sipeed/picoclaw/web/backend/launcherconfig"
)
type launcherConfigPayload struct {
Port int `json:"port"`
Public bool `json:"public"`
AllowedCIDRs []string `json:"allowed_cidrs"`
}
func (h *Handler) registerLauncherConfigRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/system/launcher-config", h.handleGetLauncherConfig)
mux.HandleFunc("PUT /api/system/launcher-config", h.handleUpdateLauncherConfig)
}
func (h *Handler) launcherConfigPath() string {
return launcherconfig.PathForAppConfig(h.configPath)
}
func (h *Handler) launcherFallbackConfig() launcherconfig.Config {
port := h.serverPort
if port <= 0 {
port = launcherconfig.DefaultPort
}
return launcherconfig.Config{
Port: port,
Public: h.serverPublic,
AllowedCIDRs: append([]string(nil), h.serverCIDRs...),
}
}
func (h *Handler) loadLauncherConfig() (launcherconfig.Config, error) {
return launcherconfig.Load(h.launcherConfigPath(), h.launcherFallbackConfig())
}
func (h *Handler) handleGetLauncherConfig(w http.ResponseWriter, r *http.Request) {
cfg, err := h.loadLauncherConfig()
if err != nil {
http.Error(w, fmt.Sprintf("Failed to load launcher config: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(launcherConfigPayload{
Port: cfg.Port,
Public: cfg.Public,
AllowedCIDRs: append([]string(nil), cfg.AllowedCIDRs...),
})
}
func (h *Handler) handleUpdateLauncherConfig(w http.ResponseWriter, r *http.Request) {
var payload launcherConfigPayload
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest)
return
}
cfg := launcherconfig.Config{
Port: payload.Port,
Public: payload.Public,
AllowedCIDRs: append([]string(nil), payload.AllowedCIDRs...),
}
if err := launcherconfig.Validate(cfg); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if err := launcherconfig.Save(h.launcherConfigPath(), cfg); err != nil {
http.Error(w, fmt.Sprintf("Failed to save launcher config: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(launcherConfigPayload{
Port: cfg.Port,
Public: cfg.Public,
AllowedCIDRs: append([]string(nil), cfg.AllowedCIDRs...),
})
}

View file

@ -0,0 +1,115 @@
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"github.com/sipeed/picoclaw/web/backend/launcherconfig"
)
func TestGetLauncherConfigUsesRuntimeFallback(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
h := NewHandler(configPath)
h.SetServerOptions(19999, true, []string{"192.168.1.0/24"})
mux := http.NewServeMux()
h.RegisterRoutes(mux)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/system/launcher-config", nil)
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
var got launcherConfigPayload
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("unmarshal response: %v", err)
}
if got.Port != 19999 || !got.Public {
t.Fatalf("response = %+v, want port=19999 public=true", got)
}
if len(got.AllowedCIDRs) != 1 || got.AllowedCIDRs[0] != "192.168.1.0/24" {
t.Fatalf("response allowed_cidrs = %v, want [192.168.1.0/24]", got.AllowedCIDRs)
}
}
func TestPutLauncherConfigPersists(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
rec := httptest.NewRecorder()
req := httptest.NewRequest(
http.MethodPut,
"/api/system/launcher-config",
strings.NewReader(`{"port":18080,"public":true,"allowed_cidrs":["192.168.1.0/24"]}`),
)
req.Header.Set("Content-Type", "application/json")
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
path := launcherconfig.PathForAppConfig(configPath)
cfg, err := launcherconfig.Load(path, launcherconfig.Default())
if err != nil {
t.Fatalf("launcherconfig.Load() error = %v", err)
}
if cfg.Port != 18080 || !cfg.Public {
t.Fatalf("saved config = %+v, want port=18080 public=true", cfg)
}
if len(cfg.AllowedCIDRs) != 1 || cfg.AllowedCIDRs[0] != "192.168.1.0/24" {
t.Fatalf("saved config allowed_cidrs = %v, want [192.168.1.0/24]", cfg.AllowedCIDRs)
}
}
func TestPutLauncherConfigRejectsInvalidPort(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
rec := httptest.NewRecorder()
req := httptest.NewRequest(
http.MethodPut,
"/api/system/launcher-config",
strings.NewReader(`{"port":70000,"public":false}`),
)
req.Header.Set("Content-Type", "application/json")
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusBadRequest, rec.Body.String())
}
}
func TestPutLauncherConfigRejectsInvalidCIDR(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
rec := httptest.NewRecorder()
req := httptest.NewRequest(
http.MethodPut,
"/api/system/launcher-config",
strings.NewReader(`{"port":18080,"public":false,"allowed_cidrs":["bad-cidr"]}`),
)
req.Header.Set("Content-Type", "application/json")
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusBadRequest, rec.Body.String())
}
}

View file

@ -1,4 +1,4 @@
package server package api
import "sync" import "sync"
@ -89,11 +89,3 @@ func (b *LogBuffer) RunID() int {
return b.runID return b.runID
} }
// Total returns the total number of lines appended in the current run.
func (b *LogBuffer) Total() int {
b.mu.RLock()
defer b.mu.RUnlock()
return b.total
}

298
web/backend/api/models.go Normal file
View file

@ -0,0 +1,298 @@
package api
import (
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"github.com/sipeed/picoclaw/pkg/config"
)
// registerModelRoutes binds model list management endpoints to the ServeMux.
func (h *Handler) registerModelRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/models", h.handleListModels)
mux.HandleFunc("POST /api/models", h.handleAddModel)
mux.HandleFunc("POST /api/models/default", h.handleSetDefaultModel)
mux.HandleFunc("PUT /api/models/{index}", h.handleUpdateModel)
mux.HandleFunc("DELETE /api/models/{index}", h.handleDeleteModel)
}
// modelResponse is the JSON structure returned for each model in the list.
// All ModelConfig fields are included so the frontend can display and edit them.
type modelResponse struct {
Index int `json:"index"`
ModelName string `json:"model_name"`
Model string `json:"model"`
APIBase string `json:"api_base,omitempty"`
APIKey string `json:"api_key"`
Proxy string `json:"proxy,omitempty"`
AuthMethod string `json:"auth_method,omitempty"`
// Advanced fields
ConnectMode string `json:"connect_mode,omitempty"`
Workspace string `json:"workspace,omitempty"`
RPM int `json:"rpm,omitempty"`
MaxTokensField string `json:"max_tokens_field,omitempty"`
RequestTimeout int `json:"request_timeout,omitempty"`
ThinkingLevel string `json:"thinking_level,omitempty"`
// Meta
Configured bool `json:"configured"`
IsDefault bool `json:"is_default"`
}
// handleListModels returns all model_list entries with masked API keys.
//
// GET /api/models
func (h *Handler) handleListModels(w http.ResponseWriter, r *http.Request) {
cfg, err := h.loadFilteredConfig()
if err != nil {
http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError)
return
}
defaultModel := cfg.Agents.Defaults.GetModelName()
models := make([]modelResponse, 0, len(cfg.ModelList))
for i, m := range cfg.ModelList {
models = append(models, modelResponse{
Index: i,
ModelName: m.ModelName,
Model: m.Model,
APIBase: m.APIBase,
APIKey: maskAPIKey(m.APIKey),
Proxy: m.Proxy,
AuthMethod: m.AuthMethod,
ConnectMode: m.ConnectMode,
Workspace: m.Workspace,
RPM: m.RPM,
MaxTokensField: m.MaxTokensField,
RequestTimeout: m.RequestTimeout,
ThinkingLevel: m.ThinkingLevel,
Configured: m.APIKey != "" || m.AuthMethod != "",
IsDefault: m.ModelName == defaultModel,
})
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"models": models,
"total": len(models),
"default_model": defaultModel,
})
}
// handleAddModel appends a new model configuration entry.
//
// POST /api/models
func (h *Handler) handleAddModel(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
if err != nil {
http.Error(w, "Failed to read request body", http.StatusBadRequest)
return
}
defer r.Body.Close()
var mc config.ModelConfig
if err = json.Unmarshal(body, &mc); err != nil {
http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest)
return
}
if err = mc.Validate(); err != nil {
http.Error(w, fmt.Sprintf("Validation error: %v", err), http.StatusBadRequest)
return
}
cfg, err := config.LoadConfig(h.configPath)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError)
return
}
cfg.ModelList = append(cfg.ModelList, mc)
if err := config.SaveConfig(h.configPath, cfg); err != nil {
http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"status": "ok",
"index": len(cfg.ModelList) - 1,
})
}
// handleUpdateModel replaces a model configuration entry at the given index.
// If the request body omits api_key (or sends an empty string), the existing
// stored key is preserved so callers can update only api_base / proxy without
// exposing or clearing the secret.
//
// PUT /api/models/{index}
func (h *Handler) handleUpdateModel(w http.ResponseWriter, r *http.Request) {
idx, err := strconv.Atoi(r.PathValue("index"))
if err != nil {
http.Error(w, "Invalid index", http.StatusBadRequest)
return
}
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
if err != nil {
http.Error(w, "Failed to read request body", http.StatusBadRequest)
return
}
defer r.Body.Close()
var mc config.ModelConfig
if err = json.Unmarshal(body, &mc); err != nil {
http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest)
return
}
if err = mc.Validate(); err != nil {
http.Error(w, fmt.Sprintf("Validation error: %v", err), http.StatusBadRequest)
return
}
cfg, err := config.LoadConfig(h.configPath)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError)
return
}
if idx < 0 || idx >= len(cfg.ModelList) {
http.Error(w, fmt.Sprintf("Index %d out of range (0-%d)", idx, len(cfg.ModelList)-1), http.StatusNotFound)
return
}
// Preserve the existing API key when the caller omits it (empty string).
// This lets the UI update api_base / proxy without clearing the stored secret.
if mc.APIKey == "" {
mc.APIKey = cfg.ModelList[idx].APIKey
}
cfg.ModelList[idx] = mc
if err := config.SaveConfig(h.configPath, cfg); err != nil {
http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}
// handleDeleteModel removes a model configuration entry at the given index.
//
// DELETE /api/models/{index}
func (h *Handler) handleDeleteModel(w http.ResponseWriter, r *http.Request) {
idx, err := strconv.Atoi(r.PathValue("index"))
if err != nil {
http.Error(w, "Invalid index", http.StatusBadRequest)
return
}
cfg, err := config.LoadConfig(h.configPath)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError)
return
}
if idx < 0 || idx >= len(cfg.ModelList) {
http.Error(w, fmt.Sprintf("Index %d out of range (0-%d)", idx, len(cfg.ModelList)-1), http.StatusNotFound)
return
}
deletedModelName := cfg.ModelList[idx].ModelName
cfg.ModelList = append(cfg.ModelList[:idx], cfg.ModelList[idx+1:]...)
// If the deleted model was the default, clear it.
if cfg.Agents.Defaults.ModelName == deletedModelName {
cfg.Agents.Defaults.ModelName = ""
}
if cfg.Agents.Defaults.Model == deletedModelName {
cfg.Agents.Defaults.Model = ""
}
if err := config.SaveConfig(h.configPath, cfg); err != nil {
http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}
// handleSetDefaultModel sets the default model for all agents.
//
// POST /api/models/default
func (h *Handler) handleSetDefaultModel(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
if err != nil {
http.Error(w, "Failed to read request body", http.StatusBadRequest)
return
}
defer r.Body.Close()
var req struct {
ModelName string `json:"model_name"`
}
if err = json.Unmarshal(body, &req); err != nil {
http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest)
return
}
if req.ModelName == "" {
http.Error(w, "model_name is required", http.StatusBadRequest)
return
}
cfg, err := config.LoadConfig(h.configPath)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError)
return
}
// Verify the model_name exists in model_list
found := false
for _, m := range cfg.ModelList {
if m.ModelName == req.ModelName {
found = true
break
}
}
if !found {
http.Error(w, fmt.Sprintf("Model %q not found in model_list", req.ModelName), http.StatusNotFound)
return
}
cfg.Agents.Defaults.ModelName = req.ModelName
if err := config.SaveConfig(h.configPath, cfg); err != nil {
http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"status": "ok",
"default_model": req.ModelName,
})
}
// maskAPIKey returns a masked version of an API key for safe display.
// Keys longer than 8 chars show prefix + last 4 chars: "sk-****abcd"
// Shorter keys are fully masked as "****".
// Empty keys return empty string.
func maskAPIKey(key string) string {
if key == "" {
return ""
}
if len(key) <= 8 {
return "****"
}
// Show first 3 chars and last 4 chars
return key[:3] + "****" + key[len(key)-4:]
}

844
web/backend/api/oauth.go Normal file
View file

@ -0,0 +1,844 @@
package api
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"html"
"io"
"log"
"net/http"
"strings"
"time"
"github.com/sipeed/picoclaw/pkg/auth"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/providers"
)
const (
oauthProviderOpenAI = "openai"
oauthProviderAnthropic = "anthropic"
oauthProviderGoogleAntigravity = "google-antigravity"
oauthMethodBrowser = "browser"
oauthMethodDeviceCode = "device_code"
oauthMethodToken = "token"
oauthFlowPending = "pending"
oauthFlowSuccess = "success"
oauthFlowError = "error"
oauthFlowExpired = "expired"
)
const (
oauthBrowserFlowTTL = 10 * time.Minute
oauthDeviceCodeFlowTTL = 15 * time.Minute
oauthTerminalFlowGC = 30 * time.Minute
)
var oauthProviderOrder = []string{
oauthProviderOpenAI,
oauthProviderAnthropic,
oauthProviderGoogleAntigravity,
}
var oauthProviderMethods = map[string][]string{
oauthProviderOpenAI: {oauthMethodBrowser, oauthMethodDeviceCode, oauthMethodToken},
oauthProviderAnthropic: {oauthMethodToken},
oauthProviderGoogleAntigravity: {oauthMethodBrowser},
}
var oauthProviderLabels = map[string]string{
oauthProviderOpenAI: "OpenAI",
oauthProviderAnthropic: "Anthropic",
oauthProviderGoogleAntigravity: "Google Antigravity",
}
var (
oauthNow = time.Now
oauthGeneratePKCE = auth.GeneratePKCE
oauthGenerateState = auth.GenerateState
oauthBuildAuthorizeURL = auth.BuildAuthorizeURL
oauthRequestDeviceCode = auth.RequestDeviceCode
oauthPollDeviceCodeOnce = auth.PollDeviceCodeOnce
oauthExchangeCodeForTokens = auth.ExchangeCodeForTokens
oauthGetCredential = auth.GetCredential
oauthSetCredential = auth.SetCredential
oauthDeleteCredential = auth.DeleteCredential
oauthLoadConfig = config.LoadConfig
oauthSaveConfig = config.SaveConfig
oauthFetchAntigravityProject = providers.FetchAntigravityProjectID
oauthFetchGoogleUserEmailFunc = fetchGoogleUserEmail
)
type oauthFlow struct {
ID string
Provider string
Method string
Status string
CreatedAt time.Time
UpdatedAt time.Time
ExpiresAt time.Time
Error string
CodeVerifier string
OAuthState string
RedirectURI string
DeviceAuthID string
UserCode string
VerifyURL string
Interval int
}
type oauthProviderStatus struct {
Provider string `json:"provider"`
DisplayName string `json:"display_name"`
Methods []string `json:"methods"`
LoggedIn bool `json:"logged_in"`
Status string `json:"status"`
AuthMethod string `json:"auth_method,omitempty"`
ExpiresAt string `json:"expires_at,omitempty"`
AccountID string `json:"account_id,omitempty"`
Email string `json:"email,omitempty"`
ProjectID string `json:"project_id,omitempty"`
}
type oauthFlowResponse struct {
FlowID string `json:"flow_id"`
Provider string `json:"provider"`
Method string `json:"method"`
Status string `json:"status"`
ExpiresAt string `json:"expires_at,omitempty"`
Error string `json:"error,omitempty"`
UserCode string `json:"user_code,omitempty"`
VerifyURL string `json:"verify_url,omitempty"`
Interval int `json:"interval,omitempty"`
}
// registerOAuthRoutes binds OAuth login/logout endpoints to the ServeMux.
func (h *Handler) registerOAuthRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/oauth/providers", h.handleListOAuthProviders)
mux.HandleFunc("POST /api/oauth/login", h.handleOAuthLogin)
mux.HandleFunc("GET /api/oauth/flows/{id}", h.handleGetOAuthFlow)
mux.HandleFunc("POST /api/oauth/flows/{id}/poll", h.handlePollOAuthFlow)
mux.HandleFunc("POST /api/oauth/logout", h.handleOAuthLogout)
mux.HandleFunc("GET /oauth/callback", h.handleOAuthCallback)
}
func (h *Handler) handleListOAuthProviders(w http.ResponseWriter, r *http.Request) {
providersResp := make([]oauthProviderStatus, 0, len(oauthProviderOrder))
for _, provider := range oauthProviderOrder {
cred, err := oauthGetCredential(provider)
if err != nil {
http.Error(w, fmt.Sprintf("failed to load credentials: %v", err), http.StatusInternalServerError)
return
}
item := oauthProviderStatus{
Provider: provider,
DisplayName: oauthProviderLabels[provider],
Methods: oauthProviderMethods[provider],
Status: "not_logged_in",
}
if cred != nil {
item.LoggedIn = true
item.AuthMethod = cred.AuthMethod
item.AccountID = cred.AccountID
item.Email = cred.Email
item.ProjectID = cred.ProjectID
if !cred.ExpiresAt.IsZero() {
item.ExpiresAt = cred.ExpiresAt.Format(time.RFC3339)
}
switch {
case cred.IsExpired():
item.Status = "expired"
case cred.NeedsRefresh():
item.Status = "needs_refresh"
default:
item.Status = "connected"
}
}
providersResp = append(providersResp, item)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"providers": providersResp,
})
}
func (h *Handler) handleOAuthLogin(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
if err != nil {
http.Error(w, "failed to read request body", http.StatusBadRequest)
return
}
defer r.Body.Close()
var req struct {
Provider string `json:"provider"`
Method string `json:"method"`
Token string `json:"token"`
}
if err = json.Unmarshal(body, &req); err != nil {
http.Error(w, fmt.Sprintf("invalid JSON: %v", err), http.StatusBadRequest)
return
}
provider, err := normalizeOAuthProvider(req.Provider)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
method := strings.ToLower(strings.TrimSpace(req.Method))
if !isOAuthMethodSupported(provider, method) {
http.Error(
w,
fmt.Sprintf("unsupported login method %q for provider %q", method, provider),
http.StatusBadRequest,
)
return
}
switch method {
case oauthMethodToken:
token := strings.TrimSpace(req.Token)
if token == "" {
http.Error(w, "token is required", http.StatusBadRequest)
return
}
cred := &auth.AuthCredential{
AccessToken: token,
Provider: provider,
AuthMethod: oauthMethodToken,
}
if err := h.persistCredentialAndConfig(provider, oauthMethodToken, cred); err != nil {
http.Error(w, fmt.Sprintf("token login failed: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"status": "ok",
"provider": provider,
"method": method,
})
return
case oauthMethodDeviceCode:
cfg := auth.OpenAIOAuthConfig()
info, err := oauthRequestDeviceCode(cfg)
if err != nil {
http.Error(w, fmt.Sprintf("failed to request device code: %v", err), http.StatusInternalServerError)
return
}
now := oauthNow()
flow := &oauthFlow{
ID: newOAuthFlowID(),
Provider: provider,
Method: method,
Status: oauthFlowPending,
CreatedAt: now,
UpdatedAt: now,
ExpiresAt: now.Add(oauthDeviceCodeFlowTTL),
DeviceAuthID: info.DeviceAuthID,
UserCode: info.UserCode,
VerifyURL: info.VerifyURL,
Interval: info.Interval,
}
h.storeOAuthFlow(flow)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"status": "ok",
"provider": provider,
"method": method,
"flow_id": flow.ID,
"user_code": flow.UserCode,
"verify_url": flow.VerifyURL,
"interval": flow.Interval,
"expires_at": flow.ExpiresAt.Format(time.RFC3339),
})
return
case oauthMethodBrowser:
cfg, err := oauthConfigForProvider(provider)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
pkce, err := oauthGeneratePKCE()
if err != nil {
http.Error(w, fmt.Sprintf("failed to generate PKCE: %v", err), http.StatusInternalServerError)
return
}
state, err := oauthGenerateState()
if err != nil {
http.Error(w, fmt.Sprintf("failed to generate state: %v", err), http.StatusInternalServerError)
return
}
redirectURI := buildOAuthRedirectURI(r)
authURL := oauthBuildAuthorizeURL(cfg, pkce, state, redirectURI)
now := oauthNow()
flow := &oauthFlow{
ID: newOAuthFlowID(),
Provider: provider,
Method: method,
Status: oauthFlowPending,
CreatedAt: now,
UpdatedAt: now,
ExpiresAt: now.Add(oauthBrowserFlowTTL),
CodeVerifier: pkce.CodeVerifier,
OAuthState: state,
RedirectURI: redirectURI,
}
h.storeOAuthFlow(flow)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"status": "ok",
"provider": provider,
"method": method,
"flow_id": flow.ID,
"auth_url": authURL,
"expires_at": flow.ExpiresAt.Format(time.RFC3339),
})
return
default:
http.Error(w, "unsupported login method", http.StatusBadRequest)
}
}
func (h *Handler) handleGetOAuthFlow(w http.ResponseWriter, r *http.Request) {
flowID := strings.TrimSpace(r.PathValue("id"))
if flowID == "" {
http.Error(w, "missing flow id", http.StatusBadRequest)
return
}
flow, ok := h.getOAuthFlow(flowID)
if !ok {
http.Error(w, "flow not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(flowToResponse(flow))
}
func (h *Handler) handlePollOAuthFlow(w http.ResponseWriter, r *http.Request) {
flowID := strings.TrimSpace(r.PathValue("id"))
if flowID == "" {
http.Error(w, "missing flow id", http.StatusBadRequest)
return
}
flow, ok := h.getOAuthFlow(flowID)
if !ok {
http.Error(w, "flow not found", http.StatusNotFound)
return
}
if flow.Method != oauthMethodDeviceCode {
http.Error(w, "flow does not support polling", http.StatusBadRequest)
return
}
if flow.Status != oauthFlowPending {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(flowToResponse(flow))
return
}
cfg := auth.OpenAIOAuthConfig()
cred, err := oauthPollDeviceCodeOnce(cfg, flow.DeviceAuthID, flow.UserCode)
if err != nil {
if strings.Contains(strings.ToLower(err.Error()), "pending") {
updated, _ := h.getOAuthFlow(flowID)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(flowToResponse(updated))
return
}
h.setOAuthFlowError(flowID, fmt.Sprintf("device code poll failed: %v", err))
updated, _ := h.getOAuthFlow(flowID)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(flowToResponse(updated))
return
}
if cred == nil {
updated, _ := h.getOAuthFlow(flowID)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(flowToResponse(updated))
return
}
if err := h.persistCredentialAndConfig(flow.Provider, oauthMethodTokenOrOAuth(flow.Method), cred); err != nil {
h.setOAuthFlowError(flowID, fmt.Sprintf("failed to save credential: %v", err))
updated, _ := h.getOAuthFlow(flowID)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(flowToResponse(updated))
return
}
h.setOAuthFlowSuccess(flowID)
updated, _ := h.getOAuthFlow(flowID)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(flowToResponse(updated))
}
func (h *Handler) handleOAuthCallback(w http.ResponseWriter, r *http.Request) {
state := strings.TrimSpace(r.URL.Query().Get("state"))
if state == "" {
renderOAuthCallbackPage(w, "", oauthFlowError, "Missing state", "missing_state")
return
}
flow, ok := h.getOAuthFlowByState(state)
if !ok {
renderOAuthCallbackPage(w, "", oauthFlowError, "OAuth flow not found", "flow_not_found")
return
}
if flow.Status != oauthFlowPending {
renderOAuthCallbackPage(w, flow.ID, flow.Status, "Flow already completed", flow.Error)
return
}
if errMsg := strings.TrimSpace(r.URL.Query().Get("error")); errMsg != "" {
if desc := strings.TrimSpace(r.URL.Query().Get("error_description")); desc != "" {
errMsg += ": " + desc
}
h.setOAuthFlowError(flow.ID, errMsg)
renderOAuthCallbackPage(w, flow.ID, oauthFlowError, "Authorization failed", errMsg)
return
}
code := strings.TrimSpace(r.URL.Query().Get("code"))
if code == "" {
h.setOAuthFlowError(flow.ID, "missing authorization code")
renderOAuthCallbackPage(w, flow.ID, oauthFlowError, "Missing authorization code", "missing_code")
return
}
cfg, err := oauthConfigForProvider(flow.Provider)
if err != nil {
h.setOAuthFlowError(flow.ID, err.Error())
renderOAuthCallbackPage(w, flow.ID, oauthFlowError, "Unsupported provider", err.Error())
return
}
cred, err := oauthExchangeCodeForTokens(cfg, code, flow.CodeVerifier, flow.RedirectURI)
if err != nil {
h.setOAuthFlowError(flow.ID, fmt.Sprintf("token exchange failed: %v", err))
renderOAuthCallbackPage(w, flow.ID, oauthFlowError, "Token exchange failed", err.Error())
return
}
if err := h.persistCredentialAndConfig(flow.Provider, oauthMethodTokenOrOAuth(flow.Method), cred); err != nil {
h.setOAuthFlowError(flow.ID, fmt.Sprintf("failed to save credential: %v", err))
renderOAuthCallbackPage(w, flow.ID, oauthFlowError, "Failed to save credential", err.Error())
return
}
h.setOAuthFlowSuccess(flow.ID)
renderOAuthCallbackPage(w, flow.ID, oauthFlowSuccess, "Authentication successful", "")
}
func (h *Handler) handleOAuthLogout(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
if err != nil {
http.Error(w, "failed to read request body", http.StatusBadRequest)
return
}
defer r.Body.Close()
var req struct {
Provider string `json:"provider"`
}
if err = json.Unmarshal(body, &req); err != nil {
http.Error(w, fmt.Sprintf("invalid JSON: %v", err), http.StatusBadRequest)
return
}
provider, err := normalizeOAuthProvider(req.Provider)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if err := oauthDeleteCredential(provider); err != nil {
http.Error(w, fmt.Sprintf("failed to delete credential: %v", err), http.StatusInternalServerError)
return
}
if err := h.syncProviderAuthMethod(provider, ""); err != nil {
http.Error(w, fmt.Sprintf("failed to update config: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"status": "ok",
"provider": provider,
})
}
func renderOAuthCallbackPage(w http.ResponseWriter, flowID, status, title, errMsg string) {
payload := map[string]string{
"type": "picoclaw-oauth-result",
"flowId": flowID,
"status": status,
}
if errMsg != "" {
payload["error"] = errMsg
}
payloadJSON, _ := json.Marshal(payload)
message := title
if errMsg != "" {
message = fmt.Sprintf("%s: %s", title, errMsg)
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if status == oauthFlowSuccess {
w.WriteHeader(http.StatusOK)
} else {
w.WriteHeader(http.StatusBadRequest)
}
_, _ = fmt.Fprintf(
w,
"<!doctype html><html><head><meta charset=\"utf-8\"><title>PicoClaw OAuth</title></head><body><script>(function(){var payload=%s;var hasOpener=false;try{if(window.opener&&!window.opener.closed){window.opener.postMessage(payload,window.location.origin);hasOpener=true}}catch(e){}var target='/credentials?oauth_flow_id='+encodeURIComponent(payload.flowId||'')+'&oauth_status='+encodeURIComponent(payload.status||'');setTimeout(function(){if(hasOpener){window.close();return}window.location.replace(target)},800)})();</script><div style=\"font-family:Inter,system-ui,sans-serif;padding:24px\"><h2>%s</h2><p>%s</p><p>You can close this window.</p></div></body></html>",
string(payloadJSON),
html.EscapeString(title),
html.EscapeString(message),
)
}
func normalizeOAuthProvider(raw string) (string, error) {
provider := strings.ToLower(strings.TrimSpace(raw))
switch provider {
case "antigravity":
return oauthProviderGoogleAntigravity, nil
case oauthProviderOpenAI, oauthProviderAnthropic, oauthProviderGoogleAntigravity:
return provider, nil
default:
return "", fmt.Errorf("unsupported provider %q", raw)
}
}
func isOAuthMethodSupported(provider, method string) bool {
methods := oauthProviderMethods[provider]
for _, m := range methods {
if m == method {
return true
}
}
return false
}
func oauthConfigForProvider(provider string) (auth.OAuthProviderConfig, error) {
switch provider {
case oauthProviderOpenAI:
return auth.OpenAIOAuthConfig(), nil
case oauthProviderGoogleAntigravity:
return auth.GoogleAntigravityOAuthConfig(), nil
default:
return auth.OAuthProviderConfig{}, fmt.Errorf("provider %q does not support browser oauth", provider)
}
}
func oauthMethodTokenOrOAuth(method string) string {
if method == oauthMethodToken {
return oauthMethodToken
}
return "oauth"
}
func buildOAuthRedirectURI(r *http.Request) string {
scheme := "http"
if r.TLS != nil {
scheme = "https"
}
if forwarded := strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")); forwarded != "" {
scheme = strings.Split(forwarded, ",")[0]
}
return fmt.Sprintf("%s://%s/oauth/callback", scheme, r.Host)
}
func flowToResponse(flow *oauthFlow) oauthFlowResponse {
resp := oauthFlowResponse{
FlowID: flow.ID,
Provider: flow.Provider,
Method: flow.Method,
Status: flow.Status,
Error: flow.Error,
}
if !flow.ExpiresAt.IsZero() {
resp.ExpiresAt = flow.ExpiresAt.Format(time.RFC3339)
}
if flow.Method == oauthMethodDeviceCode {
resp.UserCode = flow.UserCode
resp.VerifyURL = flow.VerifyURL
resp.Interval = flow.Interval
}
return resp
}
func newOAuthFlowID() string {
buf := make([]byte, 16)
if _, err := rand.Read(buf); err != nil {
return fmt.Sprintf("oauth_%d", time.Now().UnixNano())
}
return hex.EncodeToString(buf)
}
func (h *Handler) storeOAuthFlow(flow *oauthFlow) {
now := oauthNow()
h.oauthMu.Lock()
defer h.oauthMu.Unlock()
h.gcOAuthFlowsLocked(now)
h.oauthFlows[flow.ID] = flow
if flow.OAuthState != "" {
h.oauthState[flow.OAuthState] = flow.ID
}
}
func (h *Handler) getOAuthFlow(flowID string) (*oauthFlow, bool) {
now := oauthNow()
h.oauthMu.Lock()
defer h.oauthMu.Unlock()
h.gcOAuthFlowsLocked(now)
flow, ok := h.oauthFlows[flowID]
if !ok {
return nil, false
}
cp := *flow
return &cp, true
}
func (h *Handler) getOAuthFlowByState(state string) (*oauthFlow, bool) {
now := oauthNow()
h.oauthMu.Lock()
defer h.oauthMu.Unlock()
h.gcOAuthFlowsLocked(now)
flowID, ok := h.oauthState[state]
if !ok {
return nil, false
}
flow, ok := h.oauthFlows[flowID]
if !ok {
delete(h.oauthState, state)
return nil, false
}
cp := *flow
return &cp, true
}
func (h *Handler) setOAuthFlowSuccess(flowID string) {
now := oauthNow()
h.oauthMu.Lock()
defer h.oauthMu.Unlock()
flow, ok := h.oauthFlows[flowID]
if !ok {
return
}
flow.Status = oauthFlowSuccess
flow.Error = ""
flow.UpdatedAt = now
if flow.OAuthState != "" {
delete(h.oauthState, flow.OAuthState)
}
}
func (h *Handler) setOAuthFlowError(flowID, errMsg string) {
now := oauthNow()
h.oauthMu.Lock()
defer h.oauthMu.Unlock()
flow, ok := h.oauthFlows[flowID]
if !ok {
return
}
flow.Status = oauthFlowError
flow.Error = errMsg
flow.UpdatedAt = now
if flow.OAuthState != "" {
delete(h.oauthState, flow.OAuthState)
}
}
func (h *Handler) gcOAuthFlowsLocked(now time.Time) {
for id, flow := range h.oauthFlows {
if flow.Status == oauthFlowPending && !flow.ExpiresAt.IsZero() && now.After(flow.ExpiresAt) {
flow.Status = oauthFlowExpired
flow.Error = "flow expired"
flow.UpdatedAt = now
if flow.OAuthState != "" {
delete(h.oauthState, flow.OAuthState)
}
}
if flow.Status != oauthFlowPending && now.Sub(flow.UpdatedAt) > oauthTerminalFlowGC {
if flow.OAuthState != "" {
delete(h.oauthState, flow.OAuthState)
}
delete(h.oauthFlows, id)
}
}
}
func (h *Handler) persistCredentialAndConfig(provider, authMethod string, cred *auth.AuthCredential) error {
if cred == nil {
return fmt.Errorf("empty credential")
}
cp := *cred
cp.Provider = provider
if cp.AuthMethod == "" {
cp.AuthMethod = authMethod
}
if provider == oauthProviderGoogleAntigravity {
if cp.Email == "" {
email, err := oauthFetchGoogleUserEmailFunc(cp.AccessToken)
if err != nil {
log.Printf("oauth warning: could not fetch google email: %v", err)
} else {
cp.Email = email
}
}
if cp.ProjectID == "" {
projectID, err := oauthFetchAntigravityProject(cp.AccessToken)
if err != nil {
log.Printf("oauth warning: could not fetch antigravity project id: %v", err)
} else {
cp.ProjectID = projectID
}
}
}
if err := oauthSetCredential(provider, &cp); err != nil {
return fmt.Errorf("saving credential: %w", err)
}
if err := h.syncProviderAuthMethod(provider, authMethod); err != nil {
return fmt.Errorf("syncing provider auth config: %w", err)
}
return nil
}
func (h *Handler) syncProviderAuthMethod(provider, authMethod string) error {
cfg, err := oauthLoadConfig(h.configPath)
if err != nil {
return err
}
switch provider {
case oauthProviderOpenAI:
cfg.Providers.OpenAI.AuthMethod = authMethod
case oauthProviderAnthropic:
cfg.Providers.Anthropic.AuthMethod = authMethod
case oauthProviderGoogleAntigravity:
cfg.Providers.Antigravity.AuthMethod = authMethod
default:
return fmt.Errorf("unsupported provider %q", provider)
}
found := false
for i := range cfg.ModelList {
if modelBelongsToProvider(provider, cfg.ModelList[i].Model) {
cfg.ModelList[i].AuthMethod = authMethod
found = true
}
}
if !found && authMethod != "" {
cfg.ModelList = append(cfg.ModelList, defaultModelConfigForProvider(provider, authMethod))
}
return oauthSaveConfig(h.configPath, cfg)
}
func modelBelongsToProvider(provider, model string) bool {
lower := strings.ToLower(strings.TrimSpace(model))
switch provider {
case oauthProviderOpenAI:
return lower == "openai" || strings.HasPrefix(lower, "openai/")
case oauthProviderAnthropic:
return lower == "anthropic" || strings.HasPrefix(lower, "anthropic/")
case oauthProviderGoogleAntigravity:
return lower == "antigravity" ||
lower == "google-antigravity" ||
strings.HasPrefix(lower, "antigravity/") ||
strings.HasPrefix(lower, "google-antigravity/")
default:
return false
}
}
func defaultModelConfigForProvider(provider, authMethod string) config.ModelConfig {
switch provider {
case oauthProviderOpenAI:
return config.ModelConfig{
ModelName: "gpt-5.2",
Model: "openai/gpt-5.2",
AuthMethod: authMethod,
}
case oauthProviderAnthropic:
return config.ModelConfig{
ModelName: "claude-sonnet-4.6",
Model: "anthropic/claude-sonnet-4.6",
AuthMethod: authMethod,
}
case oauthProviderGoogleAntigravity:
return config.ModelConfig{
ModelName: "gemini-flash",
Model: "antigravity/gemini-3-flash",
AuthMethod: authMethod,
}
default:
return config.ModelConfig{}
}
}
func fetchGoogleUserEmail(accessToken string) (string, error) {
req, err := http.NewRequest(http.MethodGet, "https://www.googleapis.com/oauth2/v2/userinfo", nil)
if err != nil {
return "", err
}
req.Header.Set("Authorization", "Bearer "+accessToken)
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("userinfo request failed: %s", string(body))
}
var userInfo struct {
Email string `json:"email"`
}
if err := json.Unmarshal(body, &userInfo); err != nil {
return "", err
}
if userInfo.Email == "" {
return "", fmt.Errorf("empty email in userinfo response")
}
return userInfo.Email, nil
}

View file

@ -0,0 +1,293 @@
package api
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/sipeed/picoclaw/pkg/auth"
"github.com/sipeed/picoclaw/pkg/config"
)
func TestOAuthLoginRejectsUnsupportedMethod(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
resetOAuthHooks(t)
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
rec := httptest.NewRecorder()
req := httptest.NewRequest(
http.MethodPost,
"/api/oauth/login",
strings.NewReader(`{"provider":"anthropic","method":"browser"}`),
)
req.Header.Set("Content-Type", "application/json")
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusBadRequest, rec.Body.String())
}
}
func TestOAuthBrowserFlowCreatedAndQueried(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
resetOAuthHooks(t)
oauthGeneratePKCE = func() (auth.PKCECodes, error) {
return auth.PKCECodes{CodeVerifier: "verifier-1", CodeChallenge: "challenge-1"}, nil
}
oauthGenerateState = func() (string, error) { return "state-1", nil }
oauthBuildAuthorizeURL = func(cfg auth.OAuthProviderConfig, pkce auth.PKCECodes, state, redirectURI string) string {
return "https://example.com/authorize?state=" + state
}
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
rec := httptest.NewRecorder()
req := httptest.NewRequest(
http.MethodPost,
"/api/oauth/login",
strings.NewReader(`{"provider":"openai","method":"browser"}`),
)
req.Host = "localhost:18800"
req.Header.Set("Content-Type", "application/json")
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
var loginResp map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &loginResp); err != nil {
t.Fatalf("unmarshal login response: %v", err)
}
flowID, _ := loginResp["flow_id"].(string)
if flowID == "" {
t.Fatalf("flow_id is empty: %v", loginResp)
}
if loginResp["auth_url"] != "https://example.com/authorize?state=state-1" {
t.Fatalf("unexpected auth_url: %v", loginResp["auth_url"])
}
rec2 := httptest.NewRecorder()
req2 := httptest.NewRequest(http.MethodGet, "/api/oauth/flows/"+flowID, nil)
mux.ServeHTTP(rec2, req2)
if rec2.Code != http.StatusOK {
t.Fatalf("flow status code = %d, want %d, body=%s", rec2.Code, http.StatusOK, rec2.Body.String())
}
var flowResp oauthFlowResponse
if err := json.Unmarshal(rec2.Body.Bytes(), &flowResp); err != nil {
t.Fatalf("unmarshal flow response: %v", err)
}
if flowResp.Status != oauthFlowPending {
t.Fatalf("flow status = %q, want %q", flowResp.Status, oauthFlowPending)
}
if flowResp.Method != oauthMethodBrowser {
t.Fatalf("flow method = %q, want %q", flowResp.Method, oauthMethodBrowser)
}
}
func TestOAuthFlowExpiresWhenQueried(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
resetOAuthHooks(t)
now := time.Date(2026, 3, 6, 12, 0, 0, 0, time.UTC)
oauthNow = func() time.Time { return now }
h := NewHandler(configPath)
h.storeOAuthFlow(&oauthFlow{
ID: "expired-flow",
Provider: oauthProviderOpenAI,
Method: oauthMethodBrowser,
Status: oauthFlowPending,
CreatedAt: now.Add(-20 * time.Minute),
UpdatedAt: now.Add(-20 * time.Minute),
ExpiresAt: now.Add(-1 * time.Minute),
})
mux := http.NewServeMux()
h.RegisterRoutes(mux)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/oauth/flows/expired-flow", nil)
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
var flowResp oauthFlowResponse
if err := json.Unmarshal(rec.Body.Bytes(), &flowResp); err != nil {
t.Fatalf("unmarshal flow response: %v", err)
}
if flowResp.Status != oauthFlowExpired {
t.Fatalf("flow status = %q, want %q", flowResp.Status, oauthFlowExpired)
}
}
func TestOAuthCallbackUnknownState(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
resetOAuthHooks(t)
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/oauth/callback?state=unknown&code=abc", nil)
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
}
if !strings.Contains(rec.Body.String(), "OAuth flow not found") {
t.Fatalf("unexpected body: %s", rec.Body.String())
}
}
func TestOAuthLogoutClearsCredentialAndConfig(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
resetOAuthHooks(t)
cfg, err := config.LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig error: %v", err)
}
cfg.Providers.OpenAI.AuthMethod = "oauth"
cfg.ModelList = append(cfg.ModelList, config.ModelConfig{
ModelName: "gpt-5.2",
Model: "openai/gpt-5.2",
AuthMethod: "oauth",
})
if err = config.SaveConfig(configPath, cfg); err != nil {
t.Fatalf("SaveConfig error: %v", err)
}
if err = auth.SetCredential(oauthProviderOpenAI, &auth.AuthCredential{
AccessToken: "token-before-logout",
Provider: oauthProviderOpenAI,
AuthMethod: "oauth",
}); err != nil {
t.Fatalf("SetCredential error: %v", err)
}
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/oauth/logout", bytes.NewBufferString(`{"provider":"openai"}`))
req.Header.Set("Content-Type", "application/json")
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
cred, err := auth.GetCredential(oauthProviderOpenAI)
if err != nil {
t.Fatalf("GetCredential error: %v", err)
}
if cred != nil {
t.Fatalf("expected credential deleted, got %#v", cred)
}
updated, err := config.LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig error: %v", err)
}
if updated.Providers.OpenAI.AuthMethod != "" {
t.Fatalf("providers.openai.auth_method = %q, want empty", updated.Providers.OpenAI.AuthMethod)
}
for _, m := range updated.ModelList {
if strings.HasPrefix(m.Model, "openai/") && m.AuthMethod != "" {
t.Fatalf("openai model auth_method = %q, want empty", m.AuthMethod)
}
}
}
func setupOAuthTestEnv(t *testing.T) (string, func()) {
t.Helper()
tmp := t.TempDir()
oldHome := os.Getenv("HOME")
oldPicoHome := os.Getenv("PICOCLAW_HOME")
if err := os.Setenv("HOME", tmp); err != nil {
t.Fatalf("set HOME: %v", err)
}
if err := os.Setenv("PICOCLAW_HOME", filepath.Join(tmp, ".picoclaw")); err != nil {
t.Fatalf("set PICOCLAW_HOME: %v", err)
}
cfg := config.DefaultConfig()
cfg.ModelList = []config.ModelConfig{{
ModelName: "custom-default",
Model: "openai/gpt-4o",
APIKey: "sk-default",
}}
cfg.Agents.Defaults.ModelName = "custom-default"
configPath := filepath.Join(tmp, "config.json")
if err := config.SaveConfig(configPath, cfg); err != nil {
t.Fatalf("SaveConfig error: %v", err)
}
cleanup := func() {
_ = os.Setenv("HOME", oldHome)
if oldPicoHome == "" {
_ = os.Unsetenv("PICOCLAW_HOME")
} else {
_ = os.Setenv("PICOCLAW_HOME", oldPicoHome)
}
}
return configPath, cleanup
}
func resetOAuthHooks(t *testing.T) {
t.Helper()
origNow := oauthNow
origGeneratePKCE := oauthGeneratePKCE
origGenerateState := oauthGenerateState
origBuildAuthorizeURL := oauthBuildAuthorizeURL
origRequestDeviceCode := oauthRequestDeviceCode
origPollDeviceCodeOnce := oauthPollDeviceCodeOnce
origExchangeCodeForTokens := oauthExchangeCodeForTokens
origGetCredential := oauthGetCredential
origSetCredential := oauthSetCredential
origDeleteCredential := oauthDeleteCredential
origLoadConfig := oauthLoadConfig
origSaveConfig := oauthSaveConfig
origFetchProject := oauthFetchAntigravityProject
origFetchGoogleEmail := oauthFetchGoogleUserEmailFunc
t.Cleanup(func() {
oauthNow = origNow
oauthGeneratePKCE = origGeneratePKCE
oauthGenerateState = origGenerateState
oauthBuildAuthorizeURL = origBuildAuthorizeURL
oauthRequestDeviceCode = origRequestDeviceCode
oauthPollDeviceCodeOnce = origPollDeviceCodeOnce
oauthExchangeCodeForTokens = origExchangeCodeForTokens
oauthGetCredential = origGetCredential
oauthSetCredential = origSetCredential
oauthDeleteCredential = origDeleteCredential
oauthLoadConfig = origLoadConfig
oauthSaveConfig = origSaveConfig
oauthFetchAntigravityProject = origFetchProject
oauthFetchGoogleUserEmailFunc = origFetchGoogleEmail
})
}

161
web/backend/api/pico.go Normal file
View file

@ -0,0 +1,161 @@
package api
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"net"
"net/http"
"strconv"
"time"
"github.com/sipeed/picoclaw/pkg/config"
)
// registerPicoRoutes binds Pico Channel management endpoints to the ServeMux.
func (h *Handler) registerPicoRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/pico/token", h.handleGetPicoToken)
mux.HandleFunc("POST /api/pico/token", h.handleRegenPicoToken)
mux.HandleFunc("POST /api/pico/setup", h.handlePicoSetup)
}
// handleGetPicoToken returns the current WS token and URL for the frontend.
//
// GET /api/pico/token
func (h *Handler) handleGetPicoToken(w http.ResponseWriter, r *http.Request) {
cfg, err := config.LoadConfig(h.configPath)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError)
return
}
wsURL := buildWsURL(r, cfg)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"token": cfg.Channels.Pico.Token,
"ws_url": wsURL,
"enabled": cfg.Channels.Pico.Enabled,
})
}
// handleRegenPicoToken generates a new Pico WebSocket token and saves it.
//
// POST /api/pico/token
func (h *Handler) handleRegenPicoToken(w http.ResponseWriter, r *http.Request) {
cfg, err := config.LoadConfig(h.configPath)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError)
return
}
token := generateSecureToken()
cfg.Channels.Pico.Token = token
if err := config.SaveConfig(h.configPath, cfg); err != nil {
http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError)
return
}
wsURL := fmt.Sprintf("ws://%s/pico/ws", net.JoinHostPort(cfg.Gateway.Host, strconv.Itoa(cfg.Gateway.Port)))
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"token": token,
"ws_url": wsURL,
})
}
// ensurePicoChannel checks if the Pico Channel is properly configured and
// enables it with sensible defaults if not. Returns true if config was changed.
func (h *Handler) ensurePicoChannel() (bool, error) {
cfg, err := config.LoadConfig(h.configPath)
if err != nil {
return false, fmt.Errorf("failed to load config: %w", err)
}
changed := false
if !cfg.Channels.Pico.Enabled {
cfg.Channels.Pico.Enabled = true
changed = true
}
if cfg.Channels.Pico.Token == "" {
cfg.Channels.Pico.Token = generateSecureToken()
changed = true
}
if !cfg.Channels.Pico.AllowTokenQuery {
cfg.Channels.Pico.AllowTokenQuery = true
changed = true
}
// Make sure origins are allowed (frontend might be running on a different port like 5173 during dev)
if len(cfg.Channels.Pico.AllowOrigins) == 0 {
cfg.Channels.Pico.AllowOrigins = []string{"*"}
changed = true
}
if changed {
if err := config.SaveConfig(h.configPath, cfg); err != nil {
return false, fmt.Errorf("failed to save config: %w", err)
}
}
return changed, nil
}
// handlePicoSetup automatically configures everything needed for the Pico Channel to work.
//
// POST /api/pico/setup
func (h *Handler) handlePicoSetup(w http.ResponseWriter, r *http.Request) {
changed, err := h.ensurePicoChannel()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
cfg, err := config.LoadConfig(h.configPath)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError)
return
}
wsURL := buildWsURL(r, cfg)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"token": cfg.Channels.Pico.Token,
"ws_url": wsURL,
"enabled": true,
"changed": changed,
})
}
// buildWsURL creates a WebSocket URL for the Pico Channel.
// When the gateway host is "0.0.0.0" or empty, it uses the hostname from the
// incoming HTTP request so the browser gets a connectable address.
func buildWsURL(r *http.Request, cfg *config.Config) string {
host := cfg.Gateway.Host
if host == "" || host == "0.0.0.0" {
// Use the hostname the browser used to reach this backend
reqHost, _, err := net.SplitHostPort(r.Host)
if err != nil {
reqHost = r.Host // r.Host might not have a port
}
host = reqHost
}
return "ws://" + net.JoinHostPort(host, strconv.Itoa(cfg.Gateway.Port)) + "/pico/ws"
}
// generateSecureToken creates a random 32-character hex string.
func generateSecureToken() string {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
// Fallback to something pseudo-random if crypto/rand fails
return fmt.Sprintf("pico_%x", time.Now().UnixNano())
}
return hex.EncodeToString(b)
}

66
web/backend/api/router.go Normal file
View file

@ -0,0 +1,66 @@
package api
import (
"net/http"
"sync"
"github.com/sipeed/picoclaw/web/backend/launcherconfig"
)
// Handler serves HTTP API requests.
type Handler struct {
configPath string
serverPort int
serverPublic bool
serverCIDRs []string
oauthMu sync.Mutex
oauthFlows map[string]*oauthFlow
oauthState map[string]string
}
// NewHandler creates an instance of the API handler.
func NewHandler(configPath string) *Handler {
return &Handler{
configPath: configPath,
serverPort: launcherconfig.DefaultPort,
oauthFlows: make(map[string]*oauthFlow),
oauthState: make(map[string]string),
}
}
// SetServerOptions stores current backend listen options for fallback behavior.
func (h *Handler) SetServerOptions(port int, public bool, allowedCIDRs []string) {
h.serverPort = port
h.serverPublic = public
h.serverCIDRs = append([]string(nil), allowedCIDRs...)
}
// RegisterRoutes binds all API endpoint handlers to the ServeMux.
func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
// Config CRUD
h.registerConfigRoutes(mux)
// Pico Channel (WebSocket chat)
h.registerPicoRoutes(mux)
// Gateway process lifecycle
h.registerGatewayRoutes(mux)
// Session history
h.registerSessionRoutes(mux)
// OAuth login and credential management
h.registerOAuthRoutes(mux)
// Model list management
h.registerModelRoutes(mux)
// Channel catalog (for frontend navigation/config pages)
h.registerChannelRoutes(mux)
// OS startup / launch-at-login
h.registerStartupRoutes(mux)
// Launcher service parameters (port/public)
h.registerLauncherConfigRoutes(mux)
}

286
web/backend/api/session.go Normal file
View file

@ -0,0 +1,286 @@
package api
import (
"encoding/json"
"net/http"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/providers"
)
// registerSessionRoutes binds session list and detail endpoints to the ServeMux.
func (h *Handler) registerSessionRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/sessions", h.handleListSessions)
mux.HandleFunc("GET /api/sessions/{id}", h.handleGetSession)
mux.HandleFunc("DELETE /api/sessions/{id}", h.handleDeleteSession)
}
// sessionFile mirrors the on-disk session JSON structure from pkg/session.
type sessionFile struct {
Key string `json:"key"`
Messages []providers.Message `json:"messages"`
Summary string `json:"summary,omitempty"`
Created time.Time `json:"created"`
Updated time.Time `json:"updated"`
}
// sessionListItem is a lightweight summary returned by GET /api/sessions.
type sessionListItem struct {
ID string `json:"id"`
Preview string `json:"preview"`
MessageCount int `json:"message_count"`
Created string `json:"created"`
Updated string `json:"updated"`
}
// picoSessionPrefix is the key prefix used by the gateway's routing for Pico
// channel sessions. The full key format is:
//
// agent:main:pico:direct:pico:<session-uuid>
//
// The sanitized filename replaces ':' with '_', so on disk it becomes:
//
// agent_main_pico_direct_pico_<session-uuid>.json
const picoSessionPrefix = "agent:main:pico:direct:pico:"
// extractPicoSessionID extracts the session UUID from a full session key.
// Returns the UUID and true if the key matches the Pico session pattern.
func extractPicoSessionID(key string) (string, bool) {
if strings.HasPrefix(key, picoSessionPrefix) {
return strings.TrimPrefix(key, picoSessionPrefix), true
}
return "", false
}
// sessionsDir resolves the path to the gateway's session storage directory.
// It reads the workspace from config, falling back to ~/.picoclaw/workspace.
func (h *Handler) sessionsDir() (string, error) {
cfg, err := config.LoadConfig(h.configPath)
if err != nil {
return "", err
}
workspace := cfg.Agents.Defaults.Workspace
if workspace == "" {
home, _ := os.UserHomeDir()
workspace = filepath.Join(home, ".picoclaw", "workspace")
}
// Expand ~ prefix
if len(workspace) > 0 && workspace[0] == '~' {
home, _ := os.UserHomeDir()
if len(workspace) > 1 && workspace[1] == '/' {
workspace = home + workspace[1:]
} else {
workspace = home
}
}
return filepath.Join(workspace, "sessions"), nil
}
// handleListSessions returns a list of Pico session summaries.
//
// GET /api/sessions
func (h *Handler) handleListSessions(w http.ResponseWriter, r *http.Request) {
dir, err := h.sessionsDir()
if err != nil {
http.Error(w, "failed to resolve sessions directory", http.StatusInternalServerError)
return
}
entries, err := os.ReadDir(dir)
if err != nil {
// Directory doesn't exist yet = no sessions
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode([]sessionListItem{})
return
}
items := []sessionListItem{}
for _, entry := range entries {
if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" {
continue
}
data, err := os.ReadFile(filepath.Join(dir, entry.Name()))
if err != nil {
continue
}
var sess sessionFile
if err := json.Unmarshal(data, &sess); err != nil {
continue
}
// Only include Pico channel sessions
sessionID, ok := extractPicoSessionID(sess.Key)
if !ok {
continue
}
// Build a preview from the first user message
preview := ""
for _, msg := range sess.Messages {
if msg.Role == "user" && strings.TrimSpace(msg.Content) != "" {
preview = msg.Content
break
}
}
if len([]rune(preview)) > 60 {
preview = string([]rune(preview)[:60]) + "..."
}
if preview == "" {
preview = "(empty)"
}
// Only count non-empty user and assistant messages
validMessageCount := 0
for _, msg := range sess.Messages {
if (msg.Role == "user" || msg.Role == "assistant") && strings.TrimSpace(msg.Content) != "" {
validMessageCount++
}
}
items = append(items, sessionListItem{
ID: sessionID,
Preview: preview,
MessageCount: validMessageCount,
Created: sess.Created.Format(time.RFC3339),
Updated: sess.Updated.Format(time.RFC3339),
})
}
// Sort by updated descending (most recent first)
sort.Slice(items, func(i, j int) bool {
return items[i].Updated > items[j].Updated
})
// Pagination parameters
offsetStr := r.URL.Query().Get("offset")
limitStr := r.URL.Query().Get("limit")
offset := 0
limit := 20 // Default limit
if val, err := strconv.Atoi(offsetStr); err == nil && val >= 0 {
offset = val
}
if val, err := strconv.Atoi(limitStr); err == nil && val > 0 {
limit = val
}
totalItems := len(items)
end := offset + limit
if offset >= totalItems {
items = []sessionListItem{} // Out of bounds, return empty
} else {
if end > totalItems {
end = totalItems
}
items = items[offset:end]
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(items)
}
// handleGetSession returns the full message history for a specific session.
//
// GET /api/sessions/{id}
func (h *Handler) handleGetSession(w http.ResponseWriter, r *http.Request) {
sessionID := r.PathValue("id")
if sessionID == "" {
http.Error(w, "missing session id", http.StatusBadRequest)
return
}
dir, err := h.sessionsDir()
if err != nil {
http.Error(w, "failed to resolve sessions directory", http.StatusInternalServerError)
return
}
// The sanitized filename replaces ':' with '_':
// agent:main:pico:direct:pico:<uuid> -> agent_main_pico_direct_pico_<uuid>.json
filename := strings.ReplaceAll(picoSessionPrefix+sessionID, ":", "_") + ".json"
data, err := os.ReadFile(filepath.Join(dir, filename))
if err != nil {
http.Error(w, "session not found", http.StatusNotFound)
return
}
var sess sessionFile
if err := json.Unmarshal(data, &sess); err != nil {
http.Error(w, "failed to parse session", http.StatusInternalServerError)
return
}
// Convert to a simpler format for the frontend
type chatMessage struct {
Role string `json:"role"`
Content string `json:"content"`
}
messages := make([]chatMessage, 0, len(sess.Messages))
for _, msg := range sess.Messages {
// Only include user and assistant messages that have actual content
if (msg.Role == "user" || msg.Role == "assistant") && strings.TrimSpace(msg.Content) != "" {
messages = append(messages, chatMessage{
Role: msg.Role,
Content: msg.Content,
})
}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"id": sessionID,
"messages": messages,
"summary": sess.Summary,
"created": sess.Created.Format(time.RFC3339),
"updated": sess.Updated.Format(time.RFC3339),
})
}
// handleDeleteSession deletes a specific session.
//
// DELETE /api/sessions/{id}
func (h *Handler) handleDeleteSession(w http.ResponseWriter, r *http.Request) {
sessionID := r.PathValue("id")
if sessionID == "" {
http.Error(w, "missing session id", http.StatusBadRequest)
return
}
dir, err := h.sessionsDir()
if err != nil {
http.Error(w, "failed to resolve sessions directory", http.StatusInternalServerError)
return
}
// The sanitized filename replaces ':' with '_':
// agent:main:pico:direct:pico:<uuid> -> agent_main_pico_direct_pico_<uuid>.json
filename := strings.ReplaceAll(picoSessionPrefix+sessionID, ":", "_") + ".json"
filePath := filepath.Join(dir, filename)
if err := os.Remove(filePath); err != nil {
if os.IsNotExist(err) {
http.Error(w, "session not found", http.StatusNotFound)
} else {
http.Error(w, "failed to delete session", http.StatusInternalServerError)
}
return
}
w.WriteHeader(http.StatusNoContent)
}

305
web/backend/api/startup.go Normal file
View file

@ -0,0 +1,305 @@
package api
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
)
const (
autoStartEntryName = "PicoClawLauncher"
launchAgentLabel = "io.picoclaw.launcher"
)
type autoStartRequest struct {
Enabled bool `json:"enabled"`
}
type autoStartResponse struct {
Enabled bool `json:"enabled"`
Supported bool `json:"supported"`
Platform string `json:"platform"`
Message string `json:"message,omitempty"`
}
var errAutoStartUnsupported = errors.New("autostart is not supported on this platform")
func (h *Handler) registerStartupRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/system/autostart", h.handleGetAutoStart)
mux.HandleFunc("PUT /api/system/autostart", h.handleSetAutoStart)
}
func (h *Handler) handleGetAutoStart(w http.ResponseWriter, r *http.Request) {
enabled, supported, message, err := h.getAutoStartStatus()
if err != nil {
http.Error(w, fmt.Sprintf("Failed to read startup setting: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(autoStartResponse{
Enabled: enabled,
Supported: supported,
Platform: runtime.GOOS,
Message: message,
})
}
func (h *Handler) handleSetAutoStart(w http.ResponseWriter, r *http.Request) {
var req autoStartRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest)
return
}
if err := h.setAutoStart(req.Enabled); err != nil {
if errors.Is(err, errAutoStartUnsupported) {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
http.Error(w, fmt.Sprintf("Failed to update startup setting: %v", err), http.StatusInternalServerError)
return
}
enabled, supported, message, err := h.getAutoStartStatus()
if err != nil {
http.Error(w, fmt.Sprintf("Failed to verify startup setting: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(autoStartResponse{
Enabled: enabled,
Supported: supported,
Platform: runtime.GOOS,
Message: message,
})
}
func (h *Handler) resolveLaunchCommand() (string, []string, error) {
exePath, err := os.Executable()
if err != nil {
return "", nil, err
}
args := []string{"-no-browser"}
if h.configPath != "" {
args = append(args, h.configPath)
}
return exePath, args, nil
}
func (h *Handler) getAutoStartStatus() (enabled bool, supported bool, message string, err error) {
switch runtime.GOOS {
case "darwin":
exists, err := fileExists(macLaunchAgentPath())
return exists, true, "Changes apply on next login.", err
case "linux":
exists, err := fileExists(linuxAutoStartPath())
return exists, true, "Changes apply on next login.", err
case "windows":
exists, err := windowsRunKeyExists()
return exists, true, "Changes apply on next login.", err
default:
return false, false, "Current platform does not support launch at login.", nil
}
}
func (h *Handler) setAutoStart(enabled bool) error {
exePath, args, err := h.resolveLaunchCommand()
if err != nil {
return err
}
switch runtime.GOOS {
case "darwin":
return setDarwinAutoStart(enabled, exePath, args)
case "linux":
return setLinuxAutoStart(enabled, exePath, args)
case "windows":
return setWindowsAutoStart(enabled, exePath, args)
default:
return errAutoStartUnsupported
}
}
func fileExists(path string) (bool, error) {
_, err := os.Stat(path)
if err == nil {
return true, nil
}
if os.IsNotExist(err) {
return false, nil
}
return false, err
}
func macLaunchAgentPath() string {
home, _ := os.UserHomeDir()
return filepath.Join(home, "Library", "LaunchAgents", launchAgentLabel+".plist")
}
func setDarwinAutoStart(enabled bool, exePath string, args []string) error {
plistPath := macLaunchAgentPath()
if enabled {
if err := os.MkdirAll(filepath.Dir(plistPath), 0o755); err != nil {
return err
}
content := buildDarwinPlist(exePath, args)
return os.WriteFile(plistPath, []byte(content), 0o644)
}
if err := os.Remove(plistPath); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
func xmlEscape(s string) string {
var b bytes.Buffer
for _, r := range s {
switch r {
case '&':
b.WriteString("&amp;")
case '<':
b.WriteString("&lt;")
case '>':
b.WriteString("&gt;")
case '"':
b.WriteString("&quot;")
case '\'':
b.WriteString("&apos;")
default:
b.WriteRune(r)
}
}
return b.String()
}
func buildDarwinPlist(exePath string, args []string) string {
programArgs := make([]string, 0, len(args)+1)
programArgs = append(programArgs, exePath)
programArgs = append(programArgs, args...)
var b strings.Builder
b.WriteString(`<?xml version="1.0" encoding="UTF-8"?>` + "\n")
b.WriteString(
`<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">` + "\n",
)
b.WriteString(`<plist version="1.0">` + "\n")
b.WriteString(`<dict>` + "\n")
b.WriteString(` <key>Label</key>` + "\n")
b.WriteString(` <string>` + launchAgentLabel + `</string>` + "\n")
b.WriteString(` <key>ProgramArguments</key>` + "\n")
b.WriteString(` <array>` + "\n")
for _, arg := range programArgs {
b.WriteString(` <string>` + xmlEscape(arg) + `</string>` + "\n")
}
b.WriteString(` </array>` + "\n")
b.WriteString(` <key>RunAtLoad</key>` + "\n")
b.WriteString(` <true/>` + "\n")
b.WriteString(` <key>ProcessType</key>` + "\n")
b.WriteString(` <string>Background</string>` + "\n")
b.WriteString(`</dict>` + "\n")
b.WriteString(`</plist>` + "\n")
return b.String()
}
func linuxAutoStartPath() string {
home, _ := os.UserHomeDir()
return filepath.Join(home, ".config", "autostart", "picoclaw-web.desktop")
}
func shellQuote(s string) string {
if s == "" {
return "''"
}
if !strings.ContainsAny(s, " \t\n'\"\\$`") {
return s
}
return "'" + strings.ReplaceAll(s, "'", "'\"'\"'") + "'"
}
func buildLinuxExecLine(exePath string, args []string) string {
parts := make([]string, 0, len(args)+1)
parts = append(parts, shellQuote(exePath))
for _, arg := range args {
parts = append(parts, shellQuote(arg))
}
return strings.Join(parts, " ")
}
func setLinuxAutoStart(enabled bool, exePath string, args []string) error {
desktopPath := linuxAutoStartPath()
if enabled {
if err := os.MkdirAll(filepath.Dir(desktopPath), 0o755); err != nil {
return err
}
content := strings.Join([]string{
"[Desktop Entry]",
"Type=Application",
"Version=1.0",
"Name=PicoClaw Web",
"Comment=Start PicoClaw Web on login",
"Exec=" + buildLinuxExecLine(exePath, args),
"Terminal=false",
"X-GNOME-Autostart-enabled=true",
"NoDisplay=true",
"",
}, "\n")
return os.WriteFile(desktopPath, []byte(content), 0o644)
}
if err := os.Remove(desktopPath); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
func windowsCommandLine(exePath string, args []string) string {
parts := make([]string, 0, len(args)+1)
parts = append(parts, fmt.Sprintf("%q", exePath))
for _, arg := range args {
parts = append(parts, fmt.Sprintf("%q", arg))
}
return strings.Join(parts, " ")
}
func windowsRunKeyExists() (bool, error) {
cmd := exec.Command("reg", "query", `HKCU\Software\Microsoft\Windows\CurrentVersion\Run`, "/v", autoStartEntryName)
if err := cmd.Run(); err != nil {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
return false, nil
}
return false, err
}
return true, nil
}
func setWindowsAutoStart(enabled bool, exePath string, args []string) error {
key := `HKCU\Software\Microsoft\Windows\CurrentVersion\Run`
if enabled {
commandLine := windowsCommandLine(exePath, args)
cmd := exec.Command("reg", "add", key, "/v", autoStartEntryName, "/t", "REG_SZ", "/d", commandLine, "/f")
return cmd.Run()
}
cmd := exec.Command("reg", "delete", key, "/v", autoStartEntryName, "/f")
if err := cmd.Run(); err != nil {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
return nil
}
return err
}
return nil
}

View file

@ -0,0 +1,56 @@
package api
import (
"path/filepath"
"strings"
"testing"
"github.com/sipeed/picoclaw/web/backend/launcherconfig"
)
func TestResolveLaunchCommandUsesConfigFileDefaults(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
h := NewHandler(configPath)
// Persist non-default launcher options to ensure resolveLaunchCommand does not
// pin them into autostart args.
launcherPath := launcherconfig.PathForAppConfig(configPath)
if err := launcherconfig.Save(launcherPath, launcherconfig.Config{
Port: 19999,
Public: true,
}); err != nil {
t.Fatalf("launcherconfig.Save() error = %v", err)
}
exePath, args, err := h.resolveLaunchCommand()
if err != nil {
t.Fatalf("resolveLaunchCommand() error = %v", err)
}
if exePath == "" {
t.Fatal("resolveLaunchCommand() returned empty executable path")
}
if len(args) != 2 {
t.Fatalf("args len = %d, want 2 (got %v)", len(args), args)
}
if args[0] != "-no-browser" {
t.Fatalf("args[0] = %q, want %q", args[0], "-no-browser")
}
if args[1] != configPath {
t.Fatalf("args[1] = %q, want %q", args[1], configPath)
}
for _, arg := range args {
if arg == "-port" || arg == "-public" {
t.Fatalf("autostart args should not pin network flags, got %v", args)
}
}
}
func TestBuildDarwinPlistIncludesRunAtLoad(t *testing.T) {
plist := buildDarwinPlist("/tmp/picoclaw-web", []string{"-no-browser", "/tmp/config.json"})
if !strings.Contains(plist, "<key>RunAtLoad</key>") {
t.Fatalf("plist missing RunAtLoad key:\n%s", plist)
}
if !strings.Contains(plist, "<true/>") {
t.Fatalf("plist missing RunAtLoad true value:\n%s", plist)
}
}

0
web/backend/dist/.gitkeep vendored Normal file
View file

69
web/backend/embed.go Normal file
View file

@ -0,0 +1,69 @@
package main
import (
"embed"
"io/fs"
"log"
"net/http"
"path"
"strings"
)
//go:embed all:dist
var frontendFS embed.FS
// registerEmbedRoutes sets up the HTTP handler to serve the embedded frontend files
func registerEmbedRoutes(mux *http.ServeMux) {
// Attempt to get the subdirectory 'dist' where Vite usually builds
subFS, err := fs.Sub(frontendFS, "dist")
if err != nil {
// Log a warning if dist doesn't exist yet (e.g., during development before a frontend build)
log.Printf(
"Warning: no 'dist' folder found in embedded frontend. " +
"Ensure you run `pnpm build:backend` in the frontend directory " +
"before building the Go backend.",
)
return
}
fileServer := http.FileServer(http.FS(subFS))
// Serve static assets and fallback to index.html for SPA routes.
mux.Handle(
"/",
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
http.NotFound(w, r)
return
}
// Keep unknown API paths as 404 instead of falling back to SPA entry.
if r.URL.Path == "/api" || strings.HasPrefix(r.URL.Path, "/api/") {
http.NotFound(w, r)
return
}
cleanPath := path.Clean(strings.TrimPrefix(r.URL.Path, "/"))
if cleanPath == "." {
cleanPath = ""
}
// Existing static files/directories should be served directly.
if cleanPath != "" {
if _, statErr := fs.Stat(subFS, cleanPath); statErr == nil {
fileServer.ServeHTTP(w, r)
return
}
// Missing asset-like paths should remain 404.
if strings.Contains(path.Base(cleanPath), ".") {
fileServer.ServeHTTP(w, r)
return
}
}
indexReq := r.Clone(r.Context())
indexReq.URL.Path = "/"
fileServer.ServeHTTP(w, indexReq)
}),
)
}

33
web/backend/embed_test.go Normal file
View file

@ -0,0 +1,33 @@
package main
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestUnknownAPIPathStays404(t *testing.T) {
mux := http.NewServeMux()
registerEmbedRoutes(mux)
req := httptest.NewRequest(http.MethodGet, "/api/not-found", nil)
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if rr.Code != http.StatusNotFound {
t.Fatalf("status = %d, want %d", rr.Code, http.StatusNotFound)
}
}
func TestMissingAssetStays404(t *testing.T) {
mux := http.NewServeMux()
registerEmbedRoutes(mux)
req := httptest.NewRequest(http.MethodGet, "/assets/not-found.js", nil)
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if rr.Code != http.StatusNotFound {
t.Fatalf("status = %d, want %d", rr.Code, http.StatusNotFound)
}
}

View file

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 44 KiB

View file

@ -0,0 +1,113 @@
package launcherconfig
import (
"encoding/json"
"fmt"
"net"
"os"
"path/filepath"
"strings"
)
const (
// FileName is the launcher-specific settings file name.
FileName = "launcher-config.json"
// DefaultPort is the default port for the web launcher.
DefaultPort = 18800
)
// Config stores launch parameters for the web backend service.
type Config struct {
Port int `json:"port"`
Public bool `json:"public"`
AllowedCIDRs []string `json:"allowed_cidrs,omitempty"`
}
// Default returns default launcher settings.
func Default() Config {
return Config{Port: DefaultPort, Public: false}
}
// Validate checks if launcher settings are valid.
func Validate(cfg Config) error {
if cfg.Port < 1 || cfg.Port > 65535 {
return fmt.Errorf("port %d is out of range (1-65535)", cfg.Port)
}
for _, cidr := range cfg.AllowedCIDRs {
if _, _, err := net.ParseCIDR(cidr); err != nil {
return fmt.Errorf("invalid CIDR %q", cidr)
}
}
return nil
}
// NormalizeCIDRs trims entries, removes empty values, and deduplicates CIDRs.
func NormalizeCIDRs(cidrs []string) []string {
if len(cidrs) == 0 {
return nil
}
out := make([]string, 0, len(cidrs))
seen := make(map[string]struct{}, len(cidrs))
for _, raw := range cidrs {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
continue
}
if _, ok := seen[trimmed]; ok {
continue
}
seen[trimmed] = struct{}{}
out = append(out, trimmed)
}
if len(out) == 0 {
return nil
}
return out
}
// PathForAppConfig returns launcher-config path near the app config file.
func PathForAppConfig(appConfigPath string) string {
dir := filepath.Dir(appConfigPath)
if dir == "" || dir == "." {
dir = "."
}
return filepath.Join(dir, FileName)
}
// Load reads launcher settings; fallback is returned when file does not exist.
func Load(path string, fallback Config) (Config, error) {
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return fallback, nil
}
return Config{}, err
}
cfg := fallback
if err := json.Unmarshal(data, &cfg); err != nil {
return Config{}, err
}
cfg.AllowedCIDRs = NormalizeCIDRs(cfg.AllowedCIDRs)
if err := Validate(cfg); err != nil {
return Config{}, err
}
return cfg, nil
}
// Save writes launcher settings to disk.
func Save(path string, cfg Config) error {
cfg.AllowedCIDRs = NormalizeCIDRs(cfg.AllowedCIDRs)
if err := Validate(cfg); err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return err
}
data = append(data, '\n')
return os.WriteFile(path, data, 0o600)
}

View file

@ -0,0 +1,89 @@
package launcherconfig
import (
"os"
"path/filepath"
"testing"
)
func TestLoadReturnsFallbackWhenMissing(t *testing.T) {
path := filepath.Join(t.TempDir(), "launcher-config.json")
fallback := Config{Port: 19999, Public: true}
got, err := Load(path, fallback)
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if got.Port != fallback.Port || got.Public != fallback.Public {
t.Fatalf("Load() = %+v, want %+v", got, fallback)
}
}
func TestSaveAndLoadRoundTrip(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "launcher-config.json")
want := Config{
Port: 18080,
Public: true,
AllowedCIDRs: []string{"192.168.1.0/24", "10.0.0.0/8"},
}
if err := Save(path, want); err != nil {
t.Fatalf("Save() error = %v", err)
}
got, err := Load(path, Default())
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if got.Port != want.Port || got.Public != want.Public {
t.Fatalf("Load() = %+v, want %+v", got, want)
}
if len(got.AllowedCIDRs) != len(want.AllowedCIDRs) {
t.Fatalf("allowed_cidrs len = %d, want %d", len(got.AllowedCIDRs), len(want.AllowedCIDRs))
}
for i := range want.AllowedCIDRs {
if got.AllowedCIDRs[i] != want.AllowedCIDRs[i] {
t.Fatalf("allowed_cidrs[%d] = %q, want %q", i, got.AllowedCIDRs[i], want.AllowedCIDRs[i])
}
}
stat, err := os.Stat(path)
if err != nil {
t.Fatalf("Stat() error = %v", err)
}
if perm := stat.Mode().Perm(); perm != 0o600 {
t.Fatalf("file perm = %o, want 600", perm)
}
}
func TestValidateRejectsInvalidPort(t *testing.T) {
if err := Validate(Config{Port: 0, Public: false}); err == nil {
t.Fatal("Validate() expected error for port 0")
}
if err := Validate(Config{Port: 65536, Public: false}); err == nil {
t.Fatal("Validate() expected error for port 65536")
}
}
func TestValidateRejectsInvalidCIDR(t *testing.T) {
err := Validate(Config{
Port: 18800,
AllowedCIDRs: []string{"192.168.1.0/24", "not-a-cidr"},
})
if err == nil {
t.Fatal("Validate() expected error for invalid CIDR")
}
}
func TestNormalizeCIDRs(t *testing.T) {
got := NormalizeCIDRs([]string{" 192.168.1.0/24 ", "", "10.0.0.0/8", "192.168.1.0/24"})
want := []string{"192.168.1.0/24", "10.0.0.0/8"}
if len(got) != len(want) {
t.Fatalf("len(got) = %d, want %d", len(got), len(want))
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("got[%d] = %q, want %q", i, got[i], want[i])
}
}
}

164
web/backend/main.go Normal file
View file

@ -0,0 +1,164 @@
// PicoClaw Web Console - Web-based chat and management interface
//
// Provides a web UI for chatting with PicoClaw via the Pico Channel WebSocket,
// with configuration management and gateway process control.
//
// Usage:
//
// go build -o picoclaw-web ./web/backend/
// ./picoclaw-web [config.json]
// ./picoclaw-web -public config.json
package main
import (
"errors"
"flag"
"fmt"
"log"
"net/http"
"os"
"path/filepath"
"strconv"
"time"
"github.com/sipeed/picoclaw/web/backend/api"
"github.com/sipeed/picoclaw/web/backend/launcherconfig"
"github.com/sipeed/picoclaw/web/backend/middleware"
)
func main() {
port := flag.String("port", "18800", "Port to listen on")
public := flag.Bool("public", false, "Listen on all interfaces (0.0.0.0) instead of localhost only")
noBrowser := flag.Bool("no-browser", false, "Do not auto-open browser on startup")
flag.Usage = func() {
fmt.Fprintf(os.Stderr, "PicoClaw Launcher - A web-based configuration editor\n\n")
fmt.Fprintf(os.Stderr, "Usage: %s [options] [config.json]\n\n", os.Args[0])
fmt.Fprintf(os.Stderr, "Arguments:\n")
fmt.Fprintf(os.Stderr, " config.json Path to the configuration file (default: ~/.picoclaw/config.json)\n\n")
fmt.Fprintf(os.Stderr, "Options:\n")
flag.PrintDefaults()
fmt.Fprintf(os.Stderr, "\nExamples:\n")
fmt.Fprintf(os.Stderr, " %s Use default config path\n", os.Args[0])
fmt.Fprintf(os.Stderr, " %s ./config.json Specify a config file\n", os.Args[0])
fmt.Fprintf(
os.Stderr,
" %s -public ./config.json Allow access from other devices on the network\n",
os.Args[0],
)
}
flag.Parse()
// Resolve config path
configPath := getDefaultConfigPath()
if flag.NArg() > 0 {
configPath = flag.Arg(0)
}
absPath, err := filepath.Abs(configPath)
if err != nil {
log.Fatalf("Failed to resolve config path: %v", err)
}
var explicitPort bool
var explicitPublic bool
flag.Visit(func(f *flag.Flag) {
switch f.Name {
case "port":
explicitPort = true
case "public":
explicitPublic = true
}
})
launcherPath := launcherconfig.PathForAppConfig(absPath)
launcherCfg, err := launcherconfig.Load(launcherPath, launcherconfig.Default())
if err != nil {
log.Printf("Warning: Failed to load %s: %v", launcherPath, err)
launcherCfg = launcherconfig.Default()
}
effectivePort := *port
effectivePublic := *public
if !explicitPort {
effectivePort = strconv.Itoa(launcherCfg.Port)
}
if !explicitPublic {
effectivePublic = launcherCfg.Public
}
portNum, err := strconv.Atoi(effectivePort)
if err != nil || portNum < 1 || portNum > 65535 {
if err == nil {
err = errors.New("must be in range 1-65535")
}
log.Fatalf("Invalid port %q: %v", effectivePort, err)
}
// Determine listen address
var addr string
if effectivePublic {
addr = "0.0.0.0:" + effectivePort
} else {
addr = "127.0.0.1:" + effectivePort
}
// Initialize Server components
mux := http.NewServeMux()
// API Routes (e.g. /api/status)
apiHandler := api.NewHandler(absPath)
apiHandler.SetServerOptions(portNum, effectivePublic, launcherCfg.AllowedCIDRs)
apiHandler.RegisterRoutes(mux)
// Frontend Embedded Assets
registerEmbedRoutes(mux)
accessControlledMux, err := middleware.IPAllowlist(launcherCfg.AllowedCIDRs, mux)
if err != nil {
log.Fatalf("Invalid allowed CIDR configuration: %v", err)
}
// Apply middleware stack
handler := middleware.Recoverer(
middleware.Logger(
middleware.JSONContentType(accessControlledMux),
),
)
// Print startup banner
fmt.Print(banner)
fmt.Println()
fmt.Println(" Open the following URL in your browser:")
fmt.Println()
fmt.Printf(" >> http://localhost:%s <<\n", effectivePort)
if effectivePublic {
if ip := getLocalIP(); ip != "" {
fmt.Printf(" >> http://%s:%s <<\n", ip, effectivePort)
}
}
fmt.Println()
// Auto-open browser
if !*noBrowser {
go func() {
time.Sleep(500 * time.Millisecond)
url := "http://localhost:" + effectivePort
if err := openBrowser(url); err != nil {
log.Printf("Warning: Failed to auto-open browser: %v", err)
}
}()
}
// Auto-start gateway after backend starts listening.
go func() {
time.Sleep(1 * time.Second)
apiHandler.TryAutoStartGateway()
}()
// Start the Server
if err := http.ListenAndServe(addr, handler); err != nil {
log.Fatalf("Server failed to start: %v", err)
}
}

View file

@ -0,0 +1,64 @@
package middleware
import (
"fmt"
"net"
"net/http"
"strings"
)
// IPAllowlist restricts access to requests from configured CIDR ranges.
// Loopback addresses are always allowed for local administration.
// Empty CIDR list means no restriction.
func IPAllowlist(allowedCIDRs []string, next http.Handler) (http.Handler, error) {
if len(allowedCIDRs) == 0 {
return next, nil
}
nets := make([]*net.IPNet, 0, len(allowedCIDRs))
for _, cidr := range allowedCIDRs {
_, ipNet, err := net.ParseCIDR(cidr)
if err != nil {
return nil, fmt.Errorf("invalid CIDR %q: %w", cidr, err)
}
nets = append(nets, ipNet)
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ip := clientIPFromRemoteAddr(r.RemoteAddr)
if ip == nil {
rejectByPolicy(w, r)
return
}
if ip.IsLoopback() {
next.ServeHTTP(w, r)
return
}
for _, ipNet := range nets {
if ipNet.Contains(ip) {
next.ServeHTTP(w, r)
return
}
}
rejectByPolicy(w, r)
}), nil
}
func clientIPFromRemoteAddr(remoteAddr string) net.IP {
host := remoteAddr
if h, _, err := net.SplitHostPort(remoteAddr); err == nil {
host = h
}
return net.ParseIP(host)
}
func rejectByPolicy(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/api/") {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusForbidden)
_, _ = w.Write([]byte(`{"error":"access denied by network policy"}`))
return
}
http.Error(w, "Forbidden", http.StatusForbidden)
}

View file

@ -0,0 +1,86 @@
package middleware
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestIPAllowlist_EmptyCIDRsAllowsAll(t *testing.T) {
h, err := IPAllowlist(nil, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
if err != nil {
t.Fatalf("IPAllowlist() error = %v", err)
}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = "203.0.113.5:1234"
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
}
}
func TestIPAllowlist_RejectsOutsideCIDR(t *testing.T) {
h, err := IPAllowlist([]string{"192.168.1.0/24"}, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
if err != nil {
t.Fatalf("IPAllowlist() error = %v", err)
}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/config", nil)
req.RemoteAddr = "10.0.0.8:1234"
h.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusForbidden)
}
}
func TestIPAllowlist_AllowsInsideCIDR(t *testing.T) {
h, err := IPAllowlist([]string{"192.168.1.0/24"}, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
if err != nil {
t.Fatalf("IPAllowlist() error = %v", err)
}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = "192.168.1.88:1234"
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
}
}
func TestIPAllowlist_AlwaysAllowsLoopback(t *testing.T) {
h, err := IPAllowlist([]string{"192.168.1.0/24"}, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
if err != nil {
t.Fatalf("IPAllowlist() error = %v", err)
}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = "127.0.0.1:1234"
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
}
}
func TestIPAllowlist_InvalidCIDR(t *testing.T) {
_, err := IPAllowlist([]string{"bad-cidr"}, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
if err == nil {
t.Fatal("IPAllowlist() expected error for invalid CIDR")
}
}

View file

@ -0,0 +1,70 @@
package middleware
import (
"log"
"net/http"
"runtime/debug"
"strings"
"time"
)
// JSONContentType sets the Content-Type header to application/json for
// API requests handled by the wrapped handler.
// SSE endpoints (text/event-stream) are excluded.
func JSONContentType(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/api/") && !strings.HasSuffix(r.URL.Path, "/events") {
w.Header().Set("Content-Type", "application/json")
}
next.ServeHTTP(w, r)
})
}
// responseRecorder wraps http.ResponseWriter to capture the status code.
type responseRecorder struct {
http.ResponseWriter
statusCode int
}
func (rr *responseRecorder) WriteHeader(code int) {
rr.statusCode = code
rr.ResponseWriter.WriteHeader(code)
}
// Flush delegates to the underlying ResponseWriter if it implements http.Flusher.
// This is required for SSE (Server-Sent Events) to work through the middleware.
func (rr *responseRecorder) Flush() {
if f, ok := rr.ResponseWriter.(http.Flusher); ok {
f.Flush()
}
}
// Unwrap returns the underlying ResponseWriter so that http.ResponseController
// and interface checks (like http.Flusher) can see through the wrapper.
func (rr *responseRecorder) Unwrap() http.ResponseWriter {
return rr.ResponseWriter
}
// Logger logs each HTTP request with method, path, status code, and duration.
func Logger(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rec := &responseRecorder{ResponseWriter: w, statusCode: http.StatusOK}
next.ServeHTTP(rec, r)
log.Printf("%s %s %d %s", r.Method, r.URL.Path, rec.statusCode, time.Since(start))
})
}
// Recoverer recovers from panics in downstream handlers and returns a 500
// Internal Server Error response.
func Recoverer(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if err := recover(); err != nil {
log.Printf("panic recovered: %v\n%s", err, debug.Stack())
http.Error(w, `{"error":"internal server error"}`, http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}

View file

@ -0,0 +1,8 @@
package model
// StatusResponse represents the response payload for the GET /api/status endpoint.
type StatusResponse struct {
Status string `json:"status"`
Version string `json:"version"`
Uptime string `json:"uptime"`
}

61
web/backend/utils.go Normal file
View file

@ -0,0 +1,61 @@
package main
import (
"fmt"
"net"
"os"
"os/exec"
"path/filepath"
"runtime"
)
const (
colorBlue = "\x1b[38;2;62;93;185m"
colorRed = "\x1b[38;2;213;70;70m"
colorReset = "\x1b[0m"
banner = "\r\n" +
colorBlue + "██████╗ ██╗ ██████╗ ██████╗ " + colorRed + " ██████╗██╗ █████╗ ██╗ ██╗\n" +
colorBlue + "██╔══██╗██║██╔════╝██╔═══██╗" + colorRed + "██╔════╝██║ ██╔══██╗██║ ██║\n" +
colorBlue + "██████╔╝██║██║ ██║ ██║" + colorRed + "██║ ██║ ███████║██║ █╗ ██║\n" +
colorBlue + "██╔═══╝ ██║██║ ██║ ██║" + colorRed + "██║ ██║ ██╔══██║██║███╗██║\n" +
colorBlue + "██║ ██║╚██████╗╚██████╔╝" + colorRed + "╚██████╗███████╗██║ ██║╚███╔███╔╝\n" +
colorBlue + "╚═╝ ╚═╝ ╚═════╝ ╚═════╝ " + colorRed + " ╚═════╝╚══════╝╚═╝ ╚═╝ ╚══╝╚══╝\n" +
colorReset
)
// getDefaultConfigPath returns the default path to the picoclaw config file.
func getDefaultConfigPath() string {
home, err := os.UserHomeDir()
if err != nil {
return "config.json"
}
return filepath.Join(home, ".picoclaw", "config.json")
}
// getLocalIP returns the local IP address of the machine.
func getLocalIP() string {
addrs, err := net.InterfaceAddrs()
if err != nil {
return ""
}
for _, a := range addrs {
if ipnet, ok := a.(*net.IPNet); ok && !ipnet.IP.IsLoopback() && ipnet.IP.To4() != nil {
return ipnet.IP.String()
}
}
return ""
}
// openBrowser automatically opens the given URL in the default browser.
func openBrowser(url string) error {
switch runtime.GOOS {
case "linux":
return exec.Command("xdg-open", url).Start()
case "windows":
return exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start()
case "darwin":
return exec.Command("open", url).Start()
default:
return fmt.Errorf("unsupported platform")
}
}

View file

@ -0,0 +1,7 @@
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
end_of_line = lf

26
web/frontend/.gitignore vendored Normal file
View file

@ -0,0 +1,26 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
.tanstack

View file

@ -0,0 +1,5 @@
package-lock.json
pnpm-lock.yaml
yarn.lock
routeTree.gen.ts
src/components/ui

View file

@ -0,0 +1,25 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "radix-vega",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "tabler",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
}

View file

@ -0,0 +1,31 @@
import js from "@eslint/js"
import eslintConfigPrettier from "eslint-config-prettier"
import reactHooks from "eslint-plugin-react-hooks"
import reactRefresh from "eslint-plugin-react-refresh"
import { defineConfig, globalIgnores } from "eslint/config"
import globals from "globals"
import tseslint from "typescript-eslint"
export default defineConfig([
globalIgnores(["dist", "src/components/ui", "src/routeTree.gen.ts"]),
{
files: ["**/*.{ts,tsx}"],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
eslintConfigPrettier,
],
languageOptions: {
ecmaVersion: "latest",
globals: globals.browser,
},
rules: {
"react-refresh/only-export-components": [
"warn",
{ allowConstantExport: true },
],
},
},
])

18
web/frontend/index.html Normal file
View file

@ -0,0 +1,18 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="shortcut icon" href="/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="manifest" href="/site.webmanifest" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>PicoClaw</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

62
web/frontend/package.json Normal file
View file

@ -0,0 +1,62 @@
{
"name": "picoclaw-web",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"build:backend": "tsc -b && vite build --outDir ../backend/dist --emptyOutDir",
"lint": "eslint .",
"preview": "vite preview",
"format": "prettier --check .",
"check": "prettier --write . && eslint --fix"
},
"dependencies": {
"@fontsource-variable/inter": "^5.2.8",
"@tabler/icons-react": "^3.38.0",
"@tailwindcss/vite": "^4.2.1",
"@tanstack/react-query": "^5.90.21",
"@tanstack/react-router": "^1.163.3",
"@tanstack/react-router-devtools": "^1.163.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dayjs": "^1.11.19",
"i18next": "^25.8.14",
"i18next-browser-languagedetector": "^8.2.1",
"jotai": "^2.18.0",
"radix-ui": "^1.4.3",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-i18next": "^16.5.4",
"react-markdown": "^10.1.0",
"react-textarea-autosize": "^8.5.9",
"remark-gfm": "^4.0.1",
"shadcn": "^3.8.5",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.1",
"tw-animate-css": "^1.4.0"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@tailwindcss/typography": "^0.5.19",
"@tanstack/router-plugin": "^1.164.0",
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
"@types/node": "^24.10.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@typescript-eslint/eslint-plugin": "^8.56.1",
"@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.39.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"prettier": "^3.8.1",
"prettier-plugin-tailwindcss": "^0.7.2",
"typescript": "~5.9.3",
"typescript-eslint": "^8.48.0",
"vite": "^7.3.1"
}
}

7981
web/frontend/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,17 @@
// @ts-check
/** @type {import('prettier').Config} */
const config = {
semi: false,
printWidth: 80,
tabWidth: 2,
importOrder: ["<BUILTIN_MODULES>", "<THIRD_PARTY_MODULES>", "^@/", "^[./]"],
importOrderSeparation: true,
importOrderSortSpecifiers: true,
plugins: [
"@trivago/prettier-plugin-sort-imports",
"prettier-plugin-tailwindcss",
],
}
export default config

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 88 KiB

View file

@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8"?><svg width="24" height="24" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M17 29C21 29 25 26.9339 28 23.4065C36 14 41.4242 16.8166 44 17.9998C38.5 20.9998 40.5 29.6233 33 35.9998C28.382 39.9259 23.4945 41.014 19 41C12.5231 40.9799 6.86226 37.7637 4 35.4063V16.9998" stroke="#000" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/><path d="M5.64808 15.8669C5.02231 14.9567 3.77715 14.7261 2.86694 15.3519C1.95673 15.9777 1.72615 17.2228 2.35192 18.1331L5.64808 15.8669ZM36.0021 35.7309C36.958 35.1774 37.2843 33.9539 36.7309 32.9979C36.1774 32.042 34.9539 31.7157 33.9979 32.2691L36.0021 35.7309ZM2.35192 18.1331C5.2435 22.339 10.7992 28.144 16.8865 32.2239C19.9345 34.2667 23.217 35.946 26.449 36.7324C29.6946 37.522 33.0451 37.4428 36.0021 35.7309L33.9979 32.2691C32.2049 33.3072 29.9929 33.478 27.3947 32.8458C24.783 32.2103 21.9405 30.7958 19.1135 28.9011C13.4508 25.106 8.2565 19.661 5.64808 15.8669L2.35192 18.1331Z" fill="#000"/><path d="M33.5947 17C32.84 14.7027 30.8551 9.94054 27.5947 7H11.5947C15.2174 10.6757 23.0002 16 27.0002 24" stroke="#000" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

View file

@ -0,0 +1,21 @@
{
"name": "MyWebSite",
"short_name": "MySite",
"icons": [
{
"src": "/web-app-manifest-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/web-app-manifest-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

View file

@ -0,0 +1,65 @@
// API client for channels navigation and channel-specific config flows.
export type ChannelConfig = Record<string, unknown>
export type AppConfig = Record<string, unknown>
export interface SupportedChannel {
name: string
display_name?: string
config_key: string
variant?: string
}
interface ChannelsCatalogResponse {
channels: SupportedChannel[]
}
interface ConfigActionResponse {
status: string
errors?: string[]
}
const BASE_URL = ""
async function request<T>(path: string, options?: RequestInit): Promise<T> {
const res = await fetch(`${BASE_URL}${path}`, options)
if (!res.ok) {
let message = `API error: ${res.status} ${res.statusText}`
try {
const body = (await res.json()) as {
error?: string
errors?: string[]
status?: string
}
if (Array.isArray(body.errors) && body.errors.length > 0) {
message = body.errors.join("; ")
} else if (typeof body.error === "string" && body.error.trim() !== "") {
message = body.error
}
} catch {
// Keep default fallback message if response body is not JSON.
}
throw new Error(message)
}
return res.json() as Promise<T>
}
export async function getChannelsCatalog(): Promise<ChannelsCatalogResponse> {
return request<ChannelsCatalogResponse>("/api/channels/catalog")
}
export async function getAppConfig(): Promise<AppConfig> {
return request<AppConfig>("/api/config")
}
export async function patchAppConfig(
patch: Record<string, unknown>,
): Promise<ConfigActionResponse> {
return request<ConfigActionResponse>("/api/config", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(patch),
})
}
export type { ChannelsCatalogResponse, ConfigActionResponse }

View file

@ -0,0 +1,62 @@
// API client for gateway process management.
interface GatewayStatusResponse {
gateway_status: "running" | "starting" | "stopped" | "error"
gateway_start_allowed?: boolean
gateway_start_reason?: string
pid?: number
logs?: string[]
log_total?: number
log_run_id?: number
[key: string]: unknown
}
interface GatewayActionResponse {
status: string
pid?: number
}
const BASE_URL = ""
async function request<T>(path: string, options?: RequestInit): Promise<T> {
const res = await fetch(`${BASE_URL}${path}`, options)
if (!res.ok) {
throw new Error(`API error: ${res.status} ${res.statusText}`)
}
return res.json() as Promise<T>
}
export async function getGatewayStatus(options?: {
log_offset?: number
log_run_id?: number
}): Promise<GatewayStatusResponse> {
const params = new URLSearchParams()
if (options?.log_offset !== undefined) {
params.set("log_offset", options.log_offset.toString())
}
if (options?.log_run_id !== undefined) {
params.set("log_run_id", options.log_run_id.toString())
}
const queryString = params.toString() ? `?${params.toString()}` : ""
return request<GatewayStatusResponse>(`/api/gateway/status${queryString}`)
}
export async function startGateway(): Promise<GatewayActionResponse> {
return request<GatewayActionResponse>("/api/gateway/start", {
method: "POST",
})
}
export async function stopGateway(): Promise<GatewayActionResponse> {
return request<GatewayActionResponse>("/api/gateway/stop", {
method: "POST",
})
}
export async function restartGateway(): Promise<GatewayActionResponse> {
return request<GatewayActionResponse>("/api/gateway/restart", {
method: "POST",
})
}
export type { GatewayStatusResponse, GatewayActionResponse }

View file

@ -0,0 +1,91 @@
import { refreshGatewayState } from "@/store/gateway"
// API client for model list management.
export interface ModelInfo {
index: number
model_name: string
model: string
api_base?: string
api_key: string
proxy?: string
auth_method?: string
// Advanced fields
connect_mode?: string
workspace?: string
rpm?: number
max_tokens_field?: string
request_timeout?: number
thinking_level?: string
// Meta
configured: boolean
is_default: boolean
}
interface ModelsListResponse {
models: ModelInfo[]
total: number
default_model: string
}
interface ModelActionResponse {
status: string
index?: number
default_model?: string
}
const BASE_URL = ""
async function request<T>(path: string, options?: RequestInit): Promise<T> {
const res = await fetch(`${BASE_URL}${path}`, options)
if (!res.ok) {
throw new Error(`API error: ${res.status} ${res.statusText}`)
}
return res.json() as Promise<T>
}
export async function getModels(): Promise<ModelsListResponse> {
return request<ModelsListResponse>("/api/models")
}
export async function addModel(
model: Partial<ModelInfo>,
): Promise<ModelActionResponse> {
return request<ModelActionResponse>("/api/models", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(model),
})
}
export async function updateModel(
index: number,
model: Partial<ModelInfo>,
): Promise<ModelActionResponse> {
return request<ModelActionResponse>(`/api/models/${index}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(model),
})
}
export async function deleteModel(index: number): Promise<ModelActionResponse> {
return request<ModelActionResponse>(`/api/models/${index}`, {
method: "DELETE",
})
}
export async function setDefaultModel(
modelName: string,
): Promise<ModelActionResponse> {
const response = await request<ModelActionResponse>("/api/models/default", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model_name: modelName }),
})
void refreshGatewayState()
return response
}
export type { ModelsListResponse, ModelActionResponse }

View file

@ -0,0 +1,102 @@
export type OAuthProvider = "openai" | "anthropic" | "google-antigravity"
export type OAuthMethod = "browser" | "device_code" | "token"
export interface OAuthProviderStatus {
provider: OAuthProvider
display_name: string
methods: OAuthMethod[]
logged_in: boolean
status: "connected" | "expired" | "needs_refresh" | "not_logged_in"
auth_method?: string
expires_at?: string
account_id?: string
email?: string
project_id?: string
}
export interface OAuthFlowState {
flow_id: string
provider: OAuthProvider
method: OAuthMethod
status: "pending" | "success" | "error" | "expired"
expires_at?: string
error?: string
user_code?: string
verify_url?: string
interval?: number
}
export interface OAuthLoginRequest {
provider: OAuthProvider
method: OAuthMethod
token?: string
}
export interface OAuthLoginResponse {
status: string
provider: OAuthProvider
method: OAuthMethod
flow_id?: string
auth_url?: string
user_code?: string
verify_url?: string
interval?: number
expires_at?: string
}
interface OAuthProvidersResponse {
providers: OAuthProviderStatus[]
}
const BASE_URL = ""
async function request<T>(path: string, options?: RequestInit): Promise<T> {
const res = await fetch(`${BASE_URL}${path}`, options)
if (!res.ok) {
const message = await res.text()
throw new Error(message || `API error: ${res.status} ${res.statusText}`)
}
return res.json() as Promise<T>
}
export async function getOAuthProviders(): Promise<OAuthProvidersResponse> {
return request<OAuthProvidersResponse>("/api/oauth/providers")
}
export async function loginOAuth(
payload: OAuthLoginRequest,
): Promise<OAuthLoginResponse> {
return request<OAuthLoginResponse>("/api/oauth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
})
}
export async function getOAuthFlow(flowID: string): Promise<OAuthFlowState> {
return request<OAuthFlowState>(
`/api/oauth/flows/${encodeURIComponent(flowID)}`,
)
}
export async function pollOAuthFlow(flowID: string): Promise<OAuthFlowState> {
return request<OAuthFlowState>(
`/api/oauth/flows/${encodeURIComponent(flowID)}/poll`,
{
method: "POST",
},
)
}
export async function logoutOAuth(
provider: OAuthProvider,
): Promise<{ status: string; provider: OAuthProvider }> {
return request<{ status: string; provider: OAuthProvider }>(
"/api/oauth/logout",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ provider }),
},
)
}

View file

@ -0,0 +1,38 @@
// API client for Pico Channel configuration.
interface PicoTokenResponse {
token: string
ws_url: string
enabled: boolean
}
interface PicoSetupResponse {
token: string
ws_url: string
enabled: boolean
changed: boolean
}
const BASE_URL = ""
async function request<T>(path: string, options?: RequestInit): Promise<T> {
const res = await fetch(`${BASE_URL}${path}`, options)
if (!res.ok) {
throw new Error(`API error: ${res.status} ${res.statusText}`)
}
return res.json() as Promise<T>
}
export async function getPicoToken(): Promise<PicoTokenResponse> {
return request<PicoTokenResponse>("/api/pico/token")
}
export async function regenPicoToken(): Promise<PicoTokenResponse> {
return request<PicoTokenResponse>("/api/pico/token", { method: "POST" })
}
export async function setupPico(): Promise<PicoSetupResponse> {
return request<PicoSetupResponse>("/api/pico/setup", { method: "POST" })
}
export type { PicoTokenResponse, PicoSetupResponse }

View file

@ -0,0 +1,50 @@
// Sessions API — list and retrieve chat session history
export interface SessionSummary {
id: string
preview: string
message_count: number
created: string
updated: string
}
export interface SessionDetail {
id: string
messages: { role: "user" | "assistant"; content: string }[]
summary: string
created: string
updated: string
}
export async function getSessions(
offset: number = 0,
limit: number = 20,
): Promise<SessionSummary[]> {
const params = new URLSearchParams({
offset: offset.toString(),
limit: limit.toString(),
})
const res = await fetch(`/api/sessions?${params.toString()}`)
if (!res.ok) {
throw new Error(`Failed to fetch sessions: ${res.status}`)
}
return res.json()
}
export async function getSessionHistory(id: string): Promise<SessionDetail> {
const res = await fetch(`/api/sessions/${encodeURIComponent(id)}`)
if (!res.ok) {
throw new Error(`Failed to fetch session ${id}: ${res.status}`)
}
return res.json()
}
export async function deleteSession(id: string): Promise<void> {
const res = await fetch(`/api/sessions/${encodeURIComponent(id)}`, {
method: "DELETE",
})
if (!res.ok) {
throw new Error(`Failed to delete session ${id}: ${res.status}`)
}
}

View file

@ -0,0 +1,62 @@
export interface AutoStartStatus {
enabled: boolean
supported: boolean
platform: string
message?: string
}
export interface LauncherConfig {
port: number
public: boolean
allowed_cidrs: string[]
}
async function request<T>(path: string, options?: RequestInit): Promise<T> {
const res = await fetch(path, options)
if (!res.ok) {
let message = `API error: ${res.status} ${res.statusText}`
try {
const body = (await res.json()) as {
error?: string
errors?: string[]
}
if (Array.isArray(body.errors) && body.errors.length > 0) {
message = body.errors.join("; ")
} else if (typeof body.error === "string" && body.error.trim() !== "") {
message = body.error
}
} catch {
// Keep fallback error message when response body is not JSON.
}
throw new Error(message)
}
return res.json() as Promise<T>
}
export async function getAutoStartStatus(): Promise<AutoStartStatus> {
return request<AutoStartStatus>("/api/system/autostart")
}
export async function setAutoStartEnabled(
enabled: boolean,
): Promise<AutoStartStatus> {
return request<AutoStartStatus>("/api/system/autostart", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled }),
})
}
export async function getLauncherConfig(): Promise<LauncherConfig> {
return request<LauncherConfig>("/api/system/launcher-config")
}
export async function setLauncherConfig(
payload: LauncherConfig,
): Promise<LauncherConfig> {
return request<LauncherConfig>("/api/system/launcher-config", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
})
}

View file

@ -0,0 +1,193 @@
import {
IconBook,
IconLanguage,
IconLoader2,
IconMenu2,
IconMoon,
IconPlayerPlay,
IconPower,
IconSun,
} from "@tabler/icons-react"
import { Link } from "@tanstack/react-router"
import * as React from "react"
import { useTranslation } from "react-i18next"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog.tsx"
import { Button } from "@/components/ui/button.tsx"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu.tsx"
import { Separator } from "@/components/ui/separator.tsx"
import { SidebarTrigger } from "@/components/ui/sidebar"
import { useGateway } from "@/hooks/use-gateway.ts"
import { useTheme } from "@/hooks/use-theme.ts"
export function AppHeader() {
const { i18n, t } = useTranslation()
const { theme, toggleTheme } = useTheme()
const {
state: gwState,
loading: gwLoading,
canStart,
start,
stop,
} = useGateway()
const isRunning = gwState === "running"
const isStarting = gwState === "starting"
const isStopped = gwState === "stopped" || gwState === "unknown"
const showNotConnectedHint =
canStart && (gwState === "stopped" || gwState === "error")
const [showStopDialog, setShowStopDialog] = React.useState(false)
const handleGatewayToggle = () => {
if (gwLoading || (!isRunning && !canStart)) return
if (isRunning) {
setShowStopDialog(true)
} else {
start()
}
}
const confirmStop = () => {
setShowStopDialog(false)
stop()
}
return (
<header className="bg-background/95 supports-backdrop-filter:bg-background/60 border-b-border/50 sticky top-0 z-50 flex h-14 shrink-0 items-center justify-between border-b px-4 backdrop-blur">
<div className="flex items-center gap-2">
<SidebarTrigger className="text-muted-foreground hover:bg-accent hover:text-foreground flex h-9 w-9 items-center justify-center rounded-lg sm:hidden [&>svg]:size-5">
<IconMenu2 />
</SidebarTrigger>
<div className="hidden w-36 shrink-0 items-center sm:flex">
<Link to="/">
<img className="w-full" src="/logo_with_text.png" alt="Logo" />
</Link>
</div>
</div>
{/* Center prominent connection status */}
<div className="pointer-events-none absolute left-1/2 hidden h-full -translate-x-1/2 items-center justify-center lg:flex">
{showNotConnectedHint && (
<div className="text-muted-foreground flex items-center gap-2 rounded-full border border-dashed px-4 py-1.5 text-xs shadow-sm backdrop-blur-md">
<span className="bg-destructive/50 relative flex size-2 shrink-0 items-center justify-center rounded-full">
<span className="bg-destructive absolute inline-flex size-full animate-ping rounded-full opacity-75"></span>
</span>
{t("chat.notConnected")}
</div>
)}
</div>
<AlertDialog open={showStopDialog} onOpenChange={setShowStopDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("header.gateway.stopDialog.title")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("header.gateway.stopDialog.description")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
onClick={confirmStop}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{t("header.gateway.stopDialog.confirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<div className="text-muted-foreground flex items-center gap-1 text-sm font-medium md:gap-2">
{/* Gateway Start/Stop */}
<Button
variant={isStarting ? "secondary" : "default"}
size="sm"
className={`h-8 gap-2 px-3 ${
isRunning
? "bg-destructive/10 text-destructive hover:bg-destructive/20"
: isStopped
? "bg-green-500 text-white hover:bg-green-600"
: ""
}`}
onClick={handleGatewayToggle}
disabled={gwLoading || isStarting || (!isRunning && !canStart)}
>
{gwLoading || isStarting ? (
<IconLoader2 className="h-4 w-4 animate-spin opacity-70" />
) : isRunning ? (
<IconPower className="h-4 w-4 opacity-80" />
) : (
<IconPlayerPlay className="h-4 w-4 opacity-80" />
)}
<span className="text-xs font-semibold">
{isRunning
? t("header.gateway.action.stop")
: isStarting
? t("header.gateway.status.starting")
: t("header.gateway.action.start")}
</span>
</Button>
<Separator
className="mx-4 my-2 hidden md:block"
orientation="vertical"
/>
{/* Docs Link */}
<Button variant="ghost" size="icon" className="size-8" asChild>
<a href="https://docs.picoclaw.io" target="_blank" rel="noreferrer">
<IconBook className="size-4.5" />
</a>
</Button>
{/* Language Switcher */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="size-8">
<IconLanguage className="size-4.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => i18n.changeLanguage("en")}>
English
</DropdownMenuItem>
<DropdownMenuItem onClick={() => i18n.changeLanguage("zh")}>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* Theme Toggle */}
<Button
variant="ghost"
size="icon"
className="size-8"
onClick={toggleTheme}
>
{theme === "dark" ? (
<IconSun className="size-4.5" />
) : (
<IconMoon className="size-4.5" />
)}
</Button>
</div>
</header>
)
}

View file

@ -0,0 +1,27 @@
import type { ReactNode } from "react"
import { Toaster } from "sonner"
import { AppHeader } from "@/components/app-header"
import { AppSidebar } from "@/components/app-sidebar"
import { SidebarProvider } from "@/components/ui/sidebar"
import { TooltipProvider } from "@/components/ui/tooltip"
export function AppLayout({ children }: { children: ReactNode }) {
return (
<TooltipProvider>
<SidebarProvider className="flex h-dvh flex-col overflow-hidden">
<AppHeader />
<div className="flex flex-1 overflow-hidden">
<AppSidebar />
<div className="flex w-full flex-col overflow-hidden">
<main className="flex min-h-0 w-full max-w-full flex-1 flex-col overflow-hidden">
{children}
</main>
</div>
</div>
<Toaster position="bottom-center" />
</SidebarProvider>
</TooltipProvider>
)
}

View file

@ -0,0 +1,215 @@
import { IconChevronRight } from "@tabler/icons-react"
import {
IconAtom,
IconChevronsDown,
IconChevronsUp,
IconKey,
IconListDetails,
IconMessageCircle,
IconSettings,
} from "@tabler/icons-react"
import { Link, useRouterState } from "@tanstack/react-router"
import * as React from "react"
import { useTranslation } from "react-i18next"
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible"
import {
Sidebar,
SidebarContent,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarRail,
} from "@/components/ui/sidebar"
import { useSidebarChannels } from "@/hooks/use-sidebar-channels"
interface NavItem {
title: string
url: string
icon: React.ComponentType<{ className?: string }>
translateTitle?: boolean
}
interface NavGroup {
label: string
defaultOpen: boolean
items: NavItem[]
isChannelsGroup?: boolean
}
const baseNavGroups: Omit<NavGroup, "items">[] = [
{
label: "navigation.chat",
defaultOpen: true,
},
{
label: "navigation.model_group",
defaultOpen: true,
},
{
label: "navigation.services",
defaultOpen: true,
},
]
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
const routerState = useRouterState()
const { t } = useTranslation()
const currentPath = routerState.location.pathname
const {
channelItems,
hasMoreChannels,
showAllChannels,
toggleShowAllChannels,
} = useSidebarChannels({ t })
const navGroups: NavGroup[] = React.useMemo(() => {
return [
{
...baseNavGroups[0],
items: [
{
title: "navigation.chat",
url: "/",
icon: IconMessageCircle,
translateTitle: true,
},
],
},
{
...baseNavGroups[1],
items: [
{
title: "navigation.models",
url: "/models",
icon: IconAtom,
translateTitle: true,
},
{
title: "navigation.credentials",
url: "/credentials",
icon: IconKey,
translateTitle: true,
},
],
},
{
label: "navigation.channels_group",
defaultOpen: true,
items: channelItems.map((item) => ({
title: item.title,
url: item.url,
icon: item.icon,
translateTitle: false,
})),
isChannelsGroup: true,
},
{
...baseNavGroups[2],
items: [
{
title: "navigation.config",
url: "/config",
icon: IconSettings,
translateTitle: true,
},
{
title: "navigation.logs",
url: "/logs",
icon: IconListDetails,
translateTitle: true,
},
],
},
]
}, [channelItems])
return (
<Sidebar
{...props}
className="bg-background border-r-border/20 border-r pt-3"
>
<SidebarContent className="bg-background">
{navGroups.map((group) => (
<Collapsible
key={group.label}
defaultOpen={group.defaultOpen}
className="group/collapsible mb-1"
>
<SidebarGroup className="px-2 py-0">
<SidebarGroupLabel asChild>
<CollapsibleTrigger className="hover:bg-muted/60 flex w-full cursor-pointer items-center justify-between rounded-md px-2 py-1.5 transition-colors">
<span>{t(group.label)}</span>
<IconChevronRight className="size-3.5 opacity-50 transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
</CollapsibleTrigger>
</SidebarGroupLabel>
<CollapsibleContent>
<SidebarGroupContent className="pt-1">
<SidebarMenu>
{group.items.map((item) => {
const isActive =
currentPath === item.url ||
(item.url !== "/" &&
currentPath.startsWith(`${item.url}/`))
return (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton
asChild
isActive={isActive}
className={`h-9 px-3 ${isActive ? "bg-accent/80 text-foreground font-medium" : "text-muted-foreground hover:bg-muted/60"}`}
>
<Link to={item.url}>
<item.icon
className={`size-4 ${isActive ? "opacity-100" : "opacity-60"}`}
/>
<span
className={
isActive ? "opacity-100" : "opacity-80"
}
>
{item.translateTitle === false
? item.title
: t(item.title)}
</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
)
})}
{group.isChannelsGroup && hasMoreChannels && (
<SidebarMenuItem key="channels-more-toggle">
<SidebarMenuButton
onClick={toggleShowAllChannels}
className="text-muted-foreground hover:bg-muted/60 h-9 px-3"
>
{showAllChannels ? (
<IconChevronsUp className="size-4 opacity-60" />
) : (
<IconChevronsDown className="size-4 opacity-60" />
)}
<span className="opacity-80">
{showAllChannels
? t("navigation.show_less_channels")
: t("navigation.show_more_channels")}
</span>
</SidebarMenuButton>
</SidebarMenuItem>
)}
</SidebarMenu>
</SidebarGroupContent>
</CollapsibleContent>
</SidebarGroup>
</Collapsible>
))}
</SidebarContent>
<SidebarRail />
</Sidebar>
)
}

Some files were not shown because too many files have changed in this diff Show more