Merge branch 'main' into main
201
.github/workflows/nightly.yml
vendored
|
|
@ -9,67 +9,78 @@ permissions:
|
|||
contents: read
|
||||
|
||||
jobs:
|
||||
generate-version:
|
||||
name: Generate Version
|
||||
create-tag:
|
||||
name: Create Git Tag
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
outputs:
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
tag: ${{ steps.version.outputs.tag }}
|
||||
changelog: ${{ steps.version.outputs.changelog }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Generate version
|
||||
- name: Generate and push tag
|
||||
id: version
|
||||
run: |
|
||||
DATE=$(date -u +%Y%m%d)
|
||||
SHA=$(git rev-parse --short=8 HEAD)
|
||||
BASE_VERSION=$(git describe --tags --match "v*" --exclude "*nightly*" --abbrev=0 2>/dev/null || true)
|
||||
if [ -z "$BASE_VERSION" ] || [ "$BASE_VERSION" = "v0.0.0" ]; then
|
||||
VERSION="nightly-${DATE}-${SHA}"
|
||||
TAG="v0.0.0-nightly.${DATE}.${SHA}"
|
||||
else
|
||||
VERSION="${BASE_VERSION}-nightly-${DATE}-${SHA}"
|
||||
TAG="${BASE_VERSION}-nightly.${DATE}.${SHA}"
|
||||
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 "tag=${TAG}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
build:
|
||||
name: Build
|
||||
release:
|
||||
name: GoReleaser Release
|
||||
needs: create-tag
|
||||
runs-on: ubuntu-latest
|
||||
needs: generate-version
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
- name: Checkout tag
|
||||
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
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Build
|
||||
env:
|
||||
VERSION: ${{ needs.generate-version.outputs.version }}
|
||||
run: make build-all VERSION="$VERSION"
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v6
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
name: picoclaw-binaries
|
||||
path: build
|
||||
node-version: 22
|
||||
|
||||
build-docker:
|
||||
name: Build Docker
|
||||
runs-on: ubuntu-latest
|
||||
needs: generate-version
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
- name: Setup pnpm
|
||||
run: corepack enable && corepack prepare pnpm@latest --activate
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
|
@ -77,80 +88,79 @@ jobs:
|
|||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to GHCR
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
- name: Run GoReleaser
|
||||
uses: goreleaser/goreleaser-action@v6
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
type=raw,value=${{ needs.generate-version.outputs.tag }}
|
||||
type=raw,value=${{ needs.generate-version.outputs.version }}
|
||||
type=raw,value=nightly
|
||||
distribution: goreleaser
|
||||
version: ~> v2
|
||||
args: release --clean
|
||||
env:
|
||||
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
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
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
|
||||
update-rolling:
|
||||
name: Update Rolling Nightly
|
||||
needs: [create-tag, release]
|
||||
runs-on: ubuntu-latest
|
||||
needs: [generate-version, build, build-docker]
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Download artifacts
|
||||
uses: actions/download-artifact@v6
|
||||
with:
|
||||
name: picoclaw-binaries
|
||||
path: ./build
|
||||
|
||||
- name: Create release
|
||||
- name: Update nightly release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAG: ${{ needs.create-tag.outputs.tag }}
|
||||
TITLE: ${{ needs.create-tag.outputs.version }}
|
||||
run: |
|
||||
TAG="${{ needs.generate-version.outputs.tag }}"
|
||||
TITLE="${{ needs.generate-version.outputs.version }}"
|
||||
NOTES=$'Nightly build for **${{ needs.generate-version.outputs.version }}**\n\nThis is an automated build and may be unstable. Use with caution.'
|
||||
CHANGELOG='${{ needs.create-tag.outputs.changelog }}'
|
||||
NOTES=$(cat <<EOF
|
||||
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
|
||||
echo "Release $TAG already exists, updating metadata and assets..."
|
||||
gh release edit "$TAG" \
|
||||
--title "$TITLE" \
|
||||
--notes "$NOTES" \
|
||||
--prerelease
|
||||
gh release upload "$TAG" build/* --clobber
|
||||
echo "Downloading assets from GitHub release for $TAG..."
|
||||
gh release download "$TAG" --dir build
|
||||
else
|
||||
echo "Creating new release $TAG..."
|
||||
gh release create "$TAG" \
|
||||
--title "$TITLE" \
|
||||
--notes "$NOTES" \
|
||||
--target "${{ github.sha }}" \
|
||||
--prerelease \
|
||||
build/*
|
||||
echo "GitHub release for $TAG not found; falling back to local dist/ artifacts..."
|
||||
if [ -d "dist" ]; then
|
||||
cp -R dist/* build/
|
||||
else
|
||||
echo "Error: no GitHub release for $TAG and no local dist/ directory found." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "Updating rolling 'nightly' release..."
|
||||
gh release delete nightly --cleanup-tag -y >/dev/null 2>&1 || true
|
||||
sleep 2
|
||||
# Delete existing nightly release and tag to avoid conflicts
|
||||
echo "Deleting existing nightly release and tag..."
|
||||
gh release delete nightly --cleanup-tag -y || true
|
||||
git push origin :refs/tags/nightly || true
|
||||
|
||||
gh release create nightly \
|
||||
--title "Nightly Build" \
|
||||
--notes "$NOTES" \
|
||||
|
|
@ -159,9 +169,36 @@ jobs:
|
|||
build/*
|
||||
|
||||
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
|
||||
echo "Deleting old nightly release: $old_tag"
|
||||
gh release delete "$old_tag" --cleanup-tag -y || true
|
||||
fi
|
||||
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
|
||||
|
|
|
|||
13
.github/workflows/release.yml
vendored
|
|
@ -65,6 +65,14 @@ jobs:
|
|||
with:
|
||||
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
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
|
|
@ -96,6 +104,11 @@ jobs:
|
|||
GITHUB_REPOSITORY_OWNER: ${{ github.repository_owner }}
|
||||
DOCKERHUB_IMAGE_NAME: ${{ vars.DOCKERHUB_REPOSITORY }}
|
||||
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
|
||||
shell: bash
|
||||
|
|
|
|||
6
.gitignore
vendored
|
|
@ -47,6 +47,12 @@ docs/plans/
|
|||
|
||||
# Added by goreleaser init:
|
||||
dist/
|
||||
*.vite/
|
||||
|
||||
# Windows Application Icon/Resource
|
||||
*.syso
|
||||
|
||||
# Keep embedded backend dist directory placeholder in VCS
|
||||
!web/backend/dist/
|
||||
web/backend/dist/*
|
||||
!web/backend/dist/.gitkeep
|
||||
|
|
|
|||
|
|
@ -6,8 +6,9 @@ before:
|
|||
hooks:
|
||||
- go mod tidy
|
||||
- go generate ./...
|
||||
- sh -c 'cd web/frontend && pnpm install && pnpm build:backend'
|
||||
- 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:
|
||||
- id: picoclaw
|
||||
|
|
@ -70,7 +71,7 @@ builds:
|
|||
- "7"
|
||||
gomips:
|
||||
- softfloat
|
||||
main: ./cmd/picoclaw-launcher
|
||||
main: ./web/backend
|
||||
ignore:
|
||||
- goos: windows
|
||||
goarch: arm
|
||||
|
|
@ -115,15 +116,32 @@ dockers_v2:
|
|||
- picoclaw
|
||||
images:
|
||||
- "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:
|
||||
- "{{ .Tag }}"
|
||||
- "latest"
|
||||
- '{{ if isEnvSet "NIGHTLY_BUILD" }}nightly{{ else }}latest{{ end }}'
|
||||
platforms:
|
||||
- linux/amd64
|
||||
- linux/arm64
|
||||
- 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:
|
||||
- formats: [tar.gz]
|
||||
# this name template makes the OS and Arch compatible with the results of `uname`.
|
||||
|
|
@ -141,7 +159,7 @@ archives:
|
|||
|
||||
nfpms:
|
||||
- id: picoclaw
|
||||
builds:
|
||||
ids:
|
||||
- picoclaw
|
||||
- picoclaw-launcher
|
||||
- picoclaw-launcher-tui
|
||||
|
|
@ -161,6 +179,11 @@ nfpms:
|
|||
- rpm
|
||||
- deb
|
||||
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:
|
||||
sort: asc
|
||||
|
|
|
|||
12
Makefile
|
|
@ -111,6 +111,18 @@ build: generate
|
|||
@echo "Build complete: $(BINARY_PATH)"
|
||||
@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: generate
|
||||
## @echo "Building $(BINARY_NAME) with WhatsApp native for $(PLATFORM)/$(ARCH)..."
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 386 KiB After Width: | Height: | Size: 348 KiB |
|
|
@ -1,6 +1,7 @@
|
|||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
|
|
@ -67,6 +68,7 @@ func Run() error {
|
|||
root := tview.NewFlex().SetDirection(tview.FlexRow)
|
||||
root.AddItem(bannerView(), 6, 0, false)
|
||||
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 {
|
||||
return err
|
||||
|
|
@ -102,7 +104,7 @@ func (s *appState) pop() {
|
|||
}
|
||||
|
||||
func (s *appState) mainMenu() tview.Primitive {
|
||||
menu := NewMenu("Config Menu", nil)
|
||||
menu := NewMenu("Menu", nil)
|
||||
refreshMainMenu(menu, s)
|
||||
menu.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
switch event.Key() {
|
||||
|
|
@ -110,10 +112,7 @@ func (s *appState) mainMenu() tview.Primitive {
|
|||
s.requestExit()
|
||||
return nil
|
||||
}
|
||||
if event.Rune() == 'q' {
|
||||
s.requestExit()
|
||||
return nil
|
||||
}
|
||||
|
||||
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) {
|
||||
if menu, ok := s.menus["main"]; ok {
|
||||
refreshMainMenu(menu, s)
|
||||
|
|
@ -141,6 +166,7 @@ func refreshMainMenu(menu *Menu, s *appState) {
|
|||
selectedModel := s.selectedModelName()
|
||||
modelReady := selectedModel != ""
|
||||
channelReady := s.hasEnabledChannel()
|
||||
enabledCount, totalChannels := s.countChannels()
|
||||
gatewayRunning := s.gatewayCmd != nil || s.isGatewayRunning()
|
||||
|
||||
gatewayLabel := "Start Gateway"
|
||||
|
|
@ -153,7 +179,7 @@ func refreshMainMenu(menu *Menu, s *appState) {
|
|||
items := []MenuItem{
|
||||
{
|
||||
Label: rootModelLabel(selectedModel),
|
||||
Description: rootModelDescription(selectedModel),
|
||||
Description: rootModelDescription(),
|
||||
Action: func() {
|
||||
s.push("model", s.modelMenu())
|
||||
},
|
||||
|
|
@ -167,7 +193,7 @@ func refreshMainMenu(menu *Menu, s *appState) {
|
|||
},
|
||||
{
|
||||
Label: rootChannelLabel(channelReady),
|
||||
Description: rootChannelDescription(channelReady),
|
||||
Description: fmt.Sprintf("%d/%d enabled", enabledCount, totalChannels),
|
||||
Action: func() {
|
||||
s.push("channel", s.channelMenu())
|
||||
},
|
||||
|
|
@ -311,16 +337,13 @@ func (s *appState) selectedModelName() string {
|
|||
|
||||
func rootModelLabel(selected string) string {
|
||||
if selected == "" {
|
||||
return "Model (no model selected)"
|
||||
return "Model (None)"
|
||||
}
|
||||
return "Model (" + selected + ")"
|
||||
}
|
||||
|
||||
func rootModelDescription(selected string) string {
|
||||
if selected == "" {
|
||||
return "no model selected"
|
||||
}
|
||||
return "selected"
|
||||
func rootModelDescription() string {
|
||||
return "Using SPACE to choose your model"
|
||||
}
|
||||
|
||||
func rootChannelLabel(valid bool) string {
|
||||
|
|
@ -330,13 +353,6 @@ func rootChannelLabel(valid bool) string {
|
|||
return "Channel"
|
||||
}
|
||||
|
||||
func rootChannelDescription(valid bool) string {
|
||||
if !valid {
|
||||
return "no channel enabled"
|
||||
}
|
||||
return "enabled"
|
||||
}
|
||||
|
||||
func (s *appState) startTalk() {
|
||||
if !s.isActiveModelValid() {
|
||||
s.showMessage("Model required", "Select a valid model before starting talk")
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ import (
|
|||
|
||||
func (s *appState) buildChannelMenuItems() []MenuItem {
|
||||
return []MenuItem{
|
||||
{Label: "Back", Description: "Return to main menu", Action: func() { s.pop() }},
|
||||
channelItem(
|
||||
"Telegram",
|
||||
"Telegram bot settings",
|
||||
|
|
@ -101,10 +100,6 @@ func (s *appState) channelMenu() tview.Primitive {
|
|||
s.pop()
|
||||
return nil
|
||||
}
|
||||
if event.Rune() == 'q' {
|
||||
s.pop()
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
return menu
|
||||
|
|
|
|||
|
|
@ -14,23 +14,7 @@ import (
|
|||
)
|
||||
|
||||
func (s *appState) modelMenu() tview.Primitive {
|
||||
items := make([]MenuItem, 0, 2+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),
|
||||
)
|
||||
},
|
||||
},
|
||||
)
|
||||
items := make([]MenuItem, 0, 1+len(s.config.ModelList))
|
||||
currentModel := strings.TrimSpace(s.config.Agents.Defaults.Model)
|
||||
for i := range s.config.ModelList {
|
||||
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.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
|
|
@ -64,14 +65,11 @@ func (s *appState) modelMenu() tview.Primitive {
|
|||
s.pop()
|
||||
return nil
|
||||
}
|
||||
if event.Rune() == 'q' {
|
||||
s.pop()
|
||||
return nil
|
||||
}
|
||||
|
||||
if event.Rune() == ' ' {
|
||||
row, _ := menu.GetSelection()
|
||||
if row > 0 && row <= len(s.config.ModelList) {
|
||||
model := s.config.ModelList[row-1]
|
||||
if row >= 0 && row < len(s.config.ModelList) {
|
||||
model := s.config.ModelList[row]
|
||||
if !isModelValid(model) {
|
||||
s.showMessage(
|
||||
"Invalid model",
|
||||
|
|
@ -95,12 +93,23 @@ func (s *appState) modelForm(index int) tview.Primitive {
|
|||
model := &s.config.ModelList[index]
|
||||
form := tview.NewForm()
|
||||
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) {
|
||||
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
|
||||
if s.config.Agents.Defaults.Model == oldName {
|
||||
s.config.Agents.Defaults.Model = value
|
||||
}
|
||||
s.dirty = true
|
||||
form.SetTitle(fmt.Sprintf("Model: %s", model.ModelName))
|
||||
refreshMainMenuIfPresent(s)
|
||||
if menu, ok := s.menus["model"]; ok {
|
||||
refreshModelMenuFromState(menu, s)
|
||||
|
|
@ -158,7 +167,21 @@ func (s *appState) modelForm(index int) tview.Primitive {
|
|||
})
|
||||
|
||||
form.AddButton("Delete", func() {
|
||||
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() {
|
||||
s.testModel(model)
|
||||
|
|
@ -215,7 +238,7 @@ func modelStatusColor(valid bool, selected bool) *tcell.Color {
|
|||
|
||||
func refreshModelMenu(menu *Menu, currentModel string, models []picoclawconfig.ModelConfig) {
|
||||
for i, model := range models {
|
||||
row := i + 1
|
||||
row := i
|
||||
label := fmt.Sprintf("%s (%s)", model.ModelName, model.Model)
|
||||
isValid := isModelValid(model)
|
||||
if model.ModelName == currentModel && currentModel != "" {
|
||||
|
|
@ -234,23 +257,7 @@ func refreshModelMenu(menu *Menu, currentModel string, models []picoclawconfig.M
|
|||
}
|
||||
|
||||
func refreshModelMenuFromState(menu *Menu, s *appState) {
|
||||
items := make([]MenuItem, 0, 2+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),
|
||||
)
|
||||
},
|
||||
},
|
||||
)
|
||||
items := make([]MenuItem, 0, 1+len(s.config.ModelList))
|
||||
currentModel := strings.TrimSpace(s.config.Agents.Defaults.Model)
|
||||
for i := range s.config.ModelList {
|
||||
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)
|
||||
}
|
||||
|
||||
|
|
@ -287,6 +307,38 @@ func isModelValid(model picoclawconfig.ModelConfig) bool {
|
|||
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) {
|
||||
if model == nil {
|
||||
return
|
||||
|
|
|
|||
|
|
@ -41,3 +41,15 @@ func bannerView() *tview.TextView {
|
|||
text.SetBorder(false)
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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/
|
||||
```
|
||||
|
|
@ -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/
|
||||
```
|
||||
|
|
@ -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/")
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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())
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 ""
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -194,8 +194,13 @@
|
|||
"nickserv_password": "",
|
||||
"sasl_user": "",
|
||||
"sasl_password": "",
|
||||
"channels": ["#mychannel"],
|
||||
"request_caps": ["server-time", "message-tags"],
|
||||
"channels": [
|
||||
"#mychannel"
|
||||
],
|
||||
"request_caps": [
|
||||
"server-time",
|
||||
"message-tags"
|
||||
],
|
||||
"allow_from": [],
|
||||
"group_trigger": {
|
||||
"mention_only": true
|
||||
|
|
@ -316,6 +321,13 @@
|
|||
},
|
||||
"mcp": {
|
||||
"enabled": false,
|
||||
"discovery": {
|
||||
"enabled": false,
|
||||
"ttl": 5,
|
||||
"max_search_results": 5,
|
||||
"use_bm25": true,
|
||||
"use_regex": false
|
||||
},
|
||||
"servers": {
|
||||
"context7": {
|
||||
"enabled": false,
|
||||
|
|
|
|||
|
|
@ -7,11 +7,21 @@ PicoClaw's tools configuration is located in the `tools` field of `config.json`.
|
|||
```json
|
||||
{
|
||||
"tools": {
|
||||
"web": { ... },
|
||||
"mcp": { ... },
|
||||
"exec": { ... },
|
||||
"cron": { ... },
|
||||
"skills": { ... }
|
||||
"web": {
|
||||
...
|
||||
},
|
||||
"mcp": {
|
||||
...
|
||||
},
|
||||
"exec": {
|
||||
...
|
||||
},
|
||||
"cron": {
|
||||
...
|
||||
},
|
||||
"skills": {
|
||||
...
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
|
@ -23,7 +33,7 @@ Web tools are used for web search and fetching.
|
|||
### Brave
|
||||
|
||||
| Config | Type | Default | Description |
|
||||
| ------------- | ------ | ------- | ------------------------- |
|
||||
|---------------|--------|---------|---------------------------|
|
||||
| `enabled` | bool | false | Enable Brave search |
|
||||
| `api_key` | string | - | Brave Search API key |
|
||||
| `max_results` | int | 5 | Maximum number of results |
|
||||
|
|
@ -31,14 +41,14 @@ Web tools are used for web search and fetching.
|
|||
### DuckDuckGo
|
||||
|
||||
| Config | Type | Default | Description |
|
||||
| ------------- | ---- | ------- | ------------------------- |
|
||||
|---------------|------|---------|---------------------------|
|
||||
| `enabled` | bool | true | Enable DuckDuckGo search |
|
||||
| `max_results` | int | 5 | Maximum number of results |
|
||||
|
||||
### Perplexity
|
||||
|
||||
| Config | Type | Default | Description |
|
||||
| ------------- | ------ | ------- | ------------------------- |
|
||||
|---------------|--------|---------|---------------------------|
|
||||
| `enabled` | bool | false | Enable Perplexity search |
|
||||
| `api_key` | string | - | Perplexity API key |
|
||||
| `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.
|
||||
|
||||
| Config | Type | Default | Description |
|
||||
| ---------------------- | ----- | ------- | ------------------------------------------ |
|
||||
|------------------------|-------|---------|--------------------------------------------|
|
||||
| `enable_deny_patterns` | bool | true | Enable default dangerous command blocking |
|
||||
| `custom_deny_patterns` | array | [] | Custom deny patterns (regular expressions) |
|
||||
|
||||
|
|
@ -81,7 +91,10 @@ By default, PicoClaw blocks the following dangerous commands:
|
|||
"tools": {
|
||||
"exec": {
|
||||
"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.
|
||||
|
||||
| Config | Type | Default | Description |
|
||||
| ---------------------- | ---- | ------- | ---------------------------------------------- |
|
||||
|------------------------|------|---------|------------------------------------------------|
|
||||
| `exec_timeout_minutes` | int | 5 | Execution timeout in minutes, 0 means no limit |
|
||||
|
||||
## MCP Tool
|
||||
|
||||
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
|
||||
|
||||
| Config | Type | Default | Description |
|
||||
| --------- | ------ | ------- | ----------------------------------- |
|
||||
|-------------|--------|---------|----------------------------------------------|
|
||||
| `enabled` | bool | false | Enable MCP integration globally |
|
||||
| `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
|
||||
|
||||
| Config | Type | Required | Description |
|
||||
| ---------- | ------ | -------- | ------------------------------------------ |
|
||||
|------------|--------|----------|--------------------------------------------|
|
||||
| `enabled` | bool | yes | Enable this MCP server |
|
||||
| `type` | string | no | Transport type: `stdio`, `sse`, `http` |
|
||||
| `command` | string | stdio | Executable command for stdio transport |
|
||||
|
|
@ -140,7 +176,11 @@ The MCP tool enables integration with external Model Context Protocol servers.
|
|||
"filesystem": {
|
||||
"enabled": true,
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
|
||||
"args": [
|
||||
"-y",
|
||||
"@modelcontextprotocol/server-filesystem",
|
||||
"/tmp"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -170,6 +210,62 @@ 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
|
||||
|
||||
The skills tool configures skill discovery and installation via registries like ClawHub.
|
||||
|
|
@ -177,7 +273,7 @@ The skills tool configures skill discovery and installation via registries like
|
|||
### Registries
|
||||
|
||||
| Config | Type | Default | Description |
|
||||
| ---------------------------------- | ------ | -------------------- | ----------------------- |
|
||||
|------------------------------------|--------|----------------------|----------------------------------------------|
|
||||
| `registries.clawhub.enabled` | bool | true | Enable ClawHub registry |
|
||||
| `registries.clawhub.base_url` | string | `https://clawhub.ai` | ClawHub base URL |
|
||||
| `registries.clawhub.auth_token` | string | `""` | Optional Bearer token for higher rate limits |
|
||||
|
|
@ -217,4 +313,5 @@ For example:
|
|||
- `PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES=10`
|
||||
- `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.
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@ type ContextBuilder struct {
|
|||
workspace string
|
||||
skillsLoader *skills.SkillsLoader
|
||||
memory *MemoryStore
|
||||
toolDiscoveryBM25 bool
|
||||
toolDiscoveryRegex bool
|
||||
|
||||
// Cache for system prompt to avoid rebuilding on every call.
|
||||
// This fixes issue #607: repeated reprocessing of the entire context.
|
||||
|
|
@ -40,6 +42,12 @@ type ContextBuilder struct {
|
|||
skillFilesAtCache map[string]time.Time
|
||||
}
|
||||
|
||||
func (cb *ContextBuilder) WithToolDiscovery(useBM25, useRegex bool) *ContextBuilder {
|
||||
cb.toolDiscoveryBM25 = useBM25
|
||||
cb.toolDiscoveryRegex = useRegex
|
||||
return cb
|
||||
}
|
||||
|
||||
func getGlobalConfigDir() string {
|
||||
if home := os.Getenv("PICOCLAW_HOME"); home != "" {
|
||||
return home
|
||||
|
|
@ -70,6 +78,7 @@ func NewContextBuilder(workspace string) *ContextBuilder {
|
|||
|
||||
func (cb *ContextBuilder) getIdentity() string {
|
||||
workspacePath, _ := filepath.Abs(filepath.Join(cb.workspace))
|
||||
toolDiscovery := cb.getDiscoveryRule()
|
||||
|
||||
return fmt.Sprintf(`# picoclaw 🦞
|
||||
|
||||
|
|
@ -91,8 +100,28 @@ Your workspace is at: %s
|
|||
|
||||
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.
|
||||
|
||||
5. **Team delegation** - For any task that is non-trivial, multi-step, or involves distinct concerns (e.g. "convert React to Vue", "build a feature", "analyze and report"), you MUST use the 'team' tool to delegate and parallelize. Do NOT attempt to handle complex tasks inline by calling tools one by one yourself. Decompose first, delegate second, then report the outcome.`,
|
||||
workspacePath, workspacePath, workspacePath, workspacePath, workspacePath)
|
||||
5. **Team delegation** - For any task that is non-trivial, multi-step, or involves distinct concerns (e.g. "convert React to Vue", "build a feature", "analyze and report"), you MUST use the 'team' tool to delegate and parallelize. Do NOT attempt to handle complex tasks inline by calling tools one by one yourself. Decompose first, delegate second, then report the outcome.
|
||||
%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(
|
||||
`6. **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 {
|
||||
|
|
|
|||
|
|
@ -72,7 +72,8 @@ func NewAgentInstance(
|
|||
toolsRegistry := tools.NewToolRegistry()
|
||||
|
||||
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") {
|
||||
toolsRegistry.Register(tools.NewWriteFileTool(workspace, restrict, allowWritePaths))
|
||||
|
|
@ -98,7 +99,11 @@ func NewAgentInstance(
|
|||
sessionsDir := filepath.Join(workspace, "sessions")
|
||||
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
|
||||
agentName := ""
|
||||
|
|
|
|||
|
|
@ -334,7 +334,13 @@ func (al *AgentLoop) Run(ctx context.Context) error {
|
|||
}
|
||||
|
||||
mcpTool := tools.NewMCPTool(mcpManager, serverName, tool)
|
||||
|
||||
if al.cfg.Tools.MCP.Discovery.Enabled {
|
||||
agent.Tools.RegisterHidden(mcpTool)
|
||||
} else {
|
||||
agent.Tools.Register(mcpTool)
|
||||
}
|
||||
|
||||
totalRegistrations++
|
||||
logger.DebugCF("agent", "Registered MCP tool",
|
||||
map[string]any{
|
||||
|
|
@ -353,6 +359,47 @@ func (al *AgentLoop) Run(ctx context.Context) error {
|
|||
"total_registrations": totalRegistrations,
|
||||
"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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1305,6 +1352,17 @@ func (al *AgentLoop) runLLMIteration(
|
|||
// Save tool result message to session
|
||||
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
|
||||
|
|
|
|||
|
|
@ -4,11 +4,10 @@ package feishu
|
|||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/big"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"os"
|
||||
"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) {
|
||||
// Get emoji list from config
|
||||
emojiList := c.config.RandomReactionEmoji
|
||||
var chosenEmoji string
|
||||
if len(emojiList) == 0 {
|
||||
// Default to "Pin" if no config
|
||||
emojiList = []string{"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]
|
||||
chosenEmoji = "Pin"
|
||||
} else {
|
||||
chosenEmoji = emojiList[idx.Int64()]
|
||||
idx := rand.Intn(len(emojiList))
|
||||
chosenEmoji = emojiList[idx]
|
||||
}
|
||||
|
||||
req := larkim.NewCreateMessageReactionReqBuilder().
|
||||
|
|
|
|||
|
|
@ -481,6 +481,7 @@ type ProvidersConfig struct {
|
|||
Qwen ProviderConfig `json:"qwen"`
|
||||
Mistral ProviderConfig `json:"mistral"`
|
||||
Avian ProviderConfig `json:"avian"`
|
||||
Minimax ProviderConfig `json:"minimax"`
|
||||
}
|
||||
|
||||
// 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.Qwen.APIKey == "" && p.Qwen.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
|
||||
|
|
@ -577,6 +579,14 @@ type GatewayConfig struct {
|
|||
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 {
|
||||
Enabled bool `json:"enabled" env:"ENABLED"`
|
||||
}
|
||||
|
|
@ -661,6 +671,11 @@ type MediaCleanupConfig struct {
|
|||
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 {
|
||||
AllowReadPaths []string `json:"allow_read_paths" env:"PICOCLAW_TOOLS_ALLOW_READ_PATHS"`
|
||||
AllowWritePaths []string `json:"allow_write_paths" env:"PICOCLAW_TOOLS_ALLOW_WRITE_PATHS"`
|
||||
|
|
@ -678,7 +693,7 @@ type ToolsConfig struct {
|
|||
InstallSkill ToolConfig `json:"install_skill" envPrefix:"PICOCLAW_TOOLS_INSTALL_SKILL_"`
|
||||
ListDir ToolConfig `json:"list_dir" envPrefix:"PICOCLAW_TOOLS_LIST_DIR_"`
|
||||
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_"`
|
||||
Spawn ToolConfig `json:"spawn" envPrefix:"PICOCLAW_TOOLS_SPAWN_"`
|
||||
SPI ToolConfig `json:"spi" envPrefix:"PICOCLAW_TOOLS_SPI_"`
|
||||
|
|
@ -742,6 +757,7 @@ type MCPServerConfig struct {
|
|||
// MCPConfig defines configuration for all MCP servers
|
||||
type MCPConfig struct {
|
||||
ToolConfig ` envPrefix:"PICOCLAW_TOOLS_MCP_"`
|
||||
Discovery ToolDiscoveryConfig ` json:"discovery"`
|
||||
// Servers is a map of server name to server configuration
|
||||
Servers map[string]MCPServerConfig `json:"servers,omitempty"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -346,6 +346,14 @@ func DefaultConfig() *Config {
|
|||
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
|
||||
{
|
||||
ModelName: "local-model",
|
||||
|
|
@ -435,6 +443,13 @@ func DefaultConfig() *Config {
|
|||
ToolConfig: ToolConfig{
|
||||
Enabled: false,
|
||||
},
|
||||
Discovery: ToolDiscoveryConfig{
|
||||
Enabled: false,
|
||||
TTL: 5,
|
||||
MaxSearchResults: 5,
|
||||
UseBM25: true,
|
||||
UseRegex: false,
|
||||
},
|
||||
Servers: map[string]MCPServerConfig{},
|
||||
},
|
||||
AppendFile: ToolConfig{
|
||||
|
|
@ -458,8 +473,9 @@ func DefaultConfig() *Config {
|
|||
Message: ToolConfig{
|
||||
Enabled: true,
|
||||
},
|
||||
ReadFile: ToolConfig{
|
||||
ReadFile: ReadFileToolConfig{
|
||||
Enabled: true,
|
||||
MaxReadFileSize: 64 * 1024, // 64KB
|
||||
},
|
||||
Spawn: ToolConfig{
|
||||
Enabled: true,
|
||||
|
|
|
|||
|
|
@ -208,6 +208,15 @@ func resolveProviderSelection(cfg *config.Config) (providerSelection, error) {
|
|||
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":
|
||||
sel.providerType = providerTypeGitHubCopilot
|
||||
if cfg.Providers.GitHubCopilot.APIBase != "" {
|
||||
|
|
@ -325,6 +334,13 @@ func resolveProviderSelection(cfg *config.Config) (providerSelection, error) {
|
|||
if sel.apiBase == "" {
|
||||
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 != "":
|
||||
sel.apiKey = cfg.Providers.Avian.APIKey
|
||||
sel.apiBase = cfg.Providers.Avian.APIBase
|
||||
|
|
|
|||
|
|
@ -94,7 +94,8 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
|
|||
|
||||
case "litellm", "openrouter", "groq", "zhipu", "gemini", "nvidia",
|
||||
"ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras",
|
||||
"vivgrid", "volcengine", "vllm", "qwen", "mistral", "avian":
|
||||
"vivgrid", "volcengine", "vllm", "qwen", "mistral", "avian",
|
||||
"minimax":
|
||||
// All other OpenAI-compatible HTTP providers
|
||||
if cfg.APIKey == "" && cfg.APIBase == "" {
|
||||
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"
|
||||
case "avian":
|
||||
return "https://api.avian.io/v1"
|
||||
case "minimax":
|
||||
return "https://api.minimaxi.com/v1"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -508,7 +508,7 @@ func normalizeModel(model, apiBase string) string {
|
|||
prefix := strings.ToLower(before)
|
||||
switch prefix {
|
||||
case "litellm", "moonshot", "nvidia", "groq", "ollama", "deepseek", "google",
|
||||
"openrouter", "zhipu", "mistral", "vivgrid":
|
||||
"openrouter", "zhipu", "mistral", "vivgrid", "minimax":
|
||||
return after
|
||||
default:
|
||||
return model
|
||||
|
|
|
|||
|
|
@ -2,18 +2,25 @@ package tools
|
|||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"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.
|
||||
func validatePath(path, workspace string, restrict bool) (string, error) {
|
||||
if workspace == "" {
|
||||
|
|
@ -87,14 +94,29 @@ func isWithinWorkspace(candidate, workspace string) bool {
|
|||
|
||||
type ReadFileTool struct {
|
||||
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
|
||||
if len(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 {
|
||||
|
|
@ -102,7 +124,7 @@ func (t *ReadFileTool) Name() 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 {
|
||||
|
|
@ -111,7 +133,17 @@ func (t *ReadFileTool) Parameters() map[string]any {
|
|||
"properties": map[string]any{
|
||||
"path": map[string]any{
|
||||
"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"},
|
||||
|
|
@ -124,11 +156,171 @@ func (t *ReadFileTool) Execute(ctx context.Context, args map[string]any) *ToolRe
|
|||
return ErrorResult("path is required")
|
||||
}
|
||||
|
||||
content, err := t.fs.ReadFile(path)
|
||||
// offset (optional, default 0)
|
||||
offset, err := getInt64Arg(args, "offset", 0)
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func (t *ReadFileTool) UpgradeToConcurrent() Tool {
|
||||
|
|
@ -263,6 +455,7 @@ type fileSystem interface {
|
|||
WriteFile(path string, data []byte) error
|
||||
EditFile(path string, editFn func([]byte) ([]byte, error)) 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.
|
||||
|
|
@ -309,6 +502,20 @@ func (h *hostFs) WriteFile(path string, data []byte) error {
|
|||
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.
|
||||
type sandboxFs struct {
|
||||
workspace string
|
||||
|
|
@ -477,6 +684,26 @@ func (r *sandboxFs) ReadDir(path string) ([]os.DirEntry, error) {
|
|||
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
|
||||
// the workspace when they match any of the provided patterns.
|
||||
type whitelistFs struct {
|
||||
|
|
@ -527,6 +754,13 @@ func (w *whitelistFs) EditFile(path string, editFn func([]byte) ([]byte, error))
|
|||
return w.WriteFile(path, newContent)
|
||||
}
|
||||
|
||||
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
|
||||
// settings and optional path whitelist patterns.
|
||||
func buildFs(workspace string, restrict bool, patterns []*regexp.Regexp) fileSystem {
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ func TestFilesystemTool_ReadFile_Success(t *testing.T) {
|
|||
testFile := filepath.Join(tmpDir, "test.txt")
|
||||
os.WriteFile(testFile, []byte("test content"), 0o644)
|
||||
|
||||
tool := NewReadFileTool("", false)
|
||||
tool := NewReadFileTool("", false, MaxReadFileSize)
|
||||
ctx := context.Background()
|
||||
args := map[string]any{
|
||||
"path": testFile,
|
||||
|
|
@ -45,7 +45,7 @@ func TestFilesystemTool_ReadFile_Success(t *testing.T) {
|
|||
|
||||
// TestFilesystemTool_ReadFile_NotFound verifies error handling for missing file
|
||||
func TestFilesystemTool_ReadFile_NotFound(t *testing.T) {
|
||||
tool := NewReadFileTool("", false)
|
||||
tool := NewReadFileTool("", false, MaxReadFileSize)
|
||||
ctx := context.Background()
|
||||
args := map[string]any{
|
||||
"path": "/nonexistent_file_12345.txt",
|
||||
|
|
@ -59,7 +59,7 @@ func TestFilesystemTool_ReadFile_NotFound(t *testing.T) {
|
|||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -271,7 +271,7 @@ func TestFilesystemTool_ReadFile_RejectsSymlinkEscape(t *testing.T) {
|
|||
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{
|
||||
"path": link,
|
||||
})
|
||||
|
|
@ -289,7 +289,7 @@ func TestFilesystemTool_ReadFile_RejectsSymlinkEscape(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)
|
||||
tmpDir := t.TempDir()
|
||||
|
|
@ -557,7 +557,7 @@ func TestWhitelistFs_AllowsMatchingPaths(t *testing.T) {
|
|||
// Pattern allows access to the 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.
|
||||
result := tool.Execute(context.Background(), map[string]any{"path": outsideFile})
|
||||
|
|
@ -578,3 +578,127 @@ func TestWhitelistFs_AllowsMatchingPaths(t *testing.T) {
|
|||
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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,20 +5,28 @@ import (
|
|||
"fmt"
|
||||
"sort"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
"github.com/sipeed/picoclaw/pkg/providers"
|
||||
)
|
||||
|
||||
type ToolEntry struct {
|
||||
Tool Tool
|
||||
IsCore bool
|
||||
TTL int
|
||||
}
|
||||
|
||||
type ToolRegistry struct {
|
||||
tools map[string]Tool
|
||||
tools map[string]*ToolEntry
|
||||
mu sync.RWMutex
|
||||
version atomic.Uint64 // incremented on Register/RegisterHidden for cache invalidation
|
||||
}
|
||||
|
||||
func NewToolRegistry() *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",
|
||||
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) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
tool, ok := r.tools[name]
|
||||
return tool, ok
|
||||
entry, ok := r.tools[name]
|
||||
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
|
||||
}
|
||||
|
||||
// ListTools returns a slice of all registered tool names.
|
||||
|
|
@ -146,7 +256,13 @@ func (r *ToolRegistry) GetDefinitions() []map[string]any {
|
|||
sorted := r.sortedToolNames()
|
||||
definitions := make([]map[string]any, 0, len(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
|
||||
}
|
||||
|
|
@ -160,8 +276,13 @@ func (r *ToolRegistry) ToProviderDefs() []providers.ToolDefinition {
|
|||
sorted := r.sortedToolNames()
|
||||
definitions := make([]providers.ToolDefinition, 0, len(sorted))
|
||||
for _, name := range sorted {
|
||||
tool := r.tools[name]
|
||||
schema := ToolToSchema(tool)
|
||||
entry := r.tools[name]
|
||||
|
||||
if !entry.IsCore && entry.TTL <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
schema := ToolToSchema(entry.Tool)
|
||||
|
||||
// Safely extract nested values with type checks
|
||||
fn, ok := schema["function"].(map[string]any)
|
||||
|
|
@ -209,8 +330,13 @@ func (r *ToolRegistry) GetSummaries() []string {
|
|||
sorted := r.sortedToolNames()
|
||||
summaries := make([]string, 0, len(sorted))
|
||||
for _, name := range sorted {
|
||||
tool := r.tools[name]
|
||||
summaries = append(summaries, fmt.Sprintf("- `%s` - %s", tool.Name(), tool.Description()))
|
||||
entry := r.tools[name]
|
||||
|
||||
if !entry.IsCore && entry.TTL <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
summaries = append(summaries, fmt.Sprintf("- `%s` - %s", entry.Tool.Name(), entry.Tool.Description()))
|
||||
}
|
||||
return summaries
|
||||
}
|
||||
|
|
|
|||
304
pkg/tools/search_tool.go
Normal 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
|
||||
}
|
||||
339
pkg/tools/search_tools_test.go
Normal 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
|
|
@ -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.2–2.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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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/
|
||||
47
web/backend/api/channels.go
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
122
web/backend/api/gateway_test.go
Normal 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"])
|
||||
}
|
||||
}
|
||||
85
web/backend/api/launcher_config.go
Normal 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...),
|
||||
})
|
||||
}
|
||||
115
web/backend/api/launcher_config_test.go
Normal 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())
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package server
|
||||
package api
|
||||
|
||||
import "sync"
|
||||
|
||||
|
|
@ -89,11 +89,3 @@ func (b *LogBuffer) RunID() int {
|
|||
|
||||
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
|
|
@ -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
|
|
@ -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
|
||||
}
|
||||
293
web/backend/api/oauth_test.go
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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("&")
|
||||
case '<':
|
||||
b.WriteString("<")
|
||||
case '>':
|
||||
b.WriteString(">")
|
||||
case '"':
|
||||
b.WriteString(""")
|
||||
case '\'':
|
||||
b.WriteString("'")
|
||||
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
|
||||
}
|
||||
56
web/backend/api/startup_test.go
Normal 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
69
web/backend/embed.go
Normal 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
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 44 KiB |
113
web/backend/launcherconfig/config.go
Normal 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)
|
||||
}
|
||||
89
web/backend/launcherconfig/config_test.go
Normal 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
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
64
web/backend/middleware/access_control.go
Normal 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)
|
||||
}
|
||||
86
web/backend/middleware/access_control_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
70
web/backend/middleware/middleware.go
Normal 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)
|
||||
})
|
||||
}
|
||||
8
web/backend/model/status.go
Normal 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
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
7
web/frontend/.editorconfig
Normal 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
|
|
@ -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
|
||||
5
web/frontend/.prettierignore
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
package-lock.json
|
||||
pnpm-lock.yaml
|
||||
yarn.lock
|
||||
routeTree.gen.ts
|
||||
src/components/ui
|
||||
25
web/frontend/components.json
Normal 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": {}
|
||||
}
|
||||
31
web/frontend/eslint.config.js
Normal 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
|
|
@ -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
|
|
@ -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
17
web/frontend/prettier.config.js
Normal 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
|
||||
BIN
web/frontend/public/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
web/frontend/public/favicon-96x96.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
web/frontend/public/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
1
web/frontend/public/favicon.svg
Normal file
|
After Width: | Height: | Size: 88 KiB |
1
web/frontend/public/lark.svg
Normal 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 |
BIN
web/frontend/public/logo_with_text.png
Normal file
|
After Width: | Height: | Size: 8.8 KiB |
21
web/frontend/public/site.webmanifest
Normal 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"
|
||||
}
|
||||
BIN
web/frontend/public/web-app-manifest-192x192.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
web/frontend/public/web-app-manifest-512x512.png
Normal file
|
After Width: | Height: | Size: 102 KiB |
65
web/frontend/src/api/channels.ts
Normal 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 }
|
||||
62
web/frontend/src/api/gateway.ts
Normal 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 }
|
||||
91
web/frontend/src/api/models.ts
Normal 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 }
|
||||
102
web/frontend/src/api/oauth.ts
Normal 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 }),
|
||||
},
|
||||
)
|
||||
}
|
||||
38
web/frontend/src/api/pico.ts
Normal 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 }
|
||||
50
web/frontend/src/api/sessions.ts
Normal 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}`)
|
||||
}
|
||||
}
|
||||
62
web/frontend/src/api/system.ts
Normal 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),
|
||||
})
|
||||
}
|
||||
193
web/frontend/src/components/app-header.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
27
web/frontend/src/components/app-layout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
215
web/frontend/src/components/app-sidebar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||