Merge remote-tracking branch 'upstream/main' into refactor/upstream-compat
# Conflicts: # assets/wechat.png # cmd/picoclaw-launcher-tui/internal/ui/model.go # cmd/picoclaw/internal/gateway/helpers.go # go.mod # go.sum # pkg/agent/context.go # pkg/agent/instance.go # pkg/agent/loop.go # pkg/agent/memory.go # pkg/bus/types.go # pkg/channels/manager.go # pkg/config/config.go # pkg/heartbeat/service.go # pkg/logger/logger.go # pkg/migrate/sources/openclaw/common.go # pkg/providers/openai_compat/provider.go # pkg/providers/protocoltypes/types.go # pkg/providers/tool_call_extract.go # pkg/providers/types.go # pkg/session/manager.go # pkg/skills/loader.go # pkg/state/state.go # pkg/tools/filesystem.go # pkg/tools/shell.go # pkg/tools/spawn.go # pkg/tools/subagent.go # pkg/tools/toolloop.go # pkg/tools/web.go
138
.github/workflows/nightly.yml
vendored
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
name: Nightly Build
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: '0 0 * * *'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
nightly:
|
||||||
|
name: Nightly Build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
packages: write
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Compute version
|
||||||
|
id: version
|
||||||
|
run: |
|
||||||
|
DATE=$(date -u +%Y%m%d)
|
||||||
|
SHA=$(git rev-parse --short=8 HEAD)
|
||||||
|
BASE_VERSION=$(git describe --tags --match "v*" --exclude "*nightly*" --abbrev=0 2>/dev/null || true)
|
||||||
|
if [ -z "$BASE_VERSION" ] || [ "$BASE_VERSION" = "v0.0.0" ]; then
|
||||||
|
VERSION="v0.0.0-nightly.${DATE}.${SHA}"
|
||||||
|
else
|
||||||
|
VERSION="${BASE_VERSION}-nightly.${DATE}.${SHA}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
COMPARE_URL="https://github.com/${{ github.repository }}/commits/main"
|
||||||
|
if [ -n "$BASE_VERSION" ] && [ "$BASE_VERSION" != "v0.0.0" ]; then
|
||||||
|
COMPARE_URL="https://github.com/${{ github.repository }}/compare/${BASE_VERSION}...main"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "changelog=**Full Changelog**: $COMPARE_URL" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Setup Go from go.mod
|
||||||
|
id: setup-go
|
||||||
|
uses: actions/setup-go@v6
|
||||||
|
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
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Login to GitHub Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Login to Docker Hub
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: docker.io
|
||||||
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Create local tag for GoReleaser
|
||||||
|
run: git tag "${{ steps.version.outputs.version }}"
|
||||||
|
|
||||||
|
- name: Run GoReleaser
|
||||||
|
uses: goreleaser/goreleaser-action@v6
|
||||||
|
with:
|
||||||
|
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 }}
|
||||||
|
GORELEASER_CURRENT_TAG: ${{ steps.version.outputs.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: Update nightly release
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
VERSION: ${{ steps.version.outputs.version }}
|
||||||
|
run: |
|
||||||
|
CHANGELOG='${{ steps.version.outputs.changelog }}'
|
||||||
|
NOTES=$(cat <<EOF
|
||||||
|
Nightly build for **${VERSION}**
|
||||||
|
|
||||||
|
This is an automated build and may be unstable. Use with caution.
|
||||||
|
|
||||||
|
${CHANGELOG}
|
||||||
|
EOF
|
||||||
|
)
|
||||||
|
|
||||||
|
# Delete existing nightly release and tag
|
||||||
|
gh release delete nightly --cleanup-tag -y 2>/dev/null || true
|
||||||
|
|
||||||
|
# Force-update nightly tag to current HEAD
|
||||||
|
git config user.name "github-actions[bot]"
|
||||||
|
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||||
|
git tag -fa nightly -m "Nightly build ${VERSION}"
|
||||||
|
git push origin nightly
|
||||||
|
|
||||||
|
# Collect release artifacts from goreleaser dist/
|
||||||
|
ASSETS=()
|
||||||
|
for f in dist/*.tar.gz dist/*.zip dist/*.deb dist/*.rpm dist/checksums.txt; do
|
||||||
|
[ -f "$f" ] && ASSETS+=("$f")
|
||||||
|
done
|
||||||
|
|
||||||
|
# Create nightly release (prerelease, NOT latest)
|
||||||
|
gh release create nightly \
|
||||||
|
--title "Nightly Build" \
|
||||||
|
--notes "$NOTES" \
|
||||||
|
--target "${{ github.sha }}" \
|
||||||
|
--prerelease \
|
||||||
|
--latest=false \
|
||||||
|
"${ASSETS[@]}"
|
||||||
|
|
||||||
BIN
assets/logo.webp
Normal file
|
After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 386 KiB After Width: | Height: | Size: 345 KiB |
|
|
@ -14,23 +14,7 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
func (s *appState) modelMenu() tview.Primitive {
|
func (s *appState) modelMenu() tview.Primitive {
|
||||||
items := make([]MenuItem, 0, 2+len(s.config.ModelList))
|
items := make([]MenuItem, 0, 1+len(s.config.ModelList))
|
||||||
items = append(items,
|
|
||||||
MenuItem{Label: "Back", Description: "Return to main menu", Action: func() { s.pop() }},
|
|
||||||
MenuItem{
|
|
||||||
Label: "Add model",
|
|
||||||
Description: "Append a new model entry",
|
|
||||||
Action: func() {
|
|
||||||
s.addModel(
|
|
||||||
picoclawconfig.ModelConfig{ModelName: "new-model", Model: "openai/gpt-5.2"},
|
|
||||||
)
|
|
||||||
s.push(
|
|
||||||
fmt.Sprintf("model-%d", len(s.config.ModelList)-1),
|
|
||||||
s.modelForm(len(s.config.ModelList)-1),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
currentModel := strings.TrimSpace(s.config.Agents.Defaults.Model)
|
currentModel := strings.TrimSpace(s.config.Agents.Defaults.Model)
|
||||||
for i := range s.config.ModelList {
|
for i := range s.config.ModelList {
|
||||||
index := i
|
index := i
|
||||||
|
|
@ -57,6 +41,23 @@ func (s *appState) modelMenu() tview.Primitive {
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
// Add model entry appended at the end so the models map to rows 1..N
|
||||||
|
items = append(items,
|
||||||
|
MenuItem{
|
||||||
|
Label: "**Add model**",
|
||||||
|
Description: "Append a new model entry",
|
||||||
|
Action: func() {
|
||||||
|
newName := s.nextAvailableModelName("new-model")
|
||||||
|
s.addModel(
|
||||||
|
picoclawconfig.ModelConfig{ModelName: newName, Model: "openai/gpt-5.4"},
|
||||||
|
)
|
||||||
|
s.push(
|
||||||
|
fmt.Sprintf("model-%d", len(s.config.ModelList)-1),
|
||||||
|
s.modelForm(len(s.config.ModelList)-1),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
menu := NewMenu("Models", items)
|
menu := NewMenu("Models", items)
|
||||||
menu.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
menu.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||||
|
|
@ -64,14 +65,11 @@ func (s *appState) modelMenu() tview.Primitive {
|
||||||
s.pop()
|
s.pop()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if event.Rune() == 'q' {
|
|
||||||
s.pop()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if event.Rune() == ' ' {
|
if event.Rune() == ' ' {
|
||||||
row, _ := menu.GetSelection()
|
row, _ := menu.GetSelection()
|
||||||
if row > 0 && row <= len(s.config.ModelList) {
|
if row >= 0 && row < len(s.config.ModelList) {
|
||||||
model := s.config.ModelList[row-1]
|
model := s.config.ModelList[row]
|
||||||
if !isModelValid(model) {
|
if !isModelValid(model) {
|
||||||
s.showMessage(
|
s.showMessage(
|
||||||
"Invalid model",
|
"Invalid model",
|
||||||
|
|
@ -95,12 +93,23 @@ func (s *appState) modelForm(index int) tview.Primitive {
|
||||||
model := &s.config.ModelList[index]
|
model := &s.config.ModelList[index]
|
||||||
form := tview.NewForm()
|
form := tview.NewForm()
|
||||||
form.SetBorder(true).SetTitle(fmt.Sprintf("Model: %s", model.ModelName))
|
form.SetBorder(true).SetTitle(fmt.Sprintf("Model: %s", model.ModelName))
|
||||||
form.SetButtonBackgroundColor(tcell.NewRGBColor(80, 250, 123))
|
|
||||||
form.SetButtonTextColor(tcell.NewRGBColor(12, 13, 22))
|
|
||||||
|
|
||||||
addInput(form, "Model Name", model.ModelName, func(value string) {
|
addInput(form, "Model Name", model.ModelName, func(value string) {
|
||||||
|
if value == "" {
|
||||||
|
s.showMessage("Invalid model name", "Model Name cannot be empty")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if s.modelNameExists(value, index) {
|
||||||
|
s.showMessage("Duplicate model name", fmt.Sprintf("Model Name '%s' already exists", value))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
oldName := model.ModelName
|
||||||
model.ModelName = value
|
model.ModelName = value
|
||||||
|
if s.config.Agents.Defaults.Model == oldName {
|
||||||
|
s.config.Agents.Defaults.Model = value
|
||||||
|
}
|
||||||
s.dirty = true
|
s.dirty = true
|
||||||
|
form.SetTitle(fmt.Sprintf("Model: %s", model.ModelName))
|
||||||
refreshMainMenuIfPresent(s)
|
refreshMainMenuIfPresent(s)
|
||||||
if menu, ok := s.menus["model"]; ok {
|
if menu, ok := s.menus["model"]; ok {
|
||||||
refreshModelMenuFromState(menu, s)
|
refreshModelMenuFromState(menu, s)
|
||||||
|
|
@ -158,7 +167,21 @@ func (s *appState) modelForm(index int) tview.Primitive {
|
||||||
})
|
})
|
||||||
|
|
||||||
form.AddButton("Delete", func() {
|
form.AddButton("Delete", func() {
|
||||||
|
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)
|
s.deleteModel(index)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
modal.SetTitle("Confirm Delete").SetBorder(true)
|
||||||
|
s.pages.AddPage(pageName, modal, true, true)
|
||||||
})
|
})
|
||||||
form.AddButton("Test", func() {
|
form.AddButton("Test", func() {
|
||||||
s.testModel(model)
|
s.testModel(model)
|
||||||
|
|
@ -215,7 +238,7 @@ func modelStatusColor(valid bool, selected bool) *tcell.Color {
|
||||||
|
|
||||||
func refreshModelMenu(menu *Menu, currentModel string, models []picoclawconfig.ModelConfig) {
|
func refreshModelMenu(menu *Menu, currentModel string, models []picoclawconfig.ModelConfig) {
|
||||||
for i, model := range models {
|
for i, model := range models {
|
||||||
row := i + 1
|
row := i
|
||||||
label := fmt.Sprintf("%s (%s)", model.ModelName, model.Model)
|
label := fmt.Sprintf("%s (%s)", model.ModelName, model.Model)
|
||||||
isValid := isModelValid(model)
|
isValid := isModelValid(model)
|
||||||
if model.ModelName == currentModel && currentModel != "" {
|
if model.ModelName == currentModel && currentModel != "" {
|
||||||
|
|
@ -234,23 +257,7 @@ func refreshModelMenu(menu *Menu, currentModel string, models []picoclawconfig.M
|
||||||
}
|
}
|
||||||
|
|
||||||
func refreshModelMenuFromState(menu *Menu, s *appState) {
|
func refreshModelMenuFromState(menu *Menu, s *appState) {
|
||||||
items := make([]MenuItem, 0, 2+len(s.config.ModelList))
|
items := make([]MenuItem, 0, 1+len(s.config.ModelList))
|
||||||
items = append(items,
|
|
||||||
MenuItem{Label: "Back", Description: "Return to main menu", Action: func() { s.pop() }},
|
|
||||||
MenuItem{
|
|
||||||
Label: "Add model",
|
|
||||||
Description: "Append a new model entry",
|
|
||||||
Action: func() {
|
|
||||||
s.addModel(
|
|
||||||
picoclawconfig.ModelConfig{ModelName: "new-model", Model: "openai/gpt-5.2"},
|
|
||||||
)
|
|
||||||
s.push(
|
|
||||||
fmt.Sprintf("model-%d", len(s.config.ModelList)-1),
|
|
||||||
s.modelForm(len(s.config.ModelList)-1),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
currentModel := strings.TrimSpace(s.config.Agents.Defaults.Model)
|
currentModel := strings.TrimSpace(s.config.Agents.Defaults.Model)
|
||||||
for i := range s.config.ModelList {
|
for i := range s.config.ModelList {
|
||||||
index := i
|
index := i
|
||||||
|
|
@ -277,6 +284,19 @@ func refreshModelMenuFromState(menu *Menu, s *appState) {
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
items = append(items,
|
||||||
|
MenuItem{
|
||||||
|
Label: "**Add Model**",
|
||||||
|
Description: "Append a new model entry",
|
||||||
|
Action: func() {
|
||||||
|
newName := s.nextAvailableModelName("new-model")
|
||||||
|
s.addModel(
|
||||||
|
picoclawconfig.ModelConfig{ModelName: newName, Model: "openai/gpt-5.4"},
|
||||||
|
)
|
||||||
|
s.push(fmt.Sprintf("model-%d", len(s.config.ModelList)-1), s.modelForm(len(s.config.ModelList)-1))
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
menu.applyItems(items)
|
menu.applyItems(items)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -287,6 +307,38 @@ func isModelValid(model picoclawconfig.ModelConfig) bool {
|
||||||
return hasKey && hasModel
|
return hasKey && hasModel
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *appState) modelNameExists(name string, excludeIndex int) bool {
|
||||||
|
target := strings.TrimSpace(name)
|
||||||
|
if target == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i := range s.config.ModelList {
|
||||||
|
if i == excludeIndex {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(s.config.ModelList[i].ModelName) == target {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) nextAvailableModelName(base string) string {
|
||||||
|
name := strings.TrimSpace(base)
|
||||||
|
if name == "" {
|
||||||
|
name = "new-model"
|
||||||
|
}
|
||||||
|
if !s.modelNameExists(name, -1) {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
for i := 2; ; i++ {
|
||||||
|
candidate := fmt.Sprintf("%s-%d", name, i)
|
||||||
|
if !s.modelNameExists(candidate, -1) {
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (s *appState) testModel(model *picoclawconfig.ModelConfig) {
|
func (s *appState) testModel(model *picoclawconfig.ModelConfig) {
|
||||||
if model == nil {
|
if model == nil {
|
||||||
return
|
return
|
||||||
|
|
|
||||||
12
docker/Dockerfile.goreleaser.launcher
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
FROM alpine:3.21
|
||||||
|
|
||||||
|
ARG TARGETPLATFORM
|
||||||
|
|
||||||
|
RUN apk add --no-cache ca-certificates tzdata
|
||||||
|
|
||||||
|
COPY $TARGETPLATFORM/picoclaw /usr/local/bin/picoclaw
|
||||||
|
COPY $TARGETPLATFORM/picoclaw-launcher /usr/local/bin/picoclaw-launcher
|
||||||
|
COPY $TARGETPLATFORM/picoclaw-launcher-tui /usr/local/bin/picoclaw-launcher-tui
|
||||||
|
|
||||||
|
ENTRYPOINT ["picoclaw-launcher"]
|
||||||
|
CMD ["-public", "-no-browser"]
|
||||||
33
docs/debug.md
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
# Debugging PicoClaw
|
||||||
|
|
||||||
|
PicoClaw performs multiple complex interactions under the hood for every single request it receives—from routing messages and evaluating complexity, to executing tools and adapting to model failures. Being able to see exactly what is happening is crucial, not just for troubleshooting potential issues, but also for truly understanding how the agent operates.
|
||||||
|
## Starting PicoClaw in Debug Mode
|
||||||
|
|
||||||
|
To get detailed information about what the agent is doing (LLM requests, tool calls, message routing), you can start the PicoClaw gateway with the debug flag:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
picoclaw gateway --debug
|
||||||
|
# or
|
||||||
|
picoclaw gateway -d
|
||||||
|
```
|
||||||
|
|
||||||
|
In this mode, the system will format the logs extensively and display previews of system prompts and tool execution results.
|
||||||
|
|
||||||
|
## Disabling Log Truncation (Full Logs)
|
||||||
|
|
||||||
|
By default, PicoClaw truncates very long strings (such as the *System Prompt* or large JSON output results) in the debug logs to keep the console readable.
|
||||||
|
|
||||||
|
If you need to inspect the complete output of a command or the exact payload sent to the LLM model, you can use the `--no-truncate` flag.
|
||||||
|
|
||||||
|
**Note:** This flag *only* works when combined with the `--debug` mode.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
picoclaw gateway --debug --no-truncate
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
When this flag is active, the global truncation function is disabled. This is extremely useful for:
|
||||||
|
|
||||||
|
* Verifying the exact syntax of the messages sent to the provider.
|
||||||
|
* Reading the complete output of tools like `exec`, `web_fetch`, or `read_file`.
|
||||||
|
* Debugging the session history saved in memory.
|
||||||
|
|
@ -7,14 +7,17 @@ import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
"github.com/sipeed/picoclaw/pkg/logger"
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
"github.com/sipeed/picoclaw/pkg/providers"
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
"github.com/sipeed/picoclaw/pkg/skills"
|
"github.com/sipeed/picoclaw/pkg/skills"
|
||||||
"github.com/sipeed/picoclaw/pkg/tools"
|
"github.com/sipeed/picoclaw/pkg/tools"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
const orchestrationGuidance = `## Orchestration
|
const orchestrationGuidance = `## Orchestration
|
||||||
|
|
@ -135,41 +138,44 @@ Maintain these sections in MEMORY.md under ## Orchestration:
|
||||||
|
|
||||||
type ContextBuilder struct {
|
type ContextBuilder struct {
|
||||||
workspace string
|
workspace string
|
||||||
|
|
||||||
workDir string // session-specific working directory (worktree or project subdir)
|
workDir string // session-specific working directory (worktree or project subdir)
|
||||||
|
|
||||||
skillsLoader *skills.SkillsLoader
|
skillsLoader *skills.SkillsLoader
|
||||||
|
|
||||||
memory *MemoryStore
|
memory *MemoryStore
|
||||||
|
|
||||||
tools *tools.ToolRegistry // Direct reference to tool registry
|
tools *tools.ToolRegistry // Direct reference to tool registry
|
||||||
|
|
||||||
peerNote string // set per-call from loop.go for peer session awareness
|
peerNote string // set per-call from loop.go for peer session awareness
|
||||||
|
|
||||||
orchestrationEnabled bool // set from AgentLoop when --orchestration flag is used
|
orchestrationEnabled bool // set from AgentLoop when --orchestration flag is used
|
||||||
|
toolDiscoveryBM25 bool
|
||||||
|
toolDiscoveryRegex bool
|
||||||
|
|
||||||
// Cache for system prompt to avoid rebuilding on every call.
|
// Cache for system prompt to avoid rebuilding on every call.
|
||||||
// This fixes issue #607: repeated reprocessing of the entire context.
|
// This fixes issue #607: repeated reprocessing of the entire context.
|
||||||
// The cache auto-invalidates when workspace source files change (mtime check).
|
// The cache auto-invalidates when workspace source files change (mtime check).
|
||||||
|
|
||||||
systemPromptMutex sync.RWMutex
|
systemPromptMutex sync.RWMutex
|
||||||
|
|
||||||
cachedSystemPrompt string
|
cachedSystemPrompt string
|
||||||
|
|
||||||
cachedAt time.Time // max observed mtime across tracked paths at cache build time
|
cachedAt time.Time // max observed mtime across tracked paths at cache build time
|
||||||
|
|
||||||
// existedAtCache tracks which source file paths existed the last time the
|
// existedAtCache tracks which source file paths existed the last time the
|
||||||
|
|
||||||
// cache was built. This lets sourceFilesChanged detect files that are newly
|
// cache was built. This lets sourceFilesChanged detect files that are newly
|
||||||
|
|
||||||
// created (didn't exist at cache time, now exist) or deleted (existed at
|
// created (didn't exist at cache time, now exist) or deleted (existed at
|
||||||
|
|
||||||
// cache time, now gone) — both of which should trigger a cache rebuild.
|
// cache time, now gone) — both of which should trigger a cache rebuild.
|
||||||
|
|
||||||
existedAtCache map[string]bool
|
existedAtCache map[string]bool
|
||||||
|
|
||||||
|
// skillFilesAtCache snapshots the skill tree file set and mtimes at cache
|
||||||
|
// build time. This catches nested file creations/deletions/mtime changes
|
||||||
|
// that may not update the top-level skill root directory mtime.
|
||||||
|
skillFilesAtCache map[string]time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cb *ContextBuilder) WithToolDiscovery(useBM25, useRegex bool) *ContextBuilder {
|
||||||
|
cb.toolDiscoveryBM25 = useBM25
|
||||||
|
cb.toolDiscoveryRegex = useRegex
|
||||||
|
return cb
|
||||||
}
|
}
|
||||||
|
|
||||||
func getGlobalConfigDir() string {
|
func getGlobalConfigDir() string {
|
||||||
|
if home := os.Getenv("PICOCLAW_HOME"); home != "" {
|
||||||
|
return home
|
||||||
|
}
|
||||||
home, err := os.UserHomeDir()
|
home, err := os.UserHomeDir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ""
|
return ""
|
||||||
|
|
@ -180,59 +186,51 @@ func getGlobalConfigDir() string {
|
||||||
func NewContextBuilder(workspace string) *ContextBuilder {
|
func NewContextBuilder(workspace string) *ContextBuilder {
|
||||||
// builtin skills: skills directory in current project
|
// builtin skills: skills directory in current project
|
||||||
// Use the skills/ directory under the current working directory
|
// Use the skills/ directory under the current working directory
|
||||||
|
builtinSkillsDir := strings.TrimSpace(os.Getenv("PICOCLAW_BUILTIN_SKILLS"))
|
||||||
|
if builtinSkillsDir == "" {
|
||||||
wd, _ := os.Getwd()
|
wd, _ := os.Getwd()
|
||||||
|
builtinSkillsDir = filepath.Join(wd, "skills")
|
||||||
builtinSkillsDir := filepath.Join(wd, "skills")
|
}
|
||||||
|
|
||||||
globalSkillsDir := filepath.Join(getGlobalConfigDir(), "skills")
|
globalSkillsDir := filepath.Join(getGlobalConfigDir(), "skills")
|
||||||
|
|
||||||
return &ContextBuilder{
|
return &ContextBuilder{
|
||||||
workspace: workspace,
|
workspace: workspace,
|
||||||
|
|
||||||
skillsLoader: skills.NewSkillsLoader(workspace, globalSkillsDir, builtinSkillsDir),
|
skillsLoader: skills.NewSkillsLoader(workspace, globalSkillsDir, builtinSkillsDir),
|
||||||
|
|
||||||
memory: NewMemoryStore(workspace),
|
memory: NewMemoryStore(workspace),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetToolsRegistry sets the tools registry for dynamic tool summary generation.
|
// SetToolsRegistry sets the tools registry for dynamic tool summary generation.
|
||||||
|
|
||||||
func (cb *ContextBuilder) SetToolsRegistry(registry *tools.ToolRegistry) {
|
func (cb *ContextBuilder) SetToolsRegistry(registry *tools.ToolRegistry) {
|
||||||
cb.tools = registry
|
cb.tools = registry
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetWorkDir sets the session-specific working directory (e.g., worktree path
|
// SetWorkDir sets the session-specific working directory (e.g., worktree path
|
||||||
|
|
||||||
// or project subdirectory). Bootstrap files found here take priority over workspace.
|
// or project subdirectory). Bootstrap files found here take priority over workspace.
|
||||||
|
|
||||||
func (cb *ContextBuilder) SetWorkDir(dir string) {
|
func (cb *ContextBuilder) SetWorkDir(dir string) {
|
||||||
cb.workDir = dir
|
cb.workDir = dir
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetPeerNote sets the peer session awareness note for the current call.
|
// SetPeerNote sets the peer session awareness note for the current call.
|
||||||
|
|
||||||
func (cb *ContextBuilder) SetPeerNote(note string) {
|
func (cb *ContextBuilder) SetPeerNote(note string) {
|
||||||
cb.peerNote = note
|
cb.peerNote = note
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetOrchestrationEnabled sets whether orchestration is enabled.
|
// SetOrchestrationEnabled sets whether orchestration is enabled.
|
||||||
|
|
||||||
func (cb *ContextBuilder) SetOrchestrationEnabled(enabled bool) {
|
func (cb *ContextBuilder) SetOrchestrationEnabled(enabled bool) {
|
||||||
cb.orchestrationEnabled = enabled
|
cb.orchestrationEnabled = enabled
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cb *ContextBuilder) getIdentity() string {
|
func (cb *ContextBuilder) getIdentity() string {
|
||||||
workspacePath, _ := filepath.Abs(filepath.Join(cb.workspace))
|
workspacePath, _ := filepath.Abs(filepath.Join(cb.workspace))
|
||||||
|
toolDiscovery := cb.getDiscoveryRule()
|
||||||
|
version := config.FormatVersion()
|
||||||
|
|
||||||
// Build tools section dynamically
|
// Build tools section dynamically
|
||||||
|
|
||||||
toolsSection := cb.buildToolsSection()
|
toolsSection := cb.buildToolsSection()
|
||||||
|
|
||||||
// Build prompt with optional orchestration banner
|
// Build prompt with optional orchestration banner
|
||||||
|
|
||||||
var prompt string
|
var prompt string
|
||||||
|
|
||||||
if cb.orchestrationEnabled {
|
if cb.orchestrationEnabled {
|
||||||
prompt = ` /_/_/_/_/_/_/_/_/_/_/_/_/_/_/
|
prompt = ` /_/_/_/_/_/_/_/_/_/_/_/_/_/_/
|
||||||
|
|
||||||
|
|
@ -246,126 +244,75 @@ func (cb *ContextBuilder) getIdentity() string {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Conditional identity and plan executing rule for orchestration mode
|
// Conditional identity and plan executing rule for orchestration mode
|
||||||
|
|
||||||
identity := "a helpful AI assistant"
|
identity := "a helpful AI assistant"
|
||||||
|
|
||||||
executingRule := `Work through the current Phase's steps.
|
executingRule := `Work through the current Phase's steps.
|
||||||
|
|
||||||
Mark each "- [x]" via edit_file. The system will auto-advance phases.`
|
Mark each "- [x]" via edit_file. The system will auto-advance phases.`
|
||||||
|
|
||||||
if cb.orchestrationEnabled {
|
if cb.orchestrationEnabled {
|
||||||
identity = "a conductor AI agent that orchestrates subagents"
|
identity = "a conductor AI agent that orchestrates subagents"
|
||||||
|
|
||||||
executingRule = `Delegate the current Phase's steps to subagents using spawn.
|
executingRule = `Delegate the current Phase's steps to subagents using spawn.
|
||||||
|
|
||||||
For each step: spawn a subagent with the appropriate preset (scout for investigation,
|
For each step: spawn a subagent with the appropriate preset (scout for investigation,
|
||||||
|
|
||||||
coder for implementation, analyst for review). Spawn multiple independent steps in parallel.
|
coder for implementation, analyst for review). Spawn multiple independent steps in parallel.
|
||||||
|
|
||||||
When a subagent completes, mark "- [x]" via edit_file and record findings in
|
When a subagent completes, mark "- [x]" via edit_file and record findings in
|
||||||
|
|
||||||
## Orchestration > Findings in MEMORY.md.
|
## Orchestration > Findings in MEMORY.md.
|
||||||
|
|
||||||
Only do a step inline if it's a single quick tool call (e.g., reading one file).`
|
Only do a step inline if it's a single quick tool call (e.g., reading one file).`
|
||||||
}
|
}
|
||||||
|
|
||||||
return fmt.Sprintf(prompt+`# picoclaw 🦞
|
return fmt.Sprintf(prompt+`# picoclaw 🦞 (%s)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
You are picoclaw, %s.
|
You are picoclaw, %s.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## Workspace
|
## Workspace
|
||||||
Your workspace is at: %s
|
Your workspace is at: %s
|
||||||
- Memory: %s/memory/MEMORY.md
|
- Memory: %s/memory/MEMORY.md
|
||||||
- Daily Notes: %s/memory/YYYYMM/YYYYMMDD.md
|
- Daily Notes: %s/memory/YYYYMM/YYYYMMDD.md
|
||||||
- Skills: %s/skills/{skill-name}/SKILL.md
|
- Skills: %s/skills/{skill-name}/SKILL.md
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
%s
|
%s
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## Important Rules
|
## Important Rules
|
||||||
|
|
||||||
1. **ALWAYS use tools** - When you need to perform an action (schedule reminders, send messages, execute commands, etc.), you MUST call the appropriate tool. Do NOT just say you'll do it or pretend to do it.
|
1. **ALWAYS use tools** - When you need to perform an action (schedule reminders, send messages, execute commands, etc.), you MUST call the appropriate tool. Do NOT just say you'll do it or pretend to do it.
|
||||||
|
|
||||||
2. **Be helpful and accurate** - When using tools, briefly explain what you're doing.
|
2. **Be helpful and accurate** - When using tools, briefly explain what you're doing.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
3. **Memory & Plans**
|
3. **Memory & Plans**
|
||||||
|
|
||||||
- Use memory/MEMORY.md for structured plans.
|
- Use memory/MEMORY.md for structured plans.
|
||||||
|
|
||||||
- NEVER remove or overwrite the header block (# Active Plan, > Task:, > Status:, > Phase:). The system parses these lines to track plan state.
|
- NEVER remove or overwrite the header block (# Active Plan, > Task:, > Status:, > Phase:). The system parses these lines to track plan state.
|
||||||
|
|
||||||
- If Status is "interviewing": Ask clarifying questions.
|
- If Status is "interviewing": Ask clarifying questions.
|
||||||
|
|
||||||
After each answer, use edit_file to save findings to ## Context in memory/MEMORY.md.
|
After each answer, use edit_file to save findings to ## Context in memory/MEMORY.md.
|
||||||
|
|
||||||
When you have enough information, add ## Phase sections with "- [ ]" checkbox steps, and ## Commands section below the header. Then change > Status: to "review".
|
When you have enough information, add ## Phase sections with "- [ ]" checkbox steps, and ## Commands section below the header. Then change > Status: to "review".
|
||||||
|
|
||||||
- If Status is "review": The plan is awaiting user approval. Do NOT change Status yourself.
|
- If Status is "review": The plan is awaiting user approval. Do NOT change Status yourself.
|
||||||
|
|
||||||
- If Status is "executing": %s
|
- If Status is "executing": %s
|
||||||
|
|
||||||
- Plan format (header is written by the system — do NOT delete it):
|
- Plan format (header is written by the system — do NOT delete it):
|
||||||
|
|
||||||
# Active Plan
|
# Active Plan
|
||||||
|
|
||||||
> Task: <description>
|
> Task: <description>
|
||||||
|
|
||||||
> Status: interviewing | review | executing
|
> Status: interviewing | review | executing
|
||||||
|
|
||||||
> Phase: <current phase number>
|
> Phase: <current phase number>
|
||||||
|
|
||||||
## Phase 1: <title>
|
## Phase 1: <title>
|
||||||
|
|
||||||
- [ ] Step 1
|
- [ ] Step 1
|
||||||
|
|
||||||
- [ ] Step 2
|
- [ ] Step 2
|
||||||
|
|
||||||
## Phase 2: <title>
|
## Phase 2: <title>
|
||||||
|
|
||||||
- [ ] Step 1
|
- [ ] Step 1
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
build: <build command>
|
build: <build command>
|
||||||
|
|
||||||
test: <test command>
|
test: <test command>
|
||||||
|
|
||||||
lint: <lint command>
|
lint: <lint command>
|
||||||
|
|
||||||
## Context
|
## Context
|
||||||
|
|
||||||
<requirements, decisions, environment>
|
<requirements, decisions, environment>
|
||||||
|
|
||||||
- Keep each phase to 3-5 steps. Do NOT create plans without /plan.
|
- Keep each phase to 3-5 steps. Do NOT create plans without /plan.
|
||||||
|
|
||||||
- Always ask about build/test/lint commands during interview.
|
- Always ask about build/test/lint commands during interview.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
4. **Response Formatting**
|
4. **Response Formatting**
|
||||||
|
|
||||||
- NEVER use ASCII box-drawing characters (┌─┐│└─┘╔═╗║╚═╝ etc.) or ASCII art diagrams.
|
- NEVER use ASCII box-drawing characters (┌─┐│└─┘╔═╗║╚═╝ etc.) or ASCII art diagrams.
|
||||||
|
|
||||||
- Use markdown headings, bold, lists, and indentation for structure.
|
- Use markdown headings, bold, lists, and indentation for structure.
|
||||||
|
|
||||||
- Keep lines short — most users read on mobile.
|
- Keep lines short — most users read on mobile.
|
||||||
|
|
||||||
- For architecture/flow, use arrow text: CLI → Pipeline → Adapters
|
- For architecture/flow, use arrow text: CLI → Pipeline → Adapters
|
||||||
|
|
||||||
|
5. **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.
|
||||||
|
|
||||||
|
%s`,
|
||||||
5. **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.`,
|
version, identity, workspacePath, workspacePath, workspacePath, workspacePath,
|
||||||
|
toolsSection, executingRule, toolDiscovery)
|
||||||
identity, workspacePath, workspacePath, workspacePath, workspacePath, toolsSection, executingRule)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cb *ContextBuilder) buildToolsSection() string {
|
func (cb *ContextBuilder) buildToolsSection() string {
|
||||||
|
|
@ -374,30 +321,42 @@ func (cb *ContextBuilder) buildToolsSection() string {
|
||||||
}
|
}
|
||||||
|
|
||||||
summaries := cb.tools.GetSummaries()
|
summaries := cb.tools.GetSummaries()
|
||||||
|
|
||||||
if len(summaries) == 0 {
|
if len(summaries) == 0 {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
|
|
||||||
sb.WriteString("## Available Tools\n\n")
|
sb.WriteString("## Available Tools\n\n")
|
||||||
|
|
||||||
sb.WriteString(
|
sb.WriteString(
|
||||||
"**CRITICAL**: You MUST use tools to perform actions. Do NOT pretend to execute commands or schedule tasks.\n\n",
|
"**CRITICAL**: You MUST use tools to perform actions. Do NOT pretend to execute commands or schedule tasks.\n\n",
|
||||||
)
|
)
|
||||||
|
|
||||||
sb.WriteString("You have access to the following tools:\n\n")
|
sb.WriteString("You have access to the following tools:\n\n")
|
||||||
|
|
||||||
for _, s := range summaries {
|
for _, s := range summaries {
|
||||||
sb.WriteString(s)
|
sb.WriteString(s)
|
||||||
|
|
||||||
sb.WriteString("\n")
|
sb.WriteString("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
return sb.String()
|
return sb.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
func (cb *ContextBuilder) BuildSystemPrompt() string {
|
||||||
parts := []string{}
|
parts := []string{}
|
||||||
|
|
||||||
|
|
@ -405,7 +364,6 @@ func (cb *ContextBuilder) BuildSystemPrompt() string {
|
||||||
parts = append(parts, cb.getIdentity())
|
parts = append(parts, cb.getIdentity())
|
||||||
|
|
||||||
// Orchestration guidance — injected only when spawn tool is registered
|
// Orchestration guidance — injected only when spawn tool is registered
|
||||||
|
|
||||||
if cb.tools != nil {
|
if cb.tools != nil {
|
||||||
if _, hasSpawn := cb.tools.Get("spawn"); hasSpawn {
|
if _, hasSpawn := cb.tools.Get("spawn"); hasSpawn {
|
||||||
parts = append(parts, orchestrationGuidance)
|
parts = append(parts, orchestrationGuidance)
|
||||||
|
|
@ -429,7 +387,6 @@ The following skills extend your capabilities. To use a skill, read its SKILL.md
|
||||||
}
|
}
|
||||||
|
|
||||||
// Runtime status from tools (e.g., background processes)
|
// Runtime status from tools (e.g., background processes)
|
||||||
|
|
||||||
if cb.tools != nil {
|
if cb.tools != nil {
|
||||||
if status := cb.tools.GetRuntimeStatus(); status != "" {
|
if status := cb.tools.GetRuntimeStatus(); status != "" {
|
||||||
parts = append(parts, status)
|
parts = append(parts, status)
|
||||||
|
|
@ -437,7 +394,6 @@ The following skills extend your capabilities. To use a skill, read its SKILL.md
|
||||||
}
|
}
|
||||||
|
|
||||||
// Peer session coordination
|
// Peer session coordination
|
||||||
|
|
||||||
if cb.peerNote != "" {
|
if cb.peerNote != "" {
|
||||||
parts = append(parts, "## Active Sessions\n\n"+cb.peerNote)
|
parts = append(parts, "## Active Sessions\n\n"+cb.peerNote)
|
||||||
}
|
}
|
||||||
|
|
@ -485,6 +441,7 @@ func (cb *ContextBuilder) BuildSystemPromptWithCache() string {
|
||||||
cb.cachedSystemPrompt = prompt
|
cb.cachedSystemPrompt = prompt
|
||||||
cb.cachedAt = baseline.maxMtime
|
cb.cachedAt = baseline.maxMtime
|
||||||
cb.existedAtCache = baseline.existed
|
cb.existedAtCache = baseline.existed
|
||||||
|
cb.skillFilesAtCache = baseline.skillFiles
|
||||||
|
|
||||||
logger.DebugCF("agent", "System prompt cached",
|
logger.DebugCF("agent", "System prompt cached",
|
||||||
map[string]any{
|
map[string]any{
|
||||||
|
|
@ -504,79 +461,76 @@ func (cb *ContextBuilder) InvalidateCache() {
|
||||||
cb.cachedSystemPrompt = ""
|
cb.cachedSystemPrompt = ""
|
||||||
cb.cachedAt = time.Time{}
|
cb.cachedAt = time.Time{}
|
||||||
cb.existedAtCache = nil
|
cb.existedAtCache = nil
|
||||||
|
cb.skillFilesAtCache = nil
|
||||||
|
|
||||||
logger.DebugCF("agent", "System prompt cache invalidated", nil)
|
logger.DebugCF("agent", "System prompt cache invalidated", nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// sourcePaths returns the workspace source file paths tracked for cache
|
// sourcePaths returns the workspace source file paths tracked for cache
|
||||||
|
|
||||||
// invalidation (bootstrap files + memory). The skills directory is handled
|
// invalidation (bootstrap files + memory). The skills directory is handled
|
||||||
|
|
||||||
// separately in sourceFilesChangedLocked because it requires both directory-
|
// separately in sourceFilesChangedLocked because it requires both directory-
|
||||||
|
|
||||||
// level and recursive file-level mtime checks.
|
// level and recursive file-level mtime checks.
|
||||||
|
|
||||||
func (cb *ContextBuilder) sourcePaths() []string {
|
func (cb *ContextBuilder) sourcePaths() []string {
|
||||||
// Include bootstrap files from all search directories (workDir, planWorkDir, workspace).
|
// Include bootstrap files from all search directories (workDir, planWorkDir, workspace).
|
||||||
|
|
||||||
seen := map[string]bool{}
|
seen := map[string]bool{}
|
||||||
|
|
||||||
var paths []string
|
var paths []string
|
||||||
|
|
||||||
for _, spec := range bootstrapSpecs {
|
for _, spec := range bootstrapSpecs {
|
||||||
var dirs []string
|
var dirs []string
|
||||||
|
|
||||||
if spec.Scope == "global" {
|
if spec.Scope == "global" {
|
||||||
dirs = []string{cb.workspace}
|
dirs = []string{cb.workspace}
|
||||||
} else {
|
} else {
|
||||||
dirs = cb.bootstrapProjectDirs()
|
dirs = cb.bootstrapProjectDirs()
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, dir := range dirs {
|
for _, dir := range dirs {
|
||||||
p := filepath.Join(dir, spec.Name)
|
p := filepath.Join(dir, spec.Name)
|
||||||
|
|
||||||
if !seen[p] {
|
if !seen[p] {
|
||||||
seen[p] = true
|
seen[p] = true
|
||||||
|
|
||||||
paths = append(paths, p)
|
paths = append(paths, p)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Always track memory file.
|
// Always track memory file.
|
||||||
|
|
||||||
memPath := filepath.Join(cb.workspace, "memory", "MEMORY.md")
|
memPath := filepath.Join(cb.workspace, "memory", "MEMORY.md")
|
||||||
|
|
||||||
if !seen[memPath] {
|
if !seen[memPath] {
|
||||||
paths = append(paths, memPath)
|
paths = append(paths, memPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
return paths
|
return paths
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// skillRoots returns all skill root directories that can affect
|
||||||
|
// BuildSkillsSummary output (workspace/global/builtin).
|
||||||
|
func (cb *ContextBuilder) skillRoots() []string {
|
||||||
|
if cb.skillsLoader == nil {
|
||||||
|
return []string{filepath.Join(cb.workspace, "skills")}
|
||||||
|
}
|
||||||
|
|
||||||
|
roots := cb.skillsLoader.SkillRoots()
|
||||||
|
if len(roots) == 0 {
|
||||||
|
return []string{filepath.Join(cb.workspace, "skills")}
|
||||||
|
}
|
||||||
|
return roots
|
||||||
|
}
|
||||||
|
|
||||||
// cacheBaseline holds the file existence snapshot and the latest observed
|
// cacheBaseline holds the file existence snapshot and the latest observed
|
||||||
// mtime across all tracked paths. Used as the cache reference point.
|
// mtime across all tracked paths. Used as the cache reference point.
|
||||||
type cacheBaseline struct {
|
type cacheBaseline struct {
|
||||||
existed map[string]bool
|
existed map[string]bool
|
||||||
|
skillFiles map[string]time.Time
|
||||||
maxMtime time.Time
|
maxMtime time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildCacheBaseline records which tracked paths currently exist and computes
|
// buildCacheBaseline records which tracked paths currently exist and computes
|
||||||
|
|
||||||
// the latest mtime across all tracked files + skills directory contents.
|
// the latest mtime across all tracked files + skills directory contents.
|
||||||
|
|
||||||
// Called under write lock when the cache is built.
|
// Called under write lock when the cache is built.
|
||||||
|
|
||||||
func (cb *ContextBuilder) buildCacheBaseline() cacheBaseline {
|
func (cb *ContextBuilder) buildCacheBaseline() cacheBaseline {
|
||||||
skillsDir := filepath.Join(cb.workspace, "skills")
|
skillRoots := cb.skillRoots()
|
||||||
|
|
||||||
// All paths whose existence we track: source files + skills dir.
|
// All paths whose existence we track: source files + all skill roots.
|
||||||
|
allPaths := append(cb.sourcePaths(), skillRoots...)
|
||||||
allPaths := append(cb.sourcePaths(), skillsDir)
|
|
||||||
|
|
||||||
existed := make(map[string]bool, len(allPaths))
|
existed := make(map[string]bool, len(allPaths))
|
||||||
|
skillFiles := make(map[string]time.Time)
|
||||||
var maxMtime time.Time
|
var maxMtime time.Time
|
||||||
|
|
||||||
for _, p := range allPaths {
|
for _, p := range allPaths {
|
||||||
|
|
@ -587,21 +541,21 @@ func (cb *ContextBuilder) buildCacheBaseline() cacheBaseline {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Walk skills files to capture their mtimes too.
|
// Walk all skill roots recursively to snapshot skill files and mtimes.
|
||||||
|
// Use os.Stat (not d.Info) for consistency with sourceFilesChanged checks.
|
||||||
// Use os.Stat (not d.Info) to match the stat method used in
|
for _, root := range skillRoots {
|
||||||
|
_ = filepath.WalkDir(root, func(path string, d fs.DirEntry, walkErr error) error {
|
||||||
// fileChangedSince / skillFilesModifiedSince for consistency.
|
|
||||||
|
|
||||||
_ = filepath.WalkDir(skillsDir, func(path string, d fs.DirEntry, walkErr error) error {
|
|
||||||
if walkErr == nil && !d.IsDir() {
|
if walkErr == nil && !d.IsDir() {
|
||||||
if info, err := os.Stat(path); err == nil && info.ModTime().After(maxMtime) {
|
if info, err := os.Stat(path); err == nil {
|
||||||
|
skillFiles[path] = info.ModTime()
|
||||||
|
if info.ModTime().After(maxMtime) {
|
||||||
maxMtime = info.ModTime()
|
maxMtime = info.ModTime()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// If no tracked files exist yet (empty workspace), maxMtime is zero.
|
// If no tracked files exist yet (empty workspace), maxMtime is zero.
|
||||||
// Use a very old non-zero time so that:
|
// Use a very old non-zero time so that:
|
||||||
|
|
@ -613,7 +567,7 @@ func (cb *ContextBuilder) buildCacheBaseline() cacheBaseline {
|
||||||
maxMtime = time.Unix(1, 0)
|
maxMtime = time.Unix(1, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
return cacheBaseline{existed: existed, maxMtime: maxMtime}
|
return cacheBaseline{existed: existed, skillFiles: skillFiles, maxMtime: maxMtime}
|
||||||
}
|
}
|
||||||
|
|
||||||
// sourceFilesChangedLocked checks whether any workspace source file has been
|
// sourceFilesChangedLocked checks whether any workspace source file has been
|
||||||
|
|
@ -629,38 +583,21 @@ func (cb *ContextBuilder) sourceFilesChangedLocked() bool {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check tracked source files (bootstrap + memory).
|
// Check tracked source files (bootstrap + memory).
|
||||||
|
if slices.ContainsFunc(cb.sourcePaths(), cb.fileChangedSince) {
|
||||||
for _, p := range cb.sourcePaths() {
|
|
||||||
if cb.fileChangedSince(p) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Skills directory (handled separately from sourcePaths) ---
|
|
||||||
|
|
||||||
//
|
|
||||||
|
|
||||||
// 1. Creation/deletion: tracked via existedAtCache, same as bootstrap files.
|
|
||||||
|
|
||||||
skillsDir := filepath.Join(cb.workspace, "skills")
|
|
||||||
|
|
||||||
if cb.fileChangedSince(skillsDir) {
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Structural changes (add/remove entries inside the dir) are reflected
|
// --- Skill roots (workspace/global/builtin) ---
|
||||||
|
|
||||||
// in the directory's own mtime, which fileChangedSince already checks.
|
|
||||||
|
|
||||||
//
|
//
|
||||||
|
// For each root:
|
||||||
// 3. Content-only edits to files inside skills/ do NOT update the parent
|
// 1. Creation/deletion and root directory mtime changes are tracked by fileChangedSince.
|
||||||
|
// 2. Nested file create/delete/mtime changes are tracked by the skill file snapshot.
|
||||||
// directory mtime on most filesystems, so we recursively walk to check
|
for _, root := range cb.skillRoots() {
|
||||||
|
if cb.fileChangedSince(root) {
|
||||||
// individual file mtimes at any nesting depth.
|
return true
|
||||||
|
}
|
||||||
if skillFilesModifiedSince(skillsDir, cb.cachedAt) {
|
}
|
||||||
|
if skillFilesChangedSince(cb.skillRoots(), cb.skillFilesAtCache) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -701,85 +638,96 @@ func (cb *ContextBuilder) fileChangedSince(path string) bool {
|
||||||
// if the callback returned nil when its err parameter is non-nil.
|
// if the callback returned nil when its err parameter is non-nil.
|
||||||
var errWalkStop = errors.New("walk stop")
|
var errWalkStop = errors.New("walk stop")
|
||||||
|
|
||||||
// skillFilesModifiedSince recursively walks the skills directory and checks
|
// skillFilesChangedSince compares the current recursive skill file tree
|
||||||
|
// against the cache-time snapshot. Any create/delete/mtime drift invalidates
|
||||||
|
// the cache.
|
||||||
|
func skillFilesChangedSince(skillRoots []string, filesAtCache map[string]time.Time) bool {
|
||||||
|
// Defensive: if the snapshot was never initialized, force rebuild.
|
||||||
|
if filesAtCache == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
// whether any file was modified after t. This catches content-only edits at
|
// Check cached files still exist and keep the same mtime.
|
||||||
|
for path, cachedMtime := range filesAtCache {
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
// A previously tracked file disappeared (or became inaccessible):
|
||||||
|
// either way, cached skill summary may now be stale.
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if !info.ModTime().Equal(cachedMtime) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// any nesting depth (e.g. skills/name/docs/extra.md) that don't update
|
// Check no new files appeared under any skill root.
|
||||||
|
|
||||||
// parent directory mtimes.
|
|
||||||
|
|
||||||
func skillFilesModifiedSince(skillsDir string, t time.Time) bool {
|
|
||||||
changed := false
|
changed := false
|
||||||
|
for _, root := range skillRoots {
|
||||||
|
if strings.TrimSpace(root) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
err := filepath.WalkDir(skillsDir, func(path string, d fs.DirEntry, walkErr error) error {
|
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, walkErr error) error {
|
||||||
if walkErr == nil && !d.IsDir() {
|
if walkErr != nil {
|
||||||
if info, statErr := os.Stat(path); statErr == nil && info.ModTime().After(t) {
|
// Treat unexpected walk errors as changed to avoid stale cache.
|
||||||
|
if !os.IsNotExist(walkErr) {
|
||||||
changed = true
|
changed = true
|
||||||
|
return errWalkStop
|
||||||
return errWalkStop // stop walking
|
|
||||||
}
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if d.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if _, ok := filesAtCache[path]; !ok {
|
||||||
|
changed = true
|
||||||
|
return errWalkStop
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|
||||||
// errWalkStop is expected (early exit on first changed file).
|
if changed {
|
||||||
|
return true
|
||||||
// os.IsNotExist means the skills dir doesn't exist yet — not an error.
|
}
|
||||||
|
|
||||||
// Any other error is unexpected and worth logging.
|
|
||||||
|
|
||||||
if err != nil && !errors.Is(err, errWalkStop) && !os.IsNotExist(err) {
|
if err != nil && !errors.Is(err, errWalkStop) && !os.IsNotExist(err) {
|
||||||
logger.DebugCF("agent", "skills walk error", map[string]any{"error": err.Error()})
|
logger.DebugCF("agent", "skills walk error", map[string]any{"error": err.Error()})
|
||||||
|
return true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return changed
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// BootstrapFileInfo describes a resolved bootstrap file.
|
// BootstrapFileInfo describes a resolved bootstrap file.
|
||||||
|
|
||||||
type BootstrapFileInfo struct {
|
type BootstrapFileInfo struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
|
||||||
Path string `json:"path"` // empty = not found
|
Path string `json:"path"` // empty = not found
|
||||||
|
|
||||||
Scope string `json:"scope"` // "project" or "global"
|
Scope string `json:"scope"` // "project" or "global"
|
||||||
}
|
}
|
||||||
|
|
||||||
// bootstrapFileSpec defines the search scope for each bootstrap file.
|
// bootstrapFileSpec defines the search scope for each bootstrap file.
|
||||||
|
|
||||||
type bootstrapFileSpec struct {
|
type bootstrapFileSpec struct {
|
||||||
Name string
|
Name string
|
||||||
|
|
||||||
Scope string // "project" = workDir→planWorkDir→workspace, "global" = workspace only
|
Scope string // "project" = workDir→planWorkDir→workspace, "global" = workspace only
|
||||||
}
|
}
|
||||||
|
|
||||||
var bootstrapSpecs = []bootstrapFileSpec{
|
var bootstrapSpecs = []bootstrapFileSpec{
|
||||||
{Name: "AGENTS.md", Scope: "project"},
|
{Name: "AGENTS.md", Scope: "project"},
|
||||||
|
|
||||||
{Name: "IDENTITY.md", Scope: "project"},
|
{Name: "IDENTITY.md", Scope: "project"},
|
||||||
|
|
||||||
{Name: "SOUL.md", Scope: "global"},
|
{Name: "SOUL.md", Scope: "global"},
|
||||||
|
|
||||||
{Name: "USER.md", Scope: "global"},
|
{Name: "USER.md", Scope: "global"},
|
||||||
}
|
}
|
||||||
|
|
||||||
// bootstrapProjectDirs returns de-duplicated search directories for project-scoped files.
|
// bootstrapProjectDirs returns de-duplicated search directories for project-scoped files.
|
||||||
|
|
||||||
func (cb *ContextBuilder) bootstrapProjectDirs() []string {
|
func (cb *ContextBuilder) bootstrapProjectDirs() []string {
|
||||||
seen := map[string]bool{}
|
seen := map[string]bool{}
|
||||||
|
|
||||||
var dirs []string
|
var dirs []string
|
||||||
|
|
||||||
for _, d := range []string{cb.workDir, cb.memory.GetPlanWorkDir(), cb.workspace} {
|
for _, d := range []string{cb.workDir, cb.memory.GetPlanWorkDir(), cb.workspace} {
|
||||||
if d != "" && !seen[d] {
|
if d != "" && !seen[d] {
|
||||||
seen[d] = true
|
seen[d] = true
|
||||||
|
|
||||||
dirs = append(dirs, d)
|
dirs = append(dirs, d)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return dirs
|
return dirs
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -787,63 +735,47 @@ func (cb *ContextBuilder) LoadBootstrapFiles() string {
|
||||||
projectDirs := cb.bootstrapProjectDirs()
|
projectDirs := cb.bootstrapProjectDirs()
|
||||||
|
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
|
|
||||||
for _, spec := range bootstrapSpecs {
|
for _, spec := range bootstrapSpecs {
|
||||||
var dirs []string
|
var dirs []string
|
||||||
|
|
||||||
if spec.Scope == "global" {
|
if spec.Scope == "global" {
|
||||||
dirs = []string{cb.workspace}
|
dirs = []string{cb.workspace}
|
||||||
} else {
|
} else {
|
||||||
dirs = projectDirs
|
dirs = projectDirs
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, dir := range dirs {
|
for _, dir := range dirs {
|
||||||
filePath := filepath.Join(dir, spec.Name)
|
filePath := filepath.Join(dir, spec.Name)
|
||||||
|
|
||||||
if data, err := os.ReadFile(filePath); err == nil {
|
if data, err := os.ReadFile(filePath); err == nil {
|
||||||
fmt.Fprintf(&sb, "## %s\n\n%s\n\n", spec.Name, data)
|
fmt.Fprintf(&sb, "## %s\n\n%s\n\n", spec.Name, data)
|
||||||
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return sb.String()
|
return sb.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ResolveBootstrapPaths returns path resolution info for each bootstrap file
|
// ResolveBootstrapPaths returns path resolution info for each bootstrap file
|
||||||
|
|
||||||
// using the same search logic as LoadBootstrapFiles.
|
// using the same search logic as LoadBootstrapFiles.
|
||||||
|
|
||||||
func (cb *ContextBuilder) ResolveBootstrapPaths() []BootstrapFileInfo {
|
func (cb *ContextBuilder) ResolveBootstrapPaths() []BootstrapFileInfo {
|
||||||
projectDirs := cb.bootstrapProjectDirs()
|
projectDirs := cb.bootstrapProjectDirs()
|
||||||
|
|
||||||
result := make([]BootstrapFileInfo, 0, len(bootstrapSpecs))
|
result := make([]BootstrapFileInfo, 0, len(bootstrapSpecs))
|
||||||
|
|
||||||
for _, spec := range bootstrapSpecs {
|
for _, spec := range bootstrapSpecs {
|
||||||
info := BootstrapFileInfo{Name: spec.Name, Scope: spec.Scope}
|
info := BootstrapFileInfo{Name: spec.Name, Scope: spec.Scope}
|
||||||
|
|
||||||
var dirs []string
|
var dirs []string
|
||||||
|
|
||||||
if spec.Scope == "global" {
|
if spec.Scope == "global" {
|
||||||
dirs = []string{cb.workspace}
|
dirs = []string{cb.workspace}
|
||||||
} else {
|
} else {
|
||||||
dirs = projectDirs
|
dirs = projectDirs
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, dir := range dirs {
|
for _, dir := range dirs {
|
||||||
filePath := filepath.Join(dir, spec.Name)
|
filePath := filepath.Join(dir, spec.Name)
|
||||||
|
|
||||||
if _, err := os.Stat(filePath); err == nil {
|
if _, err := os.Stat(filePath); err == nil {
|
||||||
info.Path = filePath
|
info.Path = filePath
|
||||||
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
result = append(result, info)
|
result = append(result, info)
|
||||||
}
|
}
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -929,24 +861,14 @@ func (cb *ContextBuilder) BuildMessages(
|
||||||
logger.DebugCF("agent", "System prompt built",
|
logger.DebugCF("agent", "System prompt built",
|
||||||
map[string]any{
|
map[string]any{
|
||||||
"static_chars": len(staticPrompt),
|
"static_chars": len(staticPrompt),
|
||||||
|
|
||||||
"dynamic_chars": len(dynamicCtx),
|
"dynamic_chars": len(dynamicCtx),
|
||||||
|
|
||||||
"total_chars": len(fullSystemPrompt),
|
"total_chars": len(fullSystemPrompt),
|
||||||
|
|
||||||
"has_summary": summary != "",
|
"has_summary": summary != "",
|
||||||
|
|
||||||
"cached": isCached,
|
"cached": isCached,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Log preview of system prompt (avoid logging huge content)
|
// Log preview of system prompt (avoid logging huge content)
|
||||||
|
preview := utils.Truncate(fullSystemPrompt, 500)
|
||||||
preview := fullSystemPrompt
|
|
||||||
|
|
||||||
if len(preview) > 500 {
|
|
||||||
preview = preview[:500] + "... (truncated)"
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.DebugCF("agent", "System prompt preview",
|
logger.DebugCF("agent", "System prompt preview",
|
||||||
map[string]any{
|
map[string]any{
|
||||||
"preview": preview,
|
"preview": preview,
|
||||||
|
|
@ -959,9 +881,7 @@ func (cb *ContextBuilder) BuildMessages(
|
||||||
// Content is the concatenated fallback for adapters that don't read SystemParts.
|
// Content is the concatenated fallback for adapters that don't read SystemParts.
|
||||||
messages = append(messages, providers.Message{
|
messages = append(messages, providers.Message{
|
||||||
Role: "system",
|
Role: "system",
|
||||||
|
|
||||||
Content: fullSystemPrompt,
|
Content: fullSystemPrompt,
|
||||||
|
|
||||||
SystemParts: contentBlocks,
|
SystemParts: contentBlocks,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -970,11 +890,14 @@ func (cb *ContextBuilder) BuildMessages(
|
||||||
|
|
||||||
// Add current user message
|
// Add current user message
|
||||||
if strings.TrimSpace(currentMessage) != "" {
|
if strings.TrimSpace(currentMessage) != "" {
|
||||||
messages = append(messages, providers.Message{
|
msg := providers.Message{
|
||||||
Role: "user",
|
Role: "user",
|
||||||
|
|
||||||
Content: currentMessage,
|
Content: currentMessage,
|
||||||
})
|
}
|
||||||
|
if len(media) > 0 {
|
||||||
|
msg.Media = media
|
||||||
|
}
|
||||||
|
messages = append(messages, msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
return messages
|
return messages
|
||||||
|
|
@ -1042,7 +965,60 @@ func sanitizeHistoryForProvider(history []providers.Message) []providers.Message
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return sanitized
|
// Second pass: ensure every assistant message with tool_calls has matching
|
||||||
|
// tool result messages following it. This is required by strict providers
|
||||||
|
// like DeepSeek that enforce: "An assistant message with 'tool_calls' must
|
||||||
|
// be followed by tool messages responding to each 'tool_call_id'."
|
||||||
|
final := make([]providers.Message, 0, len(sanitized))
|
||||||
|
for i := 0; i < len(sanitized); i++ {
|
||||||
|
msg := sanitized[i]
|
||||||
|
if msg.Role == "assistant" && len(msg.ToolCalls) > 0 {
|
||||||
|
// Collect expected tool_call IDs
|
||||||
|
expected := make(map[string]bool, len(msg.ToolCalls))
|
||||||
|
for _, tc := range msg.ToolCalls {
|
||||||
|
expected[tc.ID] = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check following messages for matching tool results
|
||||||
|
toolMsgCount := 0
|
||||||
|
for j := i + 1; j < len(sanitized); j++ {
|
||||||
|
if sanitized[j].Role != "tool" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
toolMsgCount++
|
||||||
|
if _, exists := expected[sanitized[j].ToolCallID]; exists {
|
||||||
|
expected[sanitized[j].ToolCallID] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If any tool_call_id is missing, drop this assistant message and its partial tool messages
|
||||||
|
allFound := true
|
||||||
|
for toolCallID, found := range expected {
|
||||||
|
if !found {
|
||||||
|
allFound = false
|
||||||
|
logger.DebugCF(
|
||||||
|
"agent",
|
||||||
|
"Dropping assistant message with incomplete tool results",
|
||||||
|
map[string]any{
|
||||||
|
"missing_tool_call_id": toolCallID,
|
||||||
|
"expected_count": len(expected),
|
||||||
|
"found_count": toolMsgCount,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !allFound {
|
||||||
|
// Skip this assistant message and its tool messages
|
||||||
|
i += toolMsgCount
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final = append(final, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
return final
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cb *ContextBuilder) AddToolResult(
|
func (cb *ContextBuilder) AddToolResult(
|
||||||
|
|
@ -1051,9 +1027,7 @@ func (cb *ContextBuilder) AddToolResult(
|
||||||
) []providers.Message {
|
) []providers.Message {
|
||||||
messages = append(messages, providers.Message{
|
messages = append(messages, providers.Message{
|
||||||
Role: "tool",
|
Role: "tool",
|
||||||
|
|
||||||
Content: result,
|
Content: result,
|
||||||
|
|
||||||
ToolCallID: toolCallID,
|
ToolCallID: toolCallID,
|
||||||
})
|
})
|
||||||
return messages
|
return messages
|
||||||
|
|
@ -1066,7 +1040,6 @@ func (cb *ContextBuilder) AddAssistantMessage(
|
||||||
) []providers.Message {
|
) []providers.Message {
|
||||||
msg := providers.Message{
|
msg := providers.Message{
|
||||||
Role: "assistant",
|
Role: "assistant",
|
||||||
|
|
||||||
Content: content,
|
Content: content,
|
||||||
}
|
}
|
||||||
// Always add assistant message, whether or not it has tool calls
|
// Always add assistant message, whether or not it has tool calls
|
||||||
|
|
@ -1075,19 +1048,16 @@ func (cb *ContextBuilder) AddAssistantMessage(
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoadSkill loads a skill by name, returning its content (with frontmatter stripped) and whether it was found.
|
// LoadSkill loads a skill by name, returning its content (with frontmatter stripped) and whether it was found.
|
||||||
|
|
||||||
func (cb *ContextBuilder) LoadSkill(name string) (string, bool) {
|
func (cb *ContextBuilder) LoadSkill(name string) (string, bool) {
|
||||||
return cb.skillsLoader.LoadSkill(name)
|
return cb.skillsLoader.LoadSkill(name)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListSkills returns all available skills from all tiers.
|
// ListSkills returns all available skills from all tiers.
|
||||||
|
|
||||||
func (cb *ContextBuilder) ListSkills() []skills.SkillInfo {
|
func (cb *ContextBuilder) ListSkills() []skills.SkillInfo {
|
||||||
return cb.skillsLoader.ListSkills()
|
return cb.skillsLoader.ListSkills()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Memory returns the underlying MemoryStore for direct plan queries.
|
// Memory returns the underlying MemoryStore for direct plan queries.
|
||||||
|
|
||||||
func (cb *ContextBuilder) Memory() *MemoryStore {
|
func (cb *ContextBuilder) Memory() *MemoryStore {
|
||||||
return cb.memory
|
return cb.memory
|
||||||
}
|
}
|
||||||
|
|
@ -1095,109 +1065,91 @@ func (cb *ContextBuilder) Memory() *MemoryStore {
|
||||||
// ---------- Plan passthrough methods ----------
|
// ---------- Plan passthrough methods ----------
|
||||||
|
|
||||||
// ReadMemory reads the long-term memory (MEMORY.md).
|
// ReadMemory reads the long-term memory (MEMORY.md).
|
||||||
|
|
||||||
func (cb *ContextBuilder) ReadMemory() string {
|
func (cb *ContextBuilder) ReadMemory() string {
|
||||||
return cb.memory.ReadLongTerm()
|
return cb.memory.ReadLongTerm()
|
||||||
}
|
}
|
||||||
|
|
||||||
// WriteMemory writes content to the long-term memory file.
|
// WriteMemory writes content to the long-term memory file.
|
||||||
|
|
||||||
func (cb *ContextBuilder) WriteMemory(content string) error {
|
func (cb *ContextBuilder) WriteMemory(content string) error {
|
||||||
return cb.memory.WriteLongTerm(content)
|
return cb.memory.WriteLongTerm(content)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClearMemory removes the long-term memory file.
|
// ClearMemory removes the long-term memory file.
|
||||||
|
|
||||||
func (cb *ContextBuilder) ClearMemory() error {
|
func (cb *ContextBuilder) ClearMemory() error {
|
||||||
return cb.memory.ClearLongTerm()
|
return cb.memory.ClearLongTerm()
|
||||||
}
|
}
|
||||||
|
|
||||||
// HasActivePlan returns true if MEMORY.md contains an active plan.
|
// HasActivePlan returns true if MEMORY.md contains an active plan.
|
||||||
|
|
||||||
func (cb *ContextBuilder) HasActivePlan() bool {
|
func (cb *ContextBuilder) HasActivePlan() bool {
|
||||||
return cb.memory.HasActivePlan()
|
return cb.memory.HasActivePlan()
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetPlanStatus returns the plan status: "interviewing", "executing", or "".
|
// GetPlanStatus returns the plan status: "interviewing", "executing", or "".
|
||||||
|
|
||||||
func (cb *ContextBuilder) GetPlanStatus() string {
|
func (cb *ContextBuilder) GetPlanStatus() string {
|
||||||
return cb.memory.GetPlanStatus()
|
return cb.memory.GetPlanStatus()
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsPlanComplete returns true if all steps in all phases are [x].
|
// IsPlanComplete returns true if all steps in all phases are [x].
|
||||||
|
|
||||||
func (cb *ContextBuilder) IsPlanComplete() bool {
|
func (cb *ContextBuilder) IsPlanComplete() bool {
|
||||||
return cb.memory.IsPlanComplete()
|
return cb.memory.IsPlanComplete()
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsCurrentPhaseComplete returns true if all steps in the current phase are [x].
|
// IsCurrentPhaseComplete returns true if all steps in the current phase are [x].
|
||||||
|
|
||||||
func (cb *ContextBuilder) IsCurrentPhaseComplete() bool {
|
func (cb *ContextBuilder) IsCurrentPhaseComplete() bool {
|
||||||
return cb.memory.IsCurrentPhaseComplete()
|
return cb.memory.IsCurrentPhaseComplete()
|
||||||
}
|
}
|
||||||
|
|
||||||
// AdvancePhase increments the current phase number by 1.
|
// AdvancePhase increments the current phase number by 1.
|
||||||
|
|
||||||
func (cb *ContextBuilder) AdvancePhase() error {
|
func (cb *ContextBuilder) AdvancePhase() error {
|
||||||
return cb.memory.AdvancePhase()
|
return cb.memory.AdvancePhase()
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetCurrentPhase sets the current phase number to n.
|
// SetCurrentPhase sets the current phase number to n.
|
||||||
|
|
||||||
func (cb *ContextBuilder) SetCurrentPhase(n int) error {
|
func (cb *ContextBuilder) SetCurrentPhase(n int) error {
|
||||||
return cb.memory.SetPhase(n)
|
return cb.memory.SetPhase(n)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetCurrentPhase returns the current phase number.
|
// GetCurrentPhase returns the current phase number.
|
||||||
|
|
||||||
func (cb *ContextBuilder) GetCurrentPhase() int {
|
func (cb *ContextBuilder) GetCurrentPhase() int {
|
||||||
return cb.memory.GetCurrentPhase()
|
return cb.memory.GetCurrentPhase()
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetTotalPhases returns the total number of phases in the plan.
|
// GetTotalPhases returns the total number of phases in the plan.
|
||||||
|
|
||||||
func (cb *ContextBuilder) GetTotalPhases() int {
|
func (cb *ContextBuilder) GetTotalPhases() int {
|
||||||
return cb.memory.GetTotalPhases()
|
return cb.memory.GetTotalPhases()
|
||||||
}
|
}
|
||||||
|
|
||||||
// FormatPlanDisplay returns a user-facing display of the full plan.
|
// FormatPlanDisplay returns a user-facing display of the full plan.
|
||||||
|
|
||||||
func (cb *ContextBuilder) FormatPlanDisplay() string {
|
func (cb *ContextBuilder) FormatPlanDisplay() string {
|
||||||
return cb.memory.FormatPlanDisplay()
|
return cb.memory.FormatPlanDisplay()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MarkStep marks a step as done in the specified phase.
|
// MarkStep marks a step as done in the specified phase.
|
||||||
|
|
||||||
func (cb *ContextBuilder) MarkStep(phase, step int) error {
|
func (cb *ContextBuilder) MarkStep(phase, step int) error {
|
||||||
return cb.memory.MarkStep(phase, step)
|
return cb.memory.MarkStep(phase, step)
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddStep appends a new step to the given phase.
|
// AddStep appends a new step to the given phase.
|
||||||
|
|
||||||
func (cb *ContextBuilder) AddStep(phase int, desc string) error {
|
func (cb *ContextBuilder) AddStep(phase int, desc string) error {
|
||||||
return cb.memory.AddStep(phase, desc)
|
return cb.memory.AddStep(phase, desc)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ValidatePlanStructure validates plan structure for interview->review transition.
|
// ValidatePlanStructure validates plan structure for interview->review transition.
|
||||||
|
|
||||||
func (cb *ContextBuilder) ValidatePlanStructure() error {
|
func (cb *ContextBuilder) ValidatePlanStructure() error {
|
||||||
return cb.memory.ValidatePlanStructure()
|
return cb.memory.ValidatePlanStructure()
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetPlanStatus sets the plan status.
|
// SetPlanStatus sets the plan status.
|
||||||
|
|
||||||
func (cb *ContextBuilder) SetPlanStatus(status string) error {
|
func (cb *ContextBuilder) SetPlanStatus(status string) error {
|
||||||
return cb.memory.SetStatus(status)
|
return cb.memory.SetStatus(status)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetPlanWorkDir returns the WorkDir from the plan metadata, or "".
|
// GetPlanWorkDir returns the WorkDir from the plan metadata, or "".
|
||||||
|
|
||||||
func (cb *ContextBuilder) GetPlanWorkDir() string {
|
func (cb *ContextBuilder) GetPlanWorkDir() string {
|
||||||
return cb.memory.GetPlanWorkDir()
|
return cb.memory.GetPlanWorkDir()
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetPlanTaskName returns the task description from the plan metadata, or "".
|
// GetPlanTaskName returns the task description from the plan metadata, or "".
|
||||||
|
|
||||||
func (cb *ContextBuilder) GetPlanTaskName() string {
|
func (cb *ContextBuilder) GetPlanTaskName() string {
|
||||||
return cb.memory.GetPlanTaskName()
|
return cb.memory.GetPlanTaskName()
|
||||||
}
|
}
|
||||||
|
|
@ -1211,9 +1163,7 @@ func (cb *ContextBuilder) GetSkillsInfo() map[string]any {
|
||||||
}
|
}
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
"total": len(allSkills),
|
"total": len(allSkills),
|
||||||
|
|
||||||
"available": len(allSkills),
|
"available": len(allSkills),
|
||||||
|
|
||||||
"names": skillNames,
|
"names": skillNames,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import (
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
|
|
@ -17,66 +18,50 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
// AgentInstance represents a fully configured agent with its own workspace,
|
// AgentInstance represents a fully configured agent with its own workspace,
|
||||||
|
|
||||||
// session manager, context builder, and tool registry.
|
// session manager, context builder, and tool registry.
|
||||||
|
|
||||||
type AgentInstance struct {
|
type AgentInstance struct {
|
||||||
ID string
|
ID string
|
||||||
|
|
||||||
Name string
|
Name string
|
||||||
|
|
||||||
Model string
|
Model string
|
||||||
|
|
||||||
Fallbacks []string
|
Fallbacks []string
|
||||||
|
|
||||||
Workspace string
|
Workspace string
|
||||||
|
|
||||||
MaxIterations int
|
MaxIterations int
|
||||||
|
|
||||||
TaskReminderInterval int
|
TaskReminderInterval int
|
||||||
|
|
||||||
MaxTokens int
|
MaxTokens int
|
||||||
|
|
||||||
Temperature float64
|
Temperature float64
|
||||||
|
ThinkingLevel ThinkingLevel
|
||||||
ContextWindow int
|
ContextWindow int
|
||||||
|
SummarizeMessageThreshold int
|
||||||
|
SummarizeTokenPercent int
|
||||||
Provider providers.LLMProvider
|
Provider providers.LLMProvider
|
||||||
|
|
||||||
Sessions *session.LegacyAdapter
|
Sessions *session.LegacyAdapter
|
||||||
|
|
||||||
ContextBuilder *ContextBuilder
|
ContextBuilder *ContextBuilder
|
||||||
|
|
||||||
Tools *tools.ToolRegistry
|
Tools *tools.ToolRegistry
|
||||||
|
|
||||||
Subagents *config.SubagentsConfig
|
Subagents *config.SubagentsConfig
|
||||||
|
|
||||||
SkillsFilter []string
|
SkillsFilter []string
|
||||||
|
|
||||||
Candidates []providers.FallbackCandidate
|
Candidates []providers.FallbackCandidate
|
||||||
|
|
||||||
PlanModel string
|
PlanModel string
|
||||||
|
|
||||||
PlanFallbacks []string
|
PlanFallbacks []string
|
||||||
|
|
||||||
PlanCandidates []providers.FallbackCandidate
|
PlanCandidates []providers.FallbackCandidate
|
||||||
|
|
||||||
|
// Router is non-nil when model routing is configured and the light model
|
||||||
|
// was successfully resolved. It scores each incoming message and decides
|
||||||
|
// whether to route to LightCandidates or stay with Candidates.
|
||||||
|
Router *routing.Router
|
||||||
|
// LightCandidates holds the resolved provider candidates for the light model.
|
||||||
|
// Pre-computed at agent creation to avoid repeated model_list lookups at runtime.
|
||||||
|
LightCandidates []providers.FallbackCandidate
|
||||||
|
|
||||||
// SubagentMgr is set during registerSharedTools when orchestration is enabled.
|
// SubagentMgr is set during registerSharedTools when orchestration is enabled.
|
||||||
|
|
||||||
// Used by runAgentLoop to wait for spawned subagents before worktree cleanup.
|
// Used by runAgentLoop to wait for spawned subagents before worktree cleanup.
|
||||||
|
|
||||||
SubagentMgr *tools.SubagentManager
|
SubagentMgr *tools.SubagentManager
|
||||||
|
|
||||||
// Interview staleness tracking: consecutive turns where MEMORY.md was not updated.
|
// Interview staleness tracking: consecutive turns where MEMORY.md was not updated.
|
||||||
|
|
||||||
interviewStaleCount int
|
interviewStaleCount int
|
||||||
|
|
||||||
interviewMemoryLen int
|
interviewMemoryLen int
|
||||||
|
|
||||||
// Per-session worktree isolation
|
// Per-session worktree isolation
|
||||||
|
|
||||||
worktrees map[string]*git.WorktreeInfo // sessionKey → worktree
|
worktrees map[string]*git.WorktreeInfo // sessionKey → worktree
|
||||||
|
|
||||||
worktreeMu sync.RWMutex
|
worktreeMu sync.RWMutex
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -94,43 +79,50 @@ func NewAgentInstance(
|
||||||
fallbacks := resolveAgentFallbacks(agentCfg, defaults)
|
fallbacks := resolveAgentFallbacks(agentCfg, defaults)
|
||||||
|
|
||||||
restrict := defaults.RestrictToWorkspace
|
restrict := defaults.RestrictToWorkspace
|
||||||
|
readRestrict := restrict && !defaults.AllowReadOutsideWorkspace
|
||||||
|
|
||||||
|
// Compile path whitelist patterns from config.
|
||||||
|
allowReadPaths := compilePatterns(cfg.Tools.AllowReadPaths)
|
||||||
|
allowWritePaths := compilePatterns(cfg.Tools.AllowWritePaths)
|
||||||
|
|
||||||
toolsRegistry := tools.NewToolRegistry()
|
toolsRegistry := tools.NewToolRegistry()
|
||||||
|
|
||||||
toolsRegistry.Register(tools.NewReadFileTool(workspace, restrict))
|
if cfg.Tools.IsToolEnabled("read_file") {
|
||||||
|
maxReadFileSize := cfg.Tools.ReadFile.MaxReadFileSize
|
||||||
toolsRegistry.Register(tools.NewWriteFileTool(workspace, restrict))
|
toolsRegistry.Register(tools.NewReadFileTool(workspace, readRestrict, maxReadFileSize, allowReadPaths))
|
||||||
|
}
|
||||||
toolsRegistry.Register(tools.NewListDirTool(workspace, restrict))
|
if cfg.Tools.IsToolEnabled("write_file") {
|
||||||
|
toolsRegistry.Register(tools.NewWriteFileTool(workspace, restrict, allowWritePaths))
|
||||||
|
}
|
||||||
|
if cfg.Tools.IsToolEnabled("list_dir") {
|
||||||
|
toolsRegistry.Register(tools.NewListDirTool(workspace, readRestrict, allowReadPaths))
|
||||||
|
}
|
||||||
|
if cfg.Tools.IsToolEnabled("exec") {
|
||||||
execTool, err := tools.NewExecToolWithConfig(workspace, restrict, cfg)
|
execTool, err := tools.NewExecToolWithConfig(workspace, restrict, cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Critical error: unable to initialize exec tool: %v", err)
|
log.Fatalf("Critical error: unable to initialize exec tool: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
toolsRegistry.Register(execTool)
|
toolsRegistry.Register(execTool)
|
||||||
|
}
|
||||||
|
|
||||||
toolsRegistry.Register(tools.NewBgMonitorTool(execTool))
|
if cfg.Tools.IsToolEnabled("edit_file") {
|
||||||
|
toolsRegistry.Register(tools.NewEditFileTool(workspace, restrict, allowWritePaths))
|
||||||
toolsRegistry.Register(tools.NewEditFileTool(workspace, restrict))
|
}
|
||||||
|
if cfg.Tools.IsToolEnabled("append_file") {
|
||||||
toolsRegistry.Register(tools.NewAppendFileTool(workspace, restrict))
|
toolsRegistry.Register(tools.NewAppendFileTool(workspace, restrict, allowWritePaths))
|
||||||
|
}
|
||||||
|
|
||||||
toolsRegistry.Register(tools.NewLogsTool())
|
toolsRegistry.Register(tools.NewLogsTool())
|
||||||
|
|
||||||
toolsRegistry.Register(tools.NewGitPushTool())
|
toolsRegistry.Register(tools.NewGitPushTool())
|
||||||
|
|
||||||
toolsRegistry.Register(tools.NewCreatePRTool())
|
toolsRegistry.Register(tools.NewCreatePRTool())
|
||||||
|
|
||||||
dbPath := filepath.Join(workspace, "sessions.db")
|
dbPath := filepath.Join(workspace, "sessions.db")
|
||||||
|
|
||||||
store, err := session.OpenSQLiteStore(dbPath)
|
store, err := session.OpenSQLiteStore(dbPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("open session store: %v", err)
|
log.Fatalf("open session store: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
jsonDir := filepath.Join(workspace, "sessions")
|
jsonDir := filepath.Join(workspace, "sessions")
|
||||||
|
|
||||||
if n, merr := session.MigrateJSONSessions(jsonDir, store); merr != nil {
|
if n, merr := session.MigrateJSONSessions(jsonDir, store); merr != nil {
|
||||||
log.Printf("session migration: %d migrated, error: %v", n, merr)
|
log.Printf("session migration: %d migrated, error: %v", n, merr)
|
||||||
} else if n > 0 {
|
} else if n > 0 {
|
||||||
|
|
@ -145,7 +137,11 @@ func NewAgentInstance(
|
||||||
|
|
||||||
sessionsManager := session.NewLegacyAdapter(store)
|
sessionsManager := session.NewLegacyAdapter(store)
|
||||||
|
|
||||||
contextBuilder := NewContextBuilder(workspace)
|
mcpDiscoveryActive := cfg.Tools.MCP.Enabled && cfg.Tools.MCP.Discovery.Enabled
|
||||||
|
contextBuilder := NewContextBuilder(workspace).WithToolDiscovery(
|
||||||
|
mcpDiscoveryActive && cfg.Tools.MCP.Discovery.UseBM25,
|
||||||
|
mcpDiscoveryActive && cfg.Tools.MCP.Discovery.UseRegex,
|
||||||
|
)
|
||||||
|
|
||||||
agentID := routing.DefaultAgentID
|
agentID := routing.DefaultAgentID
|
||||||
agentName := ""
|
agentName := ""
|
||||||
|
|
@ -160,7 +156,6 @@ func NewAgentInstance(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply defaults.Orchestration: if the flag is set, ensure orchestration is enabled.
|
// Apply defaults.Orchestration: if the flag is set, ensure orchestration is enabled.
|
||||||
|
|
||||||
if defaults.Orchestration {
|
if defaults.Orchestration {
|
||||||
if subagents == nil {
|
if subagents == nil {
|
||||||
subagents = &config.SubagentsConfig{Enabled: true}
|
subagents = &config.SubagentsConfig{Enabled: true}
|
||||||
|
|
@ -175,7 +170,6 @@ func NewAgentInstance(
|
||||||
}
|
}
|
||||||
|
|
||||||
reminderInterval := defaults.TaskReminderInterval
|
reminderInterval := defaults.TaskReminderInterval
|
||||||
|
|
||||||
if reminderInterval == 0 {
|
if reminderInterval == 0 {
|
||||||
reminderInterval = 5
|
reminderInterval = 5
|
||||||
}
|
}
|
||||||
|
|
@ -190,10 +184,25 @@ func NewAgentInstance(
|
||||||
temperature = *defaults.Temperature
|
temperature = *defaults.Temperature
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var thinkingLevelStr string
|
||||||
|
if mc, err := cfg.GetModelConfig(model); err == nil {
|
||||||
|
thinkingLevelStr = mc.ThinkingLevel
|
||||||
|
}
|
||||||
|
thinkingLevel := parseThinkingLevel(thinkingLevelStr)
|
||||||
|
|
||||||
|
summarizeMessageThreshold := defaults.SummarizeMessageThreshold
|
||||||
|
if summarizeMessageThreshold == 0 {
|
||||||
|
summarizeMessageThreshold = 20
|
||||||
|
}
|
||||||
|
|
||||||
|
summarizeTokenPercent := defaults.SummarizeTokenPercent
|
||||||
|
if summarizeTokenPercent == 0 {
|
||||||
|
summarizeTokenPercent = 75
|
||||||
|
}
|
||||||
|
|
||||||
// Resolve fallback candidates
|
// Resolve fallback candidates
|
||||||
modelCfg := providers.ModelConfig{
|
modelCfg := providers.ModelConfig{
|
||||||
Primary: model,
|
Primary: model,
|
||||||
|
|
||||||
Fallbacks: fallbacks,
|
Fallbacks: fallbacks,
|
||||||
}
|
}
|
||||||
resolveFromModelList := func(raw string) (string, bool) {
|
resolveFromModelList := func(raw string) (string, bool) {
|
||||||
|
|
@ -239,71 +248,69 @@ func NewAgentInstance(
|
||||||
candidates := providers.ResolveCandidatesWithLookup(modelCfg, defaults.Provider, resolveFromModelList)
|
candidates := providers.ResolveCandidatesWithLookup(modelCfg, defaults.Provider, resolveFromModelList)
|
||||||
|
|
||||||
// Resolve plan model (for interviewing/review phases)
|
// Resolve plan model (for interviewing/review phases)
|
||||||
|
|
||||||
planModel := resolvePlanModel(agentCfg, defaults)
|
planModel := resolvePlanModel(agentCfg, defaults)
|
||||||
|
|
||||||
planFallbacks := resolvePlanFallbacks(agentCfg, defaults)
|
planFallbacks := resolvePlanFallbacks(agentCfg, defaults)
|
||||||
|
|
||||||
var planCandidates []providers.FallbackCandidate
|
var planCandidates []providers.FallbackCandidate
|
||||||
|
|
||||||
if planModel != "" {
|
if planModel != "" {
|
||||||
planModelCfg := providers.ModelConfig{
|
planModelCfg := providers.ModelConfig{
|
||||||
Primary: planModel,
|
Primary: planModel,
|
||||||
|
|
||||||
Fallbacks: planFallbacks,
|
Fallbacks: planFallbacks,
|
||||||
}
|
}
|
||||||
|
|
||||||
planCandidates = providers.ResolveCandidates(planModelCfg, defaults.Provider)
|
planCandidates = providers.ResolveCandidates(planModelCfg, defaults.Provider)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Model routing setup: pre-resolve light model candidates at creation time
|
||||||
|
// to avoid repeated model_list lookups on every incoming message.
|
||||||
|
var router *routing.Router
|
||||||
|
var lightCandidates []providers.FallbackCandidate
|
||||||
|
if rc := defaults.Routing; rc != nil && rc.Enabled && rc.LightModel != "" {
|
||||||
|
lightModelCfg := providers.ModelConfig{Primary: rc.LightModel}
|
||||||
|
resolved := providers.ResolveCandidatesWithLookup(lightModelCfg, defaults.Provider, resolveFromModelList)
|
||||||
|
if len(resolved) > 0 {
|
||||||
|
router = routing.New(routing.RouterConfig{
|
||||||
|
LightModel: rc.LightModel,
|
||||||
|
Threshold: rc.Threshold,
|
||||||
|
})
|
||||||
|
lightCandidates = resolved
|
||||||
|
} else {
|
||||||
|
log.Printf("routing: light_model %q not found in model_list — routing disabled for agent %q",
|
||||||
|
rc.LightModel, agentID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Startup cleanup: prune orphaned worktrees
|
// Startup cleanup: prune orphaned worktrees
|
||||||
|
|
||||||
worktreesDir := filepath.Join(workspace, ".worktrees")
|
worktreesDir := filepath.Join(workspace, ".worktrees")
|
||||||
|
|
||||||
if repoRoot := git.FindRepoRoot(workspace); repoRoot != "" {
|
if repoRoot := git.FindRepoRoot(workspace); repoRoot != "" {
|
||||||
git.PruneOrphaned(repoRoot, worktreesDir)
|
git.PruneOrphaned(repoRoot, worktreesDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &AgentInstance{
|
return &AgentInstance{
|
||||||
ID: agentID,
|
ID: agentID,
|
||||||
|
|
||||||
Name: agentName,
|
Name: agentName,
|
||||||
|
|
||||||
Model: model,
|
Model: model,
|
||||||
|
|
||||||
Fallbacks: fallbacks,
|
Fallbacks: fallbacks,
|
||||||
|
|
||||||
Workspace: workspace,
|
Workspace: workspace,
|
||||||
|
|
||||||
MaxIterations: maxIter,
|
MaxIterations: maxIter,
|
||||||
|
|
||||||
TaskReminderInterval: reminderInterval,
|
TaskReminderInterval: reminderInterval,
|
||||||
|
|
||||||
MaxTokens: maxTokens,
|
MaxTokens: maxTokens,
|
||||||
|
|
||||||
Temperature: temperature,
|
Temperature: temperature,
|
||||||
|
ThinkingLevel: thinkingLevel,
|
||||||
ContextWindow: maxTokens,
|
ContextWindow: maxTokens,
|
||||||
|
SummarizeMessageThreshold: summarizeMessageThreshold,
|
||||||
|
SummarizeTokenPercent: summarizeTokenPercent,
|
||||||
Provider: provider,
|
Provider: provider,
|
||||||
|
|
||||||
Sessions: sessionsManager,
|
Sessions: sessionsManager,
|
||||||
|
|
||||||
ContextBuilder: contextBuilder,
|
ContextBuilder: contextBuilder,
|
||||||
|
|
||||||
Tools: toolsRegistry,
|
Tools: toolsRegistry,
|
||||||
|
|
||||||
Subagents: subagents,
|
Subagents: subagents,
|
||||||
|
|
||||||
SkillsFilter: skillsFilter,
|
SkillsFilter: skillsFilter,
|
||||||
|
|
||||||
Candidates: candidates,
|
Candidates: candidates,
|
||||||
|
|
||||||
PlanModel: planModel,
|
PlanModel: planModel,
|
||||||
|
|
||||||
PlanFallbacks: planFallbacks,
|
PlanFallbacks: planFallbacks,
|
||||||
|
|
||||||
PlanCandidates: planCandidates,
|
PlanCandidates: planCandidates,
|
||||||
|
Router: router,
|
||||||
|
LightCandidates: lightCandidates,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -318,9 +325,7 @@ func resolveAgentWorkspace(agentCfg *config.AgentConfig, defaults *config.AgentD
|
||||||
}
|
}
|
||||||
|
|
||||||
home, _ := os.UserHomeDir()
|
home, _ := os.UserHomeDir()
|
||||||
|
|
||||||
id := routing.NormalizeAgentID(agentCfg.ID)
|
id := routing.NormalizeAgentID(agentCfg.ID)
|
||||||
|
|
||||||
return filepath.Join(home, ".picoclaw", "workspace-"+id)
|
return filepath.Join(home, ".picoclaw", "workspace-"+id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -341,48 +346,37 @@ func resolveAgentFallbacks(agentCfg *config.AgentConfig, defaults *config.AgentD
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolvePlanModel resolves the plan model for an agent (used during interviewing/review phases).
|
// resolvePlanModel resolves the plan model for an agent (used during interviewing/review phases).
|
||||||
|
|
||||||
func resolvePlanModel(agentCfg *config.AgentConfig, defaults *config.AgentDefaults) string {
|
func resolvePlanModel(agentCfg *config.AgentConfig, defaults *config.AgentDefaults) string {
|
||||||
if agentCfg != nil && agentCfg.PlanModel != nil && strings.TrimSpace(agentCfg.PlanModel.Primary) != "" {
|
if agentCfg != nil && agentCfg.PlanModel != nil && strings.TrimSpace(agentCfg.PlanModel.Primary) != "" {
|
||||||
return strings.TrimSpace(agentCfg.PlanModel.Primary)
|
return strings.TrimSpace(agentCfg.PlanModel.Primary)
|
||||||
}
|
}
|
||||||
|
|
||||||
return defaults.PlanModel
|
return defaults.PlanModel
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolvePlanFallbacks resolves the plan model fallbacks for an agent.
|
// resolvePlanFallbacks resolves the plan model fallbacks for an agent.
|
||||||
|
|
||||||
func resolvePlanFallbacks(agentCfg *config.AgentConfig, defaults *config.AgentDefaults) []string {
|
func resolvePlanFallbacks(agentCfg *config.AgentConfig, defaults *config.AgentDefaults) []string {
|
||||||
if agentCfg != nil && agentCfg.PlanModel != nil && agentCfg.PlanModel.Fallbacks != nil {
|
if agentCfg != nil && agentCfg.PlanModel != nil && agentCfg.PlanModel.Fallbacks != nil {
|
||||||
return agentCfg.PlanModel.Fallbacks
|
return agentCfg.PlanModel.Fallbacks
|
||||||
}
|
}
|
||||||
|
|
||||||
return defaults.PlanModelFallbacks
|
return defaults.PlanModelFallbacks
|
||||||
}
|
}
|
||||||
|
|
||||||
// ActivateWorktree creates a worktree for a session.
|
// ActivateWorktree creates a worktree for a session.
|
||||||
|
|
||||||
// projectDir is the git repository to create the worktree in.
|
// projectDir is the git repository to create the worktree in.
|
||||||
|
|
||||||
// If empty, falls back to ai.Workspace.
|
// If empty, falls back to ai.Workspace.
|
||||||
|
|
||||||
// Worktree path: <workspace>/.worktrees/<branch-basename>/
|
// Worktree path: <workspace>/.worktrees/<branch-basename>/
|
||||||
|
|
||||||
func (ai *AgentInstance) ActivateWorktree(sessionKey, taskName, projectDir string) (*git.WorktreeInfo, error) {
|
func (ai *AgentInstance) ActivateWorktree(sessionKey, taskName, projectDir string) (*git.WorktreeInfo, error) {
|
||||||
if projectDir == "" {
|
if projectDir == "" {
|
||||||
projectDir = ai.Workspace
|
projectDir = ai.Workspace
|
||||||
}
|
}
|
||||||
|
|
||||||
repoRoot := git.FindRepoRoot(projectDir)
|
repoRoot := git.FindRepoRoot(projectDir)
|
||||||
|
|
||||||
if repoRoot == "" {
|
if repoRoot == "" {
|
||||||
return nil, fmt.Errorf("directory is not a git repository: %s", projectDir)
|
return nil, fmt.Errorf("directory is not a git repository: %s", projectDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
branchName := git.SanitizeBranchName(taskName)
|
branchName := git.SanitizeBranchName(taskName)
|
||||||
|
|
||||||
baseName := git.BranchBaseName(branchName)
|
baseName := git.BranchBaseName(branchName)
|
||||||
|
|
||||||
wtPath := filepath.Join(ai.Workspace, ".worktrees", baseName)
|
wtPath := filepath.Join(ai.Workspace, ".worktrees", baseName)
|
||||||
|
|
||||||
wt, err := git.CreateWorktree(repoRoot, wtPath, branchName)
|
wt, err := git.CreateWorktree(repoRoot, wtPath, branchName)
|
||||||
|
|
@ -391,29 +385,22 @@ func (ai *AgentInstance) ActivateWorktree(sessionKey, taskName, projectDir strin
|
||||||
}
|
}
|
||||||
|
|
||||||
ai.worktreeMu.Lock()
|
ai.worktreeMu.Lock()
|
||||||
|
|
||||||
if ai.worktrees == nil {
|
if ai.worktrees == nil {
|
||||||
ai.worktrees = make(map[string]*git.WorktreeInfo)
|
ai.worktrees = make(map[string]*git.WorktreeInfo)
|
||||||
}
|
}
|
||||||
|
|
||||||
ai.worktrees[sessionKey] = wt
|
ai.worktrees[sessionKey] = wt
|
||||||
|
|
||||||
ai.worktreeMu.Unlock()
|
ai.worktreeMu.Unlock()
|
||||||
|
|
||||||
return wt, nil
|
return wt, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeactivateWorktree safe-disposes the session's worktree.
|
// DeactivateWorktree safe-disposes the session's worktree.
|
||||||
|
|
||||||
func (ai *AgentInstance) DeactivateWorktree(sessionKey, commitMsg string, discard bool) (*git.DisposeResult, error) {
|
func (ai *AgentInstance) DeactivateWorktree(sessionKey, commitMsg string, discard bool) (*git.DisposeResult, error) {
|
||||||
ai.worktreeMu.Lock()
|
ai.worktreeMu.Lock()
|
||||||
|
|
||||||
wt, ok := ai.worktrees[sessionKey]
|
wt, ok := ai.worktrees[sessionKey]
|
||||||
|
|
||||||
if ok {
|
if ok {
|
||||||
delete(ai.worktrees, sessionKey)
|
delete(ai.worktrees, sessionKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
ai.worktreeMu.Unlock()
|
ai.worktreeMu.Unlock()
|
||||||
|
|
||||||
if !ok || wt == nil {
|
if !ok || wt == nil {
|
||||||
|
|
@ -421,58 +408,60 @@ func (ai *AgentInstance) DeactivateWorktree(sessionKey, commitMsg string, discar
|
||||||
}
|
}
|
||||||
|
|
||||||
repoRoot := git.FindRepoRoot(ai.Workspace)
|
repoRoot := git.FindRepoRoot(ai.Workspace)
|
||||||
|
|
||||||
if repoRoot == "" {
|
if repoRoot == "" {
|
||||||
return nil, fmt.Errorf("workspace is not a git repository")
|
return nil, fmt.Errorf("workspace is not a git repository")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Even on discard, SafeDispose auto-commits first for safety
|
// Even on discard, SafeDispose auto-commits first for safety
|
||||||
|
|
||||||
if commitMsg != "" && git.HasUncommittedChanges(wt.Path) {
|
if commitMsg != "" && git.HasUncommittedChanges(wt.Path) {
|
||||||
_ = git.AutoCommit(wt.Path, commitMsg)
|
_ = git.AutoCommit(wt.Path, commitMsg)
|
||||||
}
|
}
|
||||||
|
|
||||||
result := git.SafeDispose(repoRoot, wt)
|
result := git.SafeDispose(repoRoot, wt)
|
||||||
|
|
||||||
return &result, nil
|
return &result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetWorktree returns the session's active worktree, or nil.
|
// GetWorktree returns the session's active worktree, or nil.
|
||||||
|
|
||||||
func (ai *AgentInstance) GetWorktree(sessionKey string) *git.WorktreeInfo {
|
func (ai *AgentInstance) GetWorktree(sessionKey string) *git.WorktreeInfo {
|
||||||
ai.worktreeMu.RLock()
|
ai.worktreeMu.RLock()
|
||||||
|
|
||||||
defer ai.worktreeMu.RUnlock()
|
defer ai.worktreeMu.RUnlock()
|
||||||
|
|
||||||
return ai.worktrees[sessionKey]
|
return ai.worktrees[sessionKey]
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsInWorktree returns true if the session has an active worktree.
|
// IsInWorktree returns true if the session has an active worktree.
|
||||||
|
|
||||||
func (ai *AgentInstance) IsInWorktree(sessionKey string) bool {
|
func (ai *AgentInstance) IsInWorktree(sessionKey string) bool {
|
||||||
return ai.GetWorktree(sessionKey) != nil
|
return ai.GetWorktree(sessionKey) != nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// EffectiveWorkspace returns worktree path for session, or original Workspace.
|
// EffectiveWorkspace returns worktree path for session, or original Workspace.
|
||||||
|
|
||||||
func (ai *AgentInstance) EffectiveWorkspace(sessionKey string) string {
|
func (ai *AgentInstance) EffectiveWorkspace(sessionKey string) string {
|
||||||
if wt := ai.GetWorktree(sessionKey); wt != nil {
|
if wt := ai.GetWorktree(sessionKey); wt != nil {
|
||||||
return wt.Path
|
return wt.Path
|
||||||
}
|
}
|
||||||
|
|
||||||
return ai.Workspace
|
return ai.Workspace
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetWorktreeBranch returns the branch name for the session's worktree, or "".
|
// GetWorktreeBranch returns the branch name for the session's worktree, or "".
|
||||||
|
|
||||||
func (ai *AgentInstance) GetWorktreeBranch(sessionKey string) string {
|
func (ai *AgentInstance) GetWorktreeBranch(sessionKey string) string {
|
||||||
if wt := ai.GetWorktree(sessionKey); wt != nil {
|
if wt := ai.GetWorktree(sessionKey); wt != nil {
|
||||||
return wt.Branch
|
return wt.Branch
|
||||||
}
|
}
|
||||||
|
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func compilePatterns(patterns []string) []*regexp.Regexp {
|
||||||
|
compiled := make([]*regexp.Regexp, 0, len(patterns))
|
||||||
|
for _, p := range patterns {
|
||||||
|
re, err := regexp.Compile(p)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Warning: invalid path pattern %q: %v\n", p, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
compiled = append(compiled, re)
|
||||||
|
}
|
||||||
|
return compiled
|
||||||
|
}
|
||||||
|
|
||||||
func expandHome(path string) string {
|
func expandHome(path string) string {
|
||||||
if path == "" {
|
if path == "" {
|
||||||
return path
|
return path
|
||||||
|
|
|
||||||
|
|
@ -12,13 +12,16 @@ import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/bus"
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
"github.com/sipeed/picoclaw/pkg/channels"
|
"github.com/sipeed/picoclaw/pkg/channels"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/commands"
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
"github.com/sipeed/picoclaw/pkg/constants"
|
"github.com/sipeed/picoclaw/pkg/constants"
|
||||||
"github.com/sipeed/picoclaw/pkg/git"
|
"github.com/sipeed/picoclaw/pkg/git"
|
||||||
|
|
@ -33,6 +36,7 @@ import (
|
||||||
"github.com/sipeed/picoclaw/pkg/stats"
|
"github.com/sipeed/picoclaw/pkg/stats"
|
||||||
"github.com/sipeed/picoclaw/pkg/tools"
|
"github.com/sipeed/picoclaw/pkg/tools"
|
||||||
"github.com/sipeed/picoclaw/pkg/utils"
|
"github.com/sipeed/picoclaw/pkg/utils"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/voice"
|
||||||
)
|
)
|
||||||
|
|
||||||
type AgentLoop struct {
|
type AgentLoop struct {
|
||||||
|
|
@ -56,6 +60,12 @@ type AgentLoop struct {
|
||||||
|
|
||||||
mediaStore media.MediaStore
|
mediaStore media.MediaStore
|
||||||
|
|
||||||
|
transcriber voice.Transcriber
|
||||||
|
|
||||||
|
cmdRegistry *commands.Registry
|
||||||
|
|
||||||
|
mcp mcpRuntime
|
||||||
|
|
||||||
providerCache map[string]providers.LLMProvider
|
providerCache map[string]providers.LLMProvider
|
||||||
|
|
||||||
planStartPending bool // set by /plan start to trigger LLM execution
|
planStartPending bool // set by /plan start to trigger LLM execution
|
||||||
|
|
@ -97,6 +107,8 @@ type processOptions struct {
|
||||||
|
|
||||||
UserMessage string // User message content (may include prefix)
|
UserMessage string // User message content (may include prefix)
|
||||||
|
|
||||||
|
Media []string // media:// refs from inbound message
|
||||||
|
|
||||||
HistoryMessage string // If set, save this to history instead of UserMessage (for skill compaction)
|
HistoryMessage string // If set, save this to history instead of UserMessage (for skill compaction)
|
||||||
|
|
||||||
DefaultResponse string // Response when LLM returns empty
|
DefaultResponse string // Response when LLM returns empty
|
||||||
|
|
@ -114,7 +126,15 @@ type processOptions struct {
|
||||||
SystemMessage bool // If true, this is a system message (subagent result) — skip placeholder and plan nudge
|
SystemMessage bool // If true, this is a system message (subagent result) — skip placeholder and plan nudge
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultResponse = "I've completed processing but have no response to give. Increase `max_tool_iterations` in config.json."
|
const (
|
||||||
|
defaultResponse = "I've completed processing but have no response to give. Increase `max_tool_iterations` in config.json."
|
||||||
|
sessionKeyAgentPrefix = "agent:"
|
||||||
|
metadataKeyAccountID = "account_id"
|
||||||
|
metadataKeyGuildID = "guild_id"
|
||||||
|
metadataKeyTeamID = "team_id"
|
||||||
|
metadataKeyParentPeerKind = "parent_peer_kind"
|
||||||
|
metadataKeyParentPeerID = "parent_peer_id"
|
||||||
|
)
|
||||||
|
|
||||||
func NewAgentLoop(
|
func NewAgentLoop(
|
||||||
cfg *config.Config,
|
cfg *config.Config,
|
||||||
|
|
@ -190,6 +210,8 @@ func NewAgentLoop(
|
||||||
orchReporter: orchReporter,
|
orchReporter: orchReporter,
|
||||||
|
|
||||||
done: make(chan struct{}),
|
done: make(chan struct{}),
|
||||||
|
|
||||||
|
cmdRegistry: commands.NewRegistry(commands.BuiltinDefinitions()),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register shared tools to all agents (needs al for reporter injection).
|
// Register shared tools to all agents (needs al for reporter injection).
|
||||||
|
|
@ -227,39 +249,36 @@ func registerSharedTools(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Web tools
|
// Web tools
|
||||||
|
if cfg.Tools.IsToolEnabled("web") {
|
||||||
searchTool, err := tools.NewWebSearchTool(tools.WebSearchToolOptions{
|
searchTool, err := tools.NewWebSearchTool(tools.WebSearchToolOptions{
|
||||||
BraveAPIKey: cfg.Tools.Web.Brave.APIKey,
|
BraveAPIKeys: config.MergeAPIKeys(cfg.Tools.Web.Brave.APIKey, cfg.Tools.Web.Brave.APIKeys),
|
||||||
|
|
||||||
BraveMaxResults: cfg.Tools.Web.Brave.MaxResults,
|
BraveMaxResults: cfg.Tools.Web.Brave.MaxResults,
|
||||||
|
|
||||||
BraveEnabled: cfg.Tools.Web.Brave.Enabled,
|
BraveEnabled: cfg.Tools.Web.Brave.Enabled,
|
||||||
|
TavilyAPIKeys: config.MergeAPIKeys(cfg.Tools.Web.Tavily.APIKey, cfg.Tools.Web.Tavily.APIKeys),
|
||||||
TavilyAPIKey: cfg.Tools.Web.Tavily.APIKey,
|
|
||||||
|
|
||||||
TavilyBaseURL: cfg.Tools.Web.Tavily.BaseURL,
|
TavilyBaseURL: cfg.Tools.Web.Tavily.BaseURL,
|
||||||
|
|
||||||
TavilyMaxResults: cfg.Tools.Web.Tavily.MaxResults,
|
TavilyMaxResults: cfg.Tools.Web.Tavily.MaxResults,
|
||||||
|
|
||||||
TavilyEnabled: cfg.Tools.Web.Tavily.Enabled,
|
TavilyEnabled: cfg.Tools.Web.Tavily.Enabled,
|
||||||
|
|
||||||
DuckDuckGoMaxResults: cfg.Tools.Web.DuckDuckGo.MaxResults,
|
DuckDuckGoMaxResults: cfg.Tools.Web.DuckDuckGo.MaxResults,
|
||||||
|
|
||||||
DuckDuckGoEnabled: cfg.Tools.Web.DuckDuckGo.Enabled,
|
DuckDuckGoEnabled: cfg.Tools.Web.DuckDuckGo.Enabled,
|
||||||
|
PerplexityAPIKeys: config.MergeAPIKeys(
|
||||||
PerplexityAPIKey: cfg.Tools.Web.Perplexity.APIKey,
|
cfg.Tools.Web.Perplexity.APIKey,
|
||||||
|
cfg.Tools.Web.Perplexity.APIKeys,
|
||||||
|
),
|
||||||
PerplexityMaxResults: cfg.Tools.Web.Perplexity.MaxResults,
|
PerplexityMaxResults: cfg.Tools.Web.Perplexity.MaxResults,
|
||||||
|
|
||||||
PerplexityEnabled: cfg.Tools.Web.Perplexity.Enabled,
|
PerplexityEnabled: cfg.Tools.Web.Perplexity.Enabled,
|
||||||
|
SearXNGBaseURL: cfg.Tools.Web.SearXNG.BaseURL,
|
||||||
|
SearXNGMaxResults: cfg.Tools.Web.SearXNG.MaxResults,
|
||||||
|
SearXNGEnabled: cfg.Tools.Web.SearXNG.Enabled,
|
||||||
|
GLMSearchAPIKey: cfg.Tools.Web.GLMSearch.APIKey,
|
||||||
|
GLMSearchBaseURL: cfg.Tools.Web.GLMSearch.BaseURL,
|
||||||
|
GLMSearchEngine: cfg.Tools.Web.GLMSearch.SearchEngine,
|
||||||
|
GLMSearchMaxResults: cfg.Tools.Web.GLMSearch.MaxResults,
|
||||||
|
GLMSearchEnabled: cfg.Tools.Web.GLMSearch.Enabled,
|
||||||
Proxy: cfg.Tools.Web.Proxy,
|
Proxy: cfg.Tools.Web.Proxy,
|
||||||
})
|
})
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.ErrorCF("agent", "Failed to create web search tool", map[string]any{
|
logger.ErrorCF("agent", "Failed to create web search tool", map[string]any{
|
||||||
"agent_id": agentID,
|
"agent_id": agentID,
|
||||||
|
|
||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
})
|
})
|
||||||
} else if searchTool != nil {
|
} else if searchTool != nil {
|
||||||
|
|
@ -267,7 +286,6 @@ func registerSharedTools(
|
||||||
|
|
||||||
logger.InfoCF("agent", "Web search provider registered", map[string]any{
|
logger.InfoCF("agent", "Web search provider registered", map[string]any{
|
||||||
"agent_id": agentID,
|
"agent_id": agentID,
|
||||||
|
|
||||||
"provider": searchTool.ProviderName(),
|
"provider": searchTool.ProviderName(),
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -275,27 +293,30 @@ func registerSharedTools(
|
||||||
"agent_id": agentID,
|
"agent_id": agentID,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fetchTool, err := tools.NewWebFetchToolWithProxy(50000, cfg.Tools.Web.Proxy)
|
if cfg.Tools.IsToolEnabled("web_fetch") {
|
||||||
|
fetchTool, err := tools.NewWebFetchToolWithProxy(50000, cfg.Tools.Web.Proxy, cfg.Tools.Web.FetchLimitBytes)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{
|
logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{
|
||||||
"agent_id": agentID,
|
"agent_id": agentID,
|
||||||
|
|
||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
agent.Tools.Register(fetchTool)
|
agent.Tools.Register(fetchTool)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Hardware tools (I2C, SPI) - Linux only, returns error on other platforms
|
// Hardware tools (I2C, SPI) - Linux only, returns error on other platforms
|
||||||
|
if cfg.Tools.IsToolEnabled("i2c") {
|
||||||
agent.Tools.Register(tools.NewI2CTool())
|
agent.Tools.Register(tools.NewI2CTool())
|
||||||
|
}
|
||||||
|
if cfg.Tools.IsToolEnabled("spi") {
|
||||||
agent.Tools.Register(tools.NewSPITool())
|
agent.Tools.Register(tools.NewSPITool())
|
||||||
|
}
|
||||||
|
|
||||||
// Message tool
|
// Message tool
|
||||||
|
if cfg.Tools.IsToolEnabled("message") {
|
||||||
messageTool := tools.NewMessageTool()
|
messageTool := tools.NewMessageTool()
|
||||||
|
|
||||||
messageTool.SetSendCallback(func(channel, chatID, content string) error {
|
messageTool.SetSendCallback(func(channel, chatID, content string) error {
|
||||||
|
|
@ -313,52 +334,60 @@ func registerSharedTools(
|
||||||
})
|
})
|
||||||
|
|
||||||
agent.Tools.Register(messageTool)
|
agent.Tools.Register(messageTool)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send file tool (outbound media via MediaStore — store injected later by SetMediaStore)
|
||||||
|
if cfg.Tools.IsToolEnabled("send_file") {
|
||||||
|
sendFileTool := tools.NewSendFileTool(
|
||||||
|
agent.Workspace,
|
||||||
|
cfg.Agents.Defaults.RestrictToWorkspace,
|
||||||
|
cfg.Agents.Defaults.GetMaxMediaSize(),
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
agent.Tools.Register(sendFileTool)
|
||||||
|
}
|
||||||
|
|
||||||
// Skill discovery and installation tools
|
// Skill discovery and installation tools
|
||||||
|
skills_enabled := cfg.Tools.IsToolEnabled("skills")
|
||||||
|
find_skills_enable := cfg.Tools.IsToolEnabled("find_skills")
|
||||||
|
install_skills_enable := cfg.Tools.IsToolEnabled("install_skill")
|
||||||
|
if skills_enabled && (find_skills_enable || install_skills_enable) {
|
||||||
registryMgr := skills.NewRegistryManagerFromConfig(skills.RegistryConfig{
|
registryMgr := skills.NewRegistryManagerFromConfig(skills.RegistryConfig{
|
||||||
MaxConcurrentSearches: cfg.Tools.Skills.MaxConcurrentSearches,
|
MaxConcurrentSearches: cfg.Tools.Skills.MaxConcurrentSearches,
|
||||||
|
|
||||||
ClawHub: skills.ClawHubConfig(cfg.Tools.Skills.Registries.ClawHub),
|
ClawHub: skills.ClawHubConfig(cfg.Tools.Skills.Registries.ClawHub),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if find_skills_enable {
|
||||||
searchCache := skills.NewSearchCache(
|
searchCache := skills.NewSearchCache(
|
||||||
|
|
||||||
cfg.Tools.Skills.SearchCache.MaxSize,
|
cfg.Tools.Skills.SearchCache.MaxSize,
|
||||||
|
|
||||||
time.Duration(cfg.Tools.Skills.SearchCache.TTLSeconds)*time.Second,
|
time.Duration(cfg.Tools.Skills.SearchCache.TTLSeconds)*time.Second,
|
||||||
)
|
)
|
||||||
|
|
||||||
agent.Tools.Register(tools.NewFindSkillsTool(registryMgr, searchCache))
|
agent.Tools.Register(tools.NewFindSkillsTool(registryMgr, searchCache))
|
||||||
|
}
|
||||||
|
|
||||||
|
if install_skills_enable {
|
||||||
agent.Tools.Register(tools.NewInstallSkillTool(registryMgr, agent.Workspace))
|
agent.Tools.Register(tools.NewInstallSkillTool(registryMgr, agent.Workspace))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Spawn tool — only registered when orchestration is explicitly enabled.
|
// Spawn tool — only registered when orchestration is explicitly enabled.
|
||||||
|
|
||||||
if agent.Subagents != nil && agent.Subagents.Enabled {
|
if agent.Subagents != nil && agent.Subagents.Enabled {
|
||||||
webSearchOpts := tools.WebSearchToolOptions{
|
webSearchOpts := tools.WebSearchToolOptions{
|
||||||
BraveAPIKey: cfg.Tools.Web.Brave.APIKey,
|
BraveAPIKeys: config.MergeAPIKeys(cfg.Tools.Web.Brave.APIKey, cfg.Tools.Web.Brave.APIKeys),
|
||||||
|
|
||||||
BraveMaxResults: cfg.Tools.Web.Brave.MaxResults,
|
BraveMaxResults: cfg.Tools.Web.Brave.MaxResults,
|
||||||
|
|
||||||
BraveEnabled: cfg.Tools.Web.Brave.Enabled,
|
BraveEnabled: cfg.Tools.Web.Brave.Enabled,
|
||||||
|
TavilyAPIKeys: config.MergeAPIKeys(cfg.Tools.Web.Tavily.APIKey, cfg.Tools.Web.Tavily.APIKeys),
|
||||||
TavilyAPIKey: cfg.Tools.Web.Tavily.APIKey,
|
|
||||||
|
|
||||||
TavilyBaseURL: cfg.Tools.Web.Tavily.BaseURL,
|
TavilyBaseURL: cfg.Tools.Web.Tavily.BaseURL,
|
||||||
|
|
||||||
TavilyMaxResults: cfg.Tools.Web.Tavily.MaxResults,
|
TavilyMaxResults: cfg.Tools.Web.Tavily.MaxResults,
|
||||||
|
|
||||||
TavilyEnabled: cfg.Tools.Web.Tavily.Enabled,
|
TavilyEnabled: cfg.Tools.Web.Tavily.Enabled,
|
||||||
|
|
||||||
DuckDuckGoMaxResults: cfg.Tools.Web.DuckDuckGo.MaxResults,
|
DuckDuckGoMaxResults: cfg.Tools.Web.DuckDuckGo.MaxResults,
|
||||||
|
|
||||||
DuckDuckGoEnabled: cfg.Tools.Web.DuckDuckGo.Enabled,
|
DuckDuckGoEnabled: cfg.Tools.Web.DuckDuckGo.Enabled,
|
||||||
|
PerplexityAPIKeys: config.MergeAPIKeys(
|
||||||
PerplexityAPIKey: cfg.Tools.Web.Perplexity.APIKey,
|
cfg.Tools.Web.Perplexity.APIKey,
|
||||||
|
cfg.Tools.Web.Perplexity.APIKeys,
|
||||||
|
),
|
||||||
PerplexityMaxResults: cfg.Tools.Web.Perplexity.MaxResults,
|
PerplexityMaxResults: cfg.Tools.Web.Perplexity.MaxResults,
|
||||||
|
|
||||||
PerplexityEnabled: cfg.Tools.Web.Perplexity.Enabled,
|
PerplexityEnabled: cfg.Tools.Web.Perplexity.Enabled,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -427,6 +456,10 @@ func registerSharedTools(
|
||||||
func (al *AgentLoop) Run(ctx context.Context) error {
|
func (al *AgentLoop) Run(ctx context.Context) error {
|
||||||
al.running.Store(true)
|
al.running.Store(true)
|
||||||
|
|
||||||
|
if err := al.ensureMCPInitialized(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// LLM work is dispatched to a background worker so the main loop
|
// LLM work is dispatched to a background worker so the main loop
|
||||||
|
|
||||||
// stays free to handle slash commands (/skills, …) instantly,
|
// stays free to handle slash commands (/skills, …) instantly,
|
||||||
|
|
@ -634,6 +667,16 @@ func (al *AgentLoop) Close() {
|
||||||
close(al.done)
|
close(al.done)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
mcpManager := al.mcp.takeManager()
|
||||||
|
if mcpManager != nil {
|
||||||
|
if err := mcpManager.Close(); err != nil {
|
||||||
|
logger.ErrorCF("agent", "Failed to close MCP manager",
|
||||||
|
map[string]any{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if al.stats != nil {
|
if al.stats != nil {
|
||||||
al.stats.Close()
|
al.stats.Close()
|
||||||
}
|
}
|
||||||
|
|
@ -643,6 +686,8 @@ func (al *AgentLoop) Close() {
|
||||||
agent.Sessions.Close()
|
agent.Sessions.Close()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
al.registry.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (al *AgentLoop) RegisterTool(tool tools.Tool) {
|
func (al *AgentLoop) RegisterTool(tool tools.Tool) {
|
||||||
|
|
@ -716,6 +761,115 @@ func (al *AgentLoop) resolveProvider(
|
||||||
|
|
||||||
func (al *AgentLoop) SetMediaStore(s media.MediaStore) {
|
func (al *AgentLoop) SetMediaStore(s media.MediaStore) {
|
||||||
al.mediaStore = s
|
al.mediaStore = s
|
||||||
|
|
||||||
|
// Propagate store to send_file tools in all agents.
|
||||||
|
al.registry.ForEachTool("send_file", func(t tools.Tool) {
|
||||||
|
if sf, ok := t.(*tools.SendFileTool); ok {
|
||||||
|
sf.SetMediaStore(s)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetTranscriber injects a voice transcriber for agent-level audio transcription.
|
||||||
|
func (al *AgentLoop) SetTranscriber(t voice.Transcriber) {
|
||||||
|
al.transcriber = t
|
||||||
|
}
|
||||||
|
|
||||||
|
var audioAnnotationRe = regexp.MustCompile(`\[(voice|audio)(?::[^\]]*)?\]`)
|
||||||
|
|
||||||
|
// transcribeAudioInMessage resolves audio media refs, transcribes them, and
|
||||||
|
// replaces audio annotations in msg.Content with the transcribed text.
|
||||||
|
// Returns the (possibly modified) message and true if audio was transcribed.
|
||||||
|
func (al *AgentLoop) transcribeAudioInMessage(ctx context.Context, msg bus.InboundMessage) (bus.InboundMessage, bool) {
|
||||||
|
if al.transcriber == nil || al.mediaStore == nil || len(msg.Media) == 0 {
|
||||||
|
return msg, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transcribe each audio media ref in order.
|
||||||
|
var transcriptions []string
|
||||||
|
for _, ref := range msg.Media {
|
||||||
|
path, meta, err := al.mediaStore.ResolveWithMeta(ref)
|
||||||
|
if err != nil {
|
||||||
|
logger.WarnCF("voice", "Failed to resolve media ref", map[string]any{"ref": ref, "error": err})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !utils.IsAudioFile(meta.Filename, meta.ContentType) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result, err := al.transcriber.Transcribe(ctx, path)
|
||||||
|
if err != nil {
|
||||||
|
logger.WarnCF("voice", "Transcription failed", map[string]any{"ref": ref, "error": err})
|
||||||
|
transcriptions = append(transcriptions, "")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
transcriptions = append(transcriptions, result.Text)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(transcriptions) == 0 {
|
||||||
|
return msg, false
|
||||||
|
}
|
||||||
|
|
||||||
|
al.sendTranscriptionFeedback(ctx, msg.Channel, msg.ChatID, msg.MessageID, transcriptions)
|
||||||
|
|
||||||
|
// Replace audio annotations sequentially with transcriptions.
|
||||||
|
idx := 0
|
||||||
|
newContent := audioAnnotationRe.ReplaceAllStringFunc(msg.Content, func(match string) string {
|
||||||
|
if idx >= len(transcriptions) {
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
text := transcriptions[idx]
|
||||||
|
idx++
|
||||||
|
return "[voice: " + text + "]"
|
||||||
|
})
|
||||||
|
|
||||||
|
// Append any remaining transcriptions not matched by an annotation.
|
||||||
|
for ; idx < len(transcriptions); idx++ {
|
||||||
|
newContent += "\n[voice: " + transcriptions[idx] + "]"
|
||||||
|
}
|
||||||
|
|
||||||
|
msg.Content = newContent
|
||||||
|
return msg, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendTranscriptionFeedback sends feedback to the user with the result of
|
||||||
|
// audio transcription if the option is enabled. It uses Manager.SendMessage
|
||||||
|
// which executes synchronously (rate limiting, splitting, retry) so that
|
||||||
|
// ordering with the subsequent placeholder is guaranteed.
|
||||||
|
func (al *AgentLoop) sendTranscriptionFeedback(
|
||||||
|
ctx context.Context,
|
||||||
|
channel, chatID, messageID string,
|
||||||
|
validTexts []string,
|
||||||
|
) {
|
||||||
|
if !al.cfg.Voice.EchoTranscription {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if al.channelManager == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var nonEmpty []string
|
||||||
|
for _, t := range validTexts {
|
||||||
|
if t != "" {
|
||||||
|
nonEmpty = append(nonEmpty, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var feedbackMsg string
|
||||||
|
if len(nonEmpty) > 0 {
|
||||||
|
feedbackMsg = "Transcript: " + strings.Join(nonEmpty, "\n")
|
||||||
|
} else {
|
||||||
|
feedbackMsg = "No voice detected in the audio"
|
||||||
|
}
|
||||||
|
|
||||||
|
err := al.channelManager.SendMessage(ctx, bus.OutboundMessage{
|
||||||
|
Channel: channel,
|
||||||
|
ChatID: chatID,
|
||||||
|
Content: feedbackMsg,
|
||||||
|
ReplyToMessageID: messageID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
logger.WarnCF("voice", "Failed to send transcription feedback", map[string]any{"error": err.Error()})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// inferMediaType determines the media type ("image", "audio", "video", "file")
|
// inferMediaType determines the media type ("image", "audio", "video", "file")
|
||||||
|
|
@ -788,6 +942,10 @@ func (al *AgentLoop) ProcessDirectWithChannel(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
content, sessionKey, channel, chatID string,
|
content, sessionKey, channel, chatID string,
|
||||||
) (string, error) {
|
) (string, error) {
|
||||||
|
if err := al.ensureMCPInitialized(ctx); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
msg := bus.InboundMessage{
|
msg := bus.InboundMessage{
|
||||||
Channel: channel,
|
Channel: channel,
|
||||||
|
|
||||||
|
|
@ -867,6 +1025,16 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
|
||||||
"session_key": msg.SessionKey,
|
"session_key": msg.SessionKey,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Transcribe audio in the message if a transcriber is configured.
|
||||||
|
var hadAudio bool
|
||||||
|
msg, hadAudio = al.transcribeAudioInMessage(ctx, msg)
|
||||||
|
|
||||||
|
// For audio messages the placeholder was deferred by the channel.
|
||||||
|
// Now that transcription (and optional feedback) is done, send it.
|
||||||
|
if hadAudio && al.channelManager != nil {
|
||||||
|
al.channelManager.SendPlaceholder(ctx, msg.Channel, msg.ChatID)
|
||||||
|
}
|
||||||
|
|
||||||
// Handle reply-based intervention for active tasks
|
// Handle reply-based intervention for active tasks
|
||||||
|
|
||||||
if taskID, ok := msg.Metadata["task_id"]; ok && taskID != "" {
|
if taskID, ok := msg.Metadata["task_id"]; ok && taskID != "" {
|
||||||
|
|
@ -1022,6 +1190,8 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
|
||||||
|
|
||||||
UserMessage: msg.Content,
|
UserMessage: msg.Content,
|
||||||
|
|
||||||
|
Media: msg.Media,
|
||||||
|
|
||||||
HistoryMessage: expansionCompact,
|
HistoryMessage: expansionCompact,
|
||||||
|
|
||||||
DefaultResponse: defaultResponse,
|
DefaultResponse: defaultResponse,
|
||||||
|
|
@ -1473,12 +1643,16 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt
|
||||||
summary,
|
summary,
|
||||||
opts.UserMessage,
|
opts.UserMessage,
|
||||||
|
|
||||||
nil,
|
opts.Media,
|
||||||
|
|
||||||
opts.Channel,
|
opts.Channel,
|
||||||
opts.ChatID,
|
opts.ChatID,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Resolve media:// refs to base64 data URLs (streaming)
|
||||||
|
maxMediaSize := al.cfg.Agents.Defaults.GetMaxMediaSize()
|
||||||
|
messages = resolveMediaRefs(messages, al.mediaStore, maxMediaSize)
|
||||||
|
|
||||||
// 2b. Interview staleness nudge: if MEMORY.md hasn't been updated for
|
// 2b. Interview staleness nudge: if MEMORY.md hasn't been updated for
|
||||||
|
|
||||||
// several consecutive turns, inject a reminder so the AI writes its findings.
|
// several consecutive turns, inject a reminder so the AI writes its findings.
|
||||||
|
|
@ -1516,7 +1690,6 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt
|
||||||
sb.WriteString("MEMORY.md is the only shared state between heartbeats. ")
|
sb.WriteString("MEMORY.md is the only shared state between heartbeats. ")
|
||||||
|
|
||||||
sb.WriteString(
|
sb.WriteString(
|
||||||
|
|
||||||
"After completing each plan step, immediately use edit_file to mark it [x] in memory/MEMORY.md.",
|
"After completing each plan step, immediately use edit_file to mark it [x] in memory/MEMORY.md.",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -1574,7 +1747,7 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt
|
||||||
|
|
||||||
agent.Sessions.GetSummary(opts.SessionKey),
|
agent.Sessions.GetSummary(opts.SessionKey),
|
||||||
|
|
||||||
"", nil, opts.Channel, opts.ChatID,
|
"", opts.Media, opts.Channel, opts.ChatID,
|
||||||
)
|
)
|
||||||
|
|
||||||
messages = append(messages, providers.Message{
|
messages = append(messages, providers.Message{
|
||||||
|
|
@ -1825,6 +1998,53 @@ func (al *AgentLoop) targetReasoningChannelID(channelName string) (chatID string
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (al *AgentLoop) handleReasoning(
|
||||||
|
ctx context.Context,
|
||||||
|
reasoningContent, channelName, channelID string,
|
||||||
|
) {
|
||||||
|
if reasoningContent == "" || channelName == "" || channelID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check context cancellation before attempting to publish,
|
||||||
|
// since PublishOutbound's select may race between send and ctx.Done().
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use a short timeout so the goroutine does not block indefinitely when
|
||||||
|
// the outbound bus is full. Reasoning output is best-effort; dropping it
|
||||||
|
// is acceptable to avoid goroutine accumulation.
|
||||||
|
pubCtx, pubCancel := context.WithTimeout(ctx, 5*time.Second)
|
||||||
|
defer pubCancel()
|
||||||
|
|
||||||
|
if err := al.bus.PublishOutbound(pubCtx, bus.OutboundMessage{
|
||||||
|
Channel: channelName,
|
||||||
|
ChatID: channelID,
|
||||||
|
Content: reasoningContent,
|
||||||
|
}); err != nil {
|
||||||
|
// Treat context.DeadlineExceeded / context.Canceled as expected
|
||||||
|
// (bus full under load, or parent canceled). Check the error
|
||||||
|
// itself rather than ctx.Err(), because pubCtx may time out
|
||||||
|
// (5 s) while the parent ctx is still active.
|
||||||
|
// Also treat ErrBusClosed as expected — it occurs during normal
|
||||||
|
// shutdown when the bus is closed before all goroutines finish.
|
||||||
|
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) ||
|
||||||
|
errors.Is(err, bus.ErrBusClosed) {
|
||||||
|
logger.DebugCF("agent", "Reasoning publish skipped (timeout/cancel)", map[string]any{
|
||||||
|
"channel": channelName,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
logger.WarnCF("agent", "Failed to publish reasoning (best-effort)", map[string]any{
|
||||||
|
"channel": channelName,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// runLLMIteration executes the LLM call loop with tool handling using hooks.
|
||||||
func (al *AgentLoop) runLLMIteration(
|
func (al *AgentLoop) runLLMIteration(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
agent *AgentInstance,
|
agent *AgentInstance,
|
||||||
|
|
@ -2025,6 +2245,17 @@ func (al *AgentLoop) callLLMWithRetry(
|
||||||
"prompt_cache_key": agent.ID,
|
"prompt_cache_key": agent.ID,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// parseThinkingLevel guarantees ThinkingOff for empty/unknown values,
|
||||||
|
// so checking != ThinkingOff is sufficient.
|
||||||
|
if agent.ThinkingLevel != ThinkingOff {
|
||||||
|
if tc, ok := agent.Provider.(providers.ThinkingCapable); ok && tc.SupportsThinking() {
|
||||||
|
llmOpts["thinking_level"] = string(agent.ThinkingLevel)
|
||||||
|
} else {
|
||||||
|
logger.WarnCF("agent", "thinking_level is set but current provider does not support it, ignoring",
|
||||||
|
map[string]any{"agent_id": agent.ID, "thinking_level": string(agent.ThinkingLevel)})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
doCall := func(ctx context.Context, p providers.LLMProvider, model string) (*providers.LLMResponse, error) {
|
doCall := func(ctx context.Context, p providers.LLMProvider, model string) (*providers.LLMResponse, error) {
|
||||||
if sp, ok := p.(providers.StreamingProvider); ok && sp.CanStream() {
|
if sp, ok := p.(providers.StreamingProvider); ok && sp.CanStream() {
|
||||||
streamCtx, streamCancel := context.WithCancel(ctx)
|
streamCtx, streamCancel := context.WithCancel(ctx)
|
||||||
|
|
@ -2351,6 +2582,413 @@ func (al *AgentLoop) forceTextResponse(ctx context.Context, agent *AgentInstance
|
||||||
return content
|
return content
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// selectCandidates returns the model candidates and resolved model name to use
|
||||||
|
// for a conversation turn. When model routing is configured and the incoming
|
||||||
|
// message scores below the complexity threshold, it returns the light model
|
||||||
|
// candidates instead of the primary ones.
|
||||||
|
//
|
||||||
|
// The returned (candidates, model) pair is used for all LLM calls within one
|
||||||
|
// turn — tool follow-up iterations use the same tier as the initial call so
|
||||||
|
// that a multi-step tool chain doesn't switch models mid-way.
|
||||||
|
func (al *AgentLoop) selectCandidates(
|
||||||
|
agent *AgentInstance,
|
||||||
|
userMsg string,
|
||||||
|
history []providers.Message,
|
||||||
|
) (candidates []providers.FallbackCandidate, model string) {
|
||||||
|
if agent.Router == nil || len(agent.LightCandidates) == 0 {
|
||||||
|
return agent.Candidates, agent.Model
|
||||||
|
}
|
||||||
|
|
||||||
|
_, usedLight, score := agent.Router.SelectModel(userMsg, history, agent.Model)
|
||||||
|
if !usedLight {
|
||||||
|
logger.DebugCF("agent", "Model routing: primary model selected",
|
||||||
|
map[string]any{
|
||||||
|
"agent_id": agent.ID,
|
||||||
|
"score": score,
|
||||||
|
"threshold": agent.Router.Threshold(),
|
||||||
|
})
|
||||||
|
return agent.Candidates, agent.Model
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.InfoCF("agent", "Model routing: light model selected",
|
||||||
|
map[string]any{
|
||||||
|
"agent_id": agent.ID,
|
||||||
|
"light_model": agent.Router.LightModel(),
|
||||||
|
"score": score,
|
||||||
|
"threshold": agent.Router.Threshold(),
|
||||||
|
})
|
||||||
|
return agent.LightCandidates, agent.Router.LightModel()
|
||||||
|
}
|
||||||
|
|
||||||
|
// maybeSummarize triggers summarization if the session history exceeds thresholds.
|
||||||
|
func (al *AgentLoop) maybeSummarize(agent *AgentInstance, sessionKey, channel, chatID string) {
|
||||||
|
newHistory := agent.Sessions.GetHistory(sessionKey)
|
||||||
|
tokenEstimate := al.estimateTokens(newHistory)
|
||||||
|
threshold := agent.ContextWindow * agent.SummarizeTokenPercent / 100
|
||||||
|
|
||||||
|
if len(newHistory) > agent.SummarizeMessageThreshold || tokenEstimate > threshold {
|
||||||
|
summarizeKey := agent.ID + ":" + sessionKey
|
||||||
|
if _, loading := al.summarizing.LoadOrStore(summarizeKey, true); !loading {
|
||||||
|
go func() {
|
||||||
|
defer al.summarizing.Delete(summarizeKey)
|
||||||
|
logger.Debug("Memory threshold reached. Optimizing conversation history...")
|
||||||
|
al.summarizeSession(agent, sessionKey)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// forceCompression aggressively reduces context when the limit is hit.
|
||||||
|
// It drops the oldest 50% of messages (keeping system prompt and last user message).
|
||||||
|
func (al *AgentLoop) forceCompression(agent *AgentInstance, sessionKey string) {
|
||||||
|
history := agent.Sessions.GetHistory(sessionKey)
|
||||||
|
if len(history) <= 4 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep system prompt (usually [0]) and the very last message (user's trigger)
|
||||||
|
// We want to drop the oldest half of the *conversation*
|
||||||
|
// Assuming [0] is system, [1:] is conversation
|
||||||
|
conversation := history[1 : len(history)-1]
|
||||||
|
if len(conversation) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to find the mid-point of the conversation
|
||||||
|
mid := len(conversation) / 2
|
||||||
|
|
||||||
|
// New history structure:
|
||||||
|
// 1. System Prompt (with compression note appended)
|
||||||
|
// 2. Second half of conversation
|
||||||
|
// 3. Last message
|
||||||
|
|
||||||
|
droppedCount := mid
|
||||||
|
keptConversation := conversation[mid:]
|
||||||
|
|
||||||
|
newHistory := make([]providers.Message, 0, 1+len(keptConversation)+1)
|
||||||
|
|
||||||
|
// Append compression note to the original system prompt instead of adding a new system message
|
||||||
|
// This avoids having two consecutive system messages which some APIs (like Zhipu) reject
|
||||||
|
compressionNote := fmt.Sprintf(
|
||||||
|
"\n\n[System Note: Emergency compression dropped %d oldest messages due to context limit]",
|
||||||
|
droppedCount,
|
||||||
|
)
|
||||||
|
enhancedSystemPrompt := history[0]
|
||||||
|
enhancedSystemPrompt.Content = enhancedSystemPrompt.Content + compressionNote
|
||||||
|
newHistory = append(newHistory, enhancedSystemPrompt)
|
||||||
|
|
||||||
|
newHistory = append(newHistory, keptConversation...)
|
||||||
|
newHistory = append(newHistory, history[len(history)-1]) // Last message
|
||||||
|
|
||||||
|
// Update session
|
||||||
|
agent.Sessions.SetHistory(sessionKey, newHistory)
|
||||||
|
agent.Sessions.Save(sessionKey)
|
||||||
|
|
||||||
|
logger.WarnCF("agent", "Forced compression executed", map[string]any{
|
||||||
|
"session_key": sessionKey,
|
||||||
|
"dropped_msgs": droppedCount,
|
||||||
|
"new_count": len(newHistory),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetStartupInfo returns information about loaded tools and skills for logging.
|
||||||
|
func (al *AgentLoop) GetStartupInfo() map[string]any {
|
||||||
|
info := make(map[string]any)
|
||||||
|
|
||||||
|
agent := al.registry.GetDefaultAgent()
|
||||||
|
if agent == nil {
|
||||||
|
return info
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tools info
|
||||||
|
toolsList := agent.Tools.List()
|
||||||
|
info["tools"] = map[string]any{
|
||||||
|
"count": len(toolsList),
|
||||||
|
"names": toolsList,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skills info
|
||||||
|
info["skills"] = agent.ContextBuilder.GetSkillsInfo()
|
||||||
|
|
||||||
|
// Agents info
|
||||||
|
info["agents"] = map[string]any{
|
||||||
|
"count": len(al.registry.ListAgentIDs()),
|
||||||
|
"ids": al.registry.ListAgentIDs(),
|
||||||
|
}
|
||||||
|
|
||||||
|
return info
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatMessagesForLog formats messages for logging
|
||||||
|
func formatMessagesForLog(messages []providers.Message) string {
|
||||||
|
if len(messages) == 0 {
|
||||||
|
return "[]"
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString("[\n")
|
||||||
|
for i, msg := range messages {
|
||||||
|
fmt.Fprintf(&sb, " [%d] Role: %s\n", i, msg.Role)
|
||||||
|
if len(msg.ToolCalls) > 0 {
|
||||||
|
sb.WriteString(" ToolCalls:\n")
|
||||||
|
for _, tc := range msg.ToolCalls {
|
||||||
|
fmt.Fprintf(&sb, " - ID: %s, Type: %s, Name: %s\n", tc.ID, tc.Type, tc.Name)
|
||||||
|
if tc.Function != nil {
|
||||||
|
fmt.Fprintf(
|
||||||
|
&sb,
|
||||||
|
" Arguments: %s\n",
|
||||||
|
utils.Truncate(tc.Function.Arguments, 200),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if msg.Content != "" {
|
||||||
|
content := utils.Truncate(msg.Content, 200)
|
||||||
|
fmt.Fprintf(&sb, " Content: %s\n", content)
|
||||||
|
}
|
||||||
|
if msg.ToolCallID != "" {
|
||||||
|
fmt.Fprintf(&sb, " ToolCallID: %s\n", msg.ToolCallID)
|
||||||
|
}
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
sb.WriteString("]")
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatToolsForLog formats tool definitions for logging
|
||||||
|
func formatToolsForLog(toolDefs []providers.ToolDefinition) string {
|
||||||
|
if len(toolDefs) == 0 {
|
||||||
|
return "[]"
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString("[\n")
|
||||||
|
for i, tool := range toolDefs {
|
||||||
|
fmt.Fprintf(&sb, " [%d] Type: %s, Name: %s\n", i, tool.Type, tool.Function.Name)
|
||||||
|
fmt.Fprintf(&sb, " Description: %s\n", tool.Function.Description)
|
||||||
|
if len(tool.Function.Parameters) > 0 {
|
||||||
|
fmt.Fprintf(
|
||||||
|
&sb,
|
||||||
|
" Parameters: %s\n",
|
||||||
|
utils.Truncate(fmt.Sprintf("%v", tool.Function.Parameters), 200),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sb.WriteString("]")
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// summarizeSession summarizes the conversation history for a session.
|
||||||
|
func (al *AgentLoop) summarizeSession(agent *AgentInstance, sessionKey string) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
history := agent.Sessions.GetHistory(sessionKey)
|
||||||
|
summary := agent.Sessions.GetSummary(sessionKey)
|
||||||
|
|
||||||
|
// Keep last 4 messages for continuity
|
||||||
|
if len(history) <= 4 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Oversized Message Guard
|
||||||
|
maxMessageTokens := agent.ContextWindow / 2
|
||||||
|
validMessages := make([]providers.Message, 0)
|
||||||
|
omitted := false
|
||||||
|
|
||||||
|
for _, m := range history[:len(history)-4] {
|
||||||
|
msgTokens := len(m.Content) / 2
|
||||||
|
if msgTokens > maxMessageTokens {
|
||||||
|
omitted = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
validMessages = append(validMessages, m)
|
||||||
|
}
|
||||||
|
|
||||||
|
if omitted {
|
||||||
|
logger.WarnCF("agent", "Oversized messages omitted during summarization",
|
||||||
|
map[string]any{"session_key": sessionKey})
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
maxSummarizationMessages = 10
|
||||||
|
llmMaxRetries = 3
|
||||||
|
)
|
||||||
|
|
||||||
|
// Multi-Part Summarization
|
||||||
|
var finalSummary string
|
||||||
|
if len(validMessages) > maxSummarizationMessages {
|
||||||
|
mid := len(validMessages) / 2
|
||||||
|
|
||||||
|
mid = al.findNearestUserMessage(validMessages, mid)
|
||||||
|
|
||||||
|
part1 := validMessages[:mid]
|
||||||
|
part2 := validMessages[mid:]
|
||||||
|
|
||||||
|
s1, _ := al.summarizeBatch(ctx, agent, part1, "")
|
||||||
|
s2, _ := al.summarizeBatch(ctx, agent, part2, "")
|
||||||
|
|
||||||
|
mergePrompt := fmt.Sprintf(
|
||||||
|
"Merge these two conversation summaries into one cohesive summary:\n\n1: %s\n\n2: %s",
|
||||||
|
s1,
|
||||||
|
s2,
|
||||||
|
)
|
||||||
|
|
||||||
|
resp, err := al.retryLLMCall(ctx, agent, mergePrompt, llmMaxRetries)
|
||||||
|
if err == nil && resp.Content != "" {
|
||||||
|
finalSummary = resp.Content
|
||||||
|
} else {
|
||||||
|
finalSummary = s1 + " " + s2
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
finalSummary, _ = al.summarizeBatch(ctx, agent, validMessages, summary)
|
||||||
|
}
|
||||||
|
|
||||||
|
if finalSummary != "" {
|
||||||
|
agent.Sessions.SetSummary(sessionKey, finalSummary)
|
||||||
|
agent.Sessions.TruncateHistory(sessionKey, 4)
|
||||||
|
agent.Sessions.Save(sessionKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// findNearestUserMessage finds the nearest user message to the given index.
|
||||||
|
// It searches backward first, then forward if no user message is found.
|
||||||
|
func (al *AgentLoop) findNearestUserMessage(messages []providers.Message, mid int) int {
|
||||||
|
originalMid := mid
|
||||||
|
|
||||||
|
for mid > 0 && messages[mid].Role != "user" {
|
||||||
|
mid--
|
||||||
|
}
|
||||||
|
|
||||||
|
if messages[mid].Role == "user" {
|
||||||
|
return mid
|
||||||
|
}
|
||||||
|
|
||||||
|
mid = originalMid
|
||||||
|
for mid < len(messages) && messages[mid].Role != "user" {
|
||||||
|
mid++
|
||||||
|
}
|
||||||
|
|
||||||
|
if mid < len(messages) {
|
||||||
|
return mid
|
||||||
|
}
|
||||||
|
|
||||||
|
return originalMid
|
||||||
|
}
|
||||||
|
|
||||||
|
// retryLLMCall calls the LLM with retry logic.
|
||||||
|
func (al *AgentLoop) retryLLMCall(
|
||||||
|
ctx context.Context,
|
||||||
|
agent *AgentInstance,
|
||||||
|
prompt string,
|
||||||
|
maxRetries int,
|
||||||
|
) (*providers.LLMResponse, error) {
|
||||||
|
const (
|
||||||
|
llmTemperature = 0.3
|
||||||
|
)
|
||||||
|
|
||||||
|
var resp *providers.LLMResponse
|
||||||
|
var err error
|
||||||
|
|
||||||
|
for attempt := 0; attempt < maxRetries; attempt++ {
|
||||||
|
resp, err = agent.Provider.Chat(
|
||||||
|
ctx,
|
||||||
|
[]providers.Message{{Role: "user", Content: prompt}},
|
||||||
|
nil,
|
||||||
|
agent.Model,
|
||||||
|
map[string]any{
|
||||||
|
"max_tokens": agent.MaxTokens,
|
||||||
|
"temperature": llmTemperature,
|
||||||
|
"prompt_cache_key": agent.ID,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err == nil && resp != nil && resp.Content != "" {
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
if attempt < maxRetries-1 {
|
||||||
|
time.Sleep(time.Duration(attempt+1) * 100 * time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// summarizeBatch summarizes a batch of messages.
|
||||||
|
func (al *AgentLoop) summarizeBatch(
|
||||||
|
ctx context.Context,
|
||||||
|
agent *AgentInstance,
|
||||||
|
batch []providers.Message,
|
||||||
|
existingSummary string,
|
||||||
|
) (string, error) {
|
||||||
|
const (
|
||||||
|
llmMaxRetries = 3
|
||||||
|
fallbackMinContentLength = 200
|
||||||
|
fallbackMaxContentPercent = 10
|
||||||
|
)
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString(
|
||||||
|
"Provide a concise summary of this conversation segment, preserving core context and key points.\n",
|
||||||
|
)
|
||||||
|
if existingSummary != "" {
|
||||||
|
sb.WriteString("Existing context: ")
|
||||||
|
sb.WriteString(existingSummary)
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
sb.WriteString("\nCONVERSATION:\n")
|
||||||
|
for _, m := range batch {
|
||||||
|
fmt.Fprintf(&sb, "%s: %s\n", m.Role, m.Content)
|
||||||
|
}
|
||||||
|
prompt := sb.String()
|
||||||
|
|
||||||
|
response, err := al.retryLLMCall(ctx, agent, prompt, llmMaxRetries)
|
||||||
|
if err == nil && response.Content != "" {
|
||||||
|
return strings.TrimSpace(response.Content), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var fallback strings.Builder
|
||||||
|
fallback.WriteString("Conversation summary: ")
|
||||||
|
for i, m := range batch {
|
||||||
|
if i > 0 {
|
||||||
|
fallback.WriteString(" | ")
|
||||||
|
}
|
||||||
|
content := strings.TrimSpace(m.Content)
|
||||||
|
runes := []rune(content)
|
||||||
|
if len(runes) == 0 {
|
||||||
|
fallback.WriteString(fmt.Sprintf("%s: ", m.Role))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
keepLength := len(runes) * fallbackMaxContentPercent / 100
|
||||||
|
if keepLength < fallbackMinContentLength {
|
||||||
|
keepLength = fallbackMinContentLength
|
||||||
|
}
|
||||||
|
|
||||||
|
if keepLength > len(runes) {
|
||||||
|
keepLength = len(runes)
|
||||||
|
}
|
||||||
|
|
||||||
|
content = string(runes[:keepLength])
|
||||||
|
if keepLength < len(runes) {
|
||||||
|
content += "..."
|
||||||
|
}
|
||||||
|
fallback.WriteString(fmt.Sprintf("%s: %s", m.Role, content))
|
||||||
|
}
|
||||||
|
return fallback.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// estimateTokens estimates the number of tokens in a message list.
|
||||||
|
// Uses a safe heuristic of 2.5 characters per token to account for CJK and other
|
||||||
|
// overheads better than the previous 3 chars/token.
|
||||||
|
func (al *AgentLoop) estimateTokens(messages []providers.Message) int {
|
||||||
|
totalChars := 0
|
||||||
|
for _, m := range messages {
|
||||||
|
totalChars += utf8.RuneCountInString(m.Content)
|
||||||
|
}
|
||||||
|
// 2.5 chars per token = totalChars * 2 / 5
|
||||||
|
return totalChars * 2 / 5
|
||||||
|
}
|
||||||
|
|
||||||
// updateToolContexts updates the context for tools that need channel/chatID info.
|
// updateToolContexts updates the context for tools that need channel/chatID info.
|
||||||
|
|
||||||
func (al *AgentLoop) updateToolContexts(agent *AgentInstance, channel, chatID string) {
|
func (al *AgentLoop) updateToolContexts(agent *AgentInstance, channel, chatID string) {
|
||||||
|
|
@ -2374,3 +3012,36 @@ func (al *AgentLoop) updateToolContexts(agent *AgentInstance, channel, chatID st
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// extractPeer extracts the routing peer from the inbound message's structured Peer field.
|
||||||
|
func extractPeer(msg bus.InboundMessage) *routing.RoutePeer {
|
||||||
|
if msg.Peer.Kind == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
peerID := msg.Peer.ID
|
||||||
|
if peerID == "" {
|
||||||
|
if msg.Peer.Kind == "direct" {
|
||||||
|
peerID = msg.SenderID
|
||||||
|
} else {
|
||||||
|
peerID = msg.ChatID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &routing.RoutePeer{Kind: msg.Peer.Kind, ID: peerID}
|
||||||
|
}
|
||||||
|
|
||||||
|
func inboundMetadata(msg bus.InboundMessage, key string) string {
|
||||||
|
if msg.Metadata == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return msg.Metadata[key]
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractParentPeer extracts the parent peer (reply-to) from inbound message metadata.
|
||||||
|
func extractParentPeer(msg bus.InboundMessage) *routing.RoutePeer {
|
||||||
|
parentKind := inboundMetadata(msg, metadataKeyParentPeerKind)
|
||||||
|
parentID := inboundMetadata(msg, metadataKeyParentPeerID)
|
||||||
|
if parentKind == "" || parentID == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &routing.RoutePeer{Kind: parentKind, ID: parentID}
|
||||||
|
}
|
||||||
|
|
|
||||||
184
pkg/agent/loop_mcp.go
Normal file
|
|
@ -0,0 +1,184 @@
|
||||||
|
// PicoClaw - Ultra-lightweight personal AI agent
|
||||||
|
// Inspired by and based on nanobot: https://github.com/HKUDS/nanobot
|
||||||
|
// License: MIT
|
||||||
|
//
|
||||||
|
// Copyright (c) 2026 PicoClaw contributors
|
||||||
|
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/mcp"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/tools"
|
||||||
|
)
|
||||||
|
|
||||||
|
type mcpRuntime struct {
|
||||||
|
initOnce sync.Once
|
||||||
|
mu sync.Mutex
|
||||||
|
manager *mcp.Manager
|
||||||
|
initErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *mcpRuntime) setManager(manager *mcp.Manager) {
|
||||||
|
r.mu.Lock()
|
||||||
|
r.manager = manager
|
||||||
|
r.initErr = nil
|
||||||
|
r.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *mcpRuntime) setInitErr(err error) {
|
||||||
|
r.mu.Lock()
|
||||||
|
r.initErr = err
|
||||||
|
r.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *mcpRuntime) getInitErr() error {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
return r.initErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *mcpRuntime) takeManager() *mcp.Manager {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
manager := r.manager
|
||||||
|
r.manager = nil
|
||||||
|
return manager
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *mcpRuntime) hasManager() bool {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
return r.manager != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureMCPInitialized loads MCP servers/tools once so both Run() and direct
|
||||||
|
// agent mode share the same initialization path.
|
||||||
|
func (al *AgentLoop) ensureMCPInitialized(ctx context.Context) error {
|
||||||
|
if !al.cfg.Tools.IsToolEnabled("mcp") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
al.mcp.initOnce.Do(func() {
|
||||||
|
mcpManager := mcp.NewManager()
|
||||||
|
|
||||||
|
defaultAgent := al.registry.GetDefaultAgent()
|
||||||
|
workspacePath := al.cfg.WorkspacePath()
|
||||||
|
if defaultAgent != nil && defaultAgent.Workspace != "" {
|
||||||
|
workspacePath = defaultAgent.Workspace
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := mcpManager.LoadFromMCPConfig(ctx, al.cfg.Tools.MCP, workspacePath); err != nil {
|
||||||
|
logger.WarnCF("agent", "Failed to load MCP servers, MCP tools will not be available",
|
||||||
|
map[string]any{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
if closeErr := mcpManager.Close(); closeErr != nil {
|
||||||
|
logger.ErrorCF("agent", "Failed to close MCP manager",
|
||||||
|
map[string]any{
|
||||||
|
"error": closeErr.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register MCP tools for all agents
|
||||||
|
servers := mcpManager.GetServers()
|
||||||
|
uniqueTools := 0
|
||||||
|
totalRegistrations := 0
|
||||||
|
agentIDs := al.registry.ListAgentIDs()
|
||||||
|
agentCount := len(agentIDs)
|
||||||
|
|
||||||
|
for serverName, conn := range servers {
|
||||||
|
uniqueTools += len(conn.Tools)
|
||||||
|
for _, tool := range conn.Tools {
|
||||||
|
for _, agentID := range agentIDs {
|
||||||
|
agent, ok := al.registry.GetAgent(agentID)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
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{
|
||||||
|
"agent_id": agentID,
|
||||||
|
"server": serverName,
|
||||||
|
"tool": tool.Name,
|
||||||
|
"name": mcpTool.Name(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logger.InfoCF("agent", "MCP tools registered successfully",
|
||||||
|
map[string]any{
|
||||||
|
"server_count": len(servers),
|
||||||
|
"unique_tools": uniqueTools,
|
||||||
|
"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 {
|
||||||
|
al.mcp.setInitErr(fmt.Errorf(
|
||||||
|
"tool discovery is enabled but neither 'use_bm25' nor 'use_regex' is set to true in the configuration",
|
||||||
|
))
|
||||||
|
if closeErr := mcpManager.Close(); closeErr != nil {
|
||||||
|
logger.ErrorCF("agent", "Failed to close MCP manager",
|
||||||
|
map[string]any{
|
||||||
|
"error": closeErr.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
al.mcp.setManager(mcpManager)
|
||||||
|
})
|
||||||
|
|
||||||
|
return al.mcp.getInitErr()
|
||||||
|
}
|
||||||
81
pkg/channels/line/line_test.go
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
package line
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestWebhookRejectsOversizedBody(t *testing.T) {
|
||||||
|
ch := &LINEChannel{}
|
||||||
|
|
||||||
|
oversized := bytes.Repeat([]byte("A"), maxWebhookBodySize+1)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(oversized))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
ch.webhookHandler(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusRequestEntityTooLarge {
|
||||||
|
t.Errorf("expected status %d, got %d", http.StatusRequestEntityTooLarge, rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWebhookAcceptsMaxBodySize(t *testing.T) {
|
||||||
|
ch := &LINEChannel{}
|
||||||
|
|
||||||
|
body := bytes.Repeat([]byte("A"), maxWebhookBodySize)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(body))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
ch.webhookHandler(rec, req)
|
||||||
|
|
||||||
|
// Missing signature should be rejected, but the body size should not trigger 413.
|
||||||
|
if rec.Code != http.StatusForbidden {
|
||||||
|
t.Errorf("expected status %d, got %d", http.StatusForbidden, rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWebhookRejectsOversizedBodyBeforeSignatureCheck(t *testing.T) {
|
||||||
|
ch := &LINEChannel{}
|
||||||
|
|
||||||
|
oversized := bytes.Repeat([]byte("A"), maxWebhookBodySize+1)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(oversized))
|
||||||
|
req.Header.Set("X-Line-Signature", "invalidsignature")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
ch.webhookHandler(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusRequestEntityTooLarge {
|
||||||
|
t.Errorf("expected status %d, got %d", http.StatusRequestEntityTooLarge, rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWebhookRejectsNonPostMethod(t *testing.T) {
|
||||||
|
ch := &LINEChannel{}
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/webhook", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
ch.webhookHandler(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusMethodNotAllowed {
|
||||||
|
t.Errorf("expected status %d, got %d", http.StatusMethodNotAllowed, rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWebhookRejectsInvalidSignature(t *testing.T) {
|
||||||
|
ch := &LINEChannel{}
|
||||||
|
|
||||||
|
body := `{"events":[]}`
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/webhook", strings.NewReader(body))
|
||||||
|
req.Header.Set("X-Line-Signature", "invalidsignature")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
ch.webhookHandler(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusForbidden {
|
||||||
|
t.Errorf("expected status %d, got %d", http.StatusForbidden, rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -79,6 +79,7 @@ var channelRateConfig = map[string]float64{
|
||||||
"slack": 1,
|
"slack": 1,
|
||||||
"matrix": 2,
|
"matrix": 2,
|
||||||
"line": 10,
|
"line": 10,
|
||||||
|
"qq": 5,
|
||||||
"irc": 2,
|
"irc": 2,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -118,6 +119,27 @@ func (m *Manager) RecordPlaceholder(channel, chatID, placeholderID string) {
|
||||||
m.placeholders.Store(key, placeholderEntry{id: placeholderID, createdAt: time.Now()})
|
m.placeholders.Store(key, placeholderEntry{id: placeholderID, createdAt: time.Now()})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SendPlaceholder sends a "Thinking..." placeholder for the given channel/chatID
|
||||||
|
// and records it for later editing. Returns true if a placeholder was sent.
|
||||||
|
func (m *Manager) SendPlaceholder(ctx context.Context, channel, chatID string) bool {
|
||||||
|
m.mu.RLock()
|
||||||
|
ch, ok := m.channels[channel]
|
||||||
|
m.mu.RUnlock()
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
pc, ok := ch.(PlaceholderCapable)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
phID, err := pc.SendPlaceholder(ctx, chatID)
|
||||||
|
if err != nil || phID == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
m.RecordPlaceholder(channel, chatID, phID)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
// RecordTypingStop registers a typing stop function for later invocation.
|
// RecordTypingStop registers a typing stop function for later invocation.
|
||||||
// Implements PlaceholderRecorder.
|
// Implements PlaceholderRecorder.
|
||||||
//
|
//
|
||||||
|
|
@ -127,12 +149,12 @@ func (m *Manager) RecordPlaceholder(channel, chatID, placeholderID string) {
|
||||||
// consuming the *new* message's typing entry.
|
// consuming the *new* message's typing entry.
|
||||||
func (m *Manager) RecordTypingStop(channel, chatID string, stop func()) {
|
func (m *Manager) RecordTypingStop(channel, chatID string, stop func()) {
|
||||||
key := channel + ":" + chatID
|
key := channel + ":" + chatID
|
||||||
if v, loaded := m.typingStops.Load(key); loaded {
|
entry := typingEntry{stop: stop, createdAt: time.Now()}
|
||||||
if entry, ok := v.(typingEntry); ok {
|
if previous, loaded := m.typingStops.Swap(key, entry); loaded {
|
||||||
entry.stop() // idempotent
|
if oldEntry, ok := previous.(typingEntry); ok && oldEntry.stop != nil {
|
||||||
|
oldEntry.stop()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
m.typingStops.Store(key, typingEntry{stop: stop, createdAt: time.Now()})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// RecordReactionUndo registers a reaction undo function for later invocation.
|
// RecordReactionUndo registers a reaction undo function for later invocation.
|
||||||
|
|
@ -1122,6 +1144,39 @@ func (m *Manager) UnregisterChannel(name string) {
|
||||||
delete(m.channels, name)
|
delete(m.channels, name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SendMessage sends an outbound message synchronously through the channel
|
||||||
|
// worker's rate limiter and retry logic. It blocks until the message is
|
||||||
|
// delivered (or all retries are exhausted), which preserves ordering when
|
||||||
|
// a subsequent operation depends on the message having been sent.
|
||||||
|
func (m *Manager) SendMessage(ctx context.Context, msg bus.OutboundMessage) error {
|
||||||
|
m.mu.RLock()
|
||||||
|
_, exists := m.channels[msg.Channel]
|
||||||
|
w, wExists := m.workers[msg.Channel]
|
||||||
|
m.mu.RUnlock()
|
||||||
|
|
||||||
|
if !exists {
|
||||||
|
return fmt.Errorf("channel %s not found", msg.Channel)
|
||||||
|
}
|
||||||
|
if !wExists || w == nil {
|
||||||
|
return fmt.Errorf("channel %s has no active worker", msg.Channel)
|
||||||
|
}
|
||||||
|
|
||||||
|
maxLen := 0
|
||||||
|
if mlp, ok := w.ch.(MessageLengthProvider); ok {
|
||||||
|
maxLen = mlp.MaxMessageLength()
|
||||||
|
}
|
||||||
|
if maxLen > 0 && len([]rune(msg.Content)) > maxLen {
|
||||||
|
for _, chunk := range SplitMessage(msg.Content, maxLen) {
|
||||||
|
chunkMsg := msg
|
||||||
|
chunkMsg.Content = chunk
|
||||||
|
m.sendWithRetry(ctx, msg.Channel, w, chunkMsg)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
m.sendWithRetry(ctx, msg.Channel, w, msg)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Manager) SendToChannel(ctx context.Context, channelName, chatID, content string) error {
|
func (m *Manager) SendToChannel(ctx context.Context, channelName, chatID, content string) error {
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
_, exists := m.channels[channelName]
|
_, exists := m.channels[channelName]
|
||||||
|
|
|
||||||
20
pkg/commands/cmd_clear.go
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
package commands
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
func clearCommand() Definition {
|
||||||
|
return Definition{
|
||||||
|
Name: "clear",
|
||||||
|
Description: "Clear the chat history",
|
||||||
|
Usage: "/clear",
|
||||||
|
Handler: func(_ context.Context, req Request, rt *Runtime) error {
|
||||||
|
if rt == nil || rt.ClearHistory == nil {
|
||||||
|
return req.Reply(unavailableMsg)
|
||||||
|
}
|
||||||
|
if err := rt.ClearHistory(); err != nil {
|
||||||
|
return req.Reply("Failed to clear chat history: " + err.Error())
|
||||||
|
}
|
||||||
|
return req.Reply("Chat history cleared!")
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
44
pkg/config/version.go
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"runtime"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Build-time variables injected via ldflags during build process.
|
||||||
|
// These are set by the Makefile or .goreleaser.yaml using the -X flag:
|
||||||
|
//
|
||||||
|
// -X github.com/sipeed/picoclaw/pkg/config.Version=<version>
|
||||||
|
// -X github.com/sipeed/picoclaw/pkg/config.GitCommit=<commit>
|
||||||
|
// -X github.com/sipeed/picoclaw/pkg/config.BuildTime=<timestamp>
|
||||||
|
// -X github.com/sipeed/picoclaw/pkg/config.GoVersion=<go-version>
|
||||||
|
var (
|
||||||
|
Version = "dev" // Default value when not built with ldflags
|
||||||
|
GitCommit string // Git commit SHA (short)
|
||||||
|
BuildTime string // Build timestamp in RFC3339 format
|
||||||
|
GoVersion string // Go version used for building
|
||||||
|
)
|
||||||
|
|
||||||
|
// FormatVersion returns the version string with optional git commit
|
||||||
|
func FormatVersion() string {
|
||||||
|
v := Version
|
||||||
|
if GitCommit != "" {
|
||||||
|
v += fmt.Sprintf(" (git: %s)", GitCommit)
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatBuildInfo returns build time and go version info
|
||||||
|
func FormatBuildInfo() (string, string) {
|
||||||
|
build := BuildTime
|
||||||
|
goVer := GoVersion
|
||||||
|
if goVer == "" {
|
||||||
|
goVer = runtime.Version()
|
||||||
|
}
|
||||||
|
return build, goVer
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetVersion returns the version string
|
||||||
|
func GetVersion() string {
|
||||||
|
return Version
|
||||||
|
}
|
||||||
92
pkg/config/version_test.go
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"runtime"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFormatVersion_NoGitCommit(t *testing.T) {
|
||||||
|
oldVersion, oldGit := Version, GitCommit
|
||||||
|
t.Cleanup(func() { Version, GitCommit = oldVersion, oldGit })
|
||||||
|
|
||||||
|
Version = "1.2.3"
|
||||||
|
GitCommit = ""
|
||||||
|
|
||||||
|
assert.Equal(t, "1.2.3", FormatVersion())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatVersion_WithGitCommit(t *testing.T) {
|
||||||
|
oldVersion, oldGit := Version, GitCommit
|
||||||
|
t.Cleanup(func() { Version, GitCommit = oldVersion, oldGit })
|
||||||
|
|
||||||
|
Version = "1.2.3"
|
||||||
|
GitCommit = "abc123"
|
||||||
|
|
||||||
|
assert.Equal(t, "1.2.3 (git: abc123)", FormatVersion())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatBuildInfo_UsesBuildTimeAndGoVersion_WhenSet(t *testing.T) {
|
||||||
|
oldBuildTime, oldGoVersion := BuildTime, GoVersion
|
||||||
|
t.Cleanup(func() { BuildTime, GoVersion = oldBuildTime, oldGoVersion })
|
||||||
|
|
||||||
|
BuildTime = "2026-02-20T00:00:00Z"
|
||||||
|
GoVersion = "go1.23.0"
|
||||||
|
|
||||||
|
build, goVer := FormatBuildInfo()
|
||||||
|
|
||||||
|
assert.Equal(t, BuildTime, build)
|
||||||
|
assert.Equal(t, GoVersion, goVer)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatBuildInfo_EmptyBuildTime_ReturnsEmptyBuild(t *testing.T) {
|
||||||
|
oldBuildTime, oldGoVersion := BuildTime, GoVersion
|
||||||
|
t.Cleanup(func() { BuildTime, GoVersion = oldBuildTime, oldGoVersion })
|
||||||
|
|
||||||
|
BuildTime = ""
|
||||||
|
GoVersion = "go1.23.0"
|
||||||
|
|
||||||
|
build, goVer := FormatBuildInfo()
|
||||||
|
|
||||||
|
assert.Empty(t, build)
|
||||||
|
assert.Equal(t, GoVersion, goVer)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatBuildInfo_EmptyGoVersion_FallsBackToRuntimeVersion(t *testing.T) {
|
||||||
|
oldBuildTime, oldGoVersion := BuildTime, GoVersion
|
||||||
|
t.Cleanup(func() { BuildTime, GoVersion = oldBuildTime, oldGoVersion })
|
||||||
|
|
||||||
|
BuildTime = "x"
|
||||||
|
GoVersion = ""
|
||||||
|
|
||||||
|
build, goVer := FormatBuildInfo()
|
||||||
|
|
||||||
|
assert.Equal(t, "x", build)
|
||||||
|
assert.Equal(t, runtime.Version(), goVer)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetVersion(t *testing.T) {
|
||||||
|
oldVersion := Version
|
||||||
|
t.Cleanup(func() { Version = oldVersion })
|
||||||
|
|
||||||
|
Version = "dev"
|
||||||
|
assert.Equal(t, "dev", GetVersion())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetVersion_Custom(t *testing.T) {
|
||||||
|
oldVersion := Version
|
||||||
|
t.Cleanup(func() { Version = oldVersion })
|
||||||
|
|
||||||
|
Version = "v1.0.0"
|
||||||
|
assert.Equal(t, "v1.0.0", GetVersion())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVersion_DefaultIsDev(t *testing.T) {
|
||||||
|
// Reset to default values
|
||||||
|
oldVersion := Version
|
||||||
|
Version = "dev"
|
||||||
|
t.Cleanup(func() { Version = oldVersion })
|
||||||
|
|
||||||
|
assert.Equal(t, "dev", Version)
|
||||||
|
}
|
||||||
|
|
@ -7,7 +7,6 @@
|
||||||
package heartbeat
|
package heartbeat
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
@ -15,8 +14,8 @@ import (
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/bus"
|
|
||||||
"github.com/sipeed/picoclaw/pkg/constants"
|
"github.com/sipeed/picoclaw/pkg/constants"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
"github.com/sipeed/picoclaw/pkg/fileutil"
|
"github.com/sipeed/picoclaw/pkg/fileutil"
|
||||||
"github.com/sipeed/picoclaw/pkg/logger"
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
"github.com/sipeed/picoclaw/pkg/state"
|
"github.com/sipeed/picoclaw/pkg/state"
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,26 @@
|
||||||
package logger
|
package logger
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/rs/zerolog"
|
||||||
)
|
)
|
||||||
|
|
||||||
type LogLevel int
|
type LogLevel = zerolog.Level
|
||||||
|
|
||||||
const (
|
const (
|
||||||
DEBUG LogLevel = iota
|
DEBUG = zerolog.DebugLevel
|
||||||
INFO
|
INFO = zerolog.InfoLevel
|
||||||
WARN
|
WARN = zerolog.WarnLevel
|
||||||
ERROR
|
ERROR = zerolog.ErrorLevel
|
||||||
FATAL
|
FATAL = zerolog.FatalLevel
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
|
@ -40,7 +41,9 @@ var (
|
||||||
}
|
}
|
||||||
|
|
||||||
currentLevel = INFO
|
currentLevel = INFO
|
||||||
logger *Logger
|
logger zerolog.Logger
|
||||||
|
fileLogger zerolog.Logger
|
||||||
|
logFile *os.File
|
||||||
once sync.Once
|
once sync.Once
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
|
|
||||||
|
|
@ -113,10 +116,6 @@ type LogSubscriber struct {
|
||||||
filter func(LogEntry) bool
|
filter func(LogEntry) bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type Logger struct {
|
|
||||||
file *os.File
|
|
||||||
}
|
|
||||||
|
|
||||||
type LogEntry struct {
|
type LogEntry struct {
|
||||||
Level string `json:"level"`
|
Level string `json:"level"`
|
||||||
Timestamp string `json:"timestamp"`
|
Timestamp string `json:"timestamp"`
|
||||||
|
|
@ -148,7 +147,15 @@ func SanitizeFields(fields map[string]any) map[string]any {
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
once.Do(func() {
|
once.Do(func() {
|
||||||
logger = &Logger{}
|
zerolog.SetGlobalLevel(zerolog.InfoLevel)
|
||||||
|
|
||||||
|
consoleWriter := zerolog.ConsoleWriter{
|
||||||
|
Out: os.Stdout,
|
||||||
|
TimeFormat: "15:04:05", // TODO: make it configurable???
|
||||||
|
}
|
||||||
|
|
||||||
|
logger = zerolog.New(consoleWriter).With().Timestamp().Logger()
|
||||||
|
fileLogger = zerolog.Logger{}
|
||||||
ringBuf = newLogRingBuffer(ringBufSize)
|
ringBuf = newLogRingBuffer(ringBufSize)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -157,6 +164,7 @@ func SetLevel(level LogLevel) {
|
||||||
mu.Lock()
|
mu.Lock()
|
||||||
defer mu.Unlock()
|
defer mu.Unlock()
|
||||||
currentLevel = level
|
currentLevel = level
|
||||||
|
zerolog.SetGlobalLevel(level)
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetLevel() LogLevel {
|
func GetLevel() LogLevel {
|
||||||
|
|
@ -169,17 +177,22 @@ func EnableFileLogging(filePath string) error {
|
||||||
mu.Lock()
|
mu.Lock()
|
||||||
defer mu.Unlock()
|
defer mu.Unlock()
|
||||||
|
|
||||||
file, err := os.OpenFile(filePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
|
if err := os.MkdirAll(filepath.Dir(filePath), 0o755); err != nil {
|
||||||
|
return fmt.Errorf("failed to create log directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
newFile, err := os.OpenFile(filePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to open log file: %w", err)
|
return fmt.Errorf("failed to open log file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if logger.file != nil {
|
// Close old file if exists
|
||||||
logger.file.Close()
|
if logFile != nil {
|
||||||
|
logFile.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.file = file
|
logFile = newFile
|
||||||
log.Println("File logging enabled:", filePath)
|
fileLogger = zerolog.New(logFile).With().Timestamp().Caller().Logger()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -187,10 +200,57 @@ func DisableFileLogging() {
|
||||||
mu.Lock()
|
mu.Lock()
|
||||||
defer mu.Unlock()
|
defer mu.Unlock()
|
||||||
|
|
||||||
if logger.file != nil {
|
if logFile != nil {
|
||||||
logger.file.Close()
|
logFile.Close()
|
||||||
logger.file = nil
|
logFile = nil
|
||||||
log.Println("File logging disabled")
|
}
|
||||||
|
fileLogger = zerolog.Logger{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getCallerInfo() (string, int, string) {
|
||||||
|
for i := 2; i < 15; i++ {
|
||||||
|
pc, file, line, ok := runtime.Caller(i)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
fn := runtime.FuncForPC(pc)
|
||||||
|
if fn == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// bypass common loggers
|
||||||
|
if strings.HasSuffix(file, "/logger.go") ||
|
||||||
|
strings.HasSuffix(file, "/log.go") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
funcName := fn.Name()
|
||||||
|
if strings.HasPrefix(funcName, "runtime.") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
return filepath.Base(file), line, filepath.Base(funcName)
|
||||||
|
}
|
||||||
|
|
||||||
|
return "???", 0, "???"
|
||||||
|
}
|
||||||
|
|
||||||
|
//nolint:zerologlint
|
||||||
|
func getEvent(logger zerolog.Logger, level LogLevel) *zerolog.Event {
|
||||||
|
switch level {
|
||||||
|
case zerolog.DebugLevel:
|
||||||
|
return logger.Debug()
|
||||||
|
case zerolog.InfoLevel:
|
||||||
|
return logger.Info()
|
||||||
|
case zerolog.WarnLevel:
|
||||||
|
return logger.Warn()
|
||||||
|
case zerolog.ErrorLevel:
|
||||||
|
return logger.Error()
|
||||||
|
case zerolog.FatalLevel:
|
||||||
|
return logger.Fatal()
|
||||||
|
default:
|
||||||
|
return logger.Info()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -199,6 +259,7 @@ func logMessage(level LogLevel, component string, message string, fields map[str
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build LogEntry for ring buffer and subscribers (fork-only)
|
||||||
entry := LogEntry{
|
entry := LogEntry{
|
||||||
Level: logLevelNames[level],
|
Level: logLevelNames[level],
|
||||||
Timestamp: time.Now().Format(time.RFC3339),
|
Timestamp: time.Now().Format(time.RFC3339),
|
||||||
|
|
@ -207,68 +268,46 @@ func logMessage(level LogLevel, component string, message string, fields map[str
|
||||||
Fields: fields,
|
Fields: fields,
|
||||||
}
|
}
|
||||||
|
|
||||||
if pc, file, line, ok := runtime.Caller(2); ok {
|
|
||||||
fn := runtime.FuncForPC(pc)
|
|
||||||
if fn != nil {
|
|
||||||
entry.Caller = fmt.Sprintf("%s:%d (%s)", file, line, fn.Name())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Push to ring buffer and broadcast to subscribers
|
// Push to ring buffer and broadcast to subscribers
|
||||||
ringBuf.push(entry)
|
ringBuf.push(entry)
|
||||||
broadcastToSubscribers(entry)
|
broadcastToSubscribers(entry)
|
||||||
|
|
||||||
if logger.file != nil {
|
// Upstream zerolog console output
|
||||||
jsonData, err := json.Marshal(entry)
|
callerFile, callerLine, callerFunc := getCallerInfo()
|
||||||
if err == nil {
|
|
||||||
logger.file.Write(append(jsonData, '\n'))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var fieldStr string
|
event := getEvent(logger, level)
|
||||||
if len(fields) > 0 {
|
|
||||||
fieldStr = " " + formatFields(fields)
|
// Build combined field with component and caller
|
||||||
|
if component != "" {
|
||||||
|
event.Str("caller", fmt.Sprintf("%-6s %s:%d (%s)", component, callerFile, callerLine, callerFunc))
|
||||||
} else {
|
} else {
|
||||||
fieldStr = ""
|
event.Str("caller", fmt.Sprintf("<none> %s:%d (%s)", callerFile, callerLine, callerFunc))
|
||||||
}
|
}
|
||||||
|
|
||||||
logLine := fmt.Sprintf("[%s] [%s]%s %s%s",
|
for k, v := range fields {
|
||||||
entry.Timestamp,
|
event.Interface(k, v)
|
||||||
logLevelNames[level],
|
}
|
||||||
formatComponent(component),
|
|
||||||
message,
|
|
||||||
fieldStr,
|
|
||||||
)
|
|
||||||
|
|
||||||
log.Println(logLine)
|
event.Msg(message)
|
||||||
|
|
||||||
|
// Also log to file if enabled
|
||||||
|
if fileLogger.GetLevel() != zerolog.NoLevel {
|
||||||
|
fileEvent := getEvent(fileLogger, level)
|
||||||
|
|
||||||
|
if component != "" {
|
||||||
|
fileEvent.Str("component", component)
|
||||||
|
}
|
||||||
|
for k, v := range fields {
|
||||||
|
fileEvent.Interface(k, v)
|
||||||
|
}
|
||||||
|
fileEvent.Msg(message)
|
||||||
|
}
|
||||||
|
|
||||||
if level == FATAL {
|
if level == FATAL {
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func formatComponent(component string) string {
|
|
||||||
if component == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return fmt.Sprintf(" %s:", component)
|
|
||||||
}
|
|
||||||
|
|
||||||
func formatFields(fields map[string]any) string {
|
|
||||||
var sb strings.Builder
|
|
||||||
sb.WriteByte('{')
|
|
||||||
first := true
|
|
||||||
for k, v := range fields {
|
|
||||||
if !first {
|
|
||||||
sb.WriteString(", ")
|
|
||||||
}
|
|
||||||
fmt.Fprintf(&sb, "%s=%v", k, v)
|
|
||||||
first = false
|
|
||||||
}
|
|
||||||
sb.WriteByte('}')
|
|
||||||
return sb.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
func Debug(message string) {
|
func Debug(message string) {
|
||||||
logMessage(DEBUG, "", message, nil)
|
logMessage(DEBUG, "", message, nil)
|
||||||
}
|
}
|
||||||
|
|
@ -341,6 +380,10 @@ func FatalC(component string, message string) {
|
||||||
logMessage(FATAL, component, message, nil)
|
logMessage(FATAL, component, message, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func Fatalf(message string, ss ...any) {
|
||||||
|
logMessage(FATAL, "", fmt.Sprintf(message, ss...), nil)
|
||||||
|
}
|
||||||
|
|
||||||
func FatalF(message string, fields map[string]any) {
|
func FatalF(message string, fields map[string]any) {
|
||||||
logMessage(FATAL, "", message, fields)
|
logMessage(FATAL, "", message, fields)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
95
pkg/logger/logger_3rd_party.go
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
// this file is for compatible with 3rd party loggers, should not be called in PicoClaw project
|
||||||
|
|
||||||
|
package logger
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
// Logger implements common Logger interface
|
||||||
|
type Logger struct {
|
||||||
|
component string
|
||||||
|
levels map[int]LogLevel
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debug logs debug messages
|
||||||
|
func (b *Logger) Debug(v ...any) {
|
||||||
|
logMessage(DEBUG, b.component, fmt.Sprint(v...), nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Info logs info messages
|
||||||
|
func (b *Logger) Info(v ...any) {
|
||||||
|
logMessage(INFO, b.component, fmt.Sprint(v...), nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Warn logs warning messages
|
||||||
|
func (b *Logger) Warn(v ...any) {
|
||||||
|
logMessage(WARN, b.component, fmt.Sprint(v...), nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error logs error messages
|
||||||
|
func (b *Logger) Error(v ...any) {
|
||||||
|
logMessage(ERROR, b.component, fmt.Sprint(v...), nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debugf logs formatted debug messages
|
||||||
|
func (b *Logger) Debugf(format string, v ...any) {
|
||||||
|
logMessage(DEBUG, b.component, fmt.Sprintf(format, v...), nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Infof logs formatted info messages
|
||||||
|
func (b *Logger) Infof(format string, v ...any) {
|
||||||
|
logMessage(INFO, b.component, fmt.Sprintf(format, v...), nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Warnf logs formatted warning messages
|
||||||
|
func (b *Logger) Warnf(format string, v ...any) {
|
||||||
|
logMessage(WARN, b.component, fmt.Sprintf(format, v...), nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Warningf logs formatted warning messages
|
||||||
|
func (b *Logger) Warningf(format string, v ...any) {
|
||||||
|
logMessage(WARN, b.component, fmt.Sprintf(format, v...), nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Errorf logs formatted error messages
|
||||||
|
func (b *Logger) Errorf(format string, v ...any) {
|
||||||
|
logMessage(ERROR, b.component, fmt.Sprintf(format, v...), nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fatalf logs formatted fatal messages and exits
|
||||||
|
func (b *Logger) Fatalf(format string, v ...any) {
|
||||||
|
logMessage(FATAL, b.component, fmt.Sprintf(format, v...), nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log logs a message at a given level with caller information
|
||||||
|
// the func name must be this because 3rd party loggers expect this
|
||||||
|
// msgL: message level (DEBUG, INFO, WARN, ERROR, FATAL)
|
||||||
|
// caller: unused parameter reserved for compatibility
|
||||||
|
// format: format string
|
||||||
|
// a: format arguments
|
||||||
|
//
|
||||||
|
//nolint:goprintffuncname
|
||||||
|
func (b *Logger) Log(msgL, caller int, format string, a ...any) {
|
||||||
|
level := LogLevel(msgL)
|
||||||
|
if b.levels != nil {
|
||||||
|
if lvl, ok := b.levels[msgL]; ok {
|
||||||
|
level = lvl
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logMessage(level, b.component, fmt.Sprintf(format, a...), nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync flushes log buffer (no-op for this implementation)
|
||||||
|
func (b *Logger) Sync() error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithLevels sets log levels mapping for this logger
|
||||||
|
func (b *Logger) WithLevels(levels map[int]LogLevel) *Logger {
|
||||||
|
b.levels = levels
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewLogger creates a new logger instance with optional component name
|
||||||
|
func NewLogger(component string) *Logger {
|
||||||
|
return &Logger{component: component}
|
||||||
|
}
|
||||||
|
|
@ -162,7 +162,7 @@ func (p *Provider) buildHTTPRequest(
|
||||||
|
|
||||||
requestBody := map[string]any{
|
requestBody := map[string]any{
|
||||||
"model": model,
|
"model": model,
|
||||||
"messages": stripSystemParts(messages),
|
"messages": serializeMessages(messages),
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(tools) > 0 {
|
if len(tools) > 0 {
|
||||||
|
|
@ -205,9 +205,10 @@ func (p *Provider) buildHTTPRequest(
|
||||||
// The key is typically the agent ID -- stable per agent, shared across requests.
|
// The key is typically the agent ID -- stable per agent, shared across requests.
|
||||||
// See: https://platform.openai.com/docs/guides/prompt-caching
|
// See: https://platform.openai.com/docs/guides/prompt-caching
|
||||||
// Prompt caching is only supported by OpenAI-native endpoints.
|
// Prompt caching is only supported by OpenAI-native endpoints.
|
||||||
// Gemini and other providers reject unknown fields, so skip for non-OpenAI APIs.
|
// Non-OpenAI providers (Mistral, Gemini, DeepSeek, etc.) reject unknown
|
||||||
|
// fields with 422 errors, so only include it for OpenAI APIs.
|
||||||
if cacheKey, ok := options["prompt_cache_key"].(string); ok && cacheKey != "" {
|
if cacheKey, ok := options["prompt_cache_key"].(string); ok && cacheKey != "" {
|
||||||
if !strings.Contains(p.apiBase, "generativelanguage.googleapis.com") {
|
if supportsPromptCacheKey(p.apiBase) {
|
||||||
requestBody["prompt_cache_key"] = cacheKey
|
requestBody["prompt_cache_key"] = cacheKey
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -279,17 +280,40 @@ func (p *Provider) Chat(
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
contentType := resp.Header.Get("Content-Type")
|
||||||
|
|
||||||
|
// Non-200: read a prefix to tell HTML error page apart from JSON error body.
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
body, _ := io.ReadAll(resp.Body)
|
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 256))
|
||||||
return nil, fmt.Errorf("API request failed:\n Status: %d\n Body: %s", resp.StatusCode, string(body))
|
if readErr != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read response: %w", readErr)
|
||||||
|
}
|
||||||
|
if looksLikeHTML(body, contentType) {
|
||||||
|
return nil, wrapHTMLResponseError(resp.StatusCode, body, contentType, p.apiBase)
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"API request failed:\n Status: %d\n Body: %s",
|
||||||
|
resp.StatusCode,
|
||||||
|
responsePreview(body, 128),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
// Peek without consuming so the full stream reaches the JSON decoder.
|
||||||
|
reader := bufio.NewReader(resp.Body)
|
||||||
|
prefix, err := reader.Peek(256) // io.EOF/ErrBufferFull are normal; only real errors abort
|
||||||
|
if err != nil && err != io.EOF && err != bufio.ErrBufferFull {
|
||||||
|
return nil, fmt.Errorf("failed to inspect response: %w", err)
|
||||||
|
}
|
||||||
|
if looksLikeHTML(prefix, contentType) {
|
||||||
|
return nil, wrapHTMLResponseError(resp.StatusCode, prefix, contentType, p.apiBase)
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := parseResponse(reader)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
return nil, fmt.Errorf("failed to parse JSON response: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return parseResponse(body)
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CanStream returns true when this provider is configured for SSE streaming.
|
// CanStream returns true when this provider is configured for SSE streaming.
|
||||||
|
|
@ -472,7 +496,58 @@ func AccumulateStream(ch <-chan protocoltypes.StreamEvent) (*LLMResponse, error)
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseResponse(body []byte) (*LLMResponse, error) {
|
func wrapHTMLResponseError(statusCode int, body []byte, contentType, apiBase string) error {
|
||||||
|
respPreview := responsePreview(body, 128)
|
||||||
|
return fmt.Errorf(
|
||||||
|
"API request failed: %s returned HTML instead of JSON (content-type: %s); check api_base or proxy configuration.\n Status: %d\n Body: %s",
|
||||||
|
apiBase,
|
||||||
|
contentType,
|
||||||
|
statusCode,
|
||||||
|
respPreview,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func looksLikeHTML(body []byte, contentType string) bool {
|
||||||
|
contentType = strings.ToLower(strings.TrimSpace(contentType))
|
||||||
|
if strings.Contains(contentType, "text/html") || strings.Contains(contentType, "application/xhtml+xml") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
prefix := bytes.ToLower(leadingTrimmedPrefix(body, 128))
|
||||||
|
return bytes.HasPrefix(prefix, []byte("<!doctype html")) ||
|
||||||
|
bytes.HasPrefix(prefix, []byte("<html")) ||
|
||||||
|
bytes.HasPrefix(prefix, []byte("<head")) ||
|
||||||
|
bytes.HasPrefix(prefix, []byte("<body"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func leadingTrimmedPrefix(body []byte, maxLen int) []byte {
|
||||||
|
i := 0
|
||||||
|
for i < len(body) {
|
||||||
|
switch body[i] {
|
||||||
|
case ' ', '\t', '\n', '\r', '\f', '\v':
|
||||||
|
i++
|
||||||
|
default:
|
||||||
|
end := i + maxLen
|
||||||
|
if end > len(body) {
|
||||||
|
end = len(body)
|
||||||
|
}
|
||||||
|
return body[i:end]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func responsePreview(body []byte, maxLen int) string {
|
||||||
|
trimmed := bytes.TrimSpace(body)
|
||||||
|
if len(trimmed) == 0 {
|
||||||
|
return "<empty>"
|
||||||
|
}
|
||||||
|
if len(trimmed) <= maxLen {
|
||||||
|
return string(trimmed)
|
||||||
|
}
|
||||||
|
return string(trimmed[:maxLen]) + "..."
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseResponse(body io.Reader) (*LLMResponse, error) {
|
||||||
var apiResponse struct {
|
var apiResponse struct {
|
||||||
Choices []struct {
|
Choices []struct {
|
||||||
Message struct {
|
Message struct {
|
||||||
|
|
@ -485,7 +560,7 @@ func parseResponse(body []byte) (*LLMResponse, error) {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Function *struct {
|
Function *struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Arguments string `json:"arguments"`
|
Arguments json.RawMessage `json:"arguments"`
|
||||||
} `json:"function"`
|
} `json:"function"`
|
||||||
ExtraContent *struct {
|
ExtraContent *struct {
|
||||||
Google *struct {
|
Google *struct {
|
||||||
|
|
@ -499,8 +574,8 @@ func parseResponse(body []byte) (*LLMResponse, error) {
|
||||||
Usage *UsageInfo `json:"usage"`
|
Usage *UsageInfo `json:"usage"`
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := json.Unmarshal(body, &apiResponse); err != nil {
|
if err := json.NewDecoder(body).Decode(&apiResponse); err != nil {
|
||||||
return nil, fmt.Errorf("failed to unmarshal response: %w", err)
|
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(apiResponse.Choices) == 0 {
|
if len(apiResponse.Choices) == 0 {
|
||||||
|
|
@ -524,12 +599,7 @@ func parseResponse(body []byte) (*LLMResponse, error) {
|
||||||
|
|
||||||
if tc.Function != nil {
|
if tc.Function != nil {
|
||||||
name = tc.Function.Name
|
name = tc.Function.Name
|
||||||
if tc.Function.Arguments != "" {
|
arguments = decodeToolCallArguments(tc.Function.Arguments, name)
|
||||||
if err := json.Unmarshal([]byte(tc.Function.Arguments), &arguments); err != nil {
|
|
||||||
log.Printf("openai_compat: failed to decode tool call arguments for %q: %v", name, err)
|
|
||||||
arguments["raw"] = tc.Function.Arguments
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build ToolCall with ExtraContent for Gemini 3 thought_signature persistence
|
// Build ToolCall with ExtraContent for Gemini 3 thought_signature persistence
|
||||||
|
|
@ -567,93 +637,105 @@ func parseResponse(body []byte) (*LLMResponse, error) {
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func decodeToolCallArguments(raw json.RawMessage, name string) map[string]any {
|
||||||
|
arguments := make(map[string]any)
|
||||||
|
raw = bytes.TrimSpace(raw)
|
||||||
|
if len(raw) == 0 || bytes.Equal(raw, []byte("null")) {
|
||||||
|
return arguments
|
||||||
|
}
|
||||||
|
|
||||||
|
var decoded any
|
||||||
|
if err := json.Unmarshal(raw, &decoded); err != nil {
|
||||||
|
log.Printf("openai_compat: failed to decode tool call arguments payload for %q: %v", name, err)
|
||||||
|
arguments["raw"] = string(raw)
|
||||||
|
return arguments
|
||||||
|
}
|
||||||
|
|
||||||
|
switch v := decoded.(type) {
|
||||||
|
case string:
|
||||||
|
if strings.TrimSpace(v) == "" {
|
||||||
|
return arguments
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(v), &arguments); err != nil {
|
||||||
|
log.Printf("openai_compat: failed to decode tool call arguments for %q: %v", name, err)
|
||||||
|
arguments["raw"] = v
|
||||||
|
}
|
||||||
|
return arguments
|
||||||
|
case map[string]any:
|
||||||
|
return v
|
||||||
|
default:
|
||||||
|
log.Printf("openai_compat: unsupported tool call arguments type for %q: %T", name, decoded)
|
||||||
|
arguments["raw"] = string(raw)
|
||||||
|
return arguments
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// openaiMessage is the wire-format message for OpenAI-compatible APIs.
|
// openaiMessage is the wire-format message for OpenAI-compatible APIs.
|
||||||
// It mirrors protocoltypes.Message but omits SystemParts, which is an
|
// It mirrors protocoltypes.Message but omits SystemParts, which is an
|
||||||
// internal field that would be unknown to third-party endpoints.
|
// internal field that would be unknown to third-party endpoints.
|
||||||
type openaiMessage struct {
|
type openaiMessage struct {
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
ToolCalls []openaiToolCall `json:"tool_calls,omitempty"`
|
ReasoningContent string `json:"reasoning_content,omitempty"`
|
||||||
|
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
|
||||||
ToolCallID string `json:"tool_call_id,omitempty"`
|
ToolCallID string `json:"tool_call_id,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type openaiToolCall struct {
|
// serializeMessages converts internal Message structs to the OpenAI wire format.
|
||||||
ID string `json:"id"`
|
// - Strips SystemParts (unknown to third-party endpoints)
|
||||||
Type string `json:"type,omitempty"`
|
// - Converts messages with Media to multipart content format (text + image_url parts)
|
||||||
Function *openaiFunctionCall `json:"function,omitempty"`
|
// - Preserves ToolCallID, ToolCalls, and ReasoningContent for all messages
|
||||||
}
|
func serializeMessages(messages []Message) []any {
|
||||||
|
out := make([]any, 0, len(messages))
|
||||||
type openaiFunctionCall struct {
|
for _, m := range messages {
|
||||||
Name string `json:"name"`
|
if len(m.Media) == 0 {
|
||||||
Arguments string `json:"arguments"`
|
out = append(out, openaiMessage{
|
||||||
}
|
|
||||||
|
|
||||||
// stripSystemParts converts []Message to []openaiMessage, dropping the
|
|
||||||
// SystemParts field so it doesn't leak into the JSON payload sent to
|
|
||||||
// OpenAI-compatible APIs (some strict endpoints reject unknown fields).
|
|
||||||
func stripSystemParts(messages []Message) []openaiMessage {
|
|
||||||
out := make([]openaiMessage, len(messages))
|
|
||||||
for i, m := range messages {
|
|
||||||
out[i] = openaiMessage{
|
|
||||||
Role: m.Role,
|
Role: m.Role,
|
||||||
Content: m.Content,
|
Content: m.Content,
|
||||||
ToolCalls: toOpenAIWireToolCalls(m.ToolCalls),
|
ReasoningContent: m.ReasoningContent,
|
||||||
|
ToolCalls: m.ToolCalls,
|
||||||
ToolCallID: m.ToolCallID,
|
ToolCallID: m.ToolCallID,
|
||||||
}
|
})
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func toOpenAIWireToolCalls(toolCalls []ToolCall) []openaiToolCall {
|
|
||||||
if len(toolCalls) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
out := make([]openaiToolCall, 0, len(toolCalls))
|
|
||||||
for _, tc := range toolCalls {
|
|
||||||
name, args := normalizeOpenAIWireToolCall(tc)
|
|
||||||
if name == "" {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
argsJSON, err := json.Marshal(args)
|
// Multipart content format for messages with media
|
||||||
if err != nil {
|
parts := make([]map[string]any, 0, 1+len(m.Media))
|
||||||
argsJSON = []byte(`{}`)
|
if m.Content != "" {
|
||||||
|
parts = append(parts, map[string]any{
|
||||||
|
"type": "text",
|
||||||
|
"text": m.Content,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
for _, mediaURL := range m.Media {
|
||||||
wire := openaiToolCall{
|
if strings.HasPrefix(mediaURL, "data:image/") {
|
||||||
ID: tc.ID,
|
parts = append(parts, map[string]any{
|
||||||
Type: tc.Type,
|
"type": "image_url",
|
||||||
Function: &openaiFunctionCall{
|
"image_url": map[string]any{
|
||||||
Name: name,
|
"url": mediaURL,
|
||||||
Arguments: string(argsJSON),
|
|
||||||
},
|
},
|
||||||
|
})
|
||||||
}
|
}
|
||||||
out = append(out, wire)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(out) == 0 {
|
msg := map[string]any{
|
||||||
return nil
|
"role": m.Role,
|
||||||
|
"content": parts,
|
||||||
|
}
|
||||||
|
if m.ToolCallID != "" {
|
||||||
|
msg["tool_call_id"] = m.ToolCallID
|
||||||
|
}
|
||||||
|
if len(m.ToolCalls) > 0 {
|
||||||
|
msg["tool_calls"] = m.ToolCalls
|
||||||
|
}
|
||||||
|
if m.ReasoningContent != "" {
|
||||||
|
msg["reasoning_content"] = m.ReasoningContent
|
||||||
|
}
|
||||||
|
out = append(out, msg)
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizeOpenAIWireToolCall(tc ToolCall) (name string, args map[string]any) {
|
|
||||||
name = tc.Name
|
|
||||||
if name == "" && tc.Function != nil {
|
|
||||||
name = tc.Function.Name
|
|
||||||
}
|
|
||||||
|
|
||||||
args = tc.Arguments
|
|
||||||
if len(args) == 0 && tc.Function != nil {
|
|
||||||
args = tc.Function.Arguments
|
|
||||||
}
|
|
||||||
if args == nil {
|
|
||||||
args = map[string]any{}
|
|
||||||
}
|
|
||||||
return name, args
|
|
||||||
}
|
|
||||||
|
|
||||||
func cloneOpenAIToolArgs(src map[string]any) map[string]any {
|
func cloneOpenAIToolArgs(src map[string]any) map[string]any {
|
||||||
if len(src) == 0 {
|
if len(src) == 0 {
|
||||||
return map[string]any{}
|
return map[string]any{}
|
||||||
|
|
@ -677,17 +759,8 @@ func normalizeModel(model, apiBase string) string {
|
||||||
|
|
||||||
prefix := strings.ToLower(before)
|
prefix := strings.ToLower(before)
|
||||||
switch prefix {
|
switch prefix {
|
||||||
case "openai",
|
case "openai", "litellm", "moonshot", "nvidia", "groq", "ollama", "deepseek",
|
||||||
"moonshot",
|
"google", "openrouter", "zhipu", "minimax", "mistral", "vivgrid":
|
||||||
"nvidia",
|
|
||||||
"groq",
|
|
||||||
"ollama",
|
|
||||||
"deepseek",
|
|
||||||
"google",
|
|
||||||
"openrouter",
|
|
||||||
"zhipu",
|
|
||||||
"minimax",
|
|
||||||
"mistral":
|
|
||||||
return after
|
return after
|
||||||
default:
|
default:
|
||||||
return model
|
return model
|
||||||
|
|
@ -759,3 +832,16 @@ type streamToolCallAcc struct {
|
||||||
Name string
|
Name string
|
||||||
Arguments strings.Builder
|
Arguments strings.Builder
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// supportsPromptCacheKey reports whether the given API base is known to
|
||||||
|
// support the prompt_cache_key request field. Currently only OpenAI's own
|
||||||
|
// API and Azure OpenAI support this. All other OpenAI-compatible providers
|
||||||
|
// (Mistral, Gemini, DeepSeek, Groq, etc.) reject unknown fields with 422 errors.
|
||||||
|
func supportsPromptCacheKey(apiBase string) bool {
|
||||||
|
u, err := url.Parse(apiBase)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
host := u.Hostname()
|
||||||
|
return host == "api.openai.com" || strings.HasSuffix(host, ".openai.azure.com")
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
type ToolCall struct {
|
type ToolCall struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Type string `json:"type,omitempty"`
|
Type string `json:"type,omitempty"`
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import (
|
||||||
"github.com/sipeed/picoclaw/pkg/providers/protocoltypes"
|
"github.com/sipeed/picoclaw/pkg/providers/protocoltypes"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
type (
|
type (
|
||||||
ToolCall = protocoltypes.ToolCall
|
ToolCall = protocoltypes.ToolCall
|
||||||
FunctionCall = protocoltypes.FunctionCall
|
FunctionCall = protocoltypes.FunctionCall
|
||||||
|
|
|
||||||
81
pkg/session/jsonl_backend.go
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
package session
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/memory"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
|
)
|
||||||
|
|
||||||
|
// JSONLBackend adapts a memory.Store into the SessionStore interface.
|
||||||
|
// Write errors are logged rather than returned, matching the fire-and-forget
|
||||||
|
// contract of SessionManager that the agent loop relies on.
|
||||||
|
type JSONLBackend struct {
|
||||||
|
store memory.Store
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewJSONLBackend wraps a memory.Store for use as a SessionStore.
|
||||||
|
func NewJSONLBackend(store memory.Store) *JSONLBackend {
|
||||||
|
return &JSONLBackend{store: store}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *JSONLBackend) AddMessage(sessionKey, role, content string) {
|
||||||
|
if err := b.store.AddMessage(context.Background(), sessionKey, role, content); err != nil {
|
||||||
|
log.Printf("session: add message: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *JSONLBackend) AddFullMessage(sessionKey string, msg providers.Message) {
|
||||||
|
if err := b.store.AddFullMessage(context.Background(), sessionKey, msg); err != nil {
|
||||||
|
log.Printf("session: add full message: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *JSONLBackend) GetHistory(key string) []providers.Message {
|
||||||
|
msgs, err := b.store.GetHistory(context.Background(), key)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("session: get history: %v", err)
|
||||||
|
return []providers.Message{}
|
||||||
|
}
|
||||||
|
return msgs
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *JSONLBackend) GetSummary(key string) string {
|
||||||
|
summary, err := b.store.GetSummary(context.Background(), key)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("session: get summary: %v", err)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return summary
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *JSONLBackend) SetSummary(key, summary string) {
|
||||||
|
if err := b.store.SetSummary(context.Background(), key, summary); err != nil {
|
||||||
|
log.Printf("session: set summary: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *JSONLBackend) SetHistory(key string, history []providers.Message) {
|
||||||
|
if err := b.store.SetHistory(context.Background(), key, history); err != nil {
|
||||||
|
log.Printf("session: set history: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *JSONLBackend) TruncateHistory(key string, keepLast int) {
|
||||||
|
if err := b.store.TruncateHistory(context.Background(), key, keepLast); err != nil {
|
||||||
|
log.Printf("session: truncate history: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save persists session state. Since the JSONL store fsyncs every write
|
||||||
|
// immediately, the data is already durable. Save runs compaction to reclaim
|
||||||
|
// space from logically truncated messages (no-op when there are none).
|
||||||
|
func (b *JSONLBackend) Save(key string) error {
|
||||||
|
return b.store.Compact(context.Background(), key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close releases resources held by the underlying store.
|
||||||
|
func (b *JSONLBackend) Close() error {
|
||||||
|
return b.store.Close()
|
||||||
|
}
|
||||||
179
pkg/session/jsonl_backend_test.go
Normal file
|
|
@ -0,0 +1,179 @@
|
||||||
|
package session_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/memory"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/session"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Compile-time interface satisfaction checks.
|
||||||
|
var (
|
||||||
|
_ session.SessionStore = (*session.SessionManager)(nil)
|
||||||
|
_ session.SessionStore = (*session.JSONLBackend)(nil)
|
||||||
|
)
|
||||||
|
|
||||||
|
func newBackend(t *testing.T) *session.JSONLBackend {
|
||||||
|
t.Helper()
|
||||||
|
store, err := memory.NewJSONLStore(t.TempDir())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { store.Close() })
|
||||||
|
return session.NewJSONLBackend(store)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJSONLBackend_AddAndGetHistory(t *testing.T) {
|
||||||
|
b := newBackend(t)
|
||||||
|
|
||||||
|
b.AddMessage("s1", "user", "hello")
|
||||||
|
b.AddMessage("s1", "assistant", "hi")
|
||||||
|
|
||||||
|
history := b.GetHistory("s1")
|
||||||
|
if len(history) != 2 {
|
||||||
|
t.Fatalf("got %d messages, want 2", len(history))
|
||||||
|
}
|
||||||
|
if history[0].Role != "user" || history[0].Content != "hello" {
|
||||||
|
t.Errorf("msg[0] = %+v", history[0])
|
||||||
|
}
|
||||||
|
if history[1].Role != "assistant" || history[1].Content != "hi" {
|
||||||
|
t.Errorf("msg[1] = %+v", history[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJSONLBackend_AddFullMessage(t *testing.T) {
|
||||||
|
b := newBackend(t)
|
||||||
|
|
||||||
|
msg := providers.Message{
|
||||||
|
Role: "assistant",
|
||||||
|
Content: "done",
|
||||||
|
ToolCalls: []providers.ToolCall{
|
||||||
|
{ID: "tc1", Function: &providers.FunctionCall{Name: "read_file", Arguments: `{"path":"x"}`}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
b.AddFullMessage("s1", msg)
|
||||||
|
|
||||||
|
history := b.GetHistory("s1")
|
||||||
|
if len(history) != 1 {
|
||||||
|
t.Fatalf("got %d, want 1", len(history))
|
||||||
|
}
|
||||||
|
if len(history[0].ToolCalls) != 1 || history[0].ToolCalls[0].ID != "tc1" {
|
||||||
|
t.Errorf("tool calls = %+v", history[0].ToolCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJSONLBackend_Summary(t *testing.T) {
|
||||||
|
b := newBackend(t)
|
||||||
|
|
||||||
|
if got := b.GetSummary("s1"); got != "" {
|
||||||
|
t.Errorf("got %q, want empty", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
b.SetSummary("s1", "test summary")
|
||||||
|
if got := b.GetSummary("s1"); got != "test summary" {
|
||||||
|
t.Errorf("got %q, want %q", got, "test summary")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJSONLBackend_TruncateAndSave(t *testing.T) {
|
||||||
|
b := newBackend(t)
|
||||||
|
|
||||||
|
for i := 0; i < 10; i++ {
|
||||||
|
b.AddMessage("s1", "user", fmt.Sprintf("msg %d", i))
|
||||||
|
}
|
||||||
|
b.TruncateHistory("s1", 3)
|
||||||
|
|
||||||
|
history := b.GetHistory("s1")
|
||||||
|
if len(history) != 3 {
|
||||||
|
t.Fatalf("got %d, want 3", len(history))
|
||||||
|
}
|
||||||
|
if history[0].Content != "msg 7" {
|
||||||
|
t.Errorf("got %q, want %q", history[0].Content, "msg 7")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save triggers compaction.
|
||||||
|
if err := b.Save("s1"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Messages still accessible after compaction.
|
||||||
|
history = b.GetHistory("s1")
|
||||||
|
if len(history) != 3 {
|
||||||
|
t.Fatalf("after save: got %d, want 3", len(history))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJSONLBackend_SetHistory(t *testing.T) {
|
||||||
|
b := newBackend(t)
|
||||||
|
b.AddMessage("s1", "user", "old")
|
||||||
|
|
||||||
|
b.SetHistory("s1", []providers.Message{
|
||||||
|
{Role: "user", Content: "new1"},
|
||||||
|
{Role: "assistant", Content: "new2"},
|
||||||
|
})
|
||||||
|
|
||||||
|
history := b.GetHistory("s1")
|
||||||
|
if len(history) != 2 {
|
||||||
|
t.Fatalf("got %d, want 2", len(history))
|
||||||
|
}
|
||||||
|
if history[0].Content != "new1" {
|
||||||
|
t.Errorf("got %q, want %q", history[0].Content, "new1")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJSONLBackend_EmptySession(t *testing.T) {
|
||||||
|
b := newBackend(t)
|
||||||
|
|
||||||
|
history := b.GetHistory("nonexistent")
|
||||||
|
if history == nil {
|
||||||
|
t.Fatal("got nil, want empty slice")
|
||||||
|
}
|
||||||
|
if len(history) != 0 {
|
||||||
|
t.Errorf("got %d, want 0", len(history))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJSONLBackend_SessionIsolation(t *testing.T) {
|
||||||
|
b := newBackend(t)
|
||||||
|
b.AddMessage("s1", "user", "session1")
|
||||||
|
b.AddMessage("s2", "user", "session2")
|
||||||
|
|
||||||
|
h1 := b.GetHistory("s1")
|
||||||
|
h2 := b.GetHistory("s2")
|
||||||
|
|
||||||
|
if len(h1) != 1 || h1[0].Content != "session1" {
|
||||||
|
t.Errorf("s1: %+v", h1)
|
||||||
|
}
|
||||||
|
if len(h2) != 1 || h2[0].Content != "session2" {
|
||||||
|
t.Errorf("s2: %+v", h2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJSONLBackend_SummarizeFlow(t *testing.T) {
|
||||||
|
// Simulates the real summarization flow in the agent loop:
|
||||||
|
// SetSummary → TruncateHistory → Save
|
||||||
|
b := newBackend(t)
|
||||||
|
|
||||||
|
for i := 0; i < 20; i++ {
|
||||||
|
b.AddMessage("s1", "user", fmt.Sprintf("msg %d", i))
|
||||||
|
}
|
||||||
|
|
||||||
|
b.SetSummary("s1", "conversation about testing")
|
||||||
|
b.TruncateHistory("s1", 4)
|
||||||
|
if err := b.Save("s1"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := b.GetSummary("s1"); got != "conversation about testing" {
|
||||||
|
t.Errorf("summary = %q", got)
|
||||||
|
}
|
||||||
|
history := b.GetHistory("s1")
|
||||||
|
if len(history) != 4 {
|
||||||
|
t.Fatalf("got %d messages, want 4", len(history))
|
||||||
|
}
|
||||||
|
if history[0].Content != "msg 16" {
|
||||||
|
t.Errorf("first message = %q, want %q", history[0].Content, "msg 16")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -72,11 +72,8 @@ func (sm *SessionManager) GetOrCreate(key string) *Session {
|
||||||
|
|
||||||
session = &Session{
|
session = &Session{
|
||||||
Key: key,
|
Key: key,
|
||||||
|
|
||||||
Messages: []providers.Message{},
|
Messages: []providers.Message{},
|
||||||
|
|
||||||
Created: time.Now(),
|
Created: time.Now(),
|
||||||
|
|
||||||
Updated: time.Now(),
|
Updated: time.Now(),
|
||||||
}
|
}
|
||||||
sm.sessions[key] = session
|
sm.sessions[key] = session
|
||||||
|
|
@ -87,7 +84,6 @@ func (sm *SessionManager) GetOrCreate(key string) *Session {
|
||||||
func (sm *SessionManager) AddMessage(sessionKey, role, content string) {
|
func (sm *SessionManager) AddMessage(sessionKey, role, content string) {
|
||||||
sm.AddFullMessage(sessionKey, providers.Message{
|
sm.AddFullMessage(sessionKey, providers.Message{
|
||||||
Role: role,
|
Role: role,
|
||||||
|
|
||||||
Content: content,
|
Content: content,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -174,15 +170,10 @@ func (sm *SessionManager) TruncateHistory(key string, keepLast int) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// sanitizeFilename converts a session key into a cross-platform safe filename.
|
// sanitizeFilename converts a session key into a cross-platform safe filename.
|
||||||
|
// Replaces ':' with '_' (session key separator) and '/' and '\' with '_' so
|
||||||
// Session keys use "channel:chatID" (e.g. "telegram:123456") but ':' is the
|
// composite IDs (e.g. Telegram forum "chatID/threadID") do not create
|
||||||
|
// subdirectories or break on Windows. The original key is preserved inside
|
||||||
// volume separator on Windows, so filepath.Base would misinterpret the key.
|
// the JSON file, so loadSessions still maps back to the right in-memory key.
|
||||||
|
|
||||||
// We replace it with '_'. The original key is preserved inside the JSON file,
|
|
||||||
|
|
||||||
// so loadSessions still maps back to the right in-memory key.
|
|
||||||
|
|
||||||
func sanitizeFilename(key string) string {
|
func sanitizeFilename(key string) string {
|
||||||
s := strings.ReplaceAll(key, ":", "_")
|
s := strings.ReplaceAll(key, ":", "_")
|
||||||
s = strings.ReplaceAll(s, "/", "_")
|
s = strings.ReplaceAll(s, "/", "_")
|
||||||
|
|
@ -198,13 +189,8 @@ func (sm *SessionManager) Save(key string) error {
|
||||||
filename := sanitizeFilename(key)
|
filename := sanitizeFilename(key)
|
||||||
|
|
||||||
// filepath.IsLocal rejects empty names, "..", absolute paths, and
|
// filepath.IsLocal rejects empty names, "..", absolute paths, and
|
||||||
|
// OS-reserved device names (NUL, COM1 ... on Windows). sanitizeFilename
|
||||||
// OS-reserved device names (NUL, COM1 … on Windows).
|
// already replaced '/' and '\' with '_', so no subdirs are created.
|
||||||
|
|
||||||
// The extra checks reject "." and any directory separators so that
|
|
||||||
|
|
||||||
// the session file is always written directly inside sm.storage.
|
|
||||||
|
|
||||||
if filename == "." || !filepath.IsLocal(filename) || strings.ContainsAny(filename, `/\`) {
|
if filename == "." || !filepath.IsLocal(filename) || strings.ContainsAny(filename, `/\`) {
|
||||||
return os.ErrInvalid
|
return os.ErrInvalid
|
||||||
}
|
}
|
||||||
|
|
@ -219,7 +205,6 @@ func (sm *SessionManager) Save(key string) error {
|
||||||
|
|
||||||
snapshot := Session{
|
snapshot := Session{
|
||||||
Key: stored.Key,
|
Key: stored.Key,
|
||||||
|
|
||||||
Summary: stored.Summary,
|
Summary: stored.Summary,
|
||||||
Created: stored.Created,
|
Created: stored.Created,
|
||||||
Updated: stored.Updated,
|
Updated: stored.Updated,
|
||||||
|
|
@ -255,7 +240,6 @@ func (sm *SessionManager) Save(key string) error {
|
||||||
_ = tmpFile.Close()
|
_ = tmpFile.Close()
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := tmpFile.Chmod(0o644); err != nil {
|
if err := tmpFile.Chmod(0o644); err != nil {
|
||||||
_ = tmpFile.Close()
|
_ = tmpFile.Close()
|
||||||
return err
|
return err
|
||||||
|
|
@ -463,11 +447,11 @@ func (sm *SessionManager) FlushDirty() {
|
||||||
|
|
||||||
// Close stops the background flush goroutine and writes all dirty sessions.
|
// Close stops the background flush goroutine and writes all dirty sessions.
|
||||||
|
|
||||||
func (sm *SessionManager) Close() {
|
func (sm *SessionManager) Close() error {
|
||||||
select {
|
select {
|
||||||
case <-sm.done:
|
case <-sm.done:
|
||||||
|
|
||||||
return // already closed
|
return nil // already closed
|
||||||
|
|
||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
|
|
@ -475,6 +459,7 @@ func (sm *SessionManager) Close() {
|
||||||
close(sm.done)
|
close(sm.done)
|
||||||
|
|
||||||
sm.FlushDirty()
|
sm.FlushDirty()
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (sm *SessionManager) flushLoop() {
|
func (sm *SessionManager) flushLoop() {
|
||||||
|
|
|
||||||
32
pkg/session/session_store.go
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
package session
|
||||||
|
|
||||||
|
import "github.com/sipeed/picoclaw/pkg/providers"
|
||||||
|
|
||||||
|
// SessionStore defines the persistence operations used by the agent loop.
|
||||||
|
// Both SessionManager (legacy JSON backend) and JSONLBackend satisfy this
|
||||||
|
// interface, allowing the storage layer to be swapped without touching the
|
||||||
|
// agent loop code.
|
||||||
|
//
|
||||||
|
// Write methods (Add*, Set*, Truncate*) are fire-and-forget: they do not
|
||||||
|
// return errors. Implementations should log failures internally. This
|
||||||
|
// matches the original SessionManager contract that the agent loop relies on.
|
||||||
|
type SessionStore interface {
|
||||||
|
// AddMessage appends a simple role/content message to the session.
|
||||||
|
AddMessage(sessionKey, role, content string)
|
||||||
|
// AddFullMessage appends a complete message including tool calls.
|
||||||
|
AddFullMessage(sessionKey string, msg providers.Message)
|
||||||
|
// GetHistory returns the full message history for the session.
|
||||||
|
GetHistory(key string) []providers.Message
|
||||||
|
// GetSummary returns the conversation summary, or "" if none.
|
||||||
|
GetSummary(key string) string
|
||||||
|
// SetSummary replaces the conversation summary.
|
||||||
|
SetSummary(key, summary string)
|
||||||
|
// SetHistory replaces the full message history.
|
||||||
|
SetHistory(key string, history []providers.Message)
|
||||||
|
// TruncateHistory keeps only the last keepLast messages.
|
||||||
|
TruncateHistory(key string, keepLast int)
|
||||||
|
// Save persists any pending state to durable storage.
|
||||||
|
Save(key string) error
|
||||||
|
// Close releases resources held by the store.
|
||||||
|
Close() error
|
||||||
|
}
|
||||||
|
|
@ -18,11 +18,7 @@ import (
|
||||||
"github.com/sipeed/picoclaw/pkg/logger"
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var namePattern = regexp.MustCompile(`^[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*$`)
|
||||||
namePattern = regexp.MustCompile(`^[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*$`)
|
|
||||||
reFrontmatter = regexp.MustCompile(`(?s)^---(?:\r\n|\n|\r)(.*?)(?:\r\n|\n|\r)---`)
|
|
||||||
reStripFrontmatter = regexp.MustCompile(`(?s)^---(?:\r\n|\n|\r)(.*?)(?:\r\n|\n|\r)---(?:\r\n|\n|\r)*`)
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
const (
|
||||||
MaxNameLength = 64
|
MaxNameLength = 64
|
||||||
|
|
@ -236,11 +232,20 @@ func (sl *SkillsLoader) getSkillMetadata(skillPath string) *SkillMetadata {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
frontmatter := sl.extractFrontmatter(string(content))
|
frontmatter, bodyContent := splitFrontmatter(string(content))
|
||||||
if frontmatter == "" {
|
dirName := filepath.Base(filepath.Dir(skillPath))
|
||||||
return &SkillMetadata{
|
title, bodyDescription := extractMarkdownMetadata(bodyContent)
|
||||||
Name: filepath.Base(filepath.Dir(skillPath)),
|
|
||||||
|
metadata := &SkillMetadata{
|
||||||
|
Name: dirName,
|
||||||
|
Description: bodyDescription,
|
||||||
}
|
}
|
||||||
|
if title != "" && namePattern.MatchString(title) && len(title) <= MaxNameLength {
|
||||||
|
metadata.Name = title
|
||||||
|
}
|
||||||
|
|
||||||
|
if frontmatter == "" {
|
||||||
|
return metadata
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try JSON first (for backward compatibility)
|
// Try JSON first (for backward compatibility)
|
||||||
|
|
@ -249,60 +254,133 @@ func (sl *SkillsLoader) getSkillMetadata(skillPath string) *SkillMetadata {
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal([]byte(frontmatter), &jsonMeta); err == nil {
|
if err := json.Unmarshal([]byte(frontmatter), &jsonMeta); err == nil {
|
||||||
return &SkillMetadata{
|
if jsonMeta.Name != "" {
|
||||||
Name: jsonMeta.Name,
|
metadata.Name = jsonMeta.Name
|
||||||
Description: jsonMeta.Description,
|
|
||||||
}
|
}
|
||||||
|
if jsonMeta.Description != "" {
|
||||||
|
metadata.Description = jsonMeta.Description
|
||||||
|
}
|
||||||
|
return metadata
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fall back to simple YAML parsing
|
// Fall back to simple YAML parsing
|
||||||
yamlMeta := sl.parseSimpleYAML(frontmatter)
|
yamlMeta := sl.parseSimpleYAML(frontmatter)
|
||||||
return &SkillMetadata{
|
if name := yamlMeta["name"]; name != "" {
|
||||||
Name: yamlMeta["name"],
|
metadata.Name = name
|
||||||
Description: yamlMeta["description"],
|
|
||||||
}
|
}
|
||||||
|
if description := yamlMeta["description"]; description != "" {
|
||||||
|
metadata.Description = description
|
||||||
|
}
|
||||||
|
return metadata
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseSimpleYAML parses simple key: value YAML format
|
func extractMarkdownMetadata(content string) (title, description string) {
|
||||||
// Example: name: github\n description: "..."
|
p := parser.NewWithExtensions(parser.CommonExtensions)
|
||||||
// Normalizes line endings to handle \n (Unix), \r\n (Windows), and \r (classic Mac)
|
doc := markdown.Parse([]byte(content), p)
|
||||||
|
if doc == nil {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
|
||||||
|
ast.WalkFunc(doc, func(node ast.Node, entering bool) ast.WalkStatus {
|
||||||
|
if !entering {
|
||||||
|
return ast.GoToNext
|
||||||
|
}
|
||||||
|
|
||||||
|
switch n := node.(type) {
|
||||||
|
case *ast.Heading:
|
||||||
|
if title == "" && n.Level == 1 {
|
||||||
|
title = nodeText(n)
|
||||||
|
if title != "" && description != "" {
|
||||||
|
return ast.Terminate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case *ast.Paragraph:
|
||||||
|
if description == "" {
|
||||||
|
description = nodeText(n)
|
||||||
|
if title != "" && description != "" {
|
||||||
|
return ast.Terminate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ast.GoToNext
|
||||||
|
})
|
||||||
|
|
||||||
|
return title, description
|
||||||
|
}
|
||||||
|
|
||||||
|
func nodeText(n ast.Node) string {
|
||||||
|
var b strings.Builder
|
||||||
|
ast.WalkFunc(n, func(node ast.Node, entering bool) ast.WalkStatus {
|
||||||
|
if !entering {
|
||||||
|
return ast.GoToNext
|
||||||
|
}
|
||||||
|
|
||||||
|
switch t := node.(type) {
|
||||||
|
case *ast.Text:
|
||||||
|
b.Write(t.Literal)
|
||||||
|
case *ast.Code:
|
||||||
|
b.Write(t.Literal)
|
||||||
|
case *ast.Softbreak, *ast.Hardbreak, *ast.NonBlockingSpace:
|
||||||
|
b.WriteByte(' ')
|
||||||
|
}
|
||||||
|
return ast.GoToNext
|
||||||
|
})
|
||||||
|
return strings.Join(strings.Fields(b.String()), " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseSimpleYAML parses YAML frontmatter and extracts known metadata fields.
|
||||||
func (sl *SkillsLoader) parseSimpleYAML(content string) map[string]string {
|
func (sl *SkillsLoader) parseSimpleYAML(content string) map[string]string {
|
||||||
result := make(map[string]string)
|
result := make(map[string]string)
|
||||||
|
|
||||||
// Normalize line endings: convert \r\n and \r to \n
|
var meta struct {
|
||||||
normalized := strings.ReplaceAll(content, "\r\n", "\n")
|
Name string `yaml:"name"`
|
||||||
normalized = strings.ReplaceAll(normalized, "\r", "\n")
|
Description string `yaml:"description"`
|
||||||
|
|
||||||
for line := range strings.SplitSeq(normalized, "\n") {
|
|
||||||
line = strings.TrimSpace(line)
|
|
||||||
if line == "" || strings.HasPrefix(line, "#") {
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
|
if err := yaml.Unmarshal([]byte(content), &meta); err != nil {
|
||||||
parts := strings.SplitN(line, ":", 2)
|
return result
|
||||||
if len(parts) == 2 {
|
|
||||||
key := strings.TrimSpace(parts[0])
|
|
||||||
value := strings.TrimSpace(parts[1])
|
|
||||||
// Remove quotes if present
|
|
||||||
value = strings.Trim(value, "\"'")
|
|
||||||
result[key] = value
|
|
||||||
}
|
}
|
||||||
|
if meta.Name != "" {
|
||||||
|
result["name"] = meta.Name
|
||||||
|
}
|
||||||
|
if meta.Description != "" {
|
||||||
|
result["description"] = meta.Description
|
||||||
}
|
}
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func (sl *SkillsLoader) extractFrontmatter(content string) string {
|
func (sl *SkillsLoader) extractFrontmatter(content string) string {
|
||||||
// Support \n (Unix), \r\n (Windows), and \r (classic Mac) line endings for frontmatter blocks
|
frontmatter, _ := splitFrontmatter(content)
|
||||||
match := reFrontmatter.FindStringSubmatch(content)
|
return frontmatter
|
||||||
if len(match) > 1 {
|
|
||||||
return match[1]
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (sl *SkillsLoader) stripFrontmatter(content string) string {
|
func (sl *SkillsLoader) stripFrontmatter(content string) string {
|
||||||
return reStripFrontmatter.ReplaceAllString(content, "")
|
_, body := splitFrontmatter(content)
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitFrontmatter(content string) (frontmatter, body string) {
|
||||||
|
normalized := string(parser.NormalizeNewlines([]byte(content)))
|
||||||
|
lines := strings.Split(normalized, "\n")
|
||||||
|
if len(lines) == 0 || lines[0] != "---" {
|
||||||
|
return "", content
|
||||||
|
}
|
||||||
|
|
||||||
|
end := -1
|
||||||
|
for i := 1; i < len(lines); i++ {
|
||||||
|
if lines[i] == "---" {
|
||||||
|
end = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if end == -1 {
|
||||||
|
return "", content
|
||||||
|
}
|
||||||
|
|
||||||
|
frontmatter = strings.Join(lines[1:end], "\n")
|
||||||
|
body = strings.Join(lines[end+1:], "\n")
|
||||||
|
body = strings.TrimLeft(body, "\n")
|
||||||
|
return frontmatter, body
|
||||||
}
|
}
|
||||||
|
|
||||||
func escapeXML(s string) string {
|
func escapeXML(s string) string {
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ type State struct {
|
||||||
// Format: "channel:chatID[/thread]".
|
// Format: "channel:chatID[/thread]".
|
||||||
HeartbeatTarget string `json:"heartbeat_target,omitempty"`
|
HeartbeatTarget string `json:"heartbeat_target,omitempty"`
|
||||||
|
|
||||||
|
|
||||||
// LastChatID is the last chat ID used for communication
|
// LastChatID is the last chat ID used for communication
|
||||||
LastChatID string `json:"last_chat_id,omitempty"`
|
LastChatID string `json:"last_chat_id,omitempty"`
|
||||||
|
|
||||||
|
|
@ -174,6 +175,7 @@ func (sm *Manager) load() error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// SetLastHeartbeatTarget atomically updates the last heartbeat target and saves the state.
|
// SetLastHeartbeatTarget atomically updates the last heartbeat target and saves the state.
|
||||||
func (sm *Manager) SetLastHeartbeatTarget(target string) error {
|
func (sm *Manager) SetLastHeartbeatTarget(target string) error {
|
||||||
sm.mu.Lock()
|
sm.mu.Lock()
|
||||||
|
|
|
||||||
116
pkg/tools/cron_test.go
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/cron"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newTestCronTool(t *testing.T) *CronTool {
|
||||||
|
t.Helper()
|
||||||
|
storePath := filepath.Join(t.TempDir(), "cron.json")
|
||||||
|
cronService := cron.NewCronService(storePath, nil)
|
||||||
|
msgBus := bus.NewMessageBus()
|
||||||
|
cfg := config.DefaultConfig()
|
||||||
|
tool, err := NewCronTool(cronService, nil, msgBus, t.TempDir(), true, 0, cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewCronTool() error: %v", err)
|
||||||
|
}
|
||||||
|
return tool
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCronTool_CommandBlockedFromRemoteChannel verifies command scheduling is restricted to internal channels
|
||||||
|
func TestCronTool_CommandBlockedFromRemoteChannel(t *testing.T) {
|
||||||
|
tool := newTestCronTool(t)
|
||||||
|
ctx := WithToolContext(context.Background(), "telegram", "chat-1")
|
||||||
|
result := tool.Execute(ctx, map[string]any{
|
||||||
|
"action": "add",
|
||||||
|
"message": "check disk",
|
||||||
|
"command": "df -h",
|
||||||
|
"command_confirm": true,
|
||||||
|
"at_seconds": float64(60),
|
||||||
|
})
|
||||||
|
|
||||||
|
if !result.IsError {
|
||||||
|
t.Fatal("expected command scheduling to be blocked from remote channel")
|
||||||
|
}
|
||||||
|
if !strings.Contains(result.ForLLM, "restricted to internal channels") {
|
||||||
|
t.Errorf("expected 'restricted to internal channels', got: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCronTool_CommandRequiresConfirm verifies command_confirm=true is required
|
||||||
|
func TestCronTool_CommandRequiresConfirm(t *testing.T) {
|
||||||
|
tool := newTestCronTool(t)
|
||||||
|
ctx := WithToolContext(context.Background(), "cli", "direct")
|
||||||
|
result := tool.Execute(ctx, map[string]any{
|
||||||
|
"action": "add",
|
||||||
|
"message": "check disk",
|
||||||
|
"command": "df -h",
|
||||||
|
"at_seconds": float64(60),
|
||||||
|
})
|
||||||
|
|
||||||
|
if !result.IsError {
|
||||||
|
t.Fatal("expected error when command_confirm is missing")
|
||||||
|
}
|
||||||
|
if !strings.Contains(result.ForLLM, "command_confirm=true") {
|
||||||
|
t.Errorf("expected 'command_confirm=true' message, got: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCronTool_CommandAllowedFromInternalChannel verifies command scheduling works from internal channels
|
||||||
|
func TestCronTool_CommandAllowedFromInternalChannel(t *testing.T) {
|
||||||
|
tool := newTestCronTool(t)
|
||||||
|
ctx := WithToolContext(context.Background(), "cli", "direct")
|
||||||
|
result := tool.Execute(ctx, map[string]any{
|
||||||
|
"action": "add",
|
||||||
|
"message": "check disk",
|
||||||
|
"command": "df -h",
|
||||||
|
"command_confirm": true,
|
||||||
|
"at_seconds": float64(60),
|
||||||
|
})
|
||||||
|
|
||||||
|
if result.IsError {
|
||||||
|
t.Fatalf("expected command scheduling to succeed from internal channel, got: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
if !strings.Contains(result.ForLLM, "Cron job added") {
|
||||||
|
t.Errorf("expected 'Cron job added', got: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCronTool_AddJobRequiresSessionContext verifies fail-closed when channel/chatID missing
|
||||||
|
func TestCronTool_AddJobRequiresSessionContext(t *testing.T) {
|
||||||
|
tool := newTestCronTool(t)
|
||||||
|
result := tool.Execute(context.Background(), map[string]any{
|
||||||
|
"action": "add",
|
||||||
|
"message": "reminder",
|
||||||
|
"at_seconds": float64(60),
|
||||||
|
})
|
||||||
|
|
||||||
|
if !result.IsError {
|
||||||
|
t.Fatal("expected error when session context is missing")
|
||||||
|
}
|
||||||
|
if !strings.Contains(result.ForLLM, "no session context") {
|
||||||
|
t.Errorf("expected 'no session context' message, got: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCronTool_NonCommandJobAllowedFromRemoteChannel verifies regular reminders work from any channel
|
||||||
|
func TestCronTool_NonCommandJobAllowedFromRemoteChannel(t *testing.T) {
|
||||||
|
tool := newTestCronTool(t)
|
||||||
|
ctx := WithToolContext(context.Background(), "telegram", "chat-1")
|
||||||
|
result := tool.Execute(ctx, map[string]any{
|
||||||
|
"action": "add",
|
||||||
|
"message": "time to stretch",
|
||||||
|
"at_seconds": float64(600),
|
||||||
|
})
|
||||||
|
|
||||||
|
if result.IsError {
|
||||||
|
t.Fatalf("expected non-command reminder to succeed from remote channel, got: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -18,6 +18,8 @@ import (
|
||||||
"github.com/sipeed/picoclaw/pkg/logger"
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const MaxReadFileSize = 64 * 1024 // 64KB limit to avoid context overflow
|
||||||
|
|
||||||
// validatePath ensures the given path is within the workspace if restrict is true.
|
// validatePath ensures the given path is within the workspace if restrict is true.
|
||||||
|
|
||||||
// Used by shell.go for working directory validation.
|
// Used by shell.go for working directory validation.
|
||||||
|
|
@ -94,18 +96,42 @@ func isWithinWorkspace(candidate, workspace string) bool {
|
||||||
|
|
||||||
type ReadFileTool struct {
|
type ReadFileTool struct {
|
||||||
fs fileSystem
|
fs fileSystem
|
||||||
|
maxSize int64
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewReadFileTool(workspace string, restrict bool) *ReadFileTool {
|
func NewReadFileTool(
|
||||||
var fs fileSystem
|
workspace string,
|
||||||
|
restrict bool,
|
||||||
if restrict {
|
maxReadFileSize int,
|
||||||
fs = &sandboxFs{workspace: workspace}
|
allowPaths ...[]*regexp.Regexp,
|
||||||
} else {
|
) *ReadFileTool {
|
||||||
fs = &hostFs{}
|
var patterns []*regexp.Regexp
|
||||||
|
if len(allowPaths) > 0 {
|
||||||
|
patterns = allowPaths[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
return &ReadFileTool{fs: fs}
|
maxSize := int64(maxReadFileSize)
|
||||||
|
if maxSize <= 0 {
|
||||||
|
maxSize = MaxReadFileSize
|
||||||
|
}
|
||||||
|
|
||||||
|
var fsys fileSystem
|
||||||
|
|
||||||
|
if restrict {
|
||||||
|
sfs := &sandboxFs{workspace: workspace}
|
||||||
|
if len(patterns) > 0 {
|
||||||
|
fsys = &whitelistFs{sandbox: sfs, host: hostFs{}, patterns: patterns}
|
||||||
|
} else {
|
||||||
|
fsys = sfs
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fsys = &hostFs{}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &ReadFileTool{
|
||||||
|
fs: fsys,
|
||||||
|
maxSize: maxSize,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *ReadFileTool) Name() string {
|
func (t *ReadFileTool) Name() string {
|
||||||
|
|
@ -113,7 +139,7 @@ func (t *ReadFileTool) Name() string {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *ReadFileTool) Description() string {
|
func (t *ReadFileTool) Description() string {
|
||||||
return "Read the contents of a file"
|
return "Read the contents of a file. Supports pagination via `offset` and `length`."
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *ReadFileTool) Parameters() map[string]any {
|
func (t *ReadFileTool) Parameters() map[string]any {
|
||||||
|
|
@ -123,7 +149,17 @@ func (t *ReadFileTool) Parameters() map[string]any {
|
||||||
"path": map[string]any{
|
"path": map[string]any{
|
||||||
"type": "string",
|
"type": "string",
|
||||||
|
|
||||||
"description": "Path to the file to read",
|
"description": "Path to the file to read.",
|
||||||
|
},
|
||||||
|
"offset": map[string]any{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Byte offset to start reading from.",
|
||||||
|
"default": 0,
|
||||||
|
},
|
||||||
|
"length": map[string]any{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Maximum number of bytes to read.",
|
||||||
|
"default": t.maxSize,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"required": []string{"path"},
|
"required": []string{"path"},
|
||||||
|
|
@ -136,28 +172,199 @@ func (t *ReadFileTool) Execute(ctx context.Context, args map[string]any) *ToolRe
|
||||||
return ErrorResult("path is required")
|
return ErrorResult("path is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
content, err := resolveFS(ctx, t.fs, path).ReadFile(path)
|
// offset (optional, default 0)
|
||||||
|
offset, err := getInt64Arg(args, "offset", 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ErrorResult(err.Error())
|
return ErrorResult(err.Error())
|
||||||
}
|
}
|
||||||
|
if offset < 0 {
|
||||||
|
return ErrorResult("offset must be >= 0")
|
||||||
|
}
|
||||||
|
|
||||||
return NewToolResult(string(content))
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
activeFs := resolveFS(ctx, t.fs, path)
|
||||||
|
|
||||||
|
file, err := activeFs.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return ErrorResult(err.Error())
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
// measure total size
|
||||||
|
totalSize := int64(-1) // -1 means unknown
|
||||||
|
if info, statErr := file.Stat(); statErr == nil {
|
||||||
|
totalSize = info.Size()
|
||||||
|
}
|
||||||
|
|
||||||
|
// sniff the first 512 bytes to detect binary content before loading
|
||||||
|
// it into the LLM context. Seeking back to 0 afterwards restores state.
|
||||||
|
sniff := make([]byte, 512)
|
||||||
|
sniffN, _ := file.Read(sniff)
|
||||||
|
|
||||||
|
// Reset read position to beginning before applying the caller's offset.
|
||||||
|
if seeker, ok := file.(io.Seeker); ok {
|
||||||
|
_, err = seeker.Seek(0, io.SeekStart)
|
||||||
|
if err != nil {
|
||||||
|
return ErrorResult(fmt.Sprintf("failed to reset file position after sniff: %v", err))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Non-seekable: we consumed sniffN bytes above; account for them when
|
||||||
|
// discarding to reach the requested offset below.
|
||||||
|
// If offset < sniffN the data we already read covers it, which we
|
||||||
|
// cannot replay on a non-seekable stream — return a clear error.
|
||||||
|
if offset < int64(sniffN) && offset > 0 {
|
||||||
|
return ErrorResult(
|
||||||
|
"non-seekable file: cannot seek to an offset within the first 512 bytes after binary detection",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seek to the requested offset.
|
||||||
|
if seeker, ok := file.(io.Seeker); ok {
|
||||||
|
_, err = seeker.Seek(offset, io.SeekStart)
|
||||||
|
if err != nil {
|
||||||
|
return ErrorResult(fmt.Sprintf("failed to seek to offset %d: %v", offset, err))
|
||||||
|
}
|
||||||
|
} else if offset > 0 {
|
||||||
|
// Fallback for non-seekable streams: discard leading bytes.
|
||||||
|
// sniffN bytes were already consumed above, so subtract them.
|
||||||
|
remaining := offset - int64(sniffN)
|
||||||
|
if remaining > 0 {
|
||||||
|
_, err = io.CopyN(io.Discard, file, remaining)
|
||||||
|
if err != nil {
|
||||||
|
return ErrorResult(fmt.Sprintf("failed to advance to offset %d: %v", offset, err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// read length+1 bytes to reliably detect whether more content exists
|
||||||
|
// without relying on totalSize (which may be -1 for non-seekable streams).
|
||||||
|
// This avoids the false-positive TRUNCATED message on the last page.
|
||||||
|
probe := make([]byte, length+1)
|
||||||
|
n, err := io.ReadFull(file, probe)
|
||||||
|
// FIX: io.ReadFull returns io.ErrUnexpectedEOF for partial reads (0 < n < len),
|
||||||
|
// and io.EOF only when n == 0. Both are normal terminal conditions — only
|
||||||
|
// other errors are genuine failures.
|
||||||
|
if err != nil && err != io.EOF && !errors.Is(err, io.ErrUnexpectedEOF) {
|
||||||
|
return ErrorResult(fmt.Sprintf("failed to read file content: %v", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasMore is true only when we actually got the extra probe byte.
|
||||||
|
hasMore := int64(n) > length
|
||||||
|
data := probe[:min(int64(n), length)]
|
||||||
|
|
||||||
|
if len(data) == 0 {
|
||||||
|
return NewToolResult("[END OF FILE - no content at this offset]")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build metadata header.
|
||||||
|
// use filepath.Base(path) instead of the raw path to avoid leaking
|
||||||
|
// internal filesystem structure into the LLM context.
|
||||||
|
readEnd := offset + int64(len(data))
|
||||||
|
// use ASCII hyphen-minus instead of en-dash (U+2013) to keep the
|
||||||
|
// header parseable by downstream tools and log processors.
|
||||||
|
readRange := fmt.Sprintf("bytes %d-%d", offset, readEnd-1)
|
||||||
|
|
||||||
|
displayPath := filepath.Base(path)
|
||||||
|
var header string
|
||||||
|
if totalSize >= 0 {
|
||||||
|
header = fmt.Sprintf(
|
||||||
|
"[file: %s | total: %d bytes | read: %s]",
|
||||||
|
displayPath, totalSize, readRange,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
header = fmt.Sprintf(
|
||||||
|
"[file: %s | read: %s | total size unknown]",
|
||||||
|
displayPath, readRange,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if hasMore {
|
||||||
|
header += fmt.Sprintf(
|
||||||
|
"\n[TRUNCATED - file has more content. Call read_file again with offset=%d to continue.]",
|
||||||
|
readEnd,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
header += "\n[END OF FILE - no further content.]"
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.DebugCF("tool", "ReadFileTool execution completed successfully",
|
||||||
|
map[string]any{
|
||||||
|
"path": path,
|
||||||
|
"bytes_read": len(data),
|
||||||
|
"has_more": hasMore,
|
||||||
|
})
|
||||||
|
|
||||||
|
return NewToolResult(header + "\n\n" + string(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
// getInt64Arg extracts an integer argument from the args map, returning the
|
||||||
|
// provided default if the key is absent.
|
||||||
|
func getInt64Arg(args map[string]any, key string, defaultVal int64) (int64, error) {
|
||||||
|
raw, exists := args[key]
|
||||||
|
if !exists {
|
||||||
|
return defaultVal, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch v := raw.(type) {
|
||||||
|
case float64:
|
||||||
|
if v != math.Trunc(v) {
|
||||||
|
return 0, fmt.Errorf("%s must be an integer, got float %v", key, v)
|
||||||
|
}
|
||||||
|
if v > math.MaxInt64 || v < math.MinInt64 {
|
||||||
|
return 0, fmt.Errorf("%s value %v overflows int64", key, v)
|
||||||
|
}
|
||||||
|
return int64(v), nil
|
||||||
|
case int:
|
||||||
|
return int64(v), nil
|
||||||
|
case int64:
|
||||||
|
return v, nil
|
||||||
|
case string:
|
||||||
|
parsed, err := strconv.ParseInt(v, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("invalid integer format for %s parameter: %w", key, err)
|
||||||
|
}
|
||||||
|
return parsed, nil
|
||||||
|
default:
|
||||||
|
return 0, fmt.Errorf("unsupported type %T for %s parameter", raw, key)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type WriteFileTool struct {
|
type WriteFileTool struct {
|
||||||
fs fileSystem
|
fs fileSystem
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewWriteFileTool(workspace string, restrict bool) *WriteFileTool {
|
func NewWriteFileTool(workspace string, restrict bool, allowPaths ...[]*regexp.Regexp) *WriteFileTool {
|
||||||
var fs fileSystem
|
var patterns []*regexp.Regexp
|
||||||
|
if len(allowPaths) > 0 {
|
||||||
if restrict {
|
patterns = allowPaths[0]
|
||||||
fs = &sandboxFs{workspace: workspace}
|
|
||||||
} else {
|
|
||||||
fs = &hostFs{}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return &WriteFileTool{fs: fs}
|
var fsys fileSystem
|
||||||
|
|
||||||
|
if restrict {
|
||||||
|
sfs := &sandboxFs{workspace: workspace}
|
||||||
|
if len(patterns) > 0 {
|
||||||
|
fsys = &whitelistFs{sandbox: sfs, host: hostFs{}, patterns: patterns}
|
||||||
|
} else {
|
||||||
|
fsys = sfs
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fsys = &hostFs{}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &WriteFileTool{fs: fsys}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *WriteFileTool) Name() string {
|
func (t *WriteFileTool) Name() string {
|
||||||
|
|
@ -209,16 +416,26 @@ type ListDirTool struct {
|
||||||
fs fileSystem
|
fs fileSystem
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewListDirTool(workspace string, restrict bool) *ListDirTool {
|
func NewListDirTool(workspace string, restrict bool, allowPaths ...[]*regexp.Regexp) *ListDirTool {
|
||||||
var fs fileSystem
|
var patterns []*regexp.Regexp
|
||||||
|
if len(allowPaths) > 0 {
|
||||||
if restrict {
|
patterns = allowPaths[0]
|
||||||
fs = &sandboxFs{workspace: workspace}
|
|
||||||
} else {
|
|
||||||
fs = &hostFs{}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return &ListDirTool{fs: fs}
|
var fsys fileSystem
|
||||||
|
|
||||||
|
if restrict {
|
||||||
|
sfs := &sandboxFs{workspace: workspace}
|
||||||
|
if len(patterns) > 0 {
|
||||||
|
fsys = &whitelistFs{sandbox: sfs, host: hostFs{}, patterns: patterns}
|
||||||
|
} else {
|
||||||
|
fsys = sfs
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fsys = &hostFs{}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &ListDirTool{fs: fsys}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *ListDirTool) Name() string {
|
func (t *ListDirTool) Name() string {
|
||||||
|
|
@ -278,6 +495,7 @@ type fileSystem interface {
|
||||||
ReadFile(path string) ([]byte, error)
|
ReadFile(path string) ([]byte, error)
|
||||||
WriteFile(path string, data []byte) error
|
WriteFile(path string, data []byte) error
|
||||||
ReadDir(path string) ([]os.DirEntry, error)
|
ReadDir(path string) ([]os.DirEntry, error)
|
||||||
|
Open(path string) (fs.File, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// hostFs is an unrestricted fileReadWriter that operates directly on the host filesystem.
|
// hostFs is an unrestricted fileReadWriter that operates directly on the host filesystem.
|
||||||
|
|
@ -312,6 +530,20 @@ func (h *hostFs) WriteFile(path string, data []byte) error {
|
||||||
return fileutil.WriteFileAtomic(path, data, 0o600)
|
return fileutil.WriteFileAtomic(path, data, 0o600)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *hostFs) Open(path string) (fs.File, error) {
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil, fmt.Errorf("failed to open file: file not found: %w", err)
|
||||||
|
}
|
||||||
|
if os.IsPermission(err) {
|
||||||
|
return nil, fmt.Errorf("failed to open file: access denied: %w", err)
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("failed to open file: %w", err)
|
||||||
|
}
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
|
|
||||||
// sandboxFs is a sandboxed fileSystem that operates within a strictly defined workspace using os.Root.
|
// sandboxFs is a sandboxed fileSystem that operates within a strictly defined workspace using os.Root.
|
||||||
type sandboxFs struct {
|
type sandboxFs struct {
|
||||||
workspace string
|
workspace string
|
||||||
|
|
@ -423,6 +655,71 @@ func (r *sandboxFs) ReadDir(path string) ([]os.DirEntry, error) {
|
||||||
return entries, err
|
return entries, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *sandboxFs) Open(path string) (fs.File, error) {
|
||||||
|
var f fs.File
|
||||||
|
err := r.execute(path, func(root *os.Root, relPath string) error {
|
||||||
|
file, err := root.Open(relPath)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("failed to open file: file not found: %w", err)
|
||||||
|
}
|
||||||
|
if os.IsPermission(err) || strings.Contains(err.Error(), "escapes from parent") ||
|
||||||
|
strings.Contains(err.Error(), "permission denied") {
|
||||||
|
return fmt.Errorf("failed to open file: access denied: %w", err)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("failed to open file: %w", err)
|
||||||
|
}
|
||||||
|
f = file
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return f, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// whitelistFs wraps a sandboxFs and allows access to specific paths outside
|
||||||
|
// the workspace when they match any of the provided patterns.
|
||||||
|
type whitelistFs struct {
|
||||||
|
sandbox *sandboxFs
|
||||||
|
host hostFs
|
||||||
|
patterns []*regexp.Regexp
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *whitelistFs) matches(path string) bool {
|
||||||
|
for _, p := range w.patterns {
|
||||||
|
if p.MatchString(path) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *whitelistFs) ReadFile(path string) ([]byte, error) {
|
||||||
|
if w.matches(path) {
|
||||||
|
return w.host.ReadFile(path)
|
||||||
|
}
|
||||||
|
return w.sandbox.ReadFile(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *whitelistFs) WriteFile(path string, data []byte) error {
|
||||||
|
if w.matches(path) {
|
||||||
|
return w.host.WriteFile(path, data)
|
||||||
|
}
|
||||||
|
return w.sandbox.WriteFile(path, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *whitelistFs) ReadDir(path string) ([]os.DirEntry, error) {
|
||||||
|
if w.matches(path) {
|
||||||
|
return w.host.ReadDir(path)
|
||||||
|
}
|
||||||
|
return w.sandbox.ReadDir(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *whitelistFs) Open(path string) (fs.File, error) {
|
||||||
|
if w.matches(path) {
|
||||||
|
return w.host.Open(path)
|
||||||
|
}
|
||||||
|
return w.sandbox.Open(path)
|
||||||
|
}
|
||||||
|
|
||||||
// Helper to get a safe relative path for os.Root usage
|
// Helper to get a safe relative path for os.Root usage
|
||||||
func getSafeRelPath(workspace, path string) (string, error) {
|
func getSafeRelPath(workspace, path string) (string, error) {
|
||||||
if workspace == "" {
|
if workspace == "" {
|
||||||
|
|
|
||||||
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
|
||||||
|
}
|
||||||
|
|
@ -19,6 +19,7 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/constants"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|
@ -170,10 +171,14 @@ type ExecTool struct {
|
||||||
|
|
||||||
allowRules [][]string // pre-split command prefix allowlist
|
allowRules [][]string // pre-split command prefix allowlist
|
||||||
|
|
||||||
|
customAllowPatterns []*regexp.Regexp
|
||||||
|
|
||||||
restrictToWorkspace bool
|
restrictToWorkspace bool
|
||||||
|
|
||||||
localNetOnly bool // restrict curl/wget to localhost + RFC 1918
|
localNetOnly bool // restrict curl/wget to localhost + RFC 1918
|
||||||
|
|
||||||
|
allowRemote bool
|
||||||
|
|
||||||
// Background process management
|
// Background process management
|
||||||
|
|
||||||
bgMu sync.Mutex
|
bgMu sync.Mutex
|
||||||
|
|
@ -187,7 +192,8 @@ type ExecTool struct {
|
||||||
bgCtx context.Context
|
bgCtx context.Context
|
||||||
}
|
}
|
||||||
|
|
||||||
var defaultDenyPatterns = []*regexp.Regexp{
|
var (
|
||||||
|
defaultDenyPatterns = []*regexp.Regexp{
|
||||||
regexp.MustCompile(`\brm\s+-[rf]{1,2}\b`),
|
regexp.MustCompile(`\brm\s+-[rf]{1,2}\b`),
|
||||||
|
|
||||||
regexp.MustCompile(`\bdel\s+/[fq]\b`),
|
regexp.MustCompile(`\bdel\s+/[fq]\b`),
|
||||||
|
|
@ -197,13 +203,15 @@ var defaultDenyPatterns = []*regexp.Regexp{
|
||||||
// Match disk wiping commands (must be followed by space/args)
|
// Match disk wiping commands (must be followed by space/args)
|
||||||
|
|
||||||
regexp.MustCompile(
|
regexp.MustCompile(
|
||||||
|
|
||||||
`\b(format|mkfs|diskpart)\b\s`,
|
`\b(format|mkfs|diskpart)\b\s`,
|
||||||
),
|
),
|
||||||
|
|
||||||
regexp.MustCompile(`\bdd\s+if=`),
|
regexp.MustCompile(`\bdd\s+if=`),
|
||||||
|
|
||||||
regexp.MustCompile(`>\s*/dev/sd[a-z]\b`), // Block writes to disk devices (but allow /dev/null)
|
// Block writes to block devices (all common naming schemes).
|
||||||
|
regexp.MustCompile(
|
||||||
|
`>\s*/dev/(sd[a-z]|hd[a-z]|vd[a-z]|xvd[a-z]|nvme\d|mmcblk\d|loop\d|dm-\d|md\d|sr\d|nbd\d)`,
|
||||||
|
),
|
||||||
|
|
||||||
regexp.MustCompile(`\b(shutdown|reboot|poweroff)\b`),
|
regexp.MustCompile(`\b(shutdown|reboot|poweroff)\b`),
|
||||||
|
|
||||||
|
|
@ -225,8 +233,6 @@ var defaultDenyPatterns = []*regexp.Regexp{
|
||||||
|
|
||||||
regexp.MustCompile(`\|\|\s*rm\s+-[rf]`),
|
regexp.MustCompile(`\|\|\s*rm\s+-[rf]`),
|
||||||
|
|
||||||
regexp.MustCompile(`>\s*/dev/null\s*>&?\s*\d?`),
|
|
||||||
|
|
||||||
regexp.MustCompile(`<<\s*EOF`),
|
regexp.MustCompile(`<<\s*EOF`),
|
||||||
|
|
||||||
regexp.MustCompile(`\$\(\s*cat\s+`),
|
regexp.MustCompile(`\$\(\s*cat\s+`),
|
||||||
|
|
@ -247,7 +253,7 @@ var defaultDenyPatterns = []*regexp.Regexp{
|
||||||
|
|
||||||
regexp.MustCompile(`\bkillall\b`),
|
regexp.MustCompile(`\bkillall\b`),
|
||||||
|
|
||||||
regexp.MustCompile(`\bkill\s+-[9]\b`),
|
regexp.MustCompile(`\bkill\b`),
|
||||||
|
|
||||||
regexp.MustCompile(`\bcurl\b.*\|\s*(sh|bash)`),
|
regexp.MustCompile(`\bcurl\b.*\|\s*(sh|bash)`),
|
||||||
|
|
||||||
|
|
@ -280,7 +286,24 @@ var defaultDenyPatterns = []*regexp.Regexp{
|
||||||
regexp.MustCompile(`\beval\b`),
|
regexp.MustCompile(`\beval\b`),
|
||||||
|
|
||||||
regexp.MustCompile(`\bsource\s+.*\.sh\b`),
|
regexp.MustCompile(`\bsource\s+.*\.sh\b`),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// absolutePathPattern matches absolute file paths in commands (Unix and Windows).
|
||||||
|
absolutePathPattern = regexp.MustCompile(`[A-Za-z]:\\[^\\\"']+|/[^\s\"']+`)
|
||||||
|
|
||||||
|
// safePaths are kernel pseudo-devices that are always safe to reference in
|
||||||
|
// commands, regardless of workspace restriction. They contain no user data
|
||||||
|
// and cannot cause destructive writes.
|
||||||
|
safePaths = map[string]bool{
|
||||||
|
"/dev/null": true,
|
||||||
|
"/dev/zero": true,
|
||||||
|
"/dev/random": true,
|
||||||
|
"/dev/urandom": true,
|
||||||
|
"/dev/stdin": true,
|
||||||
|
"/dev/stdout": true,
|
||||||
|
"/dev/stderr": true,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
func NewExecTool(workingDir string, restrict bool) (*ExecTool, error) {
|
func NewExecTool(workingDir string, restrict bool) (*ExecTool, error) {
|
||||||
return NewExecToolWithConfig(workingDir, restrict, nil)
|
return NewExecToolWithConfig(workingDir, restrict, nil)
|
||||||
|
|
@ -288,10 +311,13 @@ func NewExecTool(workingDir string, restrict bool) (*ExecTool, error) {
|
||||||
|
|
||||||
func NewExecToolWithConfig(workingDir string, restrict bool, config *config.Config) (*ExecTool, error) {
|
func NewExecToolWithConfig(workingDir string, restrict bool, config *config.Config) (*ExecTool, error) {
|
||||||
denyPatterns := make([]*regexp.Regexp, 0)
|
denyPatterns := make([]*regexp.Regexp, 0)
|
||||||
|
customAllowPatterns := make([]*regexp.Regexp, 0)
|
||||||
|
allowRemote := true
|
||||||
|
|
||||||
if config != nil {
|
if config != nil {
|
||||||
execConfig := config.Tools.Exec
|
execConfig := config.Tools.Exec
|
||||||
enableDenyPatterns := execConfig.EnableDenyPatterns
|
enableDenyPatterns := execConfig.EnableDenyPatterns
|
||||||
|
allowRemote = execConfig.AllowRemote
|
||||||
|
|
||||||
if enableDenyPatterns {
|
if enableDenyPatterns {
|
||||||
denyPatterns = append(denyPatterns, defaultDenyPatterns...)
|
denyPatterns = append(denyPatterns, defaultDenyPatterns...)
|
||||||
|
|
@ -309,23 +335,39 @@ func NewExecToolWithConfig(workingDir string, restrict bool, config *config.Conf
|
||||||
// If deny patterns are disabled, we won't add any patterns, allowing all commands.
|
// If deny patterns are disabled, we won't add any patterns, allowing all commands.
|
||||||
fmt.Println("Warning: deny patterns are disabled. All commands will be allowed.")
|
fmt.Println("Warning: deny patterns are disabled. All commands will be allowed.")
|
||||||
}
|
}
|
||||||
|
for _, pattern := range execConfig.CustomAllowPatterns {
|
||||||
|
re, err := regexp.Compile(pattern)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid custom allow pattern %q: %w", pattern, err)
|
||||||
|
}
|
||||||
|
customAllowPatterns = append(customAllowPatterns, re)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
denyPatterns = append(denyPatterns, defaultDenyPatterns...)
|
denyPatterns = append(denyPatterns, defaultDenyPatterns...)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
timeout := 5 * time.Minute
|
||||||
|
if config != nil && config.Tools.Exec.TimeoutSeconds > 0 {
|
||||||
|
timeout = time.Duration(config.Tools.Exec.TimeoutSeconds) * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
bgCtx, bgCancel := context.WithCancel(context.Background())
|
bgCtx, bgCancel := context.WithCancel(context.Background())
|
||||||
|
|
||||||
return &ExecTool{
|
return &ExecTool{
|
||||||
workingDir: workingDir,
|
workingDir: workingDir,
|
||||||
|
|
||||||
timeout: 5 * time.Minute,
|
timeout: timeout,
|
||||||
|
|
||||||
denyPatterns: denyPatterns,
|
denyPatterns: denyPatterns,
|
||||||
|
|
||||||
allowRules: nil,
|
allowRules: nil,
|
||||||
|
|
||||||
|
customAllowPatterns: customAllowPatterns,
|
||||||
|
|
||||||
restrictToWorkspace: restrict,
|
restrictToWorkspace: restrict,
|
||||||
|
|
||||||
|
allowRemote: allowRemote,
|
||||||
|
|
||||||
bgProcesses: make(map[string]*bgProcess),
|
bgProcesses: make(map[string]*bgProcess),
|
||||||
|
|
||||||
bgCtx: bgCtx,
|
bgCtx: bgCtx,
|
||||||
|
|
@ -401,6 +443,19 @@ func (t *ExecTool) Execute(ctx context.Context, args map[string]any) *ToolResult
|
||||||
return ErrorResult("command is required")
|
return ErrorResult("command is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GHSA-pv8c-p6jf-3fpp: block exec from remote channels (e.g. Telegram webhooks)
|
||||||
|
// unless explicitly opted-in via config. Fail-closed: empty channel = blocked.
|
||||||
|
if !t.allowRemote {
|
||||||
|
channel := ToolChannel(ctx)
|
||||||
|
if channel == "" {
|
||||||
|
channel, _ = args["__channel"].(string)
|
||||||
|
}
|
||||||
|
channel = strings.TrimSpace(channel)
|
||||||
|
if channel == "" || !constants.IsInternalChannel(channel) {
|
||||||
|
return ErrorResult("exec is restricted to internal channels")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
cwd := t.workingDir
|
cwd := t.workingDir
|
||||||
|
|
||||||
if override := WorkspaceOverrideFromCtx(ctx); override != "" {
|
if override := WorkspaceOverrideFromCtx(ctx); override != "" {
|
||||||
|
|
@ -430,6 +485,25 @@ func (t *ExecTool) Execute(ctx context.Context, args map[string]any) *ToolResult
|
||||||
return ErrorResult(guardError)
|
return ErrorResult(guardError)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Re-resolve symlinks immediately before execution to shrink the TOCTOU window
|
||||||
|
// between validation and cmd.Dir assignment.
|
||||||
|
if t.restrictToWorkspace && t.workingDir != "" && cwd != t.workingDir {
|
||||||
|
resolved, err := filepath.EvalSymlinks(cwd)
|
||||||
|
if err != nil {
|
||||||
|
return ErrorResult(fmt.Sprintf("Command blocked by safety guard (path resolution failed: %v)", err))
|
||||||
|
}
|
||||||
|
absWorkspace, _ := filepath.Abs(t.workingDir)
|
||||||
|
wsResolved, _ := filepath.EvalSymlinks(absWorkspace)
|
||||||
|
if wsResolved == "" {
|
||||||
|
wsResolved = absWorkspace
|
||||||
|
}
|
||||||
|
rel, err := filepath.Rel(wsResolved, resolved)
|
||||||
|
if err != nil || !filepath.IsLocal(rel) {
|
||||||
|
return ErrorResult("Command blocked by safety guard (working directory escaped workspace)")
|
||||||
|
}
|
||||||
|
cwd = resolved
|
||||||
|
}
|
||||||
|
|
||||||
if bg {
|
if bg {
|
||||||
return t.executeBg(command, cwd)
|
return t.executeBg(command, cwd)
|
||||||
}
|
}
|
||||||
|
|
@ -943,9 +1017,20 @@ func (t *ExecTool) guardCommand(command, cwd string) string {
|
||||||
cmd := strings.TrimSpace(command)
|
cmd := strings.TrimSpace(command)
|
||||||
lower := strings.ToLower(cmd)
|
lower := strings.ToLower(cmd)
|
||||||
|
|
||||||
|
// Custom allow patterns exempt a command from deny checks.
|
||||||
|
explicitlyAllowed := false
|
||||||
|
for _, pattern := range t.customAllowPatterns {
|
||||||
|
if pattern.MatchString(lower) {
|
||||||
|
explicitlyAllowed = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !explicitlyAllowed {
|
||||||
for _, pattern := range t.denyPatterns {
|
for _, pattern := range t.denyPatterns {
|
||||||
if pattern.MatchString(lower) {
|
if pattern.MatchString(lower) {
|
||||||
return fmt.Sprintf("Command blocked: deny pattern %s", pattern.String())
|
return "Command blocked by safety guard (dangerous pattern detected)"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1020,6 +1105,10 @@ func (t *ExecTool) guardCommand(command, cwd string) string {
|
||||||
|
|
||||||
p := filepath.Clean(token)
|
p := filepath.Clean(token)
|
||||||
|
|
||||||
|
if safePaths[p] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
rel, err := filepath.Rel(cwdPath, p)
|
rel, err := filepath.Rel(cwdPath, p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,9 @@ type SpawnTool struct {
|
||||||
callback AsyncCallback // For async completion notification
|
callback AsyncCallback // For async completion notification
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Compile-time check: SpawnTool implements AsyncExecutor.
|
||||||
|
var _ AsyncExecutor = (*SpawnTool)(nil)
|
||||||
|
|
||||||
func NewSpawnTool(manager *SubagentManager) *SpawnTool {
|
func NewSpawnTool(manager *SubagentManager) *SpawnTool {
|
||||||
return &SpawnTool{
|
return &SpawnTool{
|
||||||
manager: manager,
|
manager: manager,
|
||||||
|
|
@ -85,6 +88,16 @@ func (t *SpawnTool) SetAllowlistChecker(check func(targetAgentID string) bool) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *SpawnTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
|
func (t *SpawnTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
|
||||||
|
return t.execute(ctx, args, t.callback)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExecuteAsync implements AsyncExecutor. The callback is passed through to the
|
||||||
|
// subagent manager as a call parameter — never stored on the SpawnTool instance.
|
||||||
|
func (t *SpawnTool) ExecuteAsync(ctx context.Context, args map[string]any, cb AsyncCallback) *ToolResult {
|
||||||
|
return t.execute(ctx, args, cb)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *SpawnTool) execute(ctx context.Context, args map[string]any, cb AsyncCallback) *ToolResult {
|
||||||
task, ok := args["task"].(string)
|
task, ok := args["task"].(string)
|
||||||
if !ok || strings.TrimSpace(task) == "" {
|
if !ok || strings.TrimSpace(task) == "" {
|
||||||
return ErrorResult(
|
return ErrorResult(
|
||||||
|
|
@ -129,7 +142,7 @@ func (t *SpawnTool) Execute(ctx context.Context, args map[string]any) *ToolResul
|
||||||
|
|
||||||
// Pass callback to manager for async completion notification
|
// Pass callback to manager for async completion notification
|
||||||
|
|
||||||
result, err := t.manager.Spawn(ctx, task, label, agentID, t.originChannel, t.originChatID, preset, t.callback)
|
result, err := t.manager.Spawn(ctx, task, label, agentID, t.originChannel, t.originChatID, preset, cb)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ErrorResult(fmt.Sprintf("failed to spawn subagent: %v", err))
|
return ErrorResult(fmt.Sprintf("failed to spawn subagent: %v", err))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -759,7 +759,7 @@ func (sm *SubagentManager) buildPresetRegistry(preset Preset, writeRoot string,
|
||||||
// Register read_file and list_dir with restrict=true
|
// Register read_file and list_dir with restrict=true
|
||||||
|
|
||||||
if config.AllowedTools["read_file"] {
|
if config.AllowedTools["read_file"] {
|
||||||
registry.Register(NewReadFileTool(readRoot, true))
|
registry.Register(NewReadFileTool(readRoot, true, 0))
|
||||||
}
|
}
|
||||||
|
|
||||||
if config.AllowedTools["list_dir"] {
|
if config.AllowedTools["list_dir"] {
|
||||||
|
|
@ -830,7 +830,9 @@ func (sm *SubagentManager) buildPresetRegistry(preset Preset, writeRoot string,
|
||||||
}
|
}
|
||||||
|
|
||||||
if config.AllowedTools["web_fetch"] {
|
if config.AllowedTools["web_fetch"] {
|
||||||
registry.Register(NewWebFetchTool(50000))
|
if fetchTool, err := NewWebFetchTool(50000); err == nil {
|
||||||
|
registry.Register(fetchTool)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register message tool (always available)
|
// Register message tool (always available)
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/logger"
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
"github.com/sipeed/picoclaw/pkg/orch"
|
"github.com/sipeed/picoclaw/pkg/orch"
|
||||||
|
|
@ -73,6 +74,8 @@ func RunToolLoop(
|
||||||
|
|
||||||
toolStats := map[string]int{}
|
toolStats := map[string]int{}
|
||||||
|
|
||||||
|
var mu sync.Mutex // protects totalToolCalls and toolStats during parallel execution
|
||||||
|
|
||||||
var finalContent string
|
var finalContent string
|
||||||
|
|
||||||
for iteration < config.MaxIterations {
|
for iteration < config.MaxIterations {
|
||||||
|
|
@ -167,9 +170,22 @@ func RunToolLoop(
|
||||||
}
|
}
|
||||||
messages = append(messages, assistantMsg)
|
messages = append(messages, assistantMsg)
|
||||||
|
|
||||||
// 7. Execute tool calls (hook: toolcall per tool)
|
// 7. Execute tool calls in parallel (hook: toolcall per tool)
|
||||||
|
type indexedResult struct {
|
||||||
|
result *ToolResult
|
||||||
|
tc providers.ToolCall
|
||||||
|
}
|
||||||
|
|
||||||
|
results := make([]indexedResult, len(normalizedToolCalls))
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
|
for i, tc := range normalizedToolCalls {
|
||||||
|
results[i].tc = tc
|
||||||
|
|
||||||
|
wg.Add(1)
|
||||||
|
go func(idx int, tc providers.ToolCall) {
|
||||||
|
defer wg.Done()
|
||||||
|
|
||||||
for _, tc := range normalizedToolCalls {
|
|
||||||
argsJSON, _ := json.Marshal(tc.Arguments)
|
argsJSON, _ := json.Marshal(tc.Arguments)
|
||||||
|
|
||||||
argsPreview := utils.Truncate(string(argsJSON), 200)
|
argsPreview := utils.Truncate(string(argsJSON), 200)
|
||||||
|
|
@ -184,11 +200,10 @@ func RunToolLoop(
|
||||||
|
|
||||||
reporter.ReportStateChange(config.AgentID, orch.AgentStateToolCall, tc.Name)
|
reporter.ReportStateChange(config.AgentID, orch.AgentStateToolCall, tc.Name)
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
totalToolCalls++
|
totalToolCalls++
|
||||||
|
|
||||||
toolStats[tc.Name]++
|
toolStats[tc.Name]++
|
||||||
|
mu.Unlock()
|
||||||
// Execute tool (no async callback for subagents - they run independently)
|
|
||||||
|
|
||||||
var toolResult *ToolResult
|
var toolResult *ToolResult
|
||||||
|
|
||||||
|
|
@ -197,26 +212,25 @@ func RunToolLoop(
|
||||||
} else {
|
} else {
|
||||||
toolResult = ErrorResult("No tools available")
|
toolResult = ErrorResult("No tools available")
|
||||||
}
|
}
|
||||||
|
results[idx].result = toolResult
|
||||||
|
}(i, tc)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
// Determine content for LLM
|
// Append results in original order
|
||||||
|
for _, r := range results {
|
||||||
contentForLLM := toolResult.ForLLM
|
contentForLLM := r.result.ForLLM
|
||||||
|
if contentForLLM == "" && r.result.Err != nil {
|
||||||
if contentForLLM == "" && toolResult.Err != nil {
|
contentForLLM = r.result.Err.Error()
|
||||||
contentForLLM = toolResult.Err.Error()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add tool result message
|
messages = append(messages, providers.Message{
|
||||||
|
|
||||||
toolResultMsg := providers.Message{
|
|
||||||
Role: "tool",
|
Role: "tool",
|
||||||
|
|
||||||
Content: contentForLLM,
|
Content: contentForLLM,
|
||||||
|
|
||||||
ToolCallID: tc.ID,
|
ToolCallID: r.tc.ID,
|
||||||
}
|
})
|
||||||
|
|
||||||
messages = append(messages, toolResultMsg)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
568
pkg/tools/web.go
|
|
@ -50,6 +50,43 @@ var (
|
||||||
reDDGSnippet = regexp.MustCompile(`<a class="result__snippet[^"]*".*?>([\s\S]*?)</a>`)
|
reDDGSnippet = regexp.MustCompile(`<a class="result__snippet[^"]*".*?>([\s\S]*?)</a>`)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// APIKeyPool provides round-robin key rotation for multi-key API access.
|
||||||
|
type APIKeyPool struct {
|
||||||
|
keys []string
|
||||||
|
current uint32
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAPIKeyPool(keys []string) *APIKeyPool {
|
||||||
|
return &APIKeyPool{keys: keys}
|
||||||
|
}
|
||||||
|
|
||||||
|
type APIKeyIterator struct {
|
||||||
|
pool *APIKeyPool
|
||||||
|
startIdx uint32
|
||||||
|
attempt uint32
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *APIKeyPool) NewIterator() *APIKeyIterator {
|
||||||
|
if len(p.keys) == 0 {
|
||||||
|
return &APIKeyIterator{pool: p}
|
||||||
|
}
|
||||||
|
idx := atomic.AddUint32(&p.current, 1) - 1
|
||||||
|
return &APIKeyIterator{
|
||||||
|
pool: p,
|
||||||
|
startIdx: idx,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (it *APIKeyIterator) Next() (string, bool) {
|
||||||
|
length := uint32(len(it.pool.keys))
|
||||||
|
if length == 0 || it.attempt >= length {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
key := it.pool.keys[(it.startIdx+it.attempt)%length]
|
||||||
|
it.attempt++
|
||||||
|
return key, true
|
||||||
|
}
|
||||||
|
|
||||||
// createHTTPClient creates an HTTP client with optional proxy support
|
// createHTTPClient creates an HTTP client with optional proxy support
|
||||||
func createHTTPClient(proxyURL string, timeout time.Duration) (*http.Client, error) {
|
func createHTTPClient(proxyURL string, timeout time.Duration) (*http.Client, error) {
|
||||||
client := &http.Client{
|
client := &http.Client{
|
||||||
|
|
@ -133,10 +170,8 @@ func formatWebSearchResults(query, provider string, results []searchResultItem,
|
||||||
}
|
}
|
||||||
|
|
||||||
type BraveSearchProvider struct {
|
type BraveSearchProvider struct {
|
||||||
apiKey string
|
keyPool *APIKeyPool
|
||||||
|
|
||||||
proxy string
|
proxy string
|
||||||
|
|
||||||
client *http.Client
|
client *http.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -144,48 +179,66 @@ func (p *BraveSearchProvider) Search(ctx context.Context, query string, count in
|
||||||
searchURL := fmt.Sprintf("https://api.search.brave.com/res/v1/web/search?q=%s&count=%d",
|
searchURL := fmt.Sprintf("https://api.search.brave.com/res/v1/web/search?q=%s&count=%d",
|
||||||
url.QueryEscape(query), count)
|
url.QueryEscape(query), count)
|
||||||
|
|
||||||
|
var lastErr error
|
||||||
|
iter := p.keyPool.NewIterator()
|
||||||
|
|
||||||
|
for {
|
||||||
|
apiKey, ok := iter.Next()
|
||||||
|
if !ok {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, "GET", searchURL, nil)
|
req, err := http.NewRequestWithContext(ctx, "GET", searchURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to create request: %w", err)
|
return "", fmt.Errorf("failed to create request: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
req.Header.Set("Accept", "application/json")
|
req.Header.Set("Accept", "application/json")
|
||||||
|
req.Header.Set("X-Subscription-Token", apiKey)
|
||||||
req.Header.Set("X-Subscription-Token", p.apiKey)
|
|
||||||
|
|
||||||
resp, err := p.client.Do(req)
|
resp, err := p.client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("request failed: %w", err)
|
lastErr = fmt.Errorf("request failed: %w", err)
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to read response: %w", err)
|
lastErr = fmt.Errorf("failed to read response: %w", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
lastErr = fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body))
|
||||||
|
if resp.StatusCode == http.StatusTooManyRequests ||
|
||||||
|
resp.StatusCode == http.StatusUnauthorized ||
|
||||||
|
resp.StatusCode == http.StatusForbidden ||
|
||||||
|
resp.StatusCode >= 500 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return "", lastErr
|
||||||
}
|
}
|
||||||
|
|
||||||
var searchResp struct {
|
var searchResp struct {
|
||||||
Web struct {
|
Web struct {
|
||||||
Results []struct {
|
Results []struct {
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
|
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
|
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
} `json:"results"`
|
} `json:"results"`
|
||||||
} `json:"web"`
|
} `json:"web"`
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := json.Unmarshal(body, &searchResp); err != nil {
|
if err := json.Unmarshal(body, &searchResp); err != nil {
|
||||||
// Log error body for debugging
|
|
||||||
|
|
||||||
fmt.Printf("Brave API Error Body: %s\n", string(body))
|
|
||||||
|
|
||||||
return "", fmt.Errorf("failed to parse response: %w", err)
|
return "", fmt.Errorf("failed to parse response: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
results := searchResp.Web.Results
|
results := searchResp.Web.Results
|
||||||
|
if len(results) == 0 {
|
||||||
|
return fmt.Sprintf("No results for: %s", query), nil
|
||||||
|
}
|
||||||
|
|
||||||
items := make([]searchResultItem, 0, len(results))
|
items := make([]searchResultItem, 0, len(results))
|
||||||
|
|
||||||
|
|
@ -199,16 +252,16 @@ func (p *BraveSearchProvider) Search(ctx context.Context, query string, count in
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return formatWebSearchResults(query, "", items, count), nil
|
return formatWebSearchResults(query, "Brave", items, count), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", fmt.Errorf("all api keys failed, last error: %w", lastErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
type TavilySearchProvider struct {
|
type TavilySearchProvider struct {
|
||||||
apiKey string
|
keyPool *APIKeyPool
|
||||||
|
|
||||||
baseURL string
|
baseURL string
|
||||||
|
|
||||||
proxy string
|
proxy string
|
||||||
|
|
||||||
client *http.Client
|
client *http.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -218,8 +271,17 @@ func (p *TavilySearchProvider) Search(ctx context.Context, query string, count i
|
||||||
searchURL = "https://api.tavily.com/search"
|
searchURL = "https://api.tavily.com/search"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var lastErr error
|
||||||
|
iter := p.keyPool.NewIterator()
|
||||||
|
|
||||||
|
for {
|
||||||
|
apiKey, ok := iter.Next()
|
||||||
|
if !ok {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
payload := map[string]any{
|
payload := map[string]any{
|
||||||
"api_key": p.apiKey,
|
"api_key": apiKey,
|
||||||
|
|
||||||
"query": query,
|
"query": query,
|
||||||
|
|
||||||
|
|
@ -250,18 +312,27 @@ func (p *TavilySearchProvider) Search(ctx context.Context, query string, count i
|
||||||
|
|
||||||
resp, err := p.client.Do(req)
|
resp, err := p.client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("request failed: %w", err)
|
lastErr = fmt.Errorf("request failed: %w", err)
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to read response: %w", err)
|
lastErr = fmt.Errorf("failed to read response: %w", err)
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return "", fmt.Errorf("tavily api error (status %d): %s", resp.StatusCode, string(body))
|
lastErr = fmt.Errorf("tavily api error (status %d): %s", resp.StatusCode, string(body))
|
||||||
|
if resp.StatusCode == http.StatusTooManyRequests ||
|
||||||
|
resp.StatusCode == http.StatusUnauthorized ||
|
||||||
|
resp.StatusCode == http.StatusForbidden ||
|
||||||
|
resp.StatusCode >= 500 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return "", lastErr
|
||||||
}
|
}
|
||||||
|
|
||||||
var searchResp struct {
|
var searchResp struct {
|
||||||
|
|
@ -293,6 +364,9 @@ func (p *TavilySearchProvider) Search(ctx context.Context, query string, count i
|
||||||
}
|
}
|
||||||
|
|
||||||
return formatWebSearchResults(query, "Tavily", items, count), nil
|
return formatWebSearchResults(query, "Tavily", items, count), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", fmt.Errorf("all api keys failed, last error: %w", lastErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
type DuckDuckGoSearchProvider struct {
|
type DuckDuckGoSearchProvider struct {
|
||||||
|
|
@ -385,33 +459,35 @@ func stripTags(content string) string {
|
||||||
}
|
}
|
||||||
|
|
||||||
type PerplexitySearchProvider struct {
|
type PerplexitySearchProvider struct {
|
||||||
apiKey string
|
keyPool *APIKeyPool
|
||||||
|
|
||||||
proxy string
|
proxy string
|
||||||
|
|
||||||
client *http.Client
|
client *http.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *PerplexitySearchProvider) Search(ctx context.Context, query string, count int) (string, error) {
|
func (p *PerplexitySearchProvider) Search(ctx context.Context, query string, count int) (string, error) {
|
||||||
searchURL := "https://api.perplexity.ai/chat/completions"
|
searchURL := "https://api.perplexity.ai/chat/completions"
|
||||||
|
|
||||||
|
var lastErr error
|
||||||
|
iter := p.keyPool.NewIterator()
|
||||||
|
|
||||||
|
for {
|
||||||
|
apiKey, ok := iter.Next()
|
||||||
|
if !ok {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
payload := map[string]any{
|
payload := map[string]any{
|
||||||
"model": "sonar",
|
"model": "sonar",
|
||||||
|
|
||||||
"messages": []map[string]string{
|
"messages": []map[string]string{
|
||||||
{
|
{
|
||||||
"role": "system",
|
"role": "system",
|
||||||
|
|
||||||
"content": "You are a search assistant. Provide concise search results with titles, URLs, and brief descriptions in the following format:\n1. Title\n URL\n Description\n\nDo not add extra commentary.",
|
"content": "You are a search assistant. Provide concise search results with titles, URLs, and brief descriptions in the following format:\n1. Title\n URL\n Description\n\nDo not add extra commentary.",
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
"role": "user",
|
"role": "user",
|
||||||
|
|
||||||
"content": fmt.Sprintf("Search for: %s. Provide up to %d relevant results.", query, count),
|
"content": fmt.Sprintf("Search for: %s. Provide up to %d relevant results.", query, count),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
"max_tokens": 1000,
|
"max_tokens": 1000,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -426,23 +502,32 @@ func (p *PerplexitySearchProvider) Search(ctx context.Context, query string, cou
|
||||||
}
|
}
|
||||||
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
req.Header.Set("Authorization", "Bearer "+p.apiKey)
|
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||||
|
|
||||||
req.Header.Set("User-Agent", userAgent)
|
req.Header.Set("User-Agent", userAgent)
|
||||||
|
|
||||||
resp, err := p.client.Do(req)
|
resp, err := p.client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("request failed: %w", err)
|
lastErr = fmt.Errorf("request failed: %w", err)
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to read response: %w", err)
|
lastErr = fmt.Errorf("failed to read response: %w", err)
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return "", fmt.Errorf("Perplexity API error: %s", string(body))
|
lastErr = fmt.Errorf("Perplexity API error: %s", string(body))
|
||||||
|
if resp.StatusCode == http.StatusTooManyRequests ||
|
||||||
|
resp.StatusCode == http.StatusUnauthorized ||
|
||||||
|
resp.StatusCode == http.StatusForbidden ||
|
||||||
|
resp.StatusCode >= 500 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return "", lastErr
|
||||||
}
|
}
|
||||||
|
|
||||||
var searchResp struct {
|
var searchResp struct {
|
||||||
|
|
@ -462,6 +547,149 @@ func (p *PerplexitySearchProvider) Search(ctx context.Context, query string, cou
|
||||||
}
|
}
|
||||||
|
|
||||||
return fmt.Sprintf("Results for: %s (via Perplexity)\n%s", query, searchResp.Choices[0].Message.Content), nil
|
return fmt.Sprintf("Results for: %s (via Perplexity)\n%s", query, searchResp.Choices[0].Message.Content), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", fmt.Errorf("all api keys failed, last error: %w", lastErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
type SearXNGSearchProvider struct {
|
||||||
|
baseURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *SearXNGSearchProvider) Search(ctx context.Context, query string, count int) (string, error) {
|
||||||
|
searchURL := fmt.Sprintf("%s/search?q=%s&format=json&categories=general",
|
||||||
|
strings.TrimSuffix(p.baseURL, "/"),
|
||||||
|
url.QueryEscape(query))
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "GET", searchURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 10 * time.Second}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return "", fmt.Errorf("SearXNG returned status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
Results []struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
Engine string `json:"engine"`
|
||||||
|
Score float64 `json:"score"`
|
||||||
|
} `json:"results"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to parse response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(result.Results) == 0 {
|
||||||
|
return fmt.Sprintf("No results for: %s", query), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limit results to requested count
|
||||||
|
if len(result.Results) > count {
|
||||||
|
result.Results = result.Results[:count]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format results in standard PicoClaw format
|
||||||
|
items := make([]searchResultItem, 0, len(result.Results))
|
||||||
|
for _, r := range result.Results {
|
||||||
|
items = append(items, searchResultItem{
|
||||||
|
Title: r.Title,
|
||||||
|
URL: r.URL,
|
||||||
|
Snippet: r.Content,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return formatWebSearchResults(query, "SearXNG", items, count), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type GLMSearchProvider struct {
|
||||||
|
apiKey string
|
||||||
|
baseURL string
|
||||||
|
searchEngine string
|
||||||
|
proxy string
|
||||||
|
client *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *GLMSearchProvider) Search(ctx context.Context, query string, count int) (string, error) {
|
||||||
|
searchURL := p.baseURL
|
||||||
|
if searchURL == "" {
|
||||||
|
searchURL = "https://open.bigmodel.cn/api/paas/v4/web_search"
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := map[string]any{
|
||||||
|
"search_query": query,
|
||||||
|
"search_engine": p.searchEngine,
|
||||||
|
"search_intent": false,
|
||||||
|
"count": count,
|
||||||
|
"content_size": "medium",
|
||||||
|
}
|
||||||
|
|
||||||
|
bodyBytes, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to marshal payload: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "POST", searchURL, bytes.NewReader(bodyBytes))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+p.apiKey)
|
||||||
|
|
||||||
|
resp, err := p.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to read response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return "", fmt.Errorf("GLM Search API error (status %d): %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
var searchResp struct {
|
||||||
|
SearchResult []struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
Link string `json:"link"`
|
||||||
|
} `json:"search_result"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(body, &searchResp); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to parse response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
results := searchResp.SearchResult
|
||||||
|
if len(results) == 0 {
|
||||||
|
return fmt.Sprintf("No results for: %s", query), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
items := make([]searchResultItem, 0, len(results))
|
||||||
|
for _, item := range results {
|
||||||
|
items = append(items, searchResultItem{
|
||||||
|
Title: item.Title,
|
||||||
|
URL: item.Link,
|
||||||
|
Snippet: item.Content,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return formatWebSearchResults(query, "GLM Search", items, count), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type WebSearchTool struct {
|
type WebSearchTool struct {
|
||||||
|
|
@ -479,30 +707,26 @@ func (t *WebSearchTool) ProviderName() string {
|
||||||
}
|
}
|
||||||
|
|
||||||
type WebSearchToolOptions struct {
|
type WebSearchToolOptions struct {
|
||||||
BraveAPIKey string
|
BraveAPIKeys []string
|
||||||
|
|
||||||
BraveMaxResults int
|
BraveMaxResults int
|
||||||
|
|
||||||
BraveEnabled bool
|
BraveEnabled bool
|
||||||
|
TavilyAPIKeys []string
|
||||||
TavilyAPIKey string
|
|
||||||
|
|
||||||
TavilyBaseURL string
|
TavilyBaseURL string
|
||||||
|
|
||||||
TavilyMaxResults int
|
TavilyMaxResults int
|
||||||
|
|
||||||
TavilyEnabled bool
|
TavilyEnabled bool
|
||||||
|
|
||||||
DuckDuckGoMaxResults int
|
DuckDuckGoMaxResults int
|
||||||
|
|
||||||
DuckDuckGoEnabled bool
|
DuckDuckGoEnabled bool
|
||||||
|
PerplexityAPIKeys []string
|
||||||
PerplexityAPIKey string
|
|
||||||
|
|
||||||
PerplexityMaxResults int
|
PerplexityMaxResults int
|
||||||
|
|
||||||
PerplexityEnabled bool
|
PerplexityEnabled bool
|
||||||
|
SearXNGBaseURL string
|
||||||
|
SearXNGMaxResults int
|
||||||
|
SearXNGEnabled bool
|
||||||
|
GLMSearchAPIKey string
|
||||||
|
GLMSearchBaseURL string
|
||||||
|
GLMSearchEngine string
|
||||||
|
GLMSearchMaxResults int
|
||||||
|
GLMSearchEnabled bool
|
||||||
Proxy string
|
Proxy string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -513,42 +737,50 @@ func NewWebSearchTool(opts WebSearchToolOptions) (*WebSearchTool, error) {
|
||||||
|
|
||||||
maxResults := 5
|
maxResults := 5
|
||||||
|
|
||||||
// Priority: Perplexity > Brave > Tavily > DuckDuckGo
|
// Priority: Perplexity > Brave > SearXNG > Tavily > DuckDuckGo > GLM Search
|
||||||
|
if opts.PerplexityEnabled && len(opts.PerplexityAPIKeys) > 0 {
|
||||||
if opts.PerplexityEnabled && opts.PerplexityAPIKey != "" {
|
|
||||||
client, err := createHTTPClient(opts.Proxy, perplexityTimeout)
|
client, err := createHTTPClient(opts.Proxy, perplexityTimeout)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create HTTP client for Perplexity: %w", err)
|
return nil, fmt.Errorf("failed to create HTTP client for Perplexity: %w", err)
|
||||||
}
|
}
|
||||||
|
provider = &PerplexitySearchProvider{
|
||||||
provider = &PerplexitySearchProvider{apiKey: opts.PerplexityAPIKey, proxy: opts.Proxy, client: client}
|
keyPool: NewAPIKeyPool(opts.PerplexityAPIKeys),
|
||||||
|
proxy: opts.Proxy,
|
||||||
|
client: client,
|
||||||
|
}
|
||||||
|
|
||||||
providerName = "perplexity"
|
providerName = "perplexity"
|
||||||
|
|
||||||
if opts.PerplexityMaxResults > 0 {
|
if opts.PerplexityMaxResults > 0 {
|
||||||
maxResults = opts.PerplexityMaxResults
|
maxResults = opts.PerplexityMaxResults
|
||||||
}
|
}
|
||||||
} else if opts.BraveEnabled && opts.BraveAPIKey != "" {
|
} else if opts.BraveEnabled && len(opts.BraveAPIKeys) > 0 {
|
||||||
client, err := createHTTPClient(opts.Proxy, searchTimeout)
|
client, err := createHTTPClient(opts.Proxy, searchTimeout)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create HTTP client for Brave: %w", err)
|
return nil, fmt.Errorf("failed to create HTTP client for Brave: %w", err)
|
||||||
}
|
}
|
||||||
|
provider = &BraveSearchProvider{keyPool: NewAPIKeyPool(opts.BraveAPIKeys), proxy: opts.Proxy, client: client}
|
||||||
provider = &BraveSearchProvider{apiKey: opts.BraveAPIKey, proxy: opts.Proxy, client: client}
|
|
||||||
|
|
||||||
providerName = "brave"
|
providerName = "brave"
|
||||||
|
|
||||||
if opts.BraveMaxResults > 0 {
|
if opts.BraveMaxResults > 0 {
|
||||||
maxResults = opts.BraveMaxResults
|
maxResults = opts.BraveMaxResults
|
||||||
}
|
}
|
||||||
} else if opts.TavilyEnabled && opts.TavilyAPIKey != "" {
|
} else if opts.SearXNGEnabled && opts.SearXNGBaseURL != "" {
|
||||||
|
provider = &SearXNGSearchProvider{baseURL: opts.SearXNGBaseURL}
|
||||||
|
|
||||||
|
providerName = "searxng"
|
||||||
|
|
||||||
|
if opts.SearXNGMaxResults > 0 {
|
||||||
|
maxResults = opts.SearXNGMaxResults
|
||||||
|
}
|
||||||
|
} else if opts.TavilyEnabled && len(opts.TavilyAPIKeys) > 0 {
|
||||||
client, err := createHTTPClient(opts.Proxy, searchTimeout)
|
client, err := createHTTPClient(opts.Proxy, searchTimeout)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create HTTP client for Tavily: %w", err)
|
return nil, fmt.Errorf("failed to create HTTP client for Tavily: %w", err)
|
||||||
}
|
}
|
||||||
provider = &TavilySearchProvider{
|
provider = &TavilySearchProvider{
|
||||||
apiKey: opts.TavilyAPIKey,
|
keyPool: NewAPIKeyPool(opts.TavilyAPIKeys),
|
||||||
|
|
||||||
baseURL: opts.TavilyBaseURL,
|
baseURL: opts.TavilyBaseURL,
|
||||||
|
|
||||||
proxy: opts.Proxy,
|
proxy: opts.Proxy,
|
||||||
|
|
@ -573,6 +805,28 @@ func NewWebSearchTool(opts WebSearchToolOptions) (*WebSearchTool, error) {
|
||||||
if opts.DuckDuckGoMaxResults > 0 {
|
if opts.DuckDuckGoMaxResults > 0 {
|
||||||
maxResults = opts.DuckDuckGoMaxResults
|
maxResults = opts.DuckDuckGoMaxResults
|
||||||
}
|
}
|
||||||
|
} else if opts.GLMSearchEnabled && opts.GLMSearchAPIKey != "" {
|
||||||
|
client, err := createHTTPClient(opts.Proxy, searchTimeout)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create HTTP client for GLM Search: %w", err)
|
||||||
|
}
|
||||||
|
searchEngine := opts.GLMSearchEngine
|
||||||
|
if searchEngine == "" {
|
||||||
|
searchEngine = "search_std"
|
||||||
|
}
|
||||||
|
provider = &GLMSearchProvider{
|
||||||
|
apiKey: opts.GLMSearchAPIKey,
|
||||||
|
baseURL: opts.GLMSearchBaseURL,
|
||||||
|
searchEngine: searchEngine,
|
||||||
|
proxy: opts.Proxy,
|
||||||
|
client: client,
|
||||||
|
}
|
||||||
|
|
||||||
|
providerName = "glm"
|
||||||
|
|
||||||
|
if opts.GLMSearchMaxResults > 0 {
|
||||||
|
maxResults = opts.GLMSearchMaxResults
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
@ -648,17 +902,25 @@ type WebFetchTool struct {
|
||||||
proxy string
|
proxy string
|
||||||
|
|
||||||
client *http.Client
|
client *http.Client
|
||||||
|
|
||||||
|
fetchLimitBytes int64
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewWebFetchTool(maxChars int) *WebFetchTool {
|
// NewWebFetchTool creates a WebFetchTool. The optional fetchLimitBytes parameter
|
||||||
// createHTTPClient cannot fail with an empty proxy string.
|
// sets the maximum response body size (defaults to 10MB if not provided or <= 0).
|
||||||
|
func NewWebFetchTool(maxChars int, fetchLimitBytes ...int64) (*WebFetchTool, error) {
|
||||||
tool, _ := NewWebFetchToolWithProxy(maxChars, "")
|
var limit int64
|
||||||
|
if len(fetchLimitBytes) > 0 {
|
||||||
return tool
|
limit = fetchLimitBytes[0]
|
||||||
|
}
|
||||||
|
return NewWebFetchToolWithProxy(maxChars, "", limit)
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewWebFetchToolWithProxy(maxChars int, proxy string) (*WebFetchTool, error) {
|
// allowPrivateWebFetchHosts controls whether loopback/private hosts are allowed.
|
||||||
|
// This is false in normal runtime to reduce SSRF exposure, and tests can override it temporarily.
|
||||||
|
var allowPrivateWebFetchHosts atomic.Bool
|
||||||
|
|
||||||
|
func NewWebFetchToolWithProxy(maxChars int, proxy string, fetchLimitBytes int64) (*WebFetchTool, error) {
|
||||||
if maxChars <= 0 {
|
if maxChars <= 0 {
|
||||||
maxChars = defaultMaxChars
|
maxChars = defaultMaxChars
|
||||||
}
|
}
|
||||||
|
|
@ -666,14 +928,25 @@ func NewWebFetchToolWithProxy(maxChars int, proxy string) (*WebFetchTool, error)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create HTTP client for web fetch: %w", err)
|
return nil, fmt.Errorf("failed to create HTTP client for web fetch: %w", err)
|
||||||
}
|
}
|
||||||
|
if transport, ok := client.Transport.(*http.Transport); ok {
|
||||||
|
dialer := &net.Dialer{
|
||||||
|
Timeout: 15 * time.Second,
|
||||||
|
KeepAlive: 30 * time.Second,
|
||||||
|
}
|
||||||
|
transport.DialContext = newSafeDialContext(dialer)
|
||||||
|
}
|
||||||
client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
||||||
if len(via) >= maxRedirects {
|
if len(via) >= maxRedirects {
|
||||||
return fmt.Errorf("stopped after %d redirects", maxRedirects)
|
return fmt.Errorf("stopped after %d redirects", maxRedirects)
|
||||||
}
|
}
|
||||||
|
if isObviousPrivateHost(req.URL.Hostname()) {
|
||||||
|
return fmt.Errorf("redirect target is private or local network host")
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
if fetchLimitBytes <= 0 {
|
||||||
|
fetchLimitBytes = 10 * 1024 * 1024 // Security Fallback
|
||||||
|
}
|
||||||
|
|
||||||
return &WebFetchTool{
|
return &WebFetchTool{
|
||||||
maxChars: maxChars,
|
maxChars: maxChars,
|
||||||
|
|
@ -681,6 +954,8 @@ func NewWebFetchToolWithProxy(maxChars int, proxy string) (*WebFetchTool, error)
|
||||||
proxy: proxy,
|
proxy: proxy,
|
||||||
|
|
||||||
client: client,
|
client: client,
|
||||||
|
|
||||||
|
fetchLimitBytes: fetchLimitBytes,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -732,6 +1007,13 @@ func (t *WebFetchTool) Execute(ctx context.Context, args map[string]any) *ToolRe
|
||||||
return ErrorResult("missing domain in URL")
|
return ErrorResult("missing domain in URL")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Lightweight pre-flight: block obvious localhost/literal-IP without DNS resolution.
|
||||||
|
// The real SSRF guard is newSafeDialContext at connect time.
|
||||||
|
hostname := parsedURL.Hostname()
|
||||||
|
if isObviousPrivateHost(hostname) {
|
||||||
|
return ErrorResult("fetching private or local network hosts is not allowed")
|
||||||
|
}
|
||||||
|
|
||||||
maxChars := t.maxChars
|
maxChars := t.maxChars
|
||||||
if mc, ok := args["maxChars"].(float64); ok {
|
if mc, ok := args["maxChars"].(float64); ok {
|
||||||
if int(mc) > 100 {
|
if int(mc) > 100 {
|
||||||
|
|
@ -750,10 +1032,16 @@ func (t *WebFetchTool) Execute(ctx context.Context, args map[string]any) *ToolRe
|
||||||
return ErrorResult(fmt.Sprintf("request failed: %v", err))
|
return ErrorResult(fmt.Sprintf("request failed: %v", err))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
resp.Body = http.MaxBytesReader(nil, resp.Body, t.fetchLimitBytes)
|
||||||
|
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
body, err := io.ReadAll(resp.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
var maxBytesErr *http.MaxBytesError
|
||||||
|
if errors.As(err, &maxBytesErr) {
|
||||||
|
return ErrorResult(fmt.Sprintf("failed to read response: size exceeded %d bytes limit", t.fetchLimitBytes))
|
||||||
|
}
|
||||||
return ErrorResult(fmt.Sprintf("failed to read response: %v", err))
|
return ErrorResult(fmt.Sprintf("failed to read response: %v", err))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -807,16 +1095,14 @@ func (t *WebFetchTool) Execute(ctx context.Context, args map[string]any) *ToolRe
|
||||||
resultJSON, _ := json.MarshalIndent(result, "", " ")
|
resultJSON, _ := json.MarshalIndent(result, "", " ")
|
||||||
|
|
||||||
return &ToolResult{
|
return &ToolResult{
|
||||||
ForLLM: fmt.Sprintf(
|
ForLLM: string(resultJSON),
|
||||||
|
ForUser: fmt.Sprintf(
|
||||||
"Fetched %d bytes from %s (extractor: %s, truncated: %v)",
|
"Fetched %d bytes from %s (extractor: %s, truncated: %v)",
|
||||||
len(text),
|
len(text),
|
||||||
urlStr,
|
urlStr,
|
||||||
extractor,
|
extractor,
|
||||||
truncated,
|
truncated,
|
||||||
),
|
),
|
||||||
|
|
||||||
ForUser: string(resultJSON),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -847,3 +1133,127 @@ func (t *WebFetchTool) extractText(htmlContent string) string {
|
||||||
|
|
||||||
return sb.String()
|
return sb.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// newSafeDialContext re-resolves DNS at connect time to mitigate DNS rebinding (TOCTOU)
|
||||||
|
// where a hostname resolves to a public IP during pre-flight but a private IP at connect time.
|
||||||
|
func newSafeDialContext(dialer *net.Dialer) func(context.Context, string, string) (net.Conn, error) {
|
||||||
|
return func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||||
|
if allowPrivateWebFetchHosts.Load() {
|
||||||
|
return dialer.DialContext(ctx, network, address)
|
||||||
|
}
|
||||||
|
|
||||||
|
host, port, err := net.SplitHostPort(address)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid target address %q: %w", address, err)
|
||||||
|
}
|
||||||
|
if host == "" {
|
||||||
|
return nil, fmt.Errorf("empty target host")
|
||||||
|
}
|
||||||
|
|
||||||
|
if ip := net.ParseIP(host); ip != nil {
|
||||||
|
if isPrivateOrRestrictedIP(ip) {
|
||||||
|
return nil, fmt.Errorf("blocked private or local target: %s", host)
|
||||||
|
}
|
||||||
|
return dialer.DialContext(ctx, network, net.JoinHostPort(ip.String(), port))
|
||||||
|
}
|
||||||
|
|
||||||
|
ipAddrs, err := net.DefaultResolver.LookupIPAddr(ctx, host)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to resolve %s: %w", host, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
attempted := 0
|
||||||
|
var lastErr error
|
||||||
|
for _, ipAddr := range ipAddrs {
|
||||||
|
if isPrivateOrRestrictedIP(ipAddr.IP) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
attempted++
|
||||||
|
conn, err := dialer.DialContext(ctx, network, net.JoinHostPort(ipAddr.IP.String(), port))
|
||||||
|
if err == nil {
|
||||||
|
return conn, nil
|
||||||
|
}
|
||||||
|
lastErr = err
|
||||||
|
}
|
||||||
|
|
||||||
|
if attempted == 0 {
|
||||||
|
return nil, fmt.Errorf("all resolved addresses for %s are private or restricted", host)
|
||||||
|
}
|
||||||
|
if lastErr != nil {
|
||||||
|
return nil, fmt.Errorf("failed connecting to public addresses for %s: %w", host, lastErr)
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("failed connecting to public addresses for %s", host)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// isObviousPrivateHost performs a lightweight, no-DNS check for obviously private hosts.
|
||||||
|
// It catches localhost, literal private IPs, and empty hosts. It does NOT resolve DNS —
|
||||||
|
// the real SSRF guard is newSafeDialContext which checks IPs at connect time.
|
||||||
|
func isObviousPrivateHost(host string) bool {
|
||||||
|
if allowPrivateWebFetchHosts.Load() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
h := strings.ToLower(strings.TrimSpace(host))
|
||||||
|
h = strings.TrimSuffix(h, ".")
|
||||||
|
if h == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if h == "localhost" || strings.HasSuffix(h, ".localhost") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if ip := net.ParseIP(h); ip != nil {
|
||||||
|
return isPrivateOrRestrictedIP(ip)
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// isPrivateOrRestrictedIP returns true for IPs that should never be reached via web_fetch:
|
||||||
|
// RFC 1918, loopback, link-local (incl. cloud metadata 169.254.x.x), carrier-grade NAT,
|
||||||
|
// IPv6 unique-local (fc00::/7), 6to4 (2002::/16), and Teredo (2001:0000::/32).
|
||||||
|
func isPrivateOrRestrictedIP(ip net.IP) bool {
|
||||||
|
if ip == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() ||
|
||||||
|
ip.IsMulticast() || ip.IsUnspecified() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if ip4 := ip.To4(); ip4 != nil {
|
||||||
|
// IPv4 private, loopback, link-local, and carrier-grade NAT ranges.
|
||||||
|
if ip4[0] == 10 ||
|
||||||
|
ip4[0] == 127 ||
|
||||||
|
ip4[0] == 0 ||
|
||||||
|
(ip4[0] == 172 && ip4[1] >= 16 && ip4[1] <= 31) ||
|
||||||
|
(ip4[0] == 192 && ip4[1] == 168) ||
|
||||||
|
(ip4[0] == 169 && ip4[1] == 254) ||
|
||||||
|
(ip4[0] == 100 && ip4[1] >= 64 && ip4[1] <= 127) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(ip) == net.IPv6len {
|
||||||
|
// IPv6 unique local addresses (fc00::/7)
|
||||||
|
if (ip[0] & 0xfe) == 0xfc {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// 6to4 addresses (2002::/16): check the embedded IPv4 at bytes [2:6].
|
||||||
|
if ip[0] == 0x20 && ip[1] == 0x02 {
|
||||||
|
embedded := net.IPv4(ip[2], ip[3], ip[4], ip[5])
|
||||||
|
return isPrivateOrRestrictedIP(embedded)
|
||||||
|
}
|
||||||
|
// Teredo (2001:0000::/32): client IPv4 is at bytes [12:16], XOR-inverted.
|
||||||
|
if ip[0] == 0x20 && ip[1] == 0x01 && ip[2] == 0x00 && ip[3] == 0x00 {
|
||||||
|
client := net.IPv4(ip[12]^0xff, ip[13]^0xff, ip[14]^0xff, ip[15]^0xff)
|
||||||
|
return isPrivateOrRestrictedIP(client)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
|
||||||
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
212
web/backend/api/config.go
Normal file
|
|
@ -0,0 +1,212 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleGetConfig returns the complete system configuration.
|
||||||
|
//
|
||||||
|
// GET /api/config
|
||||||
|
func (h *Handler) handleGetConfig(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
|
||||||
|
}
|
||||||
|
|
||||||
|
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 execAllowRemoteOmitted(body) {
|
||||||
|
cfg.Tools.Exec.AllowRemote = config.DefaultConfig().Tools.Exec.AllowRemote
|
||||||
|
}
|
||||||
|
|
||||||
|
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"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func execAllowRemoteOmitted(body []byte) bool {
|
||||||
|
var raw struct {
|
||||||
|
Tools *struct {
|
||||||
|
Exec *struct {
|
||||||
|
AllowRemote *bool `json:"allow_remote"`
|
||||||
|
} `json:"exec"`
|
||||||
|
} `json:"tools"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &raw); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return raw.Tools == nil || raw.Tools.Exec == nil || raw.Tools.Exec.AllowRemote == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
88
web/backend/api/config_test.go
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHandleUpdateConfig_PreservesExecAllowRemoteDefaultWhenOmitted(t *testing.T) {
|
||||||
|
configPath, cleanup := setupOAuthTestEnv(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
h := NewHandler(configPath)
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
h.RegisterRoutes(mux)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPut, "/api/config", bytes.NewBufferString(`{
|
||||||
|
"agents": {
|
||||||
|
"defaults": {
|
||||||
|
"workspace": "~/.picoclaw/workspace"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"model_list": [
|
||||||
|
{
|
||||||
|
"model_name": "custom-default",
|
||||||
|
"model": "openai/gpt-4o",
|
||||||
|
"api_key": "sk-default"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}`))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := config.LoadConfig(configPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
if !cfg.Tools.Exec.AllowRemote {
|
||||||
|
t.Fatal("tools.exec.allow_remote should remain true when omitted from PUT /api/config")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleUpdateConfig_DoesNotInheritDefaultModelFields(t *testing.T) {
|
||||||
|
configPath, cleanup := setupOAuthTestEnv(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
h := NewHandler(configPath)
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
h.RegisterRoutes(mux)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPut, "/api/config", bytes.NewBufferString(`{
|
||||||
|
"agents": {
|
||||||
|
"defaults": {
|
||||||
|
"workspace": "~/.picoclaw/workspace"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"model_list": [
|
||||||
|
{
|
||||||
|
"model_name": "custom-default",
|
||||||
|
"model": "openai/gpt-4o",
|
||||||
|
"api_key": "sk-default"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}`))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := config.LoadConfig(configPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
if got := cfg.ModelList[0].APIBase; got != "" {
|
||||||
|
t.Fatalf("model_list[0].api_base = %q, want empty string", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
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:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
560
web/backend/api/gateway.go
Normal file
|
|
@ -0,0 +1,560 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"runtime"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
"github.com/sipeed/picoclaw/web/backend/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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/logs/clear", h.handleGatewayClearLogs)
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
if !hasModelConfiguration(*modelCfg) {
|
||||||
|
return false, fmt.Sprintf("default model %q has no credentials configured", modelName), nil
|
||||||
|
}
|
||||||
|
if requiresRuntimeProbe(*modelCfg) && !probeLocalModelAvailability(*modelCfg) {
|
||||||
|
return false, fmt.Sprintf("default model %q is not reachable", 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 := utils.FindPicoclawBinary()
|
||||||
|
|
||||||
|
cmd := exec.Command(execPath, "gateway")
|
||||||
|
cmd.Env = os.Environ()
|
||||||
|
// Forward the launcher's config path via the environment variable that
|
||||||
|
// GetConfigPath() already reads, so the gateway sub-process uses the same
|
||||||
|
// config file without requiring a --config flag on the gateway subcommand.
|
||||||
|
if h.configPath != "" {
|
||||||
|
cmd.Env = append(cmd.Env, "PICOCLAW_CONFIG="+h.configPath)
|
||||||
|
}
|
||||||
|
if host := h.gatewayHostOverride(); host != "" {
|
||||||
|
cmd.Env = append(cmd.Env, "PICOCLAW_GATEWAY_HOST="+host)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 := gatewayProbeHost(h.effectiveGatewayBindHost(cfg))
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleGatewayClearLogs clears the in-memory gateway log buffer.
|
||||||
|
//
|
||||||
|
// POST /api/gateway/logs/clear
|
||||||
|
func (h *Handler) handleGatewayClearLogs(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gateway.logs.Clear()
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"status": "cleared",
|
||||||
|
"log_total": 0,
|
||||||
|
"log_run_id": gateway.logs.RunID(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
host = gatewayProbeHost(h.effectiveGatewayBindHost(cfg))
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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())
|
||||||
|
}
|
||||||
|
}
|
||||||
66
web/backend/api/gateway_host.go
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (h *Handler) effectiveLauncherPublic() bool {
|
||||||
|
if h.serverPublicExplicit {
|
||||||
|
return h.serverPublic
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := h.loadLauncherConfig()
|
||||||
|
if err == nil {
|
||||||
|
return cfg.Public
|
||||||
|
}
|
||||||
|
|
||||||
|
return h.serverPublic
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) gatewayHostOverride() string {
|
||||||
|
if h.effectiveLauncherPublic() {
|
||||||
|
return "0.0.0.0"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) effectiveGatewayBindHost(cfg *config.Config) string {
|
||||||
|
if override := h.gatewayHostOverride(); override != "" {
|
||||||
|
return override
|
||||||
|
}
|
||||||
|
if cfg == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(cfg.Gateway.Host)
|
||||||
|
}
|
||||||
|
|
||||||
|
func gatewayProbeHost(bindHost string) string {
|
||||||
|
if bindHost == "" || bindHost == "0.0.0.0" {
|
||||||
|
return "127.0.0.1"
|
||||||
|
}
|
||||||
|
return bindHost
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestHostName(r *http.Request) string {
|
||||||
|
reqHost, _, err := net.SplitHostPort(r.Host)
|
||||||
|
if err == nil {
|
||||||
|
return reqHost
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(r.Host) != "" {
|
||||||
|
return r.Host
|
||||||
|
}
|
||||||
|
return "127.0.0.1"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) buildWsURL(r *http.Request, cfg *config.Config) string {
|
||||||
|
host := h.effectiveGatewayBindHost(cfg)
|
||||||
|
if host == "" || host == "0.0.0.0" {
|
||||||
|
host = requestHostName(r)
|
||||||
|
}
|
||||||
|
return "ws://" + net.JoinHostPort(host, strconv.Itoa(cfg.Gateway.Port)) + "/pico/ws"
|
||||||
|
}
|
||||||
59
web/backend/api/gateway_host_test.go
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http/httptest"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
"github.com/sipeed/picoclaw/web/backend/launcherconfig"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGatewayHostOverrideUsesExplicitRuntimePublic(t *testing.T) {
|
||||||
|
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||||
|
launcherPath := launcherconfig.PathForAppConfig(configPath)
|
||||||
|
if err := launcherconfig.Save(launcherPath, launcherconfig.Config{
|
||||||
|
Port: 18800,
|
||||||
|
Public: false,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("launcherconfig.Save() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
h := NewHandler(configPath)
|
||||||
|
h.SetServerOptions(18800, true, true, nil)
|
||||||
|
|
||||||
|
if got := h.gatewayHostOverride(); got != "0.0.0.0" {
|
||||||
|
t.Fatalf("gatewayHostOverride() = %q, want %q", got, "0.0.0.0")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildWsURLUsesRequestHostWhenLauncherPublicSaved(t *testing.T) {
|
||||||
|
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||||
|
launcherPath := launcherconfig.PathForAppConfig(configPath)
|
||||||
|
if err := launcherconfig.Save(launcherPath, launcherconfig.Config{
|
||||||
|
Port: 18800,
|
||||||
|
Public: true,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("launcherconfig.Save() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
h := NewHandler(configPath)
|
||||||
|
h.SetServerOptions(18800, false, false, nil)
|
||||||
|
|
||||||
|
cfg := config.DefaultConfig()
|
||||||
|
cfg.Gateway.Host = "127.0.0.1"
|
||||||
|
cfg.Gateway.Port = 18790
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "http://launcher.local/api/pico/token", nil)
|
||||||
|
req.Host = "192.168.1.9:18800"
|
||||||
|
|
||||||
|
if got := h.buildWsURL(req, cfg); got != "ws://192.168.1.9:18790/pico/ws" {
|
||||||
|
t.Fatalf("buildWsURL() = %q, want %q", got, "ws://192.168.1.9:18790/pico/ws")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGatewayProbeHostUsesLoopbackForWildcardBind(t *testing.T) {
|
||||||
|
if got := gatewayProbeHost("0.0.0.0"); got != "127.0.0.1" {
|
||||||
|
t.Fatalf("gatewayProbeHost() = %q, want %q", got, "127.0.0.1")
|
||||||
|
}
|
||||||
|
}
|
||||||
410
web/backend/api/gateway_test.go
Normal file
|
|
@ -0,0 +1,410 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/auth"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
"github.com/sipeed/picoclaw/web/backend/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
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"
|
||||||
|
err := config.SaveConfig(configPath, cfg)
|
||||||
|
if 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"
|
||||||
|
err := config.SaveConfig(configPath, cfg)
|
||||||
|
if 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 = ""
|
||||||
|
err := config.SaveConfig(configPath, cfg)
|
||||||
|
if 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 TestGatewayStartReady_LocalModelWithoutAPIKey(t *testing.T) {
|
||||||
|
configPath, cleanup := setupOAuthTestEnv(t)
|
||||||
|
defer cleanup()
|
||||||
|
resetModelProbeHooks(t)
|
||||||
|
|
||||||
|
probeOpenAICompatibleModelFunc = func(apiBase, modelID string) bool {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := config.LoadConfig(configPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
cfg.ModelList = []config.ModelConfig{{
|
||||||
|
ModelName: "local-vllm",
|
||||||
|
Model: "vllm/custom-model",
|
||||||
|
APIBase: "http://localhost:8000/v1",
|
||||||
|
}}
|
||||||
|
cfg.Agents.Defaults.ModelName = "local-vllm"
|
||||||
|
err = config.SaveConfig(configPath, cfg)
|
||||||
|
if 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 without a running local service")
|
||||||
|
}
|
||||||
|
if !strings.Contains(reason, "not reachable") {
|
||||||
|
t.Fatalf("gatewayStartReady() reason = %q, want contains %q", reason, "not reachable")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGatewayStartReady_LocalModelWithRunningService(t *testing.T) {
|
||||||
|
configPath, cleanup := setupOAuthTestEnv(t)
|
||||||
|
defer cleanup()
|
||||||
|
resetModelProbeHooks(t)
|
||||||
|
|
||||||
|
probeOpenAICompatibleModelFunc = func(apiBase, modelID string) bool {
|
||||||
|
return apiBase == "http://127.0.0.1:8000/v1" && modelID == "custom-model"
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := config.LoadConfig(configPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
cfg.ModelList = []config.ModelConfig{{
|
||||||
|
ModelName: "local-vllm",
|
||||||
|
Model: "vllm/custom-model",
|
||||||
|
APIBase: "http://127.0.0.1:8000/v1",
|
||||||
|
}}
|
||||||
|
cfg.Agents.Defaults.ModelName = "local-vllm"
|
||||||
|
err = config.SaveConfig(configPath, cfg)
|
||||||
|
if 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 with a running local service (reason=%q)", reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGatewayStartReady_RemoteVLLMWithAPIKeyDoesNotProbe(t *testing.T) {
|
||||||
|
configPath, cleanup := setupOAuthTestEnv(t)
|
||||||
|
defer cleanup()
|
||||||
|
resetModelProbeHooks(t)
|
||||||
|
|
||||||
|
probeOpenAICompatibleModelFunc = func(apiBase, modelID string) bool {
|
||||||
|
t.Fatalf("unexpected OpenAI-compatible probe for %q (%q)", apiBase, modelID)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := config.LoadConfig(configPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
cfg.ModelList = []config.ModelConfig{{
|
||||||
|
ModelName: "remote-vllm",
|
||||||
|
Model: "vllm/custom-model",
|
||||||
|
APIBase: "https://models.example.com/v1",
|
||||||
|
APIKey: "remote-key",
|
||||||
|
}}
|
||||||
|
cfg.Agents.Defaults.ModelName = "remote-vllm"
|
||||||
|
err = config.SaveConfig(configPath, cfg)
|
||||||
|
if 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 for remote vllm with api key (reason=%q)", reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGatewayStartReady_LocalOllamaUsesDefaultProbeBase(t *testing.T) {
|
||||||
|
configPath, cleanup := setupOAuthTestEnv(t)
|
||||||
|
defer cleanup()
|
||||||
|
resetModelProbeHooks(t)
|
||||||
|
|
||||||
|
probeOllamaModelFunc = func(apiBase, modelID string) bool {
|
||||||
|
return apiBase == "http://localhost:11434/v1" && modelID == "llama3"
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := config.LoadConfig(configPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
cfg.ModelList = []config.ModelConfig{{
|
||||||
|
ModelName: "local-ollama",
|
||||||
|
Model: "ollama/llama3",
|
||||||
|
}}
|
||||||
|
cfg.Agents.Defaults.ModelName = "local-ollama"
|
||||||
|
err = config.SaveConfig(configPath, cfg)
|
||||||
|
if 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 with default Ollama probe base (reason=%q)", reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGatewayStartReady_OAuthModelRequiresStoredCredential(t *testing.T) {
|
||||||
|
configPath, cleanup := setupOAuthTestEnv(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
cfg, err := config.LoadConfig(configPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
cfg.ModelList = []config.ModelConfig{{
|
||||||
|
ModelName: "openai-oauth",
|
||||||
|
Model: "openai/gpt-5.4",
|
||||||
|
AuthMethod: "oauth",
|
||||||
|
}}
|
||||||
|
cfg.Agents.Defaults.ModelName = "openai-oauth"
|
||||||
|
err = config.SaveConfig(configPath, cfg)
|
||||||
|
if 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 without stored credential")
|
||||||
|
}
|
||||||
|
if !strings.Contains(reason, "no credentials configured") {
|
||||||
|
t.Fatalf("gatewayStartReady() reason = %q, want contains %q", reason, "no credentials configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
err = auth.SetCredential(oauthProviderOpenAI, &auth.AuthCredential{
|
||||||
|
AccessToken: "openai-token",
|
||||||
|
Provider: oauthProviderOpenAI,
|
||||||
|
AuthMethod: "oauth",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SetCredential() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ready, reason, err = h.gatewayStartReady()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("gatewayStartReady() error = %v", err)
|
||||||
|
}
|
||||||
|
if !ready {
|
||||||
|
t.Fatalf("gatewayStartReady() ready = false, want true with stored credential (reason=%q)", reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGatewayClearLogsResetsBufferedHistory(t *testing.T) {
|
||||||
|
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||||
|
h := NewHandler(configPath)
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
h.RegisterRoutes(mux)
|
||||||
|
|
||||||
|
gateway.logs.Clear()
|
||||||
|
gateway.logs.Append("first line")
|
||||||
|
gateway.logs.Append("second line")
|
||||||
|
previousRunID := gateway.logs.RunID()
|
||||||
|
|
||||||
|
clearRec := httptest.NewRecorder()
|
||||||
|
clearReq := httptest.NewRequest(http.MethodPost, "/api/gateway/logs/clear", nil)
|
||||||
|
mux.ServeHTTP(clearRec, clearReq)
|
||||||
|
|
||||||
|
if clearRec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("clear status = %d, want %d", clearRec.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
var clearBody map[string]any
|
||||||
|
if err := json.Unmarshal(clearRec.Body.Bytes(), &clearBody); err != nil {
|
||||||
|
t.Fatalf("unmarshal clear response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := clearBody["status"]; got != "cleared" {
|
||||||
|
t.Fatalf("clear status body = %#v, want %q", got, "cleared")
|
||||||
|
}
|
||||||
|
|
||||||
|
clearRunID, ok := clearBody["log_run_id"].(float64)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("log_run_id missing or not number: %#v", clearBody["log_run_id"])
|
||||||
|
}
|
||||||
|
if int(clearRunID) <= previousRunID {
|
||||||
|
t.Fatalf("log_run_id = %d, want > %d", int(clearRunID), previousRunID)
|
||||||
|
}
|
||||||
|
|
||||||
|
statusRec := httptest.NewRecorder()
|
||||||
|
statusReq := httptest.NewRequest(
|
||||||
|
http.MethodGet,
|
||||||
|
"/api/gateway/status?log_offset=0&log_run_id="+strconv.Itoa(previousRunID),
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
mux.ServeHTTP(statusRec, statusReq)
|
||||||
|
|
||||||
|
if statusRec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status code = %d, want %d", statusRec.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
var statusBody map[string]any
|
||||||
|
if err := json.Unmarshal(statusRec.Body.Bytes(), &statusBody); err != nil {
|
||||||
|
t.Fatalf("unmarshal status response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
logs, ok := statusBody["logs"].([]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("logs missing or not array: %#v", statusBody["logs"])
|
||||||
|
}
|
||||||
|
if len(logs) != 0 {
|
||||||
|
t.Fatalf("logs len = %d, want 0", len(logs))
|
||||||
|
}
|
||||||
|
if got := statusBody["log_total"]; got != float64(0) {
|
||||||
|
t.Fatalf("log_total = %#v, want 0", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFindPicoclawBinary_EnvOverride(t *testing.T) {
|
||||||
|
// Create a temporary file to act as the mock binary
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
mockBinary := filepath.Join(tmpDir, "picoclaw-mock")
|
||||||
|
if err := os.WriteFile(mockBinary, []byte("mock"), 0o755); err != nil {
|
||||||
|
t.Fatalf("WriteFile() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Setenv("PICOCLAW_BINARY", mockBinary)
|
||||||
|
|
||||||
|
got := utils.FindPicoclawBinary()
|
||||||
|
if got != mockBinary {
|
||||||
|
t.Errorf("FindPicoclawBinary() = %q, want %q", got, mockBinary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFindPicoclawBinary_EnvOverride_InvalidPath(t *testing.T) {
|
||||||
|
// When PICOCLAW_BINARY points to a non-existent path, fall through to next strategy
|
||||||
|
t.Setenv("PICOCLAW_BINARY", "/nonexistent/picoclaw-binary")
|
||||||
|
|
||||||
|
got := utils.FindPicoclawBinary()
|
||||||
|
// Should not return the invalid path; falls back to "picoclaw" or another found path
|
||||||
|
if got == "/nonexistent/picoclaw-binary" {
|
||||||
|
t.Errorf("FindPicoclawBinary() returned invalid env path %q, expected fallback", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
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, false, []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())
|
||||||
|
}
|
||||||
|
}
|
||||||
97
web/backend/api/log.go
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import "sync"
|
||||||
|
|
||||||
|
// LogBuffer is a thread-safe ring buffer that stores the most recent N log lines.
|
||||||
|
// It supports incremental reads via LinesSince and tracks a runID that increments
|
||||||
|
// whenever the buffer is reset or cleared so clients can detect log history resets.
|
||||||
|
type LogBuffer struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
lines []string
|
||||||
|
cap int
|
||||||
|
total int // total lines ever appended in current run
|
||||||
|
runID int
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewLogBuffer creates a LogBuffer with the given capacity.
|
||||||
|
func NewLogBuffer(capacity int) *LogBuffer {
|
||||||
|
return &LogBuffer{
|
||||||
|
lines: make([]string, 0, capacity),
|
||||||
|
cap: capacity,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append adds a line to the buffer. If the buffer is full, the oldest line is evicted.
|
||||||
|
func (b *LogBuffer) Append(line string) {
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
|
||||||
|
if len(b.lines) < b.cap {
|
||||||
|
b.lines = append(b.lines, line)
|
||||||
|
} else {
|
||||||
|
b.lines[b.total%b.cap] = line
|
||||||
|
}
|
||||||
|
|
||||||
|
b.total++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset clears the buffer and increments the runID. Call this when starting a new gateway process.
|
||||||
|
func (b *LogBuffer) Reset() {
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
|
||||||
|
b.lines = b.lines[:0]
|
||||||
|
b.total = 0
|
||||||
|
b.runID++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear removes all buffered lines and increments the runID so clients treat
|
||||||
|
// subsequent reads as a new log stream.
|
||||||
|
func (b *LogBuffer) Clear() {
|
||||||
|
b.Reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
// LinesSince returns lines appended after the given offset, the current total count, and the runID.
|
||||||
|
// If offset >= total, no lines are returned. If offset is too old (evicted), all buffered lines are returned.
|
||||||
|
func (b *LogBuffer) LinesSince(offset int) (lines []string, total int, runID int) {
|
||||||
|
b.mu.RLock()
|
||||||
|
defer b.mu.RUnlock()
|
||||||
|
|
||||||
|
total = b.total
|
||||||
|
runID = b.runID
|
||||||
|
|
||||||
|
if offset >= b.total {
|
||||||
|
return nil, total, runID
|
||||||
|
}
|
||||||
|
|
||||||
|
buffered := len(b.lines)
|
||||||
|
|
||||||
|
// How many new lines since offset
|
||||||
|
newCount := b.total - offset
|
||||||
|
if newCount > buffered {
|
||||||
|
newCount = buffered
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make([]string, newCount)
|
||||||
|
|
||||||
|
if b.total <= b.cap {
|
||||||
|
// Buffer hasn't wrapped yet — simple slice
|
||||||
|
copy(result, b.lines[buffered-newCount:])
|
||||||
|
} else {
|
||||||
|
// Buffer has wrapped — read from ring
|
||||||
|
start := (b.total - newCount) % b.cap
|
||||||
|
for i := range newCount {
|
||||||
|
result[i] = b.lines[(start+i)%b.cap]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, total, runID
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunID returns the current run identifier.
|
||||||
|
func (b *LogBuffer) RunID() int {
|
||||||
|
b.mu.RLock()
|
||||||
|
defer b.mu.RUnlock()
|
||||||
|
|
||||||
|
return b.runID
|
||||||
|
}
|
||||||
324
web/backend/api/model_status.go
Normal file
|
|
@ -0,0 +1,324 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
const modelProbeTimeout = 800 * time.Millisecond
|
||||||
|
|
||||||
|
var (
|
||||||
|
probeTCPServiceFunc = probeTCPService
|
||||||
|
probeOllamaModelFunc = probeOllamaModel
|
||||||
|
probeOpenAICompatibleModelFunc = probeOpenAICompatibleModel
|
||||||
|
)
|
||||||
|
|
||||||
|
func hasModelConfiguration(m config.ModelConfig) bool {
|
||||||
|
authMethod := strings.ToLower(strings.TrimSpace(m.AuthMethod))
|
||||||
|
apiKey := strings.TrimSpace(m.APIKey)
|
||||||
|
|
||||||
|
if authMethod == "oauth" || authMethod == "token" {
|
||||||
|
if provider, ok := oauthProviderForModel(m.Model); ok {
|
||||||
|
cred, err := oauthGetCredential(provider)
|
||||||
|
if err != nil || cred == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(cred.AccessToken) != "" || strings.TrimSpace(cred.RefreshToken) != ""
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if requiresRuntimeProbe(m) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return apiKey != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// isModelConfigured reports whether a model is currently available to use.
|
||||||
|
// Local models must be reachable; remote/API-key models only need saved config.
|
||||||
|
func isModelConfigured(m config.ModelConfig) bool {
|
||||||
|
if !hasModelConfiguration(m) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if requiresRuntimeProbe(m) {
|
||||||
|
return probeLocalModelAvailability(m)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func requiresRuntimeProbe(m config.ModelConfig) bool {
|
||||||
|
authMethod := strings.ToLower(strings.TrimSpace(m.AuthMethod))
|
||||||
|
if authMethod == "local" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
switch modelProtocol(m.Model) {
|
||||||
|
case "claude-cli", "claudecli", "codex-cli", "codexcli", "github-copilot", "copilot":
|
||||||
|
return true
|
||||||
|
case "ollama", "vllm":
|
||||||
|
apiBase := strings.TrimSpace(m.APIBase)
|
||||||
|
return apiBase == "" || hasLocalAPIBase(apiBase)
|
||||||
|
}
|
||||||
|
|
||||||
|
if hasLocalAPIBase(m.APIBase) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func probeLocalModelAvailability(m config.ModelConfig) bool {
|
||||||
|
apiBase := modelProbeAPIBase(m)
|
||||||
|
protocol, modelID := splitModel(m.Model)
|
||||||
|
switch protocol {
|
||||||
|
case "ollama":
|
||||||
|
return probeOllamaModelFunc(apiBase, modelID)
|
||||||
|
case "vllm":
|
||||||
|
return probeOpenAICompatibleModelFunc(apiBase, modelID)
|
||||||
|
case "github-copilot", "copilot":
|
||||||
|
return probeTCPServiceFunc(apiBase)
|
||||||
|
case "claude-cli", "claudecli", "codex-cli", "codexcli":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
if hasLocalAPIBase(apiBase) {
|
||||||
|
return probeOpenAICompatibleModelFunc(apiBase, modelID)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func modelProbeAPIBase(m config.ModelConfig) string {
|
||||||
|
if apiBase := strings.TrimSpace(m.APIBase); apiBase != "" {
|
||||||
|
return normalizeModelProbeAPIBase(apiBase)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch modelProtocol(m.Model) {
|
||||||
|
case "ollama":
|
||||||
|
return "http://localhost:11434/v1"
|
||||||
|
case "vllm":
|
||||||
|
return "http://localhost:8000/v1"
|
||||||
|
case "github-copilot", "copilot":
|
||||||
|
return "localhost:4321"
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeModelProbeAPIBase(raw string) string {
|
||||||
|
u, err := parseAPIBase(raw)
|
||||||
|
if err != nil {
|
||||||
|
return strings.TrimSpace(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch strings.ToLower(u.Hostname()) {
|
||||||
|
case "0.0.0.0":
|
||||||
|
u.Host = net.JoinHostPort("127.0.0.1", u.Port())
|
||||||
|
case "::":
|
||||||
|
u.Host = net.JoinHostPort("::1", u.Port())
|
||||||
|
default:
|
||||||
|
return strings.TrimSpace(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
if u.Port() == "" {
|
||||||
|
u.Host = u.Hostname()
|
||||||
|
}
|
||||||
|
|
||||||
|
return u.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func oauthProviderForModel(model string) (string, bool) {
|
||||||
|
switch modelProtocol(model) {
|
||||||
|
case "openai":
|
||||||
|
return oauthProviderOpenAI, true
|
||||||
|
case "anthropic":
|
||||||
|
return oauthProviderAnthropic, true
|
||||||
|
case "antigravity", "google-antigravity":
|
||||||
|
return oauthProviderGoogleAntigravity, true
|
||||||
|
default:
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func modelProtocol(model string) string {
|
||||||
|
protocol, _ := splitModel(model)
|
||||||
|
return protocol
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitModel(model string) (protocol, modelID string) {
|
||||||
|
model = strings.ToLower(strings.TrimSpace(model))
|
||||||
|
protocol, _, found := strings.Cut(model, "/")
|
||||||
|
if !found {
|
||||||
|
return "openai", model
|
||||||
|
}
|
||||||
|
return protocol, strings.TrimSpace(model[strings.Index(model, "/")+1:])
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasLocalAPIBase(raw string) bool {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
u, err := url.Parse(raw)
|
||||||
|
if err != nil || u.Hostname() == "" {
|
||||||
|
u, err = url.Parse("//" + raw)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch strings.ToLower(u.Hostname()) {
|
||||||
|
case "localhost", "127.0.0.1", "::1", "0.0.0.0":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func probeTCPService(raw string) bool {
|
||||||
|
hostPort, err := hostPortFromAPIBase(raw)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, err := net.DialTimeout("tcp", hostPort, modelProbeTimeout)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_ = conn.Close()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func probeOllamaModel(apiBase, modelID string) bool {
|
||||||
|
root, err := apiRootFromAPIBase(apiBase)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp struct {
|
||||||
|
Models []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Model string `json:"model"`
|
||||||
|
} `json:"models"`
|
||||||
|
}
|
||||||
|
if err := getJSON(root+"/api/tags", &resp); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, model := range resp.Models {
|
||||||
|
if ollamaModelMatches(model.Name, modelID) || ollamaModelMatches(model.Model, modelID) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func probeOpenAICompatibleModel(apiBase, modelID string) bool {
|
||||||
|
if strings.TrimSpace(apiBase) == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp struct {
|
||||||
|
Data []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if err := getJSON(strings.TrimRight(strings.TrimSpace(apiBase), "/")+"/models", &resp); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, model := range resp.Data {
|
||||||
|
if strings.EqualFold(strings.TrimSpace(model.ID), modelID) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func getJSON(rawURL string, out any) error {
|
||||||
|
req, err := http.NewRequest(http.MethodGet, rawURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: modelProbeTimeout}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("unexpected status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
return json.NewDecoder(resp.Body).Decode(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func apiRootFromAPIBase(raw string) (string, error) {
|
||||||
|
u, err := parseAPIBase(raw)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return (&url.URL{Scheme: u.Scheme, Host: u.Host}).String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func hostPortFromAPIBase(raw string) (string, error) {
|
||||||
|
u, err := parseAPIBase(raw)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if port := u.Port(); port != "" {
|
||||||
|
return u.Host, nil
|
||||||
|
}
|
||||||
|
switch strings.ToLower(u.Scheme) {
|
||||||
|
case "https":
|
||||||
|
return net.JoinHostPort(u.Hostname(), "443"), nil
|
||||||
|
default:
|
||||||
|
return net.JoinHostPort(u.Hostname(), "80"), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseAPIBase(raw string) (*url.URL, error) {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw == "" {
|
||||||
|
return nil, fmt.Errorf("empty api base")
|
||||||
|
}
|
||||||
|
|
||||||
|
u, err := url.Parse(raw)
|
||||||
|
if err == nil && u.Hostname() != "" {
|
||||||
|
return u, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
u, err = url.Parse("//" + raw)
|
||||||
|
if err != nil || u.Hostname() == "" {
|
||||||
|
return nil, fmt.Errorf("invalid api base %q", raw)
|
||||||
|
}
|
||||||
|
if u.Scheme == "" {
|
||||||
|
u.Scheme = "http"
|
||||||
|
}
|
||||||
|
return u, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ollamaModelMatches(candidate, want string) bool {
|
||||||
|
candidate = strings.TrimSpace(candidate)
|
||||||
|
want = strings.TrimSpace(want)
|
||||||
|
if candidate == "" || want == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if strings.EqualFold(candidate, want) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
base, _, _ := strings.Cut(candidate, ":")
|
||||||
|
return strings.EqualFold(base, want)
|
||||||
|
}
|
||||||
310
web/backend/api/models.go
Normal file
|
|
@ -0,0 +1,310 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"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 := config.LoadConfig(h.configPath)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultModel := cfg.Agents.Defaults.GetModelName()
|
||||||
|
configured := make([]bool, len(cfg.ModelList))
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Add(len(cfg.ModelList))
|
||||||
|
for i, m := range cfg.ModelList {
|
||||||
|
go func(i int, m config.ModelConfig) {
|
||||||
|
defer wg.Done()
|
||||||
|
configured[i] = isModelConfigured(m)
|
||||||
|
}(i, m)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
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: configured[i],
|
||||||
|
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:]
|
||||||
|
}
|
||||||
313
web/backend/api/models_test.go
Normal file
|
|
@ -0,0 +1,313 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/auth"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func resetModelProbeHooks(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
origTCPProbe := probeTCPServiceFunc
|
||||||
|
origOllamaProbe := probeOllamaModelFunc
|
||||||
|
origOpenAIProbe := probeOpenAICompatibleModelFunc
|
||||||
|
t.Cleanup(func() {
|
||||||
|
probeTCPServiceFunc = origTCPProbe
|
||||||
|
probeOllamaModelFunc = origOllamaProbe
|
||||||
|
probeOpenAICompatibleModelFunc = origOpenAIProbe
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleListModels_ConfiguredStatusUsesRuntimeProbesForLocalModels(t *testing.T) {
|
||||||
|
configPath, cleanup := setupOAuthTestEnv(t)
|
||||||
|
defer cleanup()
|
||||||
|
resetOAuthHooks(t)
|
||||||
|
resetModelProbeHooks(t)
|
||||||
|
|
||||||
|
var mu sync.Mutex
|
||||||
|
var openAIProbes []string
|
||||||
|
var ollamaProbes []string
|
||||||
|
var tcpProbes []string
|
||||||
|
|
||||||
|
probeOpenAICompatibleModelFunc = func(apiBase, modelID string) bool {
|
||||||
|
mu.Lock()
|
||||||
|
openAIProbes = append(openAIProbes, apiBase+"|"+modelID)
|
||||||
|
mu.Unlock()
|
||||||
|
return apiBase == "http://127.0.0.1:8000/v1" && modelID == "custom-model"
|
||||||
|
}
|
||||||
|
probeOllamaModelFunc = func(apiBase, modelID string) bool {
|
||||||
|
mu.Lock()
|
||||||
|
ollamaProbes = append(ollamaProbes, apiBase+"|"+modelID)
|
||||||
|
mu.Unlock()
|
||||||
|
return apiBase == "http://localhost:11434/v1" && modelID == "llama3"
|
||||||
|
}
|
||||||
|
probeTCPServiceFunc = func(apiBase string) bool {
|
||||||
|
mu.Lock()
|
||||||
|
tcpProbes = append(tcpProbes, apiBase)
|
||||||
|
mu.Unlock()
|
||||||
|
return apiBase == "http://127.0.0.1:4321"
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := config.LoadConfig(configPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
cfg.ModelList = []config.ModelConfig{
|
||||||
|
{
|
||||||
|
ModelName: "openai-oauth",
|
||||||
|
Model: "openai/gpt-5.4",
|
||||||
|
AuthMethod: "oauth",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ModelName: "vllm-local",
|
||||||
|
Model: "vllm/custom-model",
|
||||||
|
APIBase: "http://127.0.0.1:8000/v1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ModelName: "ollama-default",
|
||||||
|
Model: "ollama/llama3",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ModelName: "vllm-remote",
|
||||||
|
Model: "vllm/custom-model",
|
||||||
|
APIBase: "https://models.example.com/v1",
|
||||||
|
APIKey: "remote-key",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ModelName: "copilot-gpt-5.4",
|
||||||
|
Model: "github-copilot/gpt-5.4",
|
||||||
|
APIBase: "http://127.0.0.1:4321",
|
||||||
|
AuthMethod: "oauth",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cfg.Agents.Defaults.ModelName = "openai-oauth"
|
||||||
|
if err := config.SaveConfig(configPath, cfg); err != nil {
|
||||||
|
t.Fatalf("SaveConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
h := NewHandler(configPath)
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
h.RegisterRoutes(mux)
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/models", nil)
|
||||||
|
mux.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp struct {
|
||||||
|
Models []modelResponse `json:"models"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("Unmarshal() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := make(map[string]bool, len(resp.Models))
|
||||||
|
for _, model := range resp.Models {
|
||||||
|
got[model.ModelName] = model.Configured
|
||||||
|
}
|
||||||
|
|
||||||
|
if got["openai-oauth"] {
|
||||||
|
t.Fatalf("openai oauth model configured = true, want false without stored credential")
|
||||||
|
}
|
||||||
|
if !got["vllm-local"] {
|
||||||
|
t.Fatalf("vllm local model configured = false, want true when local probe succeeds")
|
||||||
|
}
|
||||||
|
if !got["ollama-default"] {
|
||||||
|
t.Fatalf("ollama default model configured = false, want true when default local probe succeeds")
|
||||||
|
}
|
||||||
|
if !got["vllm-remote"] {
|
||||||
|
t.Fatalf("remote vllm model configured = false, want true with api_key")
|
||||||
|
}
|
||||||
|
if !got["copilot-gpt-5.4"] {
|
||||||
|
t.Fatalf("copilot model configured = false, want true when local bridge probe succeeds")
|
||||||
|
}
|
||||||
|
if len(openAIProbes) != 1 || openAIProbes[0] != "http://127.0.0.1:8000/v1|custom-model" {
|
||||||
|
t.Fatalf("openAI probes = %#v, want only local vllm probe", openAIProbes)
|
||||||
|
}
|
||||||
|
if len(ollamaProbes) != 1 || ollamaProbes[0] != "http://localhost:11434/v1|llama3" {
|
||||||
|
t.Fatalf("ollama probes = %#v, want default local probe", ollamaProbes)
|
||||||
|
}
|
||||||
|
if len(tcpProbes) != 1 || tcpProbes[0] != "http://127.0.0.1:4321" {
|
||||||
|
t.Fatalf("tcp probes = %#v, want only local copilot probe", tcpProbes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleListModels_ConfiguredStatusForOAuthModelWithCredential(t *testing.T) {
|
||||||
|
configPath, cleanup := setupOAuthTestEnv(t)
|
||||||
|
defer cleanup()
|
||||||
|
resetOAuthHooks(t)
|
||||||
|
resetModelProbeHooks(t)
|
||||||
|
|
||||||
|
cfg, err := config.LoadConfig(configPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
cfg.ModelList = []config.ModelConfig{{
|
||||||
|
ModelName: "claude-oauth",
|
||||||
|
Model: "anthropic/claude-sonnet-4.6",
|
||||||
|
AuthMethod: "oauth",
|
||||||
|
}}
|
||||||
|
cfg.Agents.Defaults.ModelName = "claude-oauth"
|
||||||
|
if err := config.SaveConfig(configPath, cfg); err != nil {
|
||||||
|
t.Fatalf("SaveConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := auth.SetCredential(oauthProviderAnthropic, &auth.AuthCredential{
|
||||||
|
AccessToken: "anthropic-token",
|
||||||
|
Provider: oauthProviderAnthropic,
|
||||||
|
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.MethodGet, "/api/models", nil)
|
||||||
|
mux.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp struct {
|
||||||
|
Models []modelResponse `json:"models"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("Unmarshal() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(resp.Models) != 1 {
|
||||||
|
t.Fatalf("len(models) = %d, want 1", len(resp.Models))
|
||||||
|
}
|
||||||
|
if !resp.Models[0].Configured {
|
||||||
|
t.Fatalf("oauth model configured = false, want true with stored credential")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleListModels_ProbesLocalModelsConcurrently(t *testing.T) {
|
||||||
|
configPath, cleanup := setupOAuthTestEnv(t)
|
||||||
|
defer cleanup()
|
||||||
|
resetOAuthHooks(t)
|
||||||
|
resetModelProbeHooks(t)
|
||||||
|
|
||||||
|
started := make(chan string, 2)
|
||||||
|
release := make(chan struct{})
|
||||||
|
|
||||||
|
probeOpenAICompatibleModelFunc = func(apiBase, modelID string) bool {
|
||||||
|
started <- apiBase + "|" + modelID
|
||||||
|
<-release
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := config.LoadConfig(configPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
cfg.ModelList = []config.ModelConfig{
|
||||||
|
{
|
||||||
|
ModelName: "local-vllm-a",
|
||||||
|
Model: "vllm/custom-a",
|
||||||
|
APIBase: "http://127.0.0.1:8000/v1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ModelName: "local-vllm-b",
|
||||||
|
Model: "vllm/custom-b",
|
||||||
|
APIBase: "http://127.0.0.1:8001/v1",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := config.SaveConfig(configPath, cfg); err != nil {
|
||||||
|
t.Fatalf("SaveConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
h := NewHandler(configPath)
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
h.RegisterRoutes(mux)
|
||||||
|
|
||||||
|
recCh := make(chan *httptest.ResponseRecorder, 1)
|
||||||
|
go func() {
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/models", nil)
|
||||||
|
mux.ServeHTTP(rec, req)
|
||||||
|
recCh <- rec
|
||||||
|
}()
|
||||||
|
|
||||||
|
for i := 0; i < 2; i++ {
|
||||||
|
select {
|
||||||
|
case <-started:
|
||||||
|
case <-time.After(200 * time.Millisecond):
|
||||||
|
t.Fatal("expected both local probes to start before the first one completed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
close(release)
|
||||||
|
|
||||||
|
rec := <-recCh
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleListModels_NormalizesWildcardLocalAPIBaseForProbe(t *testing.T) {
|
||||||
|
configPath, cleanup := setupOAuthTestEnv(t)
|
||||||
|
defer cleanup()
|
||||||
|
resetOAuthHooks(t)
|
||||||
|
resetModelProbeHooks(t)
|
||||||
|
|
||||||
|
var gotProbe string
|
||||||
|
probeOpenAICompatibleModelFunc = func(apiBase, modelID string) bool {
|
||||||
|
gotProbe = apiBase + "|" + modelID
|
||||||
|
return apiBase == "http://127.0.0.1:8000/v1" && modelID == "custom-model"
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := config.LoadConfig(configPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
cfg.ModelList = []config.ModelConfig{{
|
||||||
|
ModelName: "vllm-local",
|
||||||
|
Model: "vllm/custom-model",
|
||||||
|
APIBase: "http://0.0.0.0:8000/v1",
|
||||||
|
}}
|
||||||
|
if err := config.SaveConfig(configPath, cfg); err != nil {
|
||||||
|
t.Fatalf("SaveConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
h := NewHandler(configPath)
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
h.RegisterRoutes(mux)
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/models", nil)
|
||||||
|
mux.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp struct {
|
||||||
|
Models []modelResponse `json:"models"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("Unmarshal() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(resp.Models) != 1 {
|
||||||
|
t.Fatalf("len(models) = %d, want 1", len(resp.Models))
|
||||||
|
}
|
||||||
|
if !resp.Models[0].Configured {
|
||||||
|
t.Fatal("wildcard-bound local model configured = false, want true after probe host normalization")
|
||||||
|
}
|
||||||
|
if gotProbe != "http://127.0.0.1:8000/v1|custom-model" {
|
||||||
|
t.Fatalf("probe api base = %q, want %q", gotProbe, "http://127.0.0.1:8000/v1|custom-model")
|
||||||
|
}
|
||||||
|
}
|
||||||
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.4",
|
||||||
|
Model: "openai/gpt-5.4",
|
||||||
|
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.4",
|
||||||
|
Model: "openai/gpt-5.4",
|
||||||
|
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
|
||||||
|
})
|
||||||
|
}
|
||||||
143
web/backend/api/pico.go
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"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 := h.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 := h.buildWsURL(r, cfg)
|
||||||
|
|
||||||
|
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 := h.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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
72
web/backend/api/router.go
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
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
|
||||||
|
serverPublicExplicit 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, publicExplicit bool, allowedCIDRs []string) {
|
||||||
|
h.serverPort = port
|
||||||
|
h.serverPublic = public
|
||||||
|
h.serverPublicExplicit = publicExplicit
|
||||||
|
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)
|
||||||
|
|
||||||
|
// Skills and tools support/actions
|
||||||
|
h.registerSkillRoutes(mux)
|
||||||
|
h.registerToolRoutes(mux)
|
||||||
|
|
||||||
|
// OS startup / launch-at-login
|
||||||
|
h.registerStartupRoutes(mux)
|
||||||
|
|
||||||
|
// Launcher service parameters (port/public)
|
||||||
|
h.registerLauncherConfigRoutes(mux)
|
||||||
|
}
|
||||||
506
web/backend/api/session.go
Normal file
|
|
@ -0,0 +1,506 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"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"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Preview string `json:"preview"`
|
||||||
|
MessageCount int `json:"message_count"`
|
||||||
|
Created string `json:"created"`
|
||||||
|
Updated string `json:"updated"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type sessionMetaFile struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Summary string `json:"summary"`
|
||||||
|
Skip int `json:"skip"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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:"
|
||||||
|
sanitizedPicoSessionPrefix = "agent_main_pico_direct_pico_"
|
||||||
|
maxSessionJSONLLineSize = 10 * 1024 * 1024 // 10 MB
|
||||||
|
maxSessionTitleRunes = 60
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractPicoSessionIDFromSanitizedKey(key string) (string, bool) {
|
||||||
|
if strings.HasPrefix(key, sanitizedPicoSessionPrefix) {
|
||||||
|
return strings.TrimPrefix(key, sanitizedPicoSessionPrefix), true
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
func sanitizeSessionKey(key string) string {
|
||||||
|
return strings.ReplaceAll(key, ":", "_")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) readLegacySession(dir, sessionID string) (sessionFile, error) {
|
||||||
|
path := filepath.Join(dir, sanitizeSessionKey(picoSessionPrefix+sessionID)+".json")
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return sessionFile{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var sess sessionFile
|
||||||
|
if err := json.Unmarshal(data, &sess); err != nil {
|
||||||
|
return sessionFile{}, err
|
||||||
|
}
|
||||||
|
return sess, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) readSessionMeta(path, sessionKey string) (sessionMetaFile, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return sessionMetaFile{Key: sessionKey}, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return sessionMetaFile{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var meta sessionMetaFile
|
||||||
|
if err := json.Unmarshal(data, &meta); err != nil {
|
||||||
|
return sessionMetaFile{}, err
|
||||||
|
}
|
||||||
|
if meta.Key == "" {
|
||||||
|
meta.Key = sessionKey
|
||||||
|
}
|
||||||
|
return meta, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) readSessionMessages(path string, skip int) ([]providers.Message, error) {
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
msgs := make([]providers.Message, 0)
|
||||||
|
scanner := bufio.NewScanner(f)
|
||||||
|
scanner.Buffer(make([]byte, 0, 64*1024), maxSessionJSONLLineSize)
|
||||||
|
|
||||||
|
seen := 0
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Bytes()
|
||||||
|
if len(line) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
seen++
|
||||||
|
if seen <= skip {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var msg providers.Message
|
||||||
|
if err := json.Unmarshal(line, &msg); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
msgs = append(msgs, msg)
|
||||||
|
}
|
||||||
|
if err := scanner.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return msgs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) readJSONLSession(dir, sessionID string) (sessionFile, error) {
|
||||||
|
sessionKey := picoSessionPrefix + sessionID
|
||||||
|
base := filepath.Join(dir, sanitizeSessionKey(sessionKey))
|
||||||
|
jsonlPath := base + ".jsonl"
|
||||||
|
metaPath := base + ".meta.json"
|
||||||
|
|
||||||
|
meta, err := h.readSessionMeta(metaPath, sessionKey)
|
||||||
|
if err != nil {
|
||||||
|
return sessionFile{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
messages, err := h.readSessionMessages(jsonlPath, meta.Skip)
|
||||||
|
if err != nil {
|
||||||
|
return sessionFile{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
updated := meta.UpdatedAt
|
||||||
|
created := meta.CreatedAt
|
||||||
|
if created.IsZero() || updated.IsZero() {
|
||||||
|
if info, statErr := os.Stat(jsonlPath); statErr == nil {
|
||||||
|
if created.IsZero() {
|
||||||
|
created = info.ModTime()
|
||||||
|
}
|
||||||
|
if updated.IsZero() {
|
||||||
|
updated = info.ModTime()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sessionFile{
|
||||||
|
Key: meta.Key,
|
||||||
|
Messages: messages,
|
||||||
|
Summary: meta.Summary,
|
||||||
|
Created: created,
|
||||||
|
Updated: updated,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildSessionListItem(sessionID string, sess sessionFile) sessionListItem {
|
||||||
|
preview := ""
|
||||||
|
for _, msg := range sess.Messages {
|
||||||
|
if msg.Role == "user" && strings.TrimSpace(msg.Content) != "" {
|
||||||
|
preview = msg.Content
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
title := strings.TrimSpace(sess.Summary)
|
||||||
|
if title == "" {
|
||||||
|
title = preview
|
||||||
|
}
|
||||||
|
|
||||||
|
title = truncateRunes(title, maxSessionTitleRunes)
|
||||||
|
preview = truncateRunes(preview, maxSessionTitleRunes)
|
||||||
|
|
||||||
|
if preview == "" {
|
||||||
|
preview = "(empty)"
|
||||||
|
}
|
||||||
|
if title == "" {
|
||||||
|
title = preview
|
||||||
|
}
|
||||||
|
|
||||||
|
validMessageCount := 0
|
||||||
|
for _, msg := range sess.Messages {
|
||||||
|
if (msg.Role == "user" || msg.Role == "assistant") && strings.TrimSpace(msg.Content) != "" {
|
||||||
|
validMessageCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sessionListItem{
|
||||||
|
ID: sessionID,
|
||||||
|
Title: title,
|
||||||
|
Preview: preview,
|
||||||
|
MessageCount: validMessageCount,
|
||||||
|
Created: sess.Created.Format(time.RFC3339),
|
||||||
|
Updated: sess.Updated.Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isEmptySession(sess sessionFile) bool {
|
||||||
|
return len(sess.Messages) == 0 && strings.TrimSpace(sess.Summary) == ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncateRunes(s string, maxLen int) string {
|
||||||
|
if maxLen <= 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
runes := []rune(strings.TrimSpace(s))
|
||||||
|
if len(runes) <= maxLen {
|
||||||
|
return string(runes)
|
||||||
|
}
|
||||||
|
return string(runes[:maxLen]) + "..."
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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{}
|
||||||
|
seen := make(map[string]struct{})
|
||||||
|
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
name := entry.Name()
|
||||||
|
var (
|
||||||
|
sessionID string
|
||||||
|
sess sessionFile
|
||||||
|
loadErr error
|
||||||
|
ok bool
|
||||||
|
)
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case strings.HasSuffix(name, ".jsonl"):
|
||||||
|
sessionID, ok = extractPicoSessionIDFromSanitizedKey(strings.TrimSuffix(name, ".jsonl"))
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sess, loadErr = h.readJSONLSession(dir, sessionID)
|
||||||
|
if loadErr == nil && isEmptySession(sess) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
case strings.HasSuffix(name, ".meta.json"):
|
||||||
|
continue
|
||||||
|
case filepath.Ext(name) == ".json":
|
||||||
|
base := strings.TrimSuffix(name, ".json")
|
||||||
|
if _, statErr := os.Stat(filepath.Join(dir, base+".jsonl")); statErr == nil {
|
||||||
|
if jsonlSessionID, found := extractPicoSessionIDFromSanitizedKey(base); found {
|
||||||
|
if jsonlSess, jsonlErr := h.readJSONLSession(
|
||||||
|
dir,
|
||||||
|
jsonlSessionID,
|
||||||
|
); jsonlErr == nil &&
|
||||||
|
!isEmptySession(jsonlSess) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(filepath.Join(dir, name))
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &sess); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if isEmptySession(sess) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sessionID, ok = extractPicoSessionID(sess.Key)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, exists := seen[sessionID]; exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if loadErr != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, exists := seen[sessionID]; exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
seen[sessionID] = struct{}{}
|
||||||
|
items = append(items, buildSessionListItem(sessionID, sess))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
sess, err := h.readJSONLSession(dir, sessionID)
|
||||||
|
if err == nil && isEmptySession(sess) {
|
||||||
|
err = os.ErrNotExist
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
sess, err = h.readLegacySession(dir, sessionID)
|
||||||
|
if err == nil && isEmptySession(sess) {
|
||||||
|
err = os.ErrNotExist
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
http.Error(w, "session not found", http.StatusNotFound)
|
||||||
|
} else {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
base := filepath.Join(dir, sanitizeSessionKey(picoSessionPrefix+sessionID))
|
||||||
|
jsonlPath := base + ".jsonl"
|
||||||
|
metaPath := base + ".meta.json"
|
||||||
|
legacyPath := base + ".json"
|
||||||
|
|
||||||
|
removed := false
|
||||||
|
for _, path := range []string{jsonlPath, metaPath, legacyPath} {
|
||||||
|
if err := os.Remove(path); err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
http.Error(w, "failed to delete session", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
removed = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if !removed {
|
||||||
|
http.Error(w, "session not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
322
web/backend/api/session_test.go
Normal file
|
|
@ -0,0 +1,322 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/memory"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/session"
|
||||||
|
)
|
||||||
|
|
||||||
|
func sessionsTestDir(t *testing.T, configPath string) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
cfg, err := config.LoadConfig(configPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dir := filepath.Join(cfg.Agents.Defaults.Workspace, "sessions")
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
t.Fatalf("MkdirAll() error = %v", err)
|
||||||
|
}
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleListSessions_JSONLStorage(t *testing.T) {
|
||||||
|
configPath, cleanup := setupOAuthTestEnv(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
dir := sessionsTestDir(t, configPath)
|
||||||
|
store, err := memory.NewJSONLStore(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewJSONLStore() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionKey := picoSessionPrefix + "history-jsonl"
|
||||||
|
if err := store.AddFullMessage(nil, sessionKey, providers.Message{
|
||||||
|
Role: "user",
|
||||||
|
Content: "Explain why the history API is empty after migration.",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("AddFullMessage(user) error = %v", err)
|
||||||
|
}
|
||||||
|
if err := store.AddFullMessage(nil, sessionKey, providers.Message{
|
||||||
|
Role: "assistant",
|
||||||
|
Content: "Because the API still reads only legacy JSON session files.",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("AddFullMessage(assistant) error = %v", err)
|
||||||
|
}
|
||||||
|
if err := store.AddFullMessage(nil, sessionKey, providers.Message{
|
||||||
|
Role: "tool",
|
||||||
|
Content: "ignored",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("AddFullMessage(tool) error = %v", err)
|
||||||
|
}
|
||||||
|
if err := store.SetSummary(nil, sessionKey, "JSONL-backed session"); err != nil {
|
||||||
|
t.Fatalf("SetSummary() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
h := NewHandler(configPath)
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
h.RegisterRoutes(mux)
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/sessions", 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 items []sessionListItem
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &items); err != nil {
|
||||||
|
t.Fatalf("Unmarshal() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(items) != 1 {
|
||||||
|
t.Fatalf("len(items) = %d, want 1", len(items))
|
||||||
|
}
|
||||||
|
if items[0].ID != "history-jsonl" {
|
||||||
|
t.Fatalf("items[0].ID = %q, want %q", items[0].ID, "history-jsonl")
|
||||||
|
}
|
||||||
|
if items[0].MessageCount != 2 {
|
||||||
|
t.Fatalf("items[0].MessageCount = %d, want 2", items[0].MessageCount)
|
||||||
|
}
|
||||||
|
if items[0].Title != "JSONL-backed session" {
|
||||||
|
t.Fatalf("items[0].Title = %q, want %q", items[0].Title, "JSONL-backed session")
|
||||||
|
}
|
||||||
|
if items[0].Preview != "Explain why the history API is empty after migration." {
|
||||||
|
t.Fatalf("items[0].Preview = %q", items[0].Preview)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleListSessions_TitleUsesTrimmedSummary(t *testing.T) {
|
||||||
|
configPath, cleanup := setupOAuthTestEnv(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
dir := sessionsTestDir(t, configPath)
|
||||||
|
store, err := memory.NewJSONLStore(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewJSONLStore() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionKey := picoSessionPrefix + "summary-title"
|
||||||
|
if err := store.AddFullMessage(nil, sessionKey, providers.Message{
|
||||||
|
Role: "user",
|
||||||
|
Content: "fallback preview",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("AddFullMessage() error = %v", err)
|
||||||
|
}
|
||||||
|
if err := store.SetSummary(
|
||||||
|
nil,
|
||||||
|
sessionKey,
|
||||||
|
" This summary is intentionally longer than sixty characters so it must be truncated in the history menu. ",
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("SetSummary() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
h := NewHandler(configPath)
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
h.RegisterRoutes(mux)
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/sessions", 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 items []sessionListItem
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &items); err != nil {
|
||||||
|
t.Fatalf("Unmarshal() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(items) != 1 {
|
||||||
|
t.Fatalf("len(items) = %d, want 1", len(items))
|
||||||
|
}
|
||||||
|
expectedTitle := truncateRunes(
|
||||||
|
"This summary is intentionally longer than sixty characters so it must be truncated in the history menu.",
|
||||||
|
maxSessionTitleRunes,
|
||||||
|
)
|
||||||
|
if items[0].Title != expectedTitle {
|
||||||
|
t.Fatalf("items[0].Title = %q", items[0].Title)
|
||||||
|
}
|
||||||
|
if items[0].Preview != "fallback preview" {
|
||||||
|
t.Fatalf("items[0].Preview = %q, want %q", items[0].Preview, "fallback preview")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleGetSession_JSONLStorage(t *testing.T) {
|
||||||
|
configPath, cleanup := setupOAuthTestEnv(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
dir := sessionsTestDir(t, configPath)
|
||||||
|
store, err := memory.NewJSONLStore(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewJSONLStore() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionKey := picoSessionPrefix + "detail-jsonl"
|
||||||
|
for _, msg := range []providers.Message{
|
||||||
|
{Role: "user", Content: "first"},
|
||||||
|
{Role: "assistant", Content: "second"},
|
||||||
|
{Role: "tool", Content: "ignored"},
|
||||||
|
} {
|
||||||
|
if err := store.AddFullMessage(nil, sessionKey, msg); err != nil {
|
||||||
|
t.Fatalf("AddFullMessage() error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := store.SetSummary(nil, sessionKey, "detail summary"); err != nil {
|
||||||
|
t.Fatalf("SetSummary() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
h := NewHandler(configPath)
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
h.RegisterRoutes(mux)
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/sessions/detail-jsonl", nil)
|
||||||
|
mux.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Summary string `json:"summary"`
|
||||||
|
Messages []struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
} `json:"messages"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("Unmarshal() error = %v", err)
|
||||||
|
}
|
||||||
|
if resp.ID != "detail-jsonl" {
|
||||||
|
t.Fatalf("resp.ID = %q, want %q", resp.ID, "detail-jsonl")
|
||||||
|
}
|
||||||
|
if resp.Summary != "detail summary" {
|
||||||
|
t.Fatalf("resp.Summary = %q, want %q", resp.Summary, "detail summary")
|
||||||
|
}
|
||||||
|
if len(resp.Messages) != 2 {
|
||||||
|
t.Fatalf("len(resp.Messages) = %d, want 2", len(resp.Messages))
|
||||||
|
}
|
||||||
|
if resp.Messages[0].Role != "user" || resp.Messages[0].Content != "first" {
|
||||||
|
t.Fatalf("first message = %#v, want user/first", resp.Messages[0])
|
||||||
|
}
|
||||||
|
if resp.Messages[1].Role != "assistant" || resp.Messages[1].Content != "second" {
|
||||||
|
t.Fatalf("second message = %#v, want assistant/second", resp.Messages[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleDeleteSession_JSONLStorage(t *testing.T) {
|
||||||
|
configPath, cleanup := setupOAuthTestEnv(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
dir := sessionsTestDir(t, configPath)
|
||||||
|
store, err := memory.NewJSONLStore(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewJSONLStore() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionKey := picoSessionPrefix + "delete-jsonl"
|
||||||
|
if err := store.AddFullMessage(nil, sessionKey, providers.Message{
|
||||||
|
Role: "user",
|
||||||
|
Content: "delete me",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("AddFullMessage() error = %v", err)
|
||||||
|
}
|
||||||
|
if err := store.SetSummary(nil, sessionKey, "delete summary"); err != nil {
|
||||||
|
t.Fatalf("SetSummary() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
h := NewHandler(configPath)
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
h.RegisterRoutes(mux)
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodDelete, "/api/sessions/delete-jsonl", nil)
|
||||||
|
mux.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusNoContent {
|
||||||
|
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusNoContent, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
base := filepath.Join(dir, sanitizeSessionKey(sessionKey))
|
||||||
|
for _, path := range []string{base + ".jsonl", base + ".meta.json"} {
|
||||||
|
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("expected %s to be removed, stat err = %v", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleGetSession_LegacyJSONFallback(t *testing.T) {
|
||||||
|
configPath, cleanup := setupOAuthTestEnv(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
dir := sessionsTestDir(t, configPath)
|
||||||
|
manager := session.NewSessionManager(dir)
|
||||||
|
sessionKey := picoSessionPrefix + "legacy-json"
|
||||||
|
manager.AddMessage(sessionKey, "user", "legacy user")
|
||||||
|
manager.AddMessage(sessionKey, "assistant", "legacy assistant")
|
||||||
|
if err := manager.Save(sessionKey); err != nil {
|
||||||
|
t.Fatalf("Save() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
h := NewHandler(configPath)
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
h.RegisterRoutes(mux)
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/sessions/legacy-json", 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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleSessions_FiltersEmptyJSONLFiles(t *testing.T) {
|
||||||
|
configPath, cleanup := setupOAuthTestEnv(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
dir := sessionsTestDir(t, configPath)
|
||||||
|
base := filepath.Join(dir, sanitizeSessionKey(picoSessionPrefix+"empty-jsonl"))
|
||||||
|
if err := os.WriteFile(base+".jsonl", []byte{}, 0o644); err != nil {
|
||||||
|
t.Fatalf("WriteFile(jsonl) error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
h := NewHandler(configPath)
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
h.RegisterRoutes(mux)
|
||||||
|
|
||||||
|
listRec := httptest.NewRecorder()
|
||||||
|
listReq := httptest.NewRequest(http.MethodGet, "/api/sessions", nil)
|
||||||
|
mux.ServeHTTP(listRec, listReq)
|
||||||
|
|
||||||
|
if listRec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("list status = %d, want %d, body=%s", listRec.Code, http.StatusOK, listRec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var items []sessionListItem
|
||||||
|
if err := json.Unmarshal(listRec.Body.Bytes(), &items); err != nil {
|
||||||
|
t.Fatalf("Unmarshal(list) error = %v", err)
|
||||||
|
}
|
||||||
|
if len(items) != 0 {
|
||||||
|
t.Fatalf("len(items) = %d, want 0", len(items))
|
||||||
|
}
|
||||||
|
|
||||||
|
detailRec := httptest.NewRecorder()
|
||||||
|
detailReq := httptest.NewRequest(http.MethodGet, "/api/sessions/empty-jsonl", nil)
|
||||||
|
mux.ServeHTTP(detailRec, detailReq)
|
||||||
|
|
||||||
|
if detailRec.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("detail status = %d, want %d, body=%s", detailRec.Code, http.StatusNotFound, detailRec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
331
web/backend/api/skills.go
Normal file
|
|
@ -0,0 +1,331 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/skills"
|
||||||
|
)
|
||||||
|
|
||||||
|
type skillSupportResponse struct {
|
||||||
|
Skills []skills.SkillInfo `json:"skills"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type skillDetailResponse struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
skillNameSanitizer = regexp.MustCompile(`[^a-z0-9-]+`)
|
||||||
|
importedSkillFrontmatter = regexp.MustCompile(`(?s)^---(?:\r\n|\n|\r)(.*?)(?:\r\n|\n|\r)---(?:\r\n|\n|\r)*`)
|
||||||
|
skillFrontmatterStripper = regexp.MustCompile(`(?s)^---(?:\r\n|\n|\r)(.*?)(?:\r\n|\n|\r)---(?:\r\n|\n|\r)*`)
|
||||||
|
)
|
||||||
|
|
||||||
|
func (h *Handler) registerSkillRoutes(mux *http.ServeMux) {
|
||||||
|
mux.HandleFunc("GET /api/skills", h.handleListSkills)
|
||||||
|
mux.HandleFunc("GET /api/skills/{name}", h.handleGetSkill)
|
||||||
|
mux.HandleFunc("POST /api/skills/import", h.handleImportSkill)
|
||||||
|
mux.HandleFunc("DELETE /api/skills/{name}", h.handleDeleteSkill)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) handleListSkills(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
|
||||||
|
}
|
||||||
|
|
||||||
|
loader := newSkillsLoader(cfg.WorkspacePath())
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(skillSupportResponse{
|
||||||
|
Skills: loader.ListSkills(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) handleGetSkill(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
|
||||||
|
}
|
||||||
|
|
||||||
|
loader := newSkillsLoader(cfg.WorkspacePath())
|
||||||
|
name := r.PathValue("name")
|
||||||
|
allSkills := loader.ListSkills()
|
||||||
|
|
||||||
|
for _, skill := range allSkills {
|
||||||
|
if skill.Name != name {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
content, err := loadSkillContent(skill.Path)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Skill content not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(skillDetailResponse{
|
||||||
|
Name: skill.Name,
|
||||||
|
Path: skill.Path,
|
||||||
|
Source: skill.Source,
|
||||||
|
Description: skill.Description,
|
||||||
|
Content: content,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Error(w, "Skill not found", http.StatusNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) handleImportSkill(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
|
||||||
|
}
|
||||||
|
|
||||||
|
err = r.ParseMultipartForm(2 << 20)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("Invalid multipart form: %v", err), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
uploadedFile, fileHeader, err := r.FormFile("file")
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "file is required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer uploadedFile.Close()
|
||||||
|
|
||||||
|
content, err := io.ReadAll(io.LimitReader(uploadedFile, (1<<20)+1))
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("Failed to read file: %v", err), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(content) > 1<<20 {
|
||||||
|
http.Error(w, "file exceeds 1MB limit", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
skillName, err := normalizeImportedSkillName(fileHeader.Filename, content)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
content = normalizeImportedSkillContent(content, skillName)
|
||||||
|
|
||||||
|
workspace := cfg.WorkspacePath()
|
||||||
|
skillDir := filepath.Join(workspace, "skills", skillName)
|
||||||
|
skillFile := filepath.Join(skillDir, "SKILL.md")
|
||||||
|
if _, err := os.Stat(skillDir); err == nil {
|
||||||
|
http.Error(w, "skill already exists", http.StatusConflict)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.MkdirAll(skillDir, 0o755); err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("Failed to create skill directory: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(skillFile, content, 0o644); err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("Failed to save skill: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loader := newSkillsLoader(workspace)
|
||||||
|
for _, skill := range loader.ListSkills() {
|
||||||
|
if skill.Path == skillFile || (skill.Name == skillName && skill.Source == "workspace") {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(skill)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{
|
||||||
|
"name": skillName,
|
||||||
|
"path": skillFile,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) handleDeleteSkill(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
|
||||||
|
}
|
||||||
|
|
||||||
|
loader := newSkillsLoader(cfg.WorkspacePath())
|
||||||
|
name := r.PathValue("name")
|
||||||
|
for _, skill := range loader.ListSkills() {
|
||||||
|
if skill.Name != name {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if skill.Source != "workspace" {
|
||||||
|
http.Error(w, "only workspace skills can be deleted", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := os.RemoveAll(filepath.Dir(skill.Path)); err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("Failed to delete skill: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Error(w, "Skill not found", http.StatusNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newSkillsLoader(workspace string) *skills.SkillsLoader {
|
||||||
|
return skills.NewSkillsLoader(
|
||||||
|
workspace,
|
||||||
|
filepath.Join(globalConfigDir(), "skills"),
|
||||||
|
builtinSkillsDir(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeImportedSkillName(filename string, content []byte) (string, error) {
|
||||||
|
rawContent := strings.ReplaceAll(string(content), "\r\n", "\n")
|
||||||
|
rawContent = strings.ReplaceAll(rawContent, "\r", "\n")
|
||||||
|
metadata, _ := extractImportedSkillMetadata(rawContent)
|
||||||
|
|
||||||
|
raw := strings.TrimSpace(metadata["name"])
|
||||||
|
if raw == "" {
|
||||||
|
raw = strings.TrimSpace(strings.TrimSuffix(filepath.Base(filename), filepath.Ext(filename)))
|
||||||
|
}
|
||||||
|
raw = strings.ToLower(raw)
|
||||||
|
raw = strings.ReplaceAll(raw, "_", "-")
|
||||||
|
raw = strings.ReplaceAll(raw, " ", "-")
|
||||||
|
raw = skillNameSanitizer.ReplaceAllString(raw, "-")
|
||||||
|
raw = strings.Trim(raw, "-")
|
||||||
|
raw = strings.Join(strings.FieldsFunc(raw, func(r rune) bool { return r == '-' }), "-")
|
||||||
|
|
||||||
|
if raw == "" {
|
||||||
|
return "", fmt.Errorf("skill name is required in frontmatter or filename")
|
||||||
|
}
|
||||||
|
if len(raw) > 64 {
|
||||||
|
return "", fmt.Errorf("skill name exceeds 64 characters")
|
||||||
|
}
|
||||||
|
matched, err := regexp.MatchString(`^[a-z0-9]+(-[a-z0-9]+)*$`, raw)
|
||||||
|
if err != nil || !matched {
|
||||||
|
return "", fmt.Errorf("skill name must be alphanumeric with hyphens")
|
||||||
|
}
|
||||||
|
return raw, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeImportedSkillContent(content []byte, skillName string) []byte {
|
||||||
|
raw := strings.ReplaceAll(string(content), "\r\n", "\n")
|
||||||
|
raw = strings.ReplaceAll(raw, "\r", "\n")
|
||||||
|
|
||||||
|
metadata, body := extractImportedSkillMetadata(raw)
|
||||||
|
description := strings.TrimSpace(metadata["description"])
|
||||||
|
if description == "" {
|
||||||
|
description = inferImportedSkillDescription(body)
|
||||||
|
}
|
||||||
|
if description == "" {
|
||||||
|
description = "Imported skill"
|
||||||
|
}
|
||||||
|
if len(description) > 1024 {
|
||||||
|
description = strings.TrimSpace(description[:1024])
|
||||||
|
}
|
||||||
|
|
||||||
|
body = strings.TrimLeft(body, "\n")
|
||||||
|
var builder strings.Builder
|
||||||
|
builder.WriteString("---\n")
|
||||||
|
builder.WriteString("name: ")
|
||||||
|
builder.WriteString(skillName)
|
||||||
|
builder.WriteString("\n")
|
||||||
|
builder.WriteString("description: ")
|
||||||
|
builder.WriteString(description)
|
||||||
|
builder.WriteString("\n")
|
||||||
|
builder.WriteString("---\n\n")
|
||||||
|
builder.WriteString(body)
|
||||||
|
if !strings.HasSuffix(builder.String(), "\n") {
|
||||||
|
builder.WriteString("\n")
|
||||||
|
}
|
||||||
|
return []byte(builder.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractImportedSkillMetadata(raw string) (map[string]string, string) {
|
||||||
|
matches := importedSkillFrontmatter.FindStringSubmatch(raw)
|
||||||
|
if len(matches) != 2 {
|
||||||
|
return map[string]string{}, raw
|
||||||
|
}
|
||||||
|
meta := parseImportedSkillYAML(matches[1])
|
||||||
|
body := importedSkillFrontmatter.ReplaceAllString(raw, "")
|
||||||
|
return meta, body
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseImportedSkillYAML(frontmatter string) map[string]string {
|
||||||
|
result := make(map[string]string)
|
||||||
|
for _, line := range strings.Split(frontmatter, "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" || strings.HasPrefix(line, "#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key, value, ok := strings.Cut(line, ":")
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result[strings.TrimSpace(key)] = strings.Trim(strings.TrimSpace(value), `"'`)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func inferImportedSkillDescription(body string) string {
|
||||||
|
for _, line := range strings.Split(body, "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
line = strings.TrimLeft(line, "#-*0123456789. ")
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line != "" {
|
||||||
|
return line
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadSkillContent(path string) (string, error) {
|
||||||
|
content, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return skillFrontmatterStripper.ReplaceAllString(string(content), ""), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func globalConfigDir() string {
|
||||||
|
if home := os.Getenv("PICOCLAW_HOME"); home != "" {
|
||||||
|
return home
|
||||||
|
}
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return filepath.Join(home, ".picoclaw")
|
||||||
|
}
|
||||||
|
|
||||||
|
func builtinSkillsDir() string {
|
||||||
|
if path := os.Getenv("PICOCLAW_BUILTIN_SKILLS"); path != "" {
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
wd, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return filepath.Join(wd, "skills")
|
||||||
|
}
|
||||||
336
web/backend/api/skills_test.go
Normal file
|
|
@ -0,0 +1,336 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"mime/multipart"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHandleListSkills(t *testing.T) {
|
||||||
|
configPath, cleanup := setupOAuthTestEnv(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
cfg, err := config.LoadConfig(configPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
workspace := filepath.Join(t.TempDir(), "workspace")
|
||||||
|
cfg.Agents.Defaults.Workspace = workspace
|
||||||
|
err = config.SaveConfig(configPath, cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SaveConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.MkdirAll(filepath.Join(workspace, "skills", "workspace-skill"), 0o755); err != nil {
|
||||||
|
t.Fatalf("MkdirAll(workspace skill) error = %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(
|
||||||
|
filepath.Join(workspace, "skills", "workspace-skill", "SKILL.md"),
|
||||||
|
[]byte("---\nname: workspace-skill\ndescription: Workspace skill\n---\n"),
|
||||||
|
0o644,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("WriteFile(workspace skill) error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
globalSkillDir := filepath.Join(globalConfigDir(), "skills", "global-skill")
|
||||||
|
if err := os.MkdirAll(globalSkillDir, 0o755); err != nil {
|
||||||
|
t.Fatalf("MkdirAll(global skill) error = %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(
|
||||||
|
filepath.Join(globalSkillDir, "SKILL.md"),
|
||||||
|
[]byte("---\nname: global-skill\ndescription: Global skill\n---\n"),
|
||||||
|
0o644,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("WriteFile(global skill) error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
builtinRoot := filepath.Join(t.TempDir(), "builtin-skills")
|
||||||
|
oldBuiltin := os.Getenv("PICOCLAW_BUILTIN_SKILLS")
|
||||||
|
if err := os.Setenv("PICOCLAW_BUILTIN_SKILLS", builtinRoot); err != nil {
|
||||||
|
t.Fatalf("Setenv(PICOCLAW_BUILTIN_SKILLS) error = %v", err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if oldBuiltin == "" {
|
||||||
|
_ = os.Unsetenv("PICOCLAW_BUILTIN_SKILLS")
|
||||||
|
} else {
|
||||||
|
_ = os.Setenv("PICOCLAW_BUILTIN_SKILLS", oldBuiltin)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
builtinSkillDir := filepath.Join(builtinRoot, "builtin-skill")
|
||||||
|
if err := os.MkdirAll(builtinSkillDir, 0o755); err != nil {
|
||||||
|
t.Fatalf("MkdirAll(builtin skill) error = %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(
|
||||||
|
filepath.Join(builtinSkillDir, "SKILL.md"),
|
||||||
|
[]byte("---\nname: builtin-skill\ndescription: Builtin skill\n---\n"),
|
||||||
|
0o644,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("WriteFile(builtin skill) error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
h := NewHandler(configPath)
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
h.RegisterRoutes(mux)
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/skills", nil)
|
||||||
|
mux.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp skillSupportResponse
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("Unmarshal() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(resp.Skills) != 3 {
|
||||||
|
t.Fatalf("skills count = %d, want 3", len(resp.Skills))
|
||||||
|
}
|
||||||
|
|
||||||
|
gotSkills := make(map[string]string, len(resp.Skills))
|
||||||
|
for _, skill := range resp.Skills {
|
||||||
|
gotSkills[skill.Name] = skill.Source
|
||||||
|
}
|
||||||
|
if gotSkills["workspace-skill"] != "workspace" {
|
||||||
|
t.Fatalf("workspace-skill source = %q, want workspace", gotSkills["workspace-skill"])
|
||||||
|
}
|
||||||
|
if gotSkills["global-skill"] != "global" {
|
||||||
|
t.Fatalf("global-skill source = %q, want global", gotSkills["global-skill"])
|
||||||
|
}
|
||||||
|
if gotSkills["builtin-skill"] != "builtin" {
|
||||||
|
t.Fatalf("builtin-skill source = %q, want builtin", gotSkills["builtin-skill"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleGetSkill(t *testing.T) {
|
||||||
|
configPath, cleanup := setupOAuthTestEnv(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
cfg, err := config.LoadConfig(configPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
workspace := filepath.Join(t.TempDir(), "workspace")
|
||||||
|
cfg.Agents.Defaults.Workspace = workspace
|
||||||
|
err = config.SaveConfig(configPath, cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SaveConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
skillDir := filepath.Join(workspace, "skills", "viewer-skill")
|
||||||
|
if err := os.MkdirAll(skillDir, 0o755); err != nil {
|
||||||
|
t.Fatalf("MkdirAll() error = %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(
|
||||||
|
filepath.Join(skillDir, "SKILL.md"),
|
||||||
|
[]byte(
|
||||||
|
"---\nname: viewer-skill\ndescription: Viewable skill\n---\n# Viewer Skill\n\nThis is visible content.\n",
|
||||||
|
),
|
||||||
|
0o644,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("WriteFile() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
h := NewHandler(configPath)
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
h.RegisterRoutes(mux)
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/skills/viewer-skill", nil)
|
||||||
|
mux.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp skillDetailResponse
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("Unmarshal() error = %v", err)
|
||||||
|
}
|
||||||
|
if resp.Name != "viewer-skill" || resp.Source != "workspace" || resp.Description != "Viewable skill" {
|
||||||
|
t.Fatalf("unexpected response: %#v", resp)
|
||||||
|
}
|
||||||
|
if resp.Content != "# Viewer Skill\n\nThis is visible content.\n" {
|
||||||
|
t.Fatalf("content = %q", resp.Content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleGetSkillUsesResolvedPath(t *testing.T) {
|
||||||
|
configPath, cleanup := setupOAuthTestEnv(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
cfg, err := config.LoadConfig(configPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
workspace := filepath.Join(t.TempDir(), "workspace")
|
||||||
|
cfg.Agents.Defaults.Workspace = workspace
|
||||||
|
err = config.SaveConfig(configPath, cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SaveConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
skillDir := filepath.Join(workspace, "skills", "folder-name")
|
||||||
|
if err := os.MkdirAll(skillDir, 0o755); err != nil {
|
||||||
|
t.Fatalf("MkdirAll() error = %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(
|
||||||
|
filepath.Join(skillDir, "SKILL.md"),
|
||||||
|
[]byte("---\nname: display-name\ndescription: Mismatched path skill\n---\n# Display Name\n"),
|
||||||
|
0o644,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("WriteFile() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
h := NewHandler(configPath)
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
h.RegisterRoutes(mux)
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/skills/display-name", nil)
|
||||||
|
mux.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp skillDetailResponse
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("Unmarshal() error = %v", err)
|
||||||
|
}
|
||||||
|
if resp.Name != "display-name" {
|
||||||
|
t.Fatalf("resp.Name = %q, want display-name", resp.Name)
|
||||||
|
}
|
||||||
|
if resp.Content != "# Display Name\n" {
|
||||||
|
t.Fatalf("content = %q", resp.Content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleImportSkill(t *testing.T) {
|
||||||
|
configPath, cleanup := setupOAuthTestEnv(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
cfg, err := config.LoadConfig(configPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
workspace := filepath.Join(t.TempDir(), "workspace")
|
||||||
|
cfg.Agents.Defaults.Workspace = workspace
|
||||||
|
err = config.SaveConfig(configPath, cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SaveConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body bytes.Buffer
|
||||||
|
writer := multipart.NewWriter(&body)
|
||||||
|
part, err := writer.CreateFormFile("file", "Plain Skill.md")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateFormFile() error = %v", err)
|
||||||
|
}
|
||||||
|
_, err = io.WriteString(part, "# Plain Skill\n\nUse this skill to test imports.\n")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("WriteString() error = %v", err)
|
||||||
|
}
|
||||||
|
err = writer.Close()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Close() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
h := NewHandler(configPath)
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
h.RegisterRoutes(mux)
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/skills/import", &body)
|
||||||
|
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||||
|
mux.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
skillFile := filepath.Join(workspace, "skills", "plain-skill", "SKILL.md")
|
||||||
|
content, err := os.ReadFile(skillFile)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadFile() error = %v", err)
|
||||||
|
}
|
||||||
|
expected := "---\nname: plain-skill\ndescription: Plain Skill\n---\n\n# Plain Skill\n\nUse this skill to test imports.\n"
|
||||||
|
if string(content) != expected {
|
||||||
|
t.Fatalf("saved skill content mismatch:\n%s", string(content))
|
||||||
|
}
|
||||||
|
|
||||||
|
rec2 := httptest.NewRecorder()
|
||||||
|
req2 := httptest.NewRequest(http.MethodGet, "/api/skills", nil)
|
||||||
|
mux.ServeHTTP(rec2, req2)
|
||||||
|
if rec2.Code != http.StatusOK {
|
||||||
|
t.Fatalf("list status = %d, want %d, body=%s", rec2.Code, http.StatusOK, rec2.Body.String())
|
||||||
|
}
|
||||||
|
var listResp skillSupportResponse
|
||||||
|
if err := json.Unmarshal(rec2.Body.Bytes(), &listResp); err != nil {
|
||||||
|
t.Fatalf("Unmarshal list response error = %v", err)
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, skill := range listResp.Skills {
|
||||||
|
if skill.Name == "plain-skill" && skill.Source == "workspace" && skill.Description == "Plain Skill" {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatalf("plain-skill should be listed after import, got %#v", listResp.Skills)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleDeleteSkill(t *testing.T) {
|
||||||
|
configPath, cleanup := setupOAuthTestEnv(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
cfg, err := config.LoadConfig(configPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
workspace := filepath.Join(t.TempDir(), "workspace")
|
||||||
|
cfg.Agents.Defaults.Workspace = workspace
|
||||||
|
if err := config.SaveConfig(configPath, cfg); err != nil {
|
||||||
|
t.Fatalf("SaveConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
skillDir := filepath.Join(workspace, "skills", "delete-me")
|
||||||
|
if err := os.MkdirAll(skillDir, 0o755); err != nil {
|
||||||
|
t.Fatalf("MkdirAll() error = %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(
|
||||||
|
filepath.Join(skillDir, "SKILL.md"),
|
||||||
|
[]byte("---\nname: delete-me\ndescription: delete me\n---\n"),
|
||||||
|
0o644,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("WriteFile() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
h := NewHandler(configPath)
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
h.RegisterRoutes(mux)
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodDelete, "/api/skills/delete-me", 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())
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(skillDir); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("skill directory should be removed, stat err=%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
323
web/backend/api/tools.go
Normal file
|
|
@ -0,0 +1,323 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"runtime"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
type toolCatalogEntry struct {
|
||||||
|
Name string
|
||||||
|
Description string
|
||||||
|
Category string
|
||||||
|
ConfigKey string
|
||||||
|
}
|
||||||
|
|
||||||
|
type toolSupportItem struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Category string `json:"category"`
|
||||||
|
ConfigKey string `json:"config_key"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
ReasonCode string `json:"reason_code,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type toolSupportResponse struct {
|
||||||
|
Tools []toolSupportItem `json:"tools"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type toolStateRequest struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var toolCatalog = []toolCatalogEntry{
|
||||||
|
{
|
||||||
|
Name: "read_file",
|
||||||
|
Description: "Read file content from the workspace or explicitly allowed paths.",
|
||||||
|
Category: "filesystem",
|
||||||
|
ConfigKey: "read_file",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "write_file",
|
||||||
|
Description: "Create or overwrite files within the writable workspace scope.",
|
||||||
|
Category: "filesystem",
|
||||||
|
ConfigKey: "write_file",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "list_dir",
|
||||||
|
Description: "Inspect directories and enumerate files available to the agent.",
|
||||||
|
Category: "filesystem",
|
||||||
|
ConfigKey: "list_dir",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "edit_file",
|
||||||
|
Description: "Apply targeted edits to existing files without rewriting everything.",
|
||||||
|
Category: "filesystem",
|
||||||
|
ConfigKey: "edit_file",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "append_file",
|
||||||
|
Description: "Append content to the end of an existing file.",
|
||||||
|
Category: "filesystem",
|
||||||
|
ConfigKey: "append_file",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "exec",
|
||||||
|
Description: "Run shell commands inside the configured workspace sandbox.",
|
||||||
|
Category: "filesystem",
|
||||||
|
ConfigKey: "exec",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "cron",
|
||||||
|
Description: "Schedule one-time or recurring reminders, jobs, and shell commands.",
|
||||||
|
Category: "automation",
|
||||||
|
ConfigKey: "cron",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "web_search",
|
||||||
|
Description: "Search the web using the configured providers.",
|
||||||
|
Category: "web",
|
||||||
|
ConfigKey: "web",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "web_fetch",
|
||||||
|
Description: "Fetch and summarize the contents of a webpage.",
|
||||||
|
Category: "web",
|
||||||
|
ConfigKey: "web_fetch",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "message",
|
||||||
|
Description: "Send a follow-up message back to the active user or chat.",
|
||||||
|
Category: "communication",
|
||||||
|
ConfigKey: "message",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "send_file",
|
||||||
|
Description: "Send an outbound file or media attachment to the active chat.",
|
||||||
|
Category: "communication",
|
||||||
|
ConfigKey: "send_file",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "find_skills",
|
||||||
|
Description: "Search external skill registries for installable skills.",
|
||||||
|
Category: "skills",
|
||||||
|
ConfigKey: "find_skills",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "install_skill",
|
||||||
|
Description: "Install a skill into the current workspace from a registry.",
|
||||||
|
Category: "skills",
|
||||||
|
ConfigKey: "install_skill",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "spawn",
|
||||||
|
Description: "Launch a background subagent for long-running or delegated work.",
|
||||||
|
Category: "agents",
|
||||||
|
ConfigKey: "spawn",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "i2c",
|
||||||
|
Description: "Interact with I2C hardware devices exposed on the host.",
|
||||||
|
Category: "hardware",
|
||||||
|
ConfigKey: "i2c",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "spi",
|
||||||
|
Description: "Interact with SPI hardware devices exposed on the host.",
|
||||||
|
Category: "hardware",
|
||||||
|
ConfigKey: "spi",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "tool_search_tool_regex",
|
||||||
|
Description: "Discover hidden MCP tools by regex search when tool discovery is enabled.",
|
||||||
|
Category: "discovery",
|
||||||
|
ConfigKey: "mcp.discovery.use_regex",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "tool_search_tool_bm25",
|
||||||
|
Description: "Discover hidden MCP tools by semantic ranking when tool discovery is enabled.",
|
||||||
|
Category: "discovery",
|
||||||
|
ConfigKey: "mcp.discovery.use_bm25",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) registerToolRoutes(mux *http.ServeMux) {
|
||||||
|
mux.HandleFunc("GET /api/tools", h.handleListTools)
|
||||||
|
mux.HandleFunc("PUT /api/tools/{name}/state", h.handleUpdateToolState)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) handleListTools(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
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(toolSupportResponse{
|
||||||
|
Tools: buildToolSupport(cfg),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) handleUpdateToolState(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
|
||||||
|
}
|
||||||
|
|
||||||
|
var req toolStateRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := applyToolState(cfg, r.PathValue("name"), req.Enabled); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
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"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildToolSupport(cfg *config.Config) []toolSupportItem {
|
||||||
|
items := make([]toolSupportItem, 0, len(toolCatalog))
|
||||||
|
for _, entry := range toolCatalog {
|
||||||
|
status := "disabled"
|
||||||
|
reasonCode := ""
|
||||||
|
|
||||||
|
switch entry.Name {
|
||||||
|
case "find_skills", "install_skill":
|
||||||
|
if cfg.Tools.IsToolEnabled(entry.ConfigKey) {
|
||||||
|
if cfg.Tools.IsToolEnabled("skills") {
|
||||||
|
status = "enabled"
|
||||||
|
} else {
|
||||||
|
status = "blocked"
|
||||||
|
reasonCode = "requires_skills"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "spawn":
|
||||||
|
if cfg.Tools.IsToolEnabled(entry.ConfigKey) {
|
||||||
|
if cfg.Tools.IsToolEnabled("subagent") {
|
||||||
|
status = "enabled"
|
||||||
|
} else {
|
||||||
|
status = "blocked"
|
||||||
|
reasonCode = "requires_subagent"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "tool_search_tool_regex":
|
||||||
|
status, reasonCode = resolveDiscoveryToolSupport(cfg, cfg.Tools.MCP.Discovery.UseRegex)
|
||||||
|
case "tool_search_tool_bm25":
|
||||||
|
status, reasonCode = resolveDiscoveryToolSupport(cfg, cfg.Tools.MCP.Discovery.UseBM25)
|
||||||
|
case "i2c", "spi":
|
||||||
|
status, reasonCode = resolveHardwareToolSupport(cfg.Tools.IsToolEnabled(entry.ConfigKey))
|
||||||
|
default:
|
||||||
|
if cfg.Tools.IsToolEnabled(entry.ConfigKey) {
|
||||||
|
status = "enabled"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
items = append(items, toolSupportItem{
|
||||||
|
Name: entry.Name,
|
||||||
|
Description: entry.Description,
|
||||||
|
Category: entry.Category,
|
||||||
|
ConfigKey: entry.ConfigKey,
|
||||||
|
Status: status,
|
||||||
|
ReasonCode: reasonCode,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveHardwareToolSupport(enabled bool) (string, string) {
|
||||||
|
if !enabled {
|
||||||
|
return "disabled", ""
|
||||||
|
}
|
||||||
|
if runtime.GOOS != "linux" {
|
||||||
|
return "blocked", "requires_linux"
|
||||||
|
}
|
||||||
|
return "enabled", ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveDiscoveryToolSupport(cfg *config.Config, methodEnabled bool) (string, string) {
|
||||||
|
if !cfg.Tools.IsToolEnabled("mcp") {
|
||||||
|
return "disabled", ""
|
||||||
|
}
|
||||||
|
if !cfg.Tools.MCP.Discovery.Enabled {
|
||||||
|
return "blocked", "requires_mcp_discovery"
|
||||||
|
}
|
||||||
|
if !methodEnabled {
|
||||||
|
return "disabled", ""
|
||||||
|
}
|
||||||
|
return "enabled", ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyToolState(cfg *config.Config, toolName string, enabled bool) error {
|
||||||
|
switch toolName {
|
||||||
|
case "read_file":
|
||||||
|
cfg.Tools.ReadFile.Enabled = enabled
|
||||||
|
case "write_file":
|
||||||
|
cfg.Tools.WriteFile.Enabled = enabled
|
||||||
|
case "list_dir":
|
||||||
|
cfg.Tools.ListDir.Enabled = enabled
|
||||||
|
case "edit_file":
|
||||||
|
cfg.Tools.EditFile.Enabled = enabled
|
||||||
|
case "append_file":
|
||||||
|
cfg.Tools.AppendFile.Enabled = enabled
|
||||||
|
case "exec":
|
||||||
|
cfg.Tools.Exec.Enabled = enabled
|
||||||
|
case "cron":
|
||||||
|
cfg.Tools.Cron.Enabled = enabled
|
||||||
|
case "web_search":
|
||||||
|
cfg.Tools.Web.Enabled = enabled
|
||||||
|
case "web_fetch":
|
||||||
|
cfg.Tools.WebFetch.Enabled = enabled
|
||||||
|
case "message":
|
||||||
|
cfg.Tools.Message.Enabled = enabled
|
||||||
|
case "send_file":
|
||||||
|
cfg.Tools.SendFile.Enabled = enabled
|
||||||
|
case "find_skills":
|
||||||
|
cfg.Tools.FindSkills.Enabled = enabled
|
||||||
|
if enabled {
|
||||||
|
cfg.Tools.Skills.Enabled = true
|
||||||
|
}
|
||||||
|
case "install_skill":
|
||||||
|
cfg.Tools.InstallSkill.Enabled = enabled
|
||||||
|
if enabled {
|
||||||
|
cfg.Tools.Skills.Enabled = true
|
||||||
|
}
|
||||||
|
case "spawn":
|
||||||
|
cfg.Tools.Spawn.Enabled = enabled
|
||||||
|
if enabled {
|
||||||
|
cfg.Tools.Subagent.Enabled = true
|
||||||
|
}
|
||||||
|
case "i2c":
|
||||||
|
cfg.Tools.I2C.Enabled = enabled
|
||||||
|
case "spi":
|
||||||
|
cfg.Tools.SPI.Enabled = enabled
|
||||||
|
case "tool_search_tool_regex":
|
||||||
|
cfg.Tools.MCP.Discovery.UseRegex = enabled
|
||||||
|
if enabled {
|
||||||
|
cfg.Tools.MCP.Enabled = true
|
||||||
|
cfg.Tools.MCP.Discovery.Enabled = true
|
||||||
|
}
|
||||||
|
case "tool_search_tool_bm25":
|
||||||
|
cfg.Tools.MCP.Discovery.UseBM25 = enabled
|
||||||
|
if enabled {
|
||||||
|
cfg.Tools.MCP.Enabled = true
|
||||||
|
cfg.Tools.MCP.Discovery.Enabled = true
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("tool %q cannot be updated", toolName)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
198
web/backend/api/tools_test.go
Normal file
|
|
@ -0,0 +1,198 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"runtime"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHandleListTools(t *testing.T) {
|
||||||
|
configPath, cleanup := setupOAuthTestEnv(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
cfg, err := config.LoadConfig(configPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
cfg.Tools.ReadFile.Enabled = true
|
||||||
|
cfg.Tools.WriteFile.Enabled = false
|
||||||
|
cfg.Tools.Cron.Enabled = true
|
||||||
|
cfg.Tools.FindSkills.Enabled = true
|
||||||
|
cfg.Tools.Skills.Enabled = true
|
||||||
|
cfg.Tools.Spawn.Enabled = true
|
||||||
|
cfg.Tools.Subagent.Enabled = false
|
||||||
|
cfg.Tools.MCP.Enabled = true
|
||||||
|
cfg.Tools.MCP.Discovery.Enabled = true
|
||||||
|
cfg.Tools.MCP.Discovery.UseRegex = true
|
||||||
|
cfg.Tools.MCP.Discovery.UseBM25 = false
|
||||||
|
err = config.SaveConfig(configPath, cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SaveConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
h := NewHandler(configPath)
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
h.RegisterRoutes(mux)
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/tools", nil)
|
||||||
|
mux.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp toolSupportResponse
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("Unmarshal() error = %v", err)
|
||||||
|
}
|
||||||
|
gotTools := make(map[string]toolSupportItem, len(resp.Tools))
|
||||||
|
for _, tool := range resp.Tools {
|
||||||
|
gotTools[tool.Name] = tool
|
||||||
|
}
|
||||||
|
if gotTools["read_file"].Status != "enabled" {
|
||||||
|
t.Fatalf("read_file status = %q, want enabled", gotTools["read_file"].Status)
|
||||||
|
}
|
||||||
|
if gotTools["write_file"].Status != "disabled" {
|
||||||
|
t.Fatalf("write_file status = %q, want disabled", gotTools["write_file"].Status)
|
||||||
|
}
|
||||||
|
if gotTools["cron"].Status != "enabled" {
|
||||||
|
t.Fatalf("cron status = %q, want enabled", gotTools["cron"].Status)
|
||||||
|
}
|
||||||
|
if gotTools["spawn"].Status != "blocked" || gotTools["spawn"].ReasonCode != "requires_subagent" {
|
||||||
|
t.Fatalf("spawn = %#v, want blocked/requires_subagent", gotTools["spawn"])
|
||||||
|
}
|
||||||
|
if gotTools["find_skills"].Status != "enabled" {
|
||||||
|
t.Fatalf("find_skills status = %q, want enabled", gotTools["find_skills"].Status)
|
||||||
|
}
|
||||||
|
if gotTools["tool_search_tool_regex"].Status != "enabled" {
|
||||||
|
t.Fatalf("tool_search_tool_regex status = %q, want enabled", gotTools["tool_search_tool_regex"].Status)
|
||||||
|
}
|
||||||
|
if gotTools["tool_search_tool_regex"].ConfigKey != "mcp.discovery.use_regex" {
|
||||||
|
t.Fatalf(
|
||||||
|
"tool_search_tool_regex config_key = %q, want mcp.discovery.use_regex",
|
||||||
|
gotTools["tool_search_tool_regex"].ConfigKey,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if gotTools["tool_search_tool_bm25"].Status != "disabled" {
|
||||||
|
t.Fatalf("tool_search_tool_bm25 status = %q, want disabled", gotTools["tool_search_tool_bm25"].Status)
|
||||||
|
}
|
||||||
|
if gotTools["tool_search_tool_bm25"].ConfigKey != "mcp.discovery.use_bm25" {
|
||||||
|
t.Fatalf(
|
||||||
|
"tool_search_tool_bm25 config_key = %q, want mcp.discovery.use_bm25",
|
||||||
|
gotTools["tool_search_tool_bm25"].ConfigKey,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if runtime.GOOS == "linux" {
|
||||||
|
if gotTools["i2c"].Status != "disabled" {
|
||||||
|
t.Fatalf("i2c status = %q, want disabled on linux when config is off", gotTools["i2c"].Status)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
cfg.Tools.I2C.Enabled = true
|
||||||
|
cfg.Tools.SPI.Enabled = true
|
||||||
|
if err := config.SaveConfig(configPath, cfg); err != nil {
|
||||||
|
t.Fatalf("SaveConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
req = httptest.NewRequest(http.MethodGet, "/api/tools", 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())
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("Unmarshal() error = %v", err)
|
||||||
|
}
|
||||||
|
gotTools = make(map[string]toolSupportItem, len(resp.Tools))
|
||||||
|
for _, tool := range resp.Tools {
|
||||||
|
gotTools[tool.Name] = tool
|
||||||
|
}
|
||||||
|
|
||||||
|
if gotTools["i2c"].Status != "blocked" || gotTools["i2c"].ReasonCode != "requires_linux" {
|
||||||
|
t.Fatalf("i2c = %#v, want blocked/requires_linux", gotTools["i2c"])
|
||||||
|
}
|
||||||
|
if gotTools["spi"].Status != "blocked" || gotTools["spi"].ReasonCode != "requires_linux" {
|
||||||
|
t.Fatalf("spi = %#v, want blocked/requires_linux", gotTools["spi"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleUpdateToolState(t *testing.T) {
|
||||||
|
configPath, cleanup := setupOAuthTestEnv(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
cfg, err := config.LoadConfig(configPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
cfg.Tools.Spawn.Enabled = false
|
||||||
|
cfg.Tools.Subagent.Enabled = false
|
||||||
|
cfg.Tools.Cron.Enabled = false
|
||||||
|
cfg.Tools.MCP.Enabled = false
|
||||||
|
cfg.Tools.MCP.Discovery.Enabled = false
|
||||||
|
cfg.Tools.MCP.Discovery.UseRegex = false
|
||||||
|
err = config.SaveConfig(configPath, cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SaveConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
h := NewHandler(configPath)
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
h.RegisterRoutes(mux)
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(
|
||||||
|
http.MethodPut,
|
||||||
|
"/api/tools/spawn/state",
|
||||||
|
bytes.NewBufferString(`{"enabled":true}`),
|
||||||
|
)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
mux.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("spawn status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
rec2 := httptest.NewRecorder()
|
||||||
|
req2 := httptest.NewRequest(
|
||||||
|
http.MethodPut,
|
||||||
|
"/api/tools/tool_search_tool_regex/state",
|
||||||
|
bytes.NewBufferString(`{"enabled":true}`),
|
||||||
|
)
|
||||||
|
req2.Header.Set("Content-Type", "application/json")
|
||||||
|
mux.ServeHTTP(rec2, req2)
|
||||||
|
if rec2.Code != http.StatusOK {
|
||||||
|
t.Fatalf("regex status = %d, want %d, body=%s", rec2.Code, http.StatusOK, rec2.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
rec3 := httptest.NewRecorder()
|
||||||
|
req3 := httptest.NewRequest(
|
||||||
|
http.MethodPut,
|
||||||
|
"/api/tools/cron/state",
|
||||||
|
bytes.NewBufferString(`{"enabled":true}`),
|
||||||
|
)
|
||||||
|
req3.Header.Set("Content-Type", "application/json")
|
||||||
|
mux.ServeHTTP(rec3, req3)
|
||||||
|
if rec3.Code != http.StatusOK {
|
||||||
|
t.Fatalf("cron status = %d, want %d, body=%s", rec3.Code, http.StatusOK, rec3.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
updated, err := config.LoadConfig(configPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadConfig(updated) error = %v", err)
|
||||||
|
}
|
||||||
|
if !updated.Tools.Spawn.Enabled || !updated.Tools.Subagent.Enabled {
|
||||||
|
t.Fatalf("spawn/subagent should both be enabled: %#v", updated.Tools)
|
||||||
|
}
|
||||||
|
if !updated.Tools.MCP.Enabled || !updated.Tools.MCP.Discovery.Enabled || !updated.Tools.MCP.Discovery.UseRegex {
|
||||||
|
t.Fatalf("mcp regex discovery should be enabled: %#v", updated.Tools.MCP)
|
||||||
|
}
|
||||||
|
if !updated.Tools.Cron.Enabled {
|
||||||
|
t.Fatalf("cron should be enabled: %#v", updated.Tools.Cron)
|
||||||
|
}
|
||||||
|
}
|
||||||
1
web/backend/dist/.gitkeep
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
# Keep the embedded web backend dist directory in version control.
|
||||||
77
web/backend/embed.go
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"io/fs"
|
||||||
|
"log"
|
||||||
|
"mime"
|
||||||
|
"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) {
|
||||||
|
// Register correct MIME type for SVG files
|
||||||
|
// Go's built-in mime.TypeByExtension returns "image/svg" which is incorrect
|
||||||
|
// The correct MIME type per RFC 6838 is "image/svg+xml"
|
||||||
|
if err := mime.AddExtensionType(".svg", "image/svg+xml"); err != nil {
|
||||||
|
log.Printf("Warning: failed to register SVG MIME type: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
web/backend/icon.ico
Normal file
|
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])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
169
web/backend/main.go
Normal file
|
|
@ -0,0 +1,169 @@
|
||||||
|
// 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"
|
||||||
|
"github.com/sipeed/picoclaw/web/backend/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
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 := utils.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)
|
||||||
|
}
|
||||||
|
err = utils.EnsureOnboarded(absPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Warning: Failed to initialize PicoClaw config automatically: %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, explicitPublic, 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(utils.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 := utils.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 := utils.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"`
|
||||||
|
}
|
||||||
15
web/backend/utils/banner.go
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
package utils
|
||||||
|
|
||||||
|
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
|
||||||
|
)
|
||||||
42
web/backend/utils/onboard.go
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
var execCommand = exec.Command
|
||||||
|
|
||||||
|
func EnsureOnboarded(configPath string) error {
|
||||||
|
_, err := os.Stat(configPath)
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("stat config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := execCommand(FindPicoclawBinary(), "onboard")
|
||||||
|
cmd.Env = append(os.Environ(), "PICOCLAW_CONFIG="+configPath)
|
||||||
|
cmd.Stdin = strings.NewReader("n\n")
|
||||||
|
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
trimmed := strings.TrimSpace(string(output))
|
||||||
|
if trimmed == "" {
|
||||||
|
return fmt.Errorf("run onboard: %w", err)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("run onboard: %w: %s", err, trimmed)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := os.Stat(configPath); err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("onboard completed but did not create config %s", configPath)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("verify config after onboard: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
101
web/backend/utils/onboard_test.go
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestEnsureOnboardedSkipsWhenConfigExists(t *testing.T) {
|
||||||
|
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||||
|
if err := os.WriteFile(configPath, []byte(`{}`), 0o644); err != nil {
|
||||||
|
t.Fatalf("WriteFile() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
origExecCommand := execCommand
|
||||||
|
defer func() { execCommand = origExecCommand }()
|
||||||
|
|
||||||
|
called := false
|
||||||
|
execCommand = func(name string, args ...string) *exec.Cmd {
|
||||||
|
called = true
|
||||||
|
return exec.Command("sh", "-c", "exit 1")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := EnsureOnboarded(configPath); err != nil {
|
||||||
|
t.Fatalf("EnsureOnboarded() error = %v", err)
|
||||||
|
}
|
||||||
|
if called {
|
||||||
|
t.Fatal("expected onboard command not to run when config already exists")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsureOnboardedRunsOnboardWhenConfigMissing(t *testing.T) {
|
||||||
|
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||||
|
t.Setenv("EXPECTED_CONFIG_PATH", configPath)
|
||||||
|
|
||||||
|
origExecCommand := execCommand
|
||||||
|
defer func() { execCommand = origExecCommand }()
|
||||||
|
|
||||||
|
var gotName string
|
||||||
|
var gotArgs []string
|
||||||
|
execCommand = func(name string, args ...string) *exec.Cmd {
|
||||||
|
gotName = name
|
||||||
|
gotArgs = append([]string(nil), args...)
|
||||||
|
return exec.Command(
|
||||||
|
"sh",
|
||||||
|
"-c",
|
||||||
|
`test "$PICOCLAW_CONFIG" = "$EXPECTED_CONFIG_PATH" &&
|
||||||
|
mkdir -p "$(dirname "$PICOCLAW_CONFIG")" &&
|
||||||
|
printf '{}' > "$PICOCLAW_CONFIG"`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := EnsureOnboarded(configPath); err != nil {
|
||||||
|
t.Fatalf("EnsureOnboarded() error = %v", err)
|
||||||
|
}
|
||||||
|
if gotName == "" {
|
||||||
|
t.Fatal("expected onboard command to run")
|
||||||
|
}
|
||||||
|
if len(gotArgs) != 1 || gotArgs[0] != "onboard" {
|
||||||
|
t.Fatalf("command args = %#v, want []string{\"onboard\"}", gotArgs)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(configPath); err != nil {
|
||||||
|
t.Fatalf("expected config to be created: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsureOnboardedFailsWhenOnboardDoesNotCreateConfig(t *testing.T) {
|
||||||
|
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||||
|
|
||||||
|
origExecCommand := execCommand
|
||||||
|
defer func() { execCommand = origExecCommand }()
|
||||||
|
|
||||||
|
execCommand = func(name string, args ...string) *exec.Cmd {
|
||||||
|
return exec.Command("sh", "-c", "exit 0")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := EnsureOnboarded(configPath); err == nil {
|
||||||
|
t.Fatal("EnsureOnboarded() error = nil, want failure when onboard does not create config")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsureOnboardedIncludesOnboardOutputOnFailure(t *testing.T) {
|
||||||
|
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||||
|
|
||||||
|
origExecCommand := execCommand
|
||||||
|
defer func() { execCommand = origExecCommand }()
|
||||||
|
|
||||||
|
execCommand = func(name string, args ...string) *exec.Cmd {
|
||||||
|
return exec.Command("sh", "-c", "echo onboarding failed >&2; exit 2")
|
||||||
|
}
|
||||||
|
|
||||||
|
err := EnsureOnboarded(configPath)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("EnsureOnboarded() error = nil, want failure")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "onboarding failed") {
|
||||||
|
t.Fatalf("error = %q, want onboard output included", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
80
web/backend/utils/runtime.go
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetDefaultConfigPath returns the default path to the picoclaw config file.
|
||||||
|
func GetDefaultConfigPath() string {
|
||||||
|
if configPath := os.Getenv("PICOCLAW_CONFIG"); configPath != "" {
|
||||||
|
return configPath
|
||||||
|
}
|
||||||
|
if picoclawHome := os.Getenv("PICOCLAW_HOME"); picoclawHome != "" {
|
||||||
|
return filepath.Join(picoclawHome, "config.json")
|
||||||
|
}
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return "config.json"
|
||||||
|
}
|
||||||
|
return filepath.Join(home, ".picoclaw", "config.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindPicoclawBinary locates the picoclaw executable.
|
||||||
|
// Search order:
|
||||||
|
// 1. PICOCLAW_BINARY environment variable (explicit override)
|
||||||
|
// 2. Same directory as the current executable
|
||||||
|
// 3. Falls back to "picoclaw" and relies on $PATH
|
||||||
|
func FindPicoclawBinary() string {
|
||||||
|
binaryName := "picoclaw"
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
binaryName = "picoclaw.exe"
|
||||||
|
}
|
||||||
|
|
||||||
|
if p := os.Getenv("PICOCLAW_BINARY"); p != "" {
|
||||||
|
if info, _ := os.Stat(p); info != nil && !info.IsDir() {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if exe, err := os.Executable(); err == nil {
|
||||||
|
candidate := filepath.Join(filepath.Dir(exe), binaryName)
|
||||||
|
if info, err := os.Stat(candidate); err == nil && !info.IsDir() {
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "picoclaw"
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
22
web/backend/winres/winres.json
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
{
|
||||||
|
"RT_GROUP_ICON": {
|
||||||
|
"APP": {
|
||||||
|
"0000": "../icon.ico"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"RT_MANIFEST": {
|
||||||
|
"#1": {
|
||||||
|
"0409": {
|
||||||
|
"identity": {
|
||||||
|
"name": "PicoClaw Launcher",
|
||||||
|
"version": "0.0.0.0"
|
||||||
|
},
|
||||||
|
"description": "PicoClaw Launcher - Web-based configuration editor",
|
||||||
|
"minimum-os": "win7",
|
||||||
|
"execution-level": "asInvoker",
|
||||||
|
"dpi-awareness": "system",
|
||||||
|
"use-common-controls-v6": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
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
|
||||||
25
web/frontend/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
# 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>
|
||||||
63
web/frontend/package.json
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
{
|
||||||
|
"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": "^4.0.5",
|
||||||
|
"sonner": "^2.0.7",
|
||||||
|
"tailwind-merge": "^3.5.0",
|
||||||
|
"tailwindcss": "^4.2.1",
|
||||||
|
"tw-animate-css": "^1.4.0",
|
||||||
|
"wrap-ansi": "^10.0.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"
|
||||||
|
}
|
||||||
|
}
|
||||||
7988
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 |