Merge branch 'sipeed:main' into fix/google-antigravity-oauth-refresh

This commit is contained in:
estebanp9 2026-04-01 17:18:12 -03:00 committed by GitHub
commit 7e4af1d8b8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
70 changed files with 7650 additions and 1016 deletions

62
.github/workflows/create_dmg.yml vendored Normal file
View file

@ -0,0 +1,62 @@
name: Create macOS DMG
on:
workflow_dispatch:
jobs:
build:
name: Build ${{ matrix.arch }}
runs-on: macos-latest
strategy:
matrix:
# This creates two parallel jobs
arch: [arm64, amd64]
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
ref: main
# 1. 安装指定版本的 Go (可选,但推荐)
- name: Setup Go
uses: actions/setup-go@v6
with:
go-version-file: go.mod
# 2. 安装 pnpm
- name: Install pnpm
run: brew install pnpm
# 3. 运行你的 Makefile 编译二进制文件
- name: Build with Make
run: make build ARCH=${{ matrix.arch }} && make build-macos-app ARCH=${{ matrix.arch }}
# 4. 签名
- name: Ad-hoc Sign
run: codesign --force --deep --sign - "build/PicoClaw Launcher.app"
# 5. 安装打包工具
- name: Install create-dmg
run: brew install create-dmg
# 6. 执行打包命令
- name: Create DMG
run: |
mkdir -p dist
create-dmg \
--volname "PicoClaw Installer" \
--window-pos 200 120 \
--window-size 800 400 \
--icon-size 100 \
--icon "PicoClaw Launcher.app" 200 190 \
--hide-extension "PicoClaw Launcher.app" \
--app-drop-link 600 185 \
"dist/picoclaw-${{ matrix.arch }}.dmg" \
"build/PicoClaw Launcher.app"
# 6. 上传文件到 GitHub Artifacts (供你下载)
- name: Upload DMG
uses: actions/upload-artifact@v4
with:
name: macos-dmg-${{ matrix.arch }}
path: dist/*.dmg

View file

@ -93,13 +93,13 @@ ifeq ($(UNAME_S),Linux)
endif
else ifeq ($(UNAME_S),Darwin)
PLATFORM=darwin
WEB_GO=CGO_ENABLED=1 go
WEB_GO=CGO_LDFLAGS="-mmacosx-version-min=10.11" CGO_CFLAGS="-mmacosx-version-min=10.11" CGO_ENABLED=1 go
ifeq ($(UNAME_M),x86_64)
ARCH=amd64
ARCH?=amd64
else ifeq ($(UNAME_M),arm64)
ARCH=arm64
ARCH?=arm64
else
ARCH=$(UNAME_M)
ARCH?=$(UNAME_M)
endif
else
PLATFORM=$(UNAME_S)
@ -122,7 +122,7 @@ generate:
build: generate
@echo "Building $(BINARY_NAME) for $(PLATFORM)/$(ARCH)..."
@mkdir -p $(BUILD_DIR)
@$(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BINARY_PATH) ./$(CMD_DIR)
@GOARCH=${ARCH} $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BINARY_PATH) ./$(CMD_DIR)
@echo "Build complete: $(BINARY_PATH)"
@ln -sf $(BINARY_NAME)-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/$(BINARY_NAME)
@ -130,7 +130,7 @@ build: generate
build-launcher:
@echo "Building picoclaw-launcher for $(PLATFORM)/$(ARCH)..."
@mkdir -p $(BUILD_DIR)
@$(MAKE) -C web build \
@GOARCH=${ARCH} $(MAKE) -C web build \
OUTPUT="$(CURDIR)/$(BUILD_DIR)/picoclaw-launcher-$(PLATFORM)-$(ARCH)" \
WEB_GO='$(WEB_GO)' \
GO_BUILD_TAGS='$(GO_BUILD_TAGS)' \
@ -324,14 +324,13 @@ docker-clean:
## build-macos-app: Build PicoClaw macOS .app bundle (no terminal window)
build-macos-app:
build-macos-app:build-launcher
@echo "Building macOS .app bundle..."
@if [ "$(UNAME_S)" != "Darwin" ]; then \
echo "Error: This target is only available on macOS"; \
exit 1; \
fi
@cd web && $(MAKE) build && cd ..
@./scripts/build-macos-app.sh $(BINARY_NAME)-$(PLATFORM)-$(ARCH)
@./scripts/build-macos-app.sh $(PLATFORM)-$(ARCH)
@echo "macOS .app bundle created: $(BUILD_DIR)/PicoClaw.app"
## help: Show this help message

View file

@ -24,6 +24,7 @@ import (
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/status"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/version"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/updater"
)
func NewPicoclawCommand() *cobra.Command {
@ -45,6 +46,7 @@ func NewPicoclawCommand() *cobra.Command {
migrate.NewMigrateCommand(),
skills.NewSkillsCommand(),
model.NewModelCommand(),
updater.NewUpdateCommand("picoclaw"),
version.NewVersionCommand(),
)

View file

@ -43,6 +43,7 @@ func TestNewPicoclawCommand(t *testing.T) {
"onboard",
"skills",
"status",
"update",
"version",
}

View file

@ -48,6 +48,11 @@
"model": "deepseek/deepseek-chat",
"api_key": "sk-your-deepseek-key"
},
{
"model_name": "venice-uncensored",
"model": "venice/venice-uncensored",
"api_key": "your-venice-api-key"
},
{
"model_name": "lmstudio-local",
"model": "lmstudio/openai/gpt-oss-20b"

View file

@ -16,6 +16,7 @@
| `openrouter` | LLM (recommended, access to all models) | [openrouter.ai](https://openrouter.ai) |
| `anthropic` | LLM (Claude direct) | [console.anthropic.com](https://console.anthropic.com) |
| `openai` | LLM (GPT direct) | [platform.openai.com](https://platform.openai.com) |
| `venice` | LLM (Venice AI direct) | [venice.ai](https://venice.ai) |
| `deepseek` | LLM (DeepSeek direct) | [platform.deepseek.com](https://platform.deepseek.com) |
| `qwen` | LLM (Qwen direct) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) |
| `groq` | LLM + **Voice transcription** (Whisper) | [console.groq.com](https://console.groq.com) |
@ -46,6 +47,7 @@ This design also enables **multi-agent support** with flexible provider selectio
| Vendor | `model` Prefix | Default API Base | Protocol | API Key |
| ------------------- | ----------------- |-----------------------------------------------------| --------- | ---------------------------------------------------------------- |
| **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [Get Key](https://platform.openai.com) |
| **Venice AI** | `venice/` | `https://api.venice.ai/api/v1` | OpenAI | [Get Key](https://venice.ai) |
| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [Get Key](https://console.anthropic.com) |
| **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Get Key](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) |
| **Z.AI Coding Plan** | `openai/` | `https://api.z.ai/api/coding/paas/v4` | OpenAI | [Get Key](https://z.ai/manage-apikey/apikey-list) |

View file

@ -15,6 +15,7 @@
| `openrouter` | LLM (推荐,可访问所有模型) | [openrouter.ai](https://openrouter.ai) |
| `anthropic` | LLM (Claude 直连) | [console.anthropic.com](https://console.anthropic.com) |
| `openai` | LLM (GPT 直连) | [platform.openai.com](https://platform.openai.com) |
| `venice` | LLM (Venice AI 直连) | [venice.ai](https://venice.ai) |
| `deepseek` | LLM (DeepSeek 直连) | [platform.deepseek.com](https://platform.deepseek.com) |
| `qwen` | LLM (通义千问) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) |
| `groq` | LLM + **语音转录** (Whisper) | [console.groq.com](https://console.groq.com) |
@ -44,6 +45,7 @@
| 厂商 | `model` 前缀 | 默认 API Base | 协议 | 获取 API Key |
| ------------------- | ----------------- | --------------------------------------------------- | --------- | ----------------------------------------------------------------- |
| **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [获取密钥](https://platform.openai.com) |
| **Venice AI** | `venice/` | `https://api.venice.ai/api/v1` | OpenAI | [获取密钥](https://venice.ai) |
| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [获取密钥](https://console.anthropic.com) |
| **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [获取密钥](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) |
| **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [获取密钥](https://platform.deepseek.com) |

2
go.mod
View file

@ -23,6 +23,7 @@ require (
github.com/h2non/filetype v1.1.3
github.com/larksuite/oapi-sdk-go/v3 v3.5.3
github.com/mdp/qrterminal/v3 v3.2.1
github.com/minio/selfupdate v0.6.0
github.com/modelcontextprotocol/go-sdk v1.4.1
github.com/mymmrac/telego v1.7.0
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1
@ -48,6 +49,7 @@ require (
)
require (
aead.dev/minisign v0.2.0 // indirect
filippo.io/edwards25519 v1.2.0 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.19.12 // indirect

11
go.sum
View file

@ -1,3 +1,5 @@
aead.dev/minisign v0.2.0 h1:kAWrq/hBRu4AARY6AlciO83xhNnW9UaC8YipS2uhLPk=
aead.dev/minisign v0.2.0/go.mod h1:zdq6LdSd9TbuSxchxwhpA9zEb9YXcVGoE8JakuiGaIQ=
cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
@ -184,6 +186,8 @@ github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp
github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mdp/qrterminal/v3 v3.2.1 h1:6+yQjiiOsSuXT5n9/m60E54vdgFsw0zhADHhHLrFet4=
github.com/mdp/qrterminal/v3 v3.2.1/go.mod h1:jOTmXvnBsMy5xqLniO0R++Jmjs2sTm9dFSuQ5kpz/SU=
github.com/minio/selfupdate v0.6.0 h1:i76PgT0K5xO9+hjzKcacQtO7+MjJ4JKA8Ak8XQ9DDwU=
github.com/minio/selfupdate v0.6.0/go.mod h1:bO02GTIPCMQFTEvE5h4DjYB58bCoZ35XLeBf0buTDdM=
github.com/modelcontextprotocol/go-sdk v1.4.1 h1:M4x9GyIPj+HoIlHNGpK2hq5o3BFhC+78PkEaldQRphc=
github.com/modelcontextprotocol/go-sdk v1.4.1/go.mod h1:Bo/mS87hPQqHSRkMv4dQq1XCu6zv4INdXnFZabkNU6s=
github.com/mymmrac/telego v1.7.0 h1:yRO/l00tFGG4nY66ufUKb4ARqv7qx9+LsjQv/b0NEyo=
@ -308,7 +312,10 @@ golang.org/x/arch v0.24.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20211209193657-4570a0811e8b/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
@ -329,6 +336,7 @@ golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwY
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
@ -351,11 +359,13 @@ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5h
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210228012217-479acdf4ea46/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@ -369,6 +379,7 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=

379
pkg/agent/context_legacy.go Normal file
View file

@ -0,0 +1,379 @@
package agent
import (
"context"
"fmt"
"strings"
"sync"
"time"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/providers"
)
// legacyContextManager wraps the existing summarization/compression logic
// as a ContextManager implementation. It is the default when no other
// ContextManager is configured.
type legacyContextManager struct {
al *AgentLoop
summarizing sync.Map // dedup for async Compact (post-turn)
}
func (m *legacyContextManager) Assemble(_ context.Context, req *AssembleRequest) (*AssembleResponse, error) {
// Legacy: read history from session, return as-is.
// Budget enforcement happens in BuildMessages caller via
// isOverContextBudget + forceCompression.
agent := m.al.registry.GetDefaultAgent()
if agent == nil {
return &AssembleResponse{}, nil
}
history := agent.Sessions.GetHistory(req.SessionKey)
summary := agent.Sessions.GetSummary(req.SessionKey)
return &AssembleResponse{
History: history,
Summary: summary,
}, nil
}
func (m *legacyContextManager) Compact(_ context.Context, req *CompactRequest) error {
switch req.Reason {
case ContextCompressReasonProactive, ContextCompressReasonRetry:
// Sync emergency compression — budget exceeded.
if result, ok := m.forceCompression(req.SessionKey); ok {
m.al.emitEvent(
EventKindContextCompress,
m.al.newTurnEventScope("", req.SessionKey).meta(0, "forceCompression", "turn.context.compress"),
ContextCompressPayload{
Reason: req.Reason,
DroppedMessages: result.DroppedMessages,
RemainingMessages: result.RemainingMessages,
},
)
}
case ContextCompressReasonSummarize:
m.maybeSummarize(req.SessionKey)
}
return nil
}
func (m *legacyContextManager) Ingest(_ context.Context, _ *IngestRequest) error {
// Legacy: no-op. Messages are persisted by Sessions JSONL.
return nil
}
// maybeSummarize triggers summarization if the session history exceeds thresholds.
// It runs asynchronously in a goroutine.
func (m *legacyContextManager) maybeSummarize(sessionKey string) {
agent := m.al.registry.GetDefaultAgent()
if agent == nil {
return
}
newHistory := agent.Sessions.GetHistory(sessionKey)
tokenEstimate := m.estimateTokens(newHistory)
threshold := agent.ContextWindow * agent.SummarizeTokenPercent / 100
if len(newHistory) > agent.SummarizeMessageThreshold || tokenEstimate > threshold {
summarizeKey := agent.ID + ":" + sessionKey
if _, loading := m.summarizing.LoadOrStore(summarizeKey, true); !loading {
go func() {
defer m.summarizing.Delete(summarizeKey)
defer func() {
if r := recover(); r != nil {
logger.WarnCF("agent", "Summarization panic recovered", map[string]any{
"session_key": sessionKey,
"panic": r,
})
}
}()
logger.Debug("Memory threshold reached. Optimizing conversation history...")
m.summarizeSession(agent, sessionKey)
}()
}
}
}
type compressionResult struct {
DroppedMessages int
RemainingMessages int
}
// forceCompression aggressively reduces context when the limit is hit.
// It drops the oldest ~50% of Turns (a Turn is a complete user→LLM→response
// cycle, as defined in #1316), so tool-call sequences are never split.
func (m *legacyContextManager) forceCompression(sessionKey string) (compressionResult, bool) {
agent := m.al.registry.GetDefaultAgent()
if agent == nil {
return compressionResult{}, false
}
history := agent.Sessions.GetHistory(sessionKey)
if len(history) <= 2 {
return compressionResult{}, false
}
turns := parseTurnBoundaries(history)
var mid int
if len(turns) >= 2 {
mid = turns[len(turns)/2]
} else {
mid = findSafeBoundary(history, len(history)/2)
}
var keptHistory []providers.Message
if mid <= 0 {
for i := len(history) - 1; i >= 0; i-- {
if history[i].Role == "user" {
keptHistory = []providers.Message{history[i]}
break
}
}
} else {
keptHistory = history[mid:]
}
droppedCount := len(history) - len(keptHistory)
existingSummary := agent.Sessions.GetSummary(sessionKey)
compressionNote := fmt.Sprintf(
"[Emergency compression dropped %d oldest messages due to context limit]",
droppedCount,
)
if existingSummary != "" {
compressionNote = existingSummary + "\n\n" + compressionNote
}
agent.Sessions.SetSummary(sessionKey, compressionNote)
agent.Sessions.SetHistory(sessionKey, keptHistory)
agent.Sessions.Save(sessionKey)
logger.WarnCF("agent", "Forced compression executed", map[string]any{
"session_key": sessionKey,
"dropped_msgs": droppedCount,
"new_count": len(keptHistory),
})
return compressionResult{
DroppedMessages: droppedCount,
RemainingMessages: len(keptHistory),
}, true
}
func (m *legacyContextManager) 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)
if len(history) <= 4 {
return
}
safeCut := findSafeBoundary(history, len(history)-4)
if safeCut <= 0 {
return
}
keepCount := len(history) - safeCut
toSummarize := history[:safeCut]
maxMessageTokens := agent.ContextWindow / 2
validMessages := make([]providers.Message, 0)
omitted := false
for _, msg := range toSummarize {
if msg.Role != "user" && msg.Role != "assistant" {
continue
}
msgTokens := len(msg.Content) / 2
if msgTokens > maxMessageTokens {
omitted = true
continue
}
validMessages = append(validMessages, msg)
}
if len(validMessages) == 0 {
return
}
const (
maxSummarizationMessages = 10
llmMaxRetries = 3
)
var finalSummary string
if len(validMessages) > maxSummarizationMessages {
mid := len(validMessages) / 2
mid = m.findNearestUserMessage(validMessages, mid)
part1 := validMessages[:mid]
part2 := validMessages[mid:]
s1, _ := m.summarizeBatch(ctx, agent, part1, "")
s2, _ := m.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 := m.retryLLMCall(ctx, agent, mergePrompt, llmMaxRetries)
if err == nil && resp.Content != "" {
finalSummary = resp.Content
} else {
finalSummary = s1 + " " + s2
}
} else {
finalSummary, _ = m.summarizeBatch(ctx, agent, validMessages, summary)
}
if omitted && finalSummary != "" {
finalSummary += "\n[Note: Some oversized messages were omitted from this summary for efficiency.]"
}
if finalSummary != "" {
agent.Sessions.SetSummary(sessionKey, finalSummary)
agent.Sessions.TruncateHistory(sessionKey, keepCount)
agent.Sessions.Save(sessionKey)
m.al.emitEvent(
EventKindSessionSummarize,
m.al.newTurnEventScope(agent.ID, sessionKey).meta(0, "summarizeSession", "turn.session.summarize"),
SessionSummarizePayload{
SummarizedMessages: len(validMessages),
KeptMessages: keepCount,
SummaryLen: len(finalSummary),
OmittedOversized: omitted,
},
)
}
}
func (m *legacyContextManager) 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
}
func (m *legacyContextManager) 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++ {
m.al.activeRequests.Add(1)
resp, err = func() (*providers.LLMResponse, error) {
defer m.al.activeRequests.Done()
return 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
}
func (m *legacyContextManager) 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 _, msg := range batch {
fmt.Fprintf(&sb, "%s: %s\n", msg.Role, msg.Content)
}
prompt := sb.String()
response, err := m.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, msg := range batch {
if i > 0 {
fallback.WriteString(" | ")
}
content := strings.TrimSpace(msg.Content)
runes := []rune(content)
if len(runes) == 0 {
fallback.WriteString(fmt.Sprintf("%s: ", msg.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", msg.Role, content))
}
return fallback.String(), nil
}
func (m *legacyContextManager) estimateTokens(messages []providers.Message) int {
total := 0
for _, msg := range messages {
total += estimateMessageTokens(msg)
}
return total
}

View file

@ -0,0 +1,89 @@
package agent
import (
"context"
"encoding/json"
"fmt"
"sync"
"github.com/sipeed/picoclaw/pkg/providers"
)
// ContextManager manages conversation context via a pluggable strategy.
// Exactly ONE ContextManager is active per AgentLoop, selected by config.
// The default ("legacy") preserves current summarization behavior.
type ContextManager interface {
// Assemble builds budget-aware context from the ContextManager's own storage.
// Called before BuildMessages. Returns assembled messages ready for LLM.
Assemble(ctx context.Context, req *AssembleRequest) (*AssembleResponse, error)
// Compact compresses conversation history.
// Called after turn completes (may be async internally) and on context overflow (sync).
Compact(ctx context.Context, req *CompactRequest) error
// Ingest records a message into the ContextManager's own storage.
// Called after each message is persisted to session JSONL.
Ingest(ctx context.Context, req *IngestRequest) error
}
// AssembleRequest is the input to Assemble.
type AssembleRequest struct {
SessionKey string // session identifier
Budget int // context window in tokens
MaxTokens int // max response tokens
}
// AssembleResponse is the output of Assemble.
type AssembleResponse struct {
History []providers.Message // assembled conversation history for BuildMessages
Summary string // conversation summary embedded into system prompt by BuildMessages
}
// CompactRequest is the input to Compact.
type CompactRequest struct {
SessionKey string // session identifier
Reason ContextCompressReason // proactive_budget | llm_retry | summarize
}
// IngestRequest is the input to Ingest.
type IngestRequest struct {
SessionKey string // session identifier
Message providers.Message // the message just persisted
}
// ContextManagerFactory constructs a ContextManager from config.
// al provides access to the AgentLoop's runtime resources (provider, model, workspace, etc.)
// cfg is the raw JSON configuration from config.json (may be nil).
type ContextManagerFactory func(cfg json.RawMessage, al *AgentLoop) (ContextManager, error)
var (
cmRegistryMu sync.RWMutex
cmRegistry = map[string]ContextManagerFactory{}
)
// RegisterContextManager registers a named ContextManager factory.
func RegisterContextManager(name string, factory ContextManagerFactory) error {
if name == "" {
return fmt.Errorf("context manager name is required")
}
if factory == nil {
return fmt.Errorf("context manager %q factory is nil", name)
}
cmRegistryMu.Lock()
defer cmRegistryMu.Unlock()
if _, exists := cmRegistry[name]; exists {
return fmt.Errorf("context manager %q is already registered", name)
}
cmRegistry[name] = factory
return nil
}
func lookupContextManager(name string) (ContextManagerFactory, bool) {
cmRegistryMu.RLock()
defer cmRegistryMu.RUnlock()
f, ok := cmRegistry[name]
return f, ok
}

View file

@ -0,0 +1,764 @@
package agent
import (
"context"
"encoding/json"
"os"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/providers"
)
// ---------------------------------------------------------------------------
// Factory registry tests
// ---------------------------------------------------------------------------
func TestRegisterContextManager_Success(t *testing.T) {
cleanup := resetCMRegistry()
defer cleanup()
factory := func(cfg json.RawMessage, al *AgentLoop) (ContextManager, error) {
return &noopContextManager{}, nil
}
if err := RegisterContextManager("test_cm", factory); err != nil {
t.Fatalf("unexpected error: %v", err)
}
f, ok := lookupContextManager("test_cm")
if !ok {
t.Fatal("expected factory to be registered")
}
if f == nil {
t.Fatal("expected non-nil factory")
}
}
func TestRegisterContextManager_EmptyName(t *testing.T) {
cleanup := resetCMRegistry()
defer cleanup()
err := RegisterContextManager("", func(cfg json.RawMessage, al *AgentLoop) (ContextManager, error) {
return &noopContextManager{}, nil
})
if err == nil {
t.Fatal("expected error for empty name")
}
if !strings.Contains(err.Error(), "name is required") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestRegisterContextManager_NilFactory(t *testing.T) {
cleanup := resetCMRegistry()
defer cleanup()
err := RegisterContextManager("nil_factory", nil)
if err == nil {
t.Fatal("expected error for nil factory")
}
if !strings.Contains(err.Error(), "factory is nil") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestRegisterContextManager_Duplicate(t *testing.T) {
cleanup := resetCMRegistry()
defer cleanup()
factory := func(cfg json.RawMessage, al *AgentLoop) (ContextManager, error) {
return &noopContextManager{}, nil
}
if err := RegisterContextManager("dup_cm", factory); err != nil {
t.Fatalf("first registration failed: %v", err)
}
err := RegisterContextManager("dup_cm", factory)
if err == nil {
t.Fatal("expected error for duplicate registration")
}
if !strings.Contains(err.Error(), "already registered") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestLookupContextManager_Unknown(t *testing.T) {
cleanup := resetCMRegistry()
defer cleanup()
_, ok := lookupContextManager("nonexistent")
if ok {
t.Fatal("expected lookup to fail for unknown name")
}
}
// ---------------------------------------------------------------------------
// resolveContextManager tests
// ---------------------------------------------------------------------------
func TestResolveContextManager_Default(t *testing.T) {
cleanup := resetCMRegistry()
defer cleanup()
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: t.TempDir(),
ModelName: "test-model",
MaxTokens: 4096,
MaxToolIterations: 10,
ContextManager: "", // default → legacy
},
},
}
al := newCMTestAgentLoop(cfg)
cm := al.contextManager
if cm == nil {
t.Fatal("expected non-nil context manager")
}
if _, ok := cm.(*legacyContextManager); !ok {
t.Fatalf("expected *legacyContextManager, got %T", cm)
}
}
func TestResolveContextManager_ExplicitLegacy(t *testing.T) {
cleanup := resetCMRegistry()
defer cleanup()
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: t.TempDir(),
ModelName: "test-model",
MaxTokens: 4096,
MaxToolIterations: 10,
ContextManager: "legacy",
},
},
}
al := newCMTestAgentLoop(cfg)
if _, ok := al.contextManager.(*legacyContextManager); !ok {
t.Fatalf("expected *legacyContextManager, got %T", al.contextManager)
}
}
func TestResolveContextManager_UnknownFallsBackToLegacy(t *testing.T) {
cleanup := resetCMRegistry()
defer cleanup()
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: t.TempDir(),
ModelName: "test-model",
MaxTokens: 4096,
MaxToolIterations: 10,
ContextManager: "unknown_cm",
},
},
}
al := newCMTestAgentLoop(cfg)
if _, ok := al.contextManager.(*legacyContextManager); !ok {
t.Fatalf("expected fallback to *legacyContextManager, got %T", al.contextManager)
}
}
func TestResolveContextManager_RegisteredFactory(t *testing.T) {
cleanup := resetCMRegistry()
defer cleanup()
factory := func(cfg json.RawMessage, al *AgentLoop) (ContextManager, error) {
return &noopContextManager{}, nil
}
if err := RegisterContextManager("custom_cm", factory); err != nil {
t.Fatalf("register failed: %v", err)
}
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: t.TempDir(),
ModelName: "test-model",
MaxTokens: 4096,
MaxToolIterations: 10,
ContextManager: "custom_cm",
},
},
}
al := newCMTestAgentLoop(cfg)
if _, ok := al.contextManager.(*noopContextManager); !ok {
t.Fatalf("expected *noopContextManager, got %T", al.contextManager)
}
}
func TestResolveContextManager_FactoryError(t *testing.T) {
cleanup := resetCMRegistry()
defer cleanup()
factory := func(cfg json.RawMessage, al *AgentLoop) (ContextManager, error) {
return nil, os.ErrPermission
}
if err := RegisterContextManager("broken_cm", factory); err != nil {
t.Fatalf("register failed: %v", err)
}
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: t.TempDir(),
ModelName: "test-model",
MaxTokens: 4096,
MaxToolIterations: 10,
ContextManager: "broken_cm",
},
},
}
al := newCMTestAgentLoop(cfg)
// Should fall back to legacy when factory returns error
if _, ok := al.contextManager.(*legacyContextManager); !ok {
t.Fatalf("expected fallback to *legacyContextManager on factory error, got %T", al.contextManager)
}
}
// ---------------------------------------------------------------------------
// Legacy Assemble tests
// ---------------------------------------------------------------------------
func TestLegacyAssemble_Passthrough(t *testing.T) {
cfg := testConfig(t)
al := newCMTestAgentLoop(cfg)
agent := al.registry.GetDefaultAgent()
if agent == nil {
t.Fatal("expected default agent")
}
history := []providers.Message{
{Role: "user", Content: "hello"},
{Role: "assistant", Content: "hi there"},
}
agent.Sessions.SetHistory("test-session", history)
resp, err := al.contextManager.Assemble(context.Background(), &AssembleRequest{
SessionKey: "test-session",
Budget: 8000,
MaxTokens: 4096,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(resp.History) != len(history) {
t.Fatalf("expected %d messages, got %d", len(history), len(resp.History))
}
for i, msg := range resp.History {
if msg.Content != history[i].Content || msg.Role != history[i].Role {
t.Fatalf("message %d mismatch: want %+v, got %+v", i, history[i], msg)
}
}
}
func TestLegacyAssemble_EmptyHistory(t *testing.T) {
cfg := testConfig(t)
al := newCMTestAgentLoop(cfg)
resp, err := al.contextManager.Assemble(context.Background(), &AssembleRequest{
SessionKey: "test-session",
Budget: 8000,
MaxTokens: 4096,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(resp.History) != 0 {
t.Fatalf("expected empty messages, got %d", len(resp.History))
}
}
// ---------------------------------------------------------------------------
// Legacy Compact overflow tests
// ---------------------------------------------------------------------------
func TestLegacyCompact_Overflow(t *testing.T) {
cfg := testConfig(t)
al := newCMTestAgentLoop(cfg)
defaultAgent := al.registry.GetDefaultAgent()
if defaultAgent == nil {
t.Fatal("expected default agent")
}
history := []providers.Message{
{Role: "user", Content: "msg 1"},
{Role: "assistant", Content: "resp 1"},
{Role: "user", Content: "msg 2"},
{Role: "assistant", Content: "resp 2"},
{Role: "user", Content: "msg 3"},
}
defaultAgent.Sessions.SetHistory("session-overflow", history)
sub := al.SubscribeEvents(16)
defer al.UnsubscribeEvents(sub.ID)
err := al.contextManager.Compact(context.Background(), &CompactRequest{
SessionKey: "session-overflow",
Reason: ContextCompressReasonRetry,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// After overflow compression, history should be shorter
newHistory := defaultAgent.Sessions.GetHistory("session-overflow")
if len(newHistory) >= len(history) {
t.Fatalf("expected compressed history, got %d messages (was %d)", len(newHistory), len(history))
}
// Summary should contain compression note
summary := defaultAgent.Sessions.GetSummary("session-overflow")
if !strings.Contains(summary, "Emergency compression") {
t.Fatalf("expected compression note in summary, got %q", summary)
}
// Event should carry the proactive reason
events := collectEventStream(sub.C)
compressEvt, ok := findEvent(events, EventKindContextCompress)
if !ok {
t.Fatal("expected context compress event")
}
payload, ok := compressEvt.Payload.(ContextCompressPayload)
if !ok {
t.Fatalf("expected ContextCompressPayload, got %T", compressEvt.Payload)
}
if payload.Reason != ContextCompressReasonRetry {
t.Fatalf("expected retry reason, got %q", payload.Reason)
}
}
func TestLegacyCompact_Overflow_ProactiveReason(t *testing.T) {
cfg := testConfig(t)
al := newCMTestAgentLoop(cfg)
defaultAgent := al.registry.GetDefaultAgent()
if defaultAgent == nil {
t.Fatal("expected default agent")
}
history := []providers.Message{
{Role: "user", Content: "msg 1"},
{Role: "assistant", Content: "resp 1"},
{Role: "user", Content: "msg 2"},
{Role: "assistant", Content: "resp 2"},
{Role: "user", Content: "msg 3"},
}
defaultAgent.Sessions.SetHistory("session-proactive", history)
sub := al.SubscribeEvents(16)
defer al.UnsubscribeEvents(sub.ID)
err := al.contextManager.Compact(context.Background(), &CompactRequest{
SessionKey: "session-proactive",
Reason: ContextCompressReasonProactive,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
events := collectEventStream(sub.C)
compressEvt, ok := findEvent(events, EventKindContextCompress)
if !ok {
t.Fatal("expected context compress event")
}
payload, ok := compressEvt.Payload.(ContextCompressPayload)
if !ok {
t.Fatalf("expected ContextCompressPayload, got %T", compressEvt.Payload)
}
if payload.Reason != ContextCompressReasonProactive {
t.Fatalf("expected proactive reason, got %q", payload.Reason)
}
}
func TestLegacyCompact_Overflow_TooShortToCompress(t *testing.T) {
cfg := testConfig(t)
al := newCMTestAgentLoop(cfg)
defaultAgent := al.registry.GetDefaultAgent()
if defaultAgent == nil {
t.Fatal("expected default agent")
}
history := []providers.Message{
{Role: "user", Content: "only one"},
}
defaultAgent.Sessions.SetHistory("session-tiny", history)
err := al.contextManager.Compact(context.Background(), &CompactRequest{
SessionKey: "session-tiny",
Reason: ContextCompressReasonRetry,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// History should be unchanged (too short to compress)
newHistory := defaultAgent.Sessions.GetHistory("session-tiny")
if len(newHistory) != len(history) {
t.Fatalf("expected history unchanged, got %d messages (was %d)", len(newHistory), len(history))
}
}
// ---------------------------------------------------------------------------
// Legacy Compact post-turn tests
// ---------------------------------------------------------------------------
func TestLegacyCompact_PostTurn_BelowThreshold(t *testing.T) {
cfg := testConfig(t)
al := newCMTestAgentLoop(cfg)
defaultAgent := al.registry.GetDefaultAgent()
if defaultAgent == nil {
t.Fatal("expected default agent")
}
// Small history, below summarization thresholds
history := []providers.Message{
{Role: "user", Content: "hi"},
{Role: "assistant", Content: "hello"},
}
defaultAgent.Sessions.SetHistory("session-small", history)
err := al.contextManager.Compact(context.Background(), &CompactRequest{
SessionKey: "session-small",
Reason: ContextCompressReasonSummarize,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// History should remain unchanged
newHistory := defaultAgent.Sessions.GetHistory("session-small")
if len(newHistory) != len(history) {
t.Fatalf("expected unchanged history, got %d messages (was %d)", len(newHistory), len(history))
}
}
func TestLegacyCompact_PostTurn_ExceedsMessageThreshold(t *testing.T) {
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: t.TempDir(),
ModelName: "test-model",
MaxTokens: 4096,
MaxToolIterations: 10,
ContextWindow: 8000,
SummarizeMessageThreshold: 2,
SummarizeTokenPercent: 75,
},
},
}
msgBus := bus.NewMessageBus()
al := NewAgentLoop(cfg, msgBus, &simpleMockProvider{response: "summary"})
defaultAgent := al.registry.GetDefaultAgent()
if defaultAgent == nil {
t.Fatal("expected default agent")
}
// 6 messages > threshold of 2
history := []providers.Message{
{Role: "user", Content: "q1"},
{Role: "assistant", Content: "a1"},
{Role: "user", Content: "q2"},
{Role: "assistant", Content: "a2"},
{Role: "user", Content: "q3"},
{Role: "assistant", Content: "a3"},
}
defaultAgent.Sessions.SetHistory("session-threshold", history)
err := al.contextManager.Compact(context.Background(), &CompactRequest{
SessionKey: "session-threshold",
Reason: ContextCompressReasonSummarize,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Wait for async summarization to complete via event
sub := al.SubscribeEvents(16)
defer al.UnsubscribeEvents(sub.ID)
waitForEvent(t, sub.C, 5*time.Second, func(evt Event) bool {
return evt.Kind == EventKindSessionSummarize
})
newHistory := defaultAgent.Sessions.GetHistory("session-threshold")
if len(newHistory) >= len(history) {
t.Fatalf("expected summarization to reduce history from %d messages, got %d", len(history), len(newHistory))
}
}
// ---------------------------------------------------------------------------
// Legacy Ingest tests
// ---------------------------------------------------------------------------
func TestLegacyIngest_NoOp(t *testing.T) {
cfg := testConfig(t)
al := newCMTestAgentLoop(cfg)
err := al.contextManager.Ingest(context.Background(), &IngestRequest{
SessionKey: "session-ingest",
Message: providers.Message{Role: "user", Content: "test"},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
// ---------------------------------------------------------------------------
// Mock ContextManager — verifies dispatch through AgentLoop
// ---------------------------------------------------------------------------
func TestAgentLoop_UsesCustomContextManager(t *testing.T) {
cleanup := resetCMRegistry()
defer cleanup()
mock := &trackingContextManager{}
factory := func(cfg json.RawMessage, al *AgentLoop) (ContextManager, error) {
return mock, nil
}
if err := RegisterContextManager("tracking_cm", factory); err != nil {
t.Fatalf("register failed: %v", err)
}
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: t.TempDir(),
ModelName: "test-model",
MaxTokens: 4096,
MaxToolIterations: 10,
ContextManager: "tracking_cm",
},
},
}
al := newCMTestAgentLoop(cfg)
// Verify the mock was installed
if al.contextManager != mock {
t.Fatalf("expected mock context manager, got %T", al.contextManager)
}
// Direct method calls
_, err := mock.Assemble(context.Background(), &AssembleRequest{
SessionKey: "s1",
Budget: 8000,
MaxTokens: 4096,
})
if err != nil {
t.Fatalf("Assemble error: %v", err)
}
if mock.assembleCalls.Load() != 1 {
t.Fatalf("expected 1 assemble call, got %d", mock.assembleCalls.Load())
}
err = mock.Compact(context.Background(), &CompactRequest{
SessionKey: "s1",
Reason: ContextCompressReasonRetry,
})
if err != nil {
t.Fatalf("Compact error: %v", err)
}
if mock.compactCalls.Load() != 1 {
t.Fatalf("expected 1 compact call, got %d", mock.compactCalls.Load())
}
err = mock.Ingest(context.Background(), &IngestRequest{
SessionKey: "s1",
Message: providers.Message{Role: "user", Content: "test"},
})
if err != nil {
t.Fatalf("Ingest error: %v", err)
}
if mock.ingestCalls.Load() != 1 {
t.Fatalf("expected 1 ingest call, got %d", mock.ingestCalls.Load())
}
}
func TestIngestCalledDuringTurn(t *testing.T) {
cleanup := resetCMRegistry()
defer cleanup()
mock := &trackingContextManager{}
factory := func(cfg json.RawMessage, al *AgentLoop) (ContextManager, error) {
return mock, nil
}
if err := RegisterContextManager("ingest_track_cm", factory); err != nil {
t.Fatalf("register failed: %v", err)
}
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: t.TempDir(),
ModelName: "test-model",
MaxTokens: 4096,
MaxToolIterations: 10,
ContextManager: "ingest_track_cm",
},
},
}
msgBus := bus.NewMessageBus()
al := NewAgentLoop(cfg, msgBus, &simpleMockProvider{response: "done"})
defaultAgent := al.registry.GetDefaultAgent()
if defaultAgent == nil {
t.Fatal("expected default agent")
}
// Run a turn — ingestMessage is called for user message and final assistant message
_, err := al.runAgentLoop(context.Background(), defaultAgent, processOptions{
SessionKey: "session-ingest-turn",
Channel: "cli",
ChatID: "direct",
UserMessage: "test ingest",
DefaultResponse: defaultResponse,
EnableSummary: false,
SendResponse: false,
})
if err != nil {
t.Fatalf("runAgentLoop failed: %v", err)
}
// Should have at least 2 ingest calls: user message + final assistant message
if mock.ingestCalls.Load() < 2 {
t.Fatalf("expected >= 2 ingest calls during turn, got %d", mock.ingestCalls.Load())
}
}
// ---------------------------------------------------------------------------
// forceCompression edge cases (via legacy Compact)
// ---------------------------------------------------------------------------
func TestLegacyCompact_Overflow_SingleTurnKeepsLastUserMessage(t *testing.T) {
cfg := testConfig(t)
al := newCMTestAgentLoop(cfg)
defaultAgent := al.registry.GetDefaultAgent()
if defaultAgent == nil {
t.Fatal("expected default agent")
}
// History with only 2 messages — forceCompression should still handle it
history := []providers.Message{
{Role: "user", Content: "first question"},
{Role: "assistant", Content: "first answer"},
}
defaultAgent.Sessions.SetHistory("session-2msg", history)
err := al.contextManager.Compact(context.Background(), &CompactRequest{
SessionKey: "session-2msg",
Reason: ContextCompressReasonRetry,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
newHistory := defaultAgent.Sessions.GetHistory("session-2msg")
// With 2 messages, forceCompression returns false (len <= 2), so no compression
if len(newHistory) != len(history) {
t.Fatalf("expected no compression for 2-message history, got %d", len(newHistory))
}
}
// ---------------------------------------------------------------------------
// Test helpers
// ---------------------------------------------------------------------------
// noopContextManager is a minimal ContextManager that does nothing.
type noopContextManager struct{}
func (m *noopContextManager) Assemble(_ context.Context, req *AssembleRequest) (*AssembleResponse, error) {
return &AssembleResponse{}, nil
}
func (m *noopContextManager) Compact(_ context.Context, _ *CompactRequest) error { return nil }
func (m *noopContextManager) Ingest(_ context.Context, _ *IngestRequest) error { return nil }
// trackingContextManager tracks call counts for each method.
type trackingContextManager struct {
assembleCalls atomic.Int64
compactCalls atomic.Int64
ingestCalls atomic.Int64
mu sync.Mutex
lastAssemble *AssembleRequest
lastCompact *CompactRequest
lastIngest *IngestRequest
}
func (m *trackingContextManager) Assemble(_ context.Context, req *AssembleRequest) (*AssembleResponse, error) {
m.assembleCalls.Add(1)
m.mu.Lock()
m.lastAssemble = req
m.mu.Unlock()
return &AssembleResponse{}, nil
}
func (m *trackingContextManager) Compact(_ context.Context, req *CompactRequest) error {
m.compactCalls.Add(1)
m.mu.Lock()
m.lastCompact = req
m.mu.Unlock()
return nil
}
func (m *trackingContextManager) Ingest(_ context.Context, req *IngestRequest) error {
m.ingestCalls.Add(1)
m.mu.Lock()
m.lastIngest = req
m.mu.Unlock()
return nil
}
// resetCMRegistry clears the global factory registry and returns a cleanup
// function that restores the original state after the test.
func resetCMRegistry() func() {
cmRegistryMu.Lock()
original := make(map[string]ContextManagerFactory, len(cmRegistry))
for k, v := range cmRegistry {
original[k] = v
}
cmRegistry = make(map[string]ContextManagerFactory)
cmRegistryMu.Unlock()
return func() {
cmRegistryMu.Lock()
cmRegistry = original
cmRegistryMu.Unlock()
}
}
func testConfig(t *testing.T) *config.Config {
t.Helper()
return &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: t.TempDir(),
ModelName: "test-model",
MaxTokens: 4096,
MaxToolIterations: 10,
},
},
}
}
func newCMTestAgentLoop(cfg *config.Config) *AgentLoop {
msgBus := bus.NewMessageBus()
return NewAgentLoop(cfg, msgBus, &simpleMockProvider{response: "test"})
}

View file

@ -472,8 +472,9 @@ func TestAgentLoop_EmitsSessionSummarizeEvent(t *testing.T) {
sub := al.SubscribeEvents(16)
defer al.UnsubscribeEvents(sub.ID)
turnScope := al.newTurnEventScope(defaultAgent.ID, "session-1")
al.summarizeSession(defaultAgent, "session-1", turnScope)
// Use legacyContextManager's summarizeSession via contextManager interface
lcm := &legacyContextManager{al: al}
lcm.summarizeSession(defaultAgent, "session-1")
events := collectEventStream(sub.C)
summaryEvt, ok := findEvent(events, EventKindSessionSummarize)

View file

@ -167,6 +167,8 @@ const (
ContextCompressReasonProactive ContextCompressReason = "proactive_budget"
// ContextCompressReasonRetry indicates compression during context-error retry handling.
ContextCompressReasonRetry ContextCompressReason = "llm_retry"
// ContextCompressReasonSummarize indicates post-turn async summarization.
ContextCompressReasonSummarize ContextCompressReason = "summarize"
)
// ContextCompressPayload describes a forced history compression.

View file

@ -48,7 +48,7 @@ type AgentLoop struct {
// Runtime state
running atomic.Bool
summarizing sync.Map
contextManager ContextManager
fallback *providers.FallbackChain
channelManager *channels.Manager
mediaStore media.MediaStore
@ -137,13 +137,13 @@ func NewAgentLoop(
registry: registry,
state: stateManager,
eventBus: eventBus,
summarizing: sync.Map{},
fallback: fallbackChain,
cmdRegistry: commands.NewRegistry(commands.BuiltinDefinitions()),
steering: newSteeringQueue(parseSteeringMode(cfg.Agents.Defaults.SteeringMode)),
}
al.hooks = NewHookManager(eventBus)
configureHookManagerFromConfig(al.hooks, cfg)
al.contextManager = al.resolveContextManager()
// Register shared tools to all agents (now that al is created)
registerSharedTools(al, cfg, msgBus, registry, provider)
@ -281,6 +281,17 @@ func registerSharedTools(
agent.Tools.Register(tools.NewSendTTSTool(ttsProvider, nil))
}
if cfg.Tools.IsToolEnabled("load_image") {
loadImageTool := tools.NewLoadImageTool(
agent.Workspace,
cfg.Agents.Defaults.RestrictToWorkspace,
cfg.Agents.Defaults.GetMaxMediaSize(),
nil,
allowReadPaths,
)
agent.Tools.Register(loadImageTool)
}
// Skill discovery and installation tools
skills_enabled := cfg.Tools.IsToolEnabled("skills")
find_skills_enable := cfg.Tools.IsToolEnabled("find_skills")
@ -323,6 +334,14 @@ func registerSharedTools(
subagentManager := tools.NewSubagentManager(provider, agent.Model, agent.Workspace)
subagentManager.SetLLMOptions(agent.MaxTokens, agent.Temperature)
// Inject a media resolver so the legacy RunToolLoop fallback path can
// resolve media:// refs in the same way the main AgentLoop does.
// This keeps subagent vision support working even when the optimized
// sub-turn spawner path is unavailable.
subagentManager.SetMediaResolver(func(msgs []providers.Message) []providers.Message {
return resolveMediaRefs(msgs, al.mediaStore, cfg.Agents.Defaults.GetMaxMediaSize())
})
// Set the spawner that links into AgentLoop's turnState
subagentManager.SetSpawner(func(
ctx context.Context,
@ -972,6 +991,7 @@ func (al *AgentLoop) ReloadProviderAndConfig(
go func() {
defer func() {
if r := recover(); r != nil {
logger.RecoverPanicNoExit(r)
panicErr = fmt.Errorf("panic during registry creation: %v", r)
logger.ErrorCF("agent", "Panic during registry creation",
map[string]any{"panic": r})
@ -1670,8 +1690,15 @@ func (al *AgentLoop) runTurn(ctx context.Context, ts *turnState) (turnResult, er
var history []providers.Message
var summary string
if !ts.opts.NoHistory {
history = ts.agent.Sessions.GetHistory(ts.sessionKey)
summary = ts.agent.Sessions.GetSummary(ts.sessionKey)
// ContextManager assembles budget-aware history and summary.
if resp, err := al.contextManager.Assemble(turnCtx, &AssembleRequest{
SessionKey: ts.sessionKey,
Budget: ts.agent.ContextWindow,
MaxTokens: ts.agent.MaxTokens,
}); err == nil && resp != nil {
history = resp.History
summary = resp.Summary
}
}
ts.captureRestorePoint(history, summary)
@ -1696,22 +1723,27 @@ func (al *AgentLoop) runTurn(ctx context.Context, ts *turnState) (turnResult, er
if isOverContextBudget(ts.agent.ContextWindow, messages, toolDefs, ts.agent.MaxTokens) {
logger.WarnCF("agent", "Proactive compression: context budget exceeded before LLM call",
map[string]any{"session_key": ts.sessionKey})
if compression, ok := al.forceCompression(ts.agent, ts.sessionKey); ok {
al.emitEvent(
EventKindContextCompress,
ts.eventMeta("runTurn", "turn.context.compress"),
ContextCompressPayload{
Reason: ContextCompressReasonProactive,
DroppedMessages: compression.DroppedMessages,
RemainingMessages: compression.RemainingMessages,
},
)
ts.refreshRestorePointFromSession(ts.agent)
if err := al.contextManager.Compact(turnCtx, &CompactRequest{
SessionKey: ts.sessionKey,
Reason: ContextCompressReasonProactive,
}); err != nil {
logger.WarnCF("agent", "Proactive compact failed", map[string]any{
"session_key": ts.sessionKey,
"error": err.Error(),
})
}
ts.refreshRestorePointFromSession(ts.agent)
// Re-assemble from CM after compact.
if resp, err := al.contextManager.Assemble(turnCtx, &AssembleRequest{
SessionKey: ts.sessionKey,
Budget: ts.agent.ContextWindow,
MaxTokens: ts.agent.MaxTokens,
}); err == nil && resp != nil {
history = resp.History
summary = resp.Summary
}
newHistory := ts.agent.Sessions.GetHistory(ts.sessionKey)
newSummary := ts.agent.Sessions.GetSummary(ts.sessionKey)
messages = ts.agent.ContextBuilder.BuildMessages(
newHistory, newSummary, ts.userMessage,
history, summary, ts.userMessage,
ts.media, ts.channel, ts.chatID,
ts.opts.SenderID, ts.opts.SenderDisplayName,
activeSkillNames(ts.agent, ts.opts)...,
@ -1733,6 +1765,7 @@ func (al *AgentLoop) runTurn(ctx context.Context, ts *turnState) (turnResult, er
ts.agent.Sessions.AddMessage(ts.sessionKey, rootMsg.Role, rootMsg.Content)
}
ts.recordPersistedMessage(rootMsg)
ts.ingestMessage(turnCtx, al, rootMsg)
}
activeCandidates, activeModel, usedLight := al.selectCandidates(ts.agent, ts.userMessage, messages)
@ -1861,6 +1894,14 @@ turnLoop:
providerToolDefs = filtered
}
// Resolve media:// refs produced by tool results (e.g. load_image).
// Skipped on iteration 1 because inbound user media is already resolved
// before entering the loop; only subsequent iterations can contain new
// tool-generated media refs that need base64 encoding.
if iteration > 1 {
messages = resolveMediaRefs(messages, al.mediaStore, maxMediaSize)
}
callMessages := messages
if gracefulTerminal {
callMessages = append(append([]providers.Message(nil), messages...), ts.interruptHintMessage())
@ -2068,23 +2109,27 @@ turnLoop:
})
}
if compression, ok := al.forceCompression(ts.agent, ts.sessionKey); ok {
al.emitEvent(
EventKindContextCompress,
ts.eventMeta("runTurn", "turn.context.compress"),
ContextCompressPayload{
Reason: ContextCompressReasonRetry,
DroppedMessages: compression.DroppedMessages,
RemainingMessages: compression.RemainingMessages,
},
)
ts.refreshRestorePointFromSession(ts.agent)
if compactErr := al.contextManager.Compact(turnCtx, &CompactRequest{
SessionKey: ts.sessionKey,
Reason: ContextCompressReasonRetry,
}); compactErr != nil {
logger.WarnCF("agent", "Context overflow compact failed", map[string]any{
"session_key": ts.sessionKey,
"error": compactErr.Error(),
})
}
ts.refreshRestorePointFromSession(ts.agent)
// Re-assemble from CM after compact.
if asmResp, asmErr := al.contextManager.Assemble(turnCtx, &AssembleRequest{
SessionKey: ts.sessionKey,
Budget: ts.agent.ContextWindow,
MaxTokens: ts.agent.MaxTokens,
}); asmErr == nil && asmResp != nil {
history = asmResp.History
summary = asmResp.Summary
}
newHistory := ts.agent.Sessions.GetHistory(ts.sessionKey)
newSummary := ts.agent.Sessions.GetSummary(ts.sessionKey)
messages = ts.agent.ContextBuilder.BuildMessages(
newHistory, newSummary, "",
history, summary, "",
nil, ts.channel, ts.chatID, ts.opts.SenderID, ts.opts.SenderDisplayName,
activeSkillNames(ts.agent, ts.opts)...,
)
@ -2257,6 +2302,7 @@ turnLoop:
if !ts.opts.NoHistory {
ts.agent.Sessions.AddFullMessage(ts.sessionKey, assistantMsg)
ts.recordPersistedMessage(assistantMsg)
ts.ingestMessage(turnCtx, al, assistantMsg)
}
ts.setPhase(TurnPhaseTools)
@ -2551,6 +2597,13 @@ turnLoop:
}
if len(toolResult.Media) > 0 && !toolResult.ResponseHandled {
// For tools like load_image that produce media refs without sending them
// to the user channel (ResponseHandled == false), both Media and ArtifactTags
// coexist on the result:
// - Media: carries media:// refs that resolveMediaRefs will base64-encode
// into image_url parts in the next LLM iteration (enabling vision).
// - ArtifactTags: exposes the local file path as a structured [file:…] tag
// in the tool result text, so the LLM knows an artifact was produced.
toolResult.ArtifactTags = buildArtifactTags(al.mediaStore, toolResult.Media)
}
@ -2570,6 +2623,9 @@ turnLoop:
Content: contentForLLM,
ToolCallID: toolCallID,
}
if len(toolResult.Media) > 0 && !toolResult.ResponseHandled {
toolResultMsg.Media = append(toolResultMsg.Media, toolResult.Media...)
}
al.emitEvent(
EventKindToolExecEnd,
ts.eventMeta("runTurn", "turn.tool.end"),
@ -2586,6 +2642,7 @@ turnLoop:
if !ts.opts.NoHistory {
ts.agent.Sessions.AddFullMessage(ts.sessionKey, toolResultMsg)
ts.recordPersistedMessage(toolResultMsg)
ts.ingestMessage(turnCtx, al, toolResultMsg)
}
if steerMsgs := al.dequeueSteeringMessagesForScope(ts.sessionKey); len(steerMsgs) > 0 {
@ -2685,6 +2742,7 @@ turnLoop:
if !ts.opts.NoHistory {
ts.agent.Sessions.AddMessage(ts.sessionKey, summaryMsg.Role, summaryMsg.Content)
ts.recordPersistedMessage(summaryMsg)
ts.ingestMessage(turnCtx, al, summaryMsg)
if err := ts.agent.Sessions.Save(ts.sessionKey); err != nil {
turnStatus = TurnEndStatusError
al.emitEvent(
@ -2699,7 +2757,7 @@ turnLoop:
}
}
if ts.opts.EnableSummary {
al.maybeSummarize(ts.agent, ts.sessionKey, ts.scope)
al.contextManager.Compact(turnCtx, &CompactRequest{SessionKey: ts.sessionKey, Reason: ContextCompressReasonSummarize})
}
ts.setPhase(TurnPhaseCompleted)
@ -2754,6 +2812,7 @@ turnLoop:
finalMsg := providers.Message{Role: "assistant", Content: finalContent}
ts.agent.Sessions.AddMessage(ts.sessionKey, finalMsg.Role, finalMsg.Content)
ts.recordPersistedMessage(finalMsg)
ts.ingestMessage(turnCtx, al, finalMsg)
if err := ts.agent.Sessions.Save(ts.sessionKey); err != nil {
turnStatus = TurnEndStatusError
al.emitEvent(
@ -2769,7 +2828,13 @@ turnLoop:
}
if ts.opts.EnableSummary {
al.maybeSummarize(ts.agent, ts.sessionKey, ts.scope)
al.contextManager.Compact(
turnCtx,
&CompactRequest{
SessionKey: ts.sessionKey,
Reason: ContextCompressReasonSummarize,
},
)
}
ts.setPhase(TurnPhaseCompleted)
@ -2848,103 +2913,28 @@ func (al *AgentLoop) selectCandidates(
return agent.LightCandidates, resolvedCandidateModel(agent.LightCandidates, agent.Router.LightModel()), true
}
// maybeSummarize triggers summarization if the session history exceeds thresholds.
func (al *AgentLoop) maybeSummarize(agent *AgentInstance, sessionKey string, turnScope turnEventScope) {
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, turnScope)
}()
}
// resolveContextManager selects the ContextManager implementation based on config.
func (al *AgentLoop) resolveContextManager() ContextManager {
name := al.cfg.Agents.Defaults.ContextManager
if name == "" || name == "legacy" {
return &legacyContextManager{al: al}
}
}
type compressionResult struct {
DroppedMessages int
RemainingMessages int
}
// forceCompression aggressively reduces context when the limit is hit.
// It drops the oldest ~50% of Turns (a Turn is a complete user→LLM→response
// cycle, as defined in #1316), so tool-call sequences are never split.
//
// If the history is a single Turn with no safe split point, the function
// falls back to keeping only the most recent user message. This breaks
// Turn atomicity as a last resort to avoid a context-exceeded loop.
//
// Session history contains only user/assistant/tool messages — the system
// prompt is built dynamically by BuildMessages and is NOT stored here.
// The compression note is recorded in the session summary so that
// BuildMessages can include it in the next system prompt.
func (al *AgentLoop) forceCompression(agent *AgentInstance, sessionKey string) (compressionResult, bool) {
history := agent.Sessions.GetHistory(sessionKey)
if len(history) <= 2 {
return compressionResult{}, false
factory, ok := lookupContextManager(name)
if !ok {
logger.WarnCF("agent", "Unknown context manager, falling back to legacy", map[string]any{
"name": name,
})
return &legacyContextManager{al: al}
}
// Split at a Turn boundary so no tool-call sequence is torn apart.
// parseTurnBoundaries gives us the start of each Turn; we drop the
// oldest half of Turns and keep the most recent ones.
turns := parseTurnBoundaries(history)
var mid int
if len(turns) >= 2 {
mid = turns[len(turns)/2]
} else {
// Fewer than 2 Turns — fall back to message-level midpoint
// aligned to the nearest Turn boundary.
mid = findSafeBoundary(history, len(history)/2)
cm, err := factory(al.cfg.Agents.Defaults.ContextManagerConfig, al)
if err != nil {
logger.WarnCF("agent", "Failed to create context manager, falling back to legacy", map[string]any{
"name": name,
"error": err.Error(),
})
return &legacyContextManager{al: al}
}
var keptHistory []providers.Message
if mid <= 0 {
// No safe Turn boundary — the entire history is a single Turn
// (e.g. one user message followed by a massive tool response).
// Keeping everything would leave the agent stuck in a context-
// exceeded loop, so fall back to keeping only the most recent
// user message. This breaks Turn atomicity as a last resort.
for i := len(history) - 1; i >= 0; i-- {
if history[i].Role == "user" {
keptHistory = []providers.Message{history[i]}
break
}
}
} else {
keptHistory = history[mid:]
}
droppedCount := len(history) - len(keptHistory)
// Record compression in the session summary so BuildMessages includes it
// in the system prompt. We do not modify history messages themselves.
existingSummary := agent.Sessions.GetSummary(sessionKey)
compressionNote := fmt.Sprintf(
"[Emergency compression dropped %d oldest messages due to context limit]",
droppedCount,
)
if existingSummary != "" {
compressionNote = existingSummary + "\n\n" + compressionNote
}
agent.Sessions.SetSummary(sessionKey, compressionNote)
agent.Sessions.SetHistory(sessionKey, keptHistory)
agent.Sessions.Save(sessionKey)
logger.WarnCF("agent", "Forced compression executed", map[string]any{
"session_key": sessionKey,
"dropped_msgs": droppedCount,
"new_count": len(keptHistory),
})
return compressionResult{
DroppedMessages: droppedCount,
RemainingMessages: len(keptHistory),
}, true
return cm
}
// GetStartupInfo returns information about loaded tools and skills for logging.
@ -3036,247 +3026,13 @@ func formatToolsForLog(toolDefs []providers.ToolDefinition) string {
}
// summarizeSession summarizes the conversation history for a session.
func (al *AgentLoop) summarizeSession(agent *AgentInstance, sessionKey string, turnScope turnEventScope) {
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel()
history := agent.Sessions.GetHistory(sessionKey)
summary := agent.Sessions.GetSummary(sessionKey)
// Keep the most recent Turns for continuity, aligned to a Turn boundary
// so that no tool-call sequence is split.
if len(history) <= 4 {
return
}
safeCut := findSafeBoundary(history, len(history)-4)
if safeCut <= 0 {
return
}
keepCount := len(history) - safeCut
toSummarize := history[:safeCut]
// Oversized Message Guard
maxMessageTokens := agent.ContextWindow / 2
validMessages := make([]providers.Message, 0)
omitted := false
for _, m := range toSummarize {
if m.Role != "user" && m.Role != "assistant" {
continue
}
msgTokens := len(m.Content) / 2
if msgTokens > maxMessageTokens {
omitted = true
continue
}
validMessages = append(validMessages, m)
}
if len(validMessages) == 0 {
return
}
const (
maxSummarizationMessages = 10
llmMaxRetries = 3
llmTemperature = 0.3
fallbackMaxContentLength = 200
)
// 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 omitted && finalSummary != "" {
finalSummary += "\n[Note: Some oversized messages were omitted from this summary for efficiency.]"
}
if finalSummary != "" {
agent.Sessions.SetSummary(sessionKey, finalSummary)
agent.Sessions.TruncateHistory(sessionKey, keepCount)
agent.Sessions.Save(sessionKey)
al.emitEvent(
EventKindSessionSummarize,
turnScope.meta(0, "summarizeSession", "turn.session.summarize"),
SessionSummarizePayload{
SummarizedMessages: len(validMessages),
KeptMessages: keepCount,
SummaryLen: len(finalSummary),
OmittedOversized: omitted,
},
)
}
}
// 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++ {
al.activeRequests.Add(1)
resp, err = func() (*providers.LLMResponse, error) {
defer al.activeRequests.Done()
return 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
llmTemperature = 0.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.
// Counts Content, ToolCalls arguments, and ToolCallID metadata so that
// tool-heavy conversations are not systematically undercounted.
func (al *AgentLoop) estimateTokens(messages []providers.Message) int {
total := 0
for _, m := range messages {
total += estimateMessageTokens(m)
}
return total
}
func (al *AgentLoop) handleCommand(
ctx context.Context,
msg bus.InboundMessage,

View file

@ -427,6 +427,7 @@ func spawnSubTurn(
// 7. Defer cleanup: deliver result (for async), emit End event, and recover from panics
defer func() {
if r := recover(); r != nil {
logger.RecoverPanicNoExit(r)
err = fmt.Errorf("subturn panicked: %v", r)
result = nil
logger.ErrorCF("subturn", "SubTurn panicked", map[string]any{
@ -510,6 +511,7 @@ func deliverSubTurnResult(al *AgentLoop, parentTS *turnState, childID string, re
// We use defer/recover to catch any unlikely channel panics if it were ever closed.
defer func() {
if r := recover(); r != nil {
logger.RecoverPanicNoExit(r)
logger.WarnCF("subturn", "recovered panic sending to pendingResults", map[string]any{
"parent_id": parentTS.turnID,
"child_id": childID,

View file

@ -8,6 +8,7 @@ import (
"time"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/providers"
"github.com/sipeed/picoclaw/pkg/session"
"github.com/sipeed/picoclaw/pkg/tools"
@ -338,6 +339,23 @@ func (ts *turnState) refreshRestorePointFromSession(agent *AgentInstance) {
ts.captureRestorePoint(history, summary)
}
// ingestMessage calls the ContextManager's Ingest method for a persisted message.
// Errors are logged but never block the turn.
func (ts *turnState) ingestMessage(ctx context.Context, al *AgentLoop, msg providers.Message) {
if al.contextManager == nil {
return
}
if err := al.contextManager.Ingest(ctx, &IngestRequest{
SessionKey: ts.sessionKey,
Message: msg,
}); err != nil {
logger.WarnCF("agent", "Context manager ingest failed", map[string]any{
"session_key": ts.sessionKey,
"error": err.Error(),
})
}
}
func (ts *turnState) restoreSession(agent *AgentInstance) error {
ts.mu.RLock()
history := append([]providers.Message(nil), ts.restorePointHistory...)

View file

@ -120,6 +120,7 @@ func streamOggOpusToDiscord(ctx context.Context, vc *discordgo.VoiceConnection,
defer func() {
if rec := recover(); rec != nil {
retErr = fmt.Errorf("voice connection closed during playback")
logger.RecoverPanicNoExit(rec)
}
}()

View file

@ -226,26 +226,28 @@ type ToolFeedbackConfig struct {
}
type AgentDefaults struct {
Workspace string `json:"workspace" env:"PICOCLAW_AGENTS_DEFAULTS_WORKSPACE"`
RestrictToWorkspace bool `json:"restrict_to_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE"`
AllowReadOutsideWorkspace bool `json:"allow_read_outside_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_ALLOW_READ_OUTSIDE_WORKSPACE"`
Provider string `json:"provider" env:"PICOCLAW_AGENTS_DEFAULTS_PROVIDER"`
ModelName string `json:"model_name" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL_NAME"`
Workspace string `json:"workspace" env:"PICOCLAW_AGENTS_DEFAULTS_WORKSPACE"`
RestrictToWorkspace bool `json:"restrict_to_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE"`
AllowReadOutsideWorkspace bool `json:"allow_read_outside_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_ALLOW_READ_OUTSIDE_WORKSPACE"`
Provider string `json:"provider" env:"PICOCLAW_AGENTS_DEFAULTS_PROVIDER"`
ModelName string `json:"model_name" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL_NAME"`
ModelFallbacks []string `json:"model_fallbacks,omitempty"`
ImageModel string `json:"image_model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_IMAGE_MODEL"`
ImageModel string `json:"image_model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_IMAGE_MODEL"`
ImageModelFallbacks []string `json:"image_model_fallbacks,omitempty"`
MaxTokens int `json:"max_tokens" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOKENS"`
ContextWindow int `json:"context_window,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_CONTEXT_WINDOW"`
Temperature *float64 `json:"temperature,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_TEMPERATURE"`
MaxToolIterations int `json:"max_tool_iterations" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS"`
SummarizeMessageThreshold int `json:"summarize_message_threshold" env:"PICOCLAW_AGENTS_DEFAULTS_SUMMARIZE_MESSAGE_THRESHOLD"`
SummarizeTokenPercent int `json:"summarize_token_percent" env:"PICOCLAW_AGENTS_DEFAULTS_SUMMARIZE_TOKEN_PERCENT"`
MaxMediaSize int `json:"max_media_size,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_MEDIA_SIZE"`
MaxTokens int `json:"max_tokens" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOKENS"`
ContextWindow int `json:"context_window,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_CONTEXT_WINDOW"`
Temperature *float64 `json:"temperature,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_TEMPERATURE"`
MaxToolIterations int `json:"max_tool_iterations" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS"`
SummarizeMessageThreshold int `json:"summarize_message_threshold" env:"PICOCLAW_AGENTS_DEFAULTS_SUMMARIZE_MESSAGE_THRESHOLD"`
SummarizeTokenPercent int `json:"summarize_token_percent" env:"PICOCLAW_AGENTS_DEFAULTS_SUMMARIZE_TOKEN_PERCENT"`
MaxMediaSize int `json:"max_media_size,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_MEDIA_SIZE"`
Routing *RoutingConfig `json:"routing,omitempty"`
SteeringMode string `json:"steering_mode,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_STEERING_MODE"` // "one-at-a-time" (default) or "all"
SubTurn SubTurnConfig `json:"subturn" envPrefix:"PICOCLAW_AGENTS_DEFAULTS_SUBTURN_"`
SteeringMode string `json:"steering_mode,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_STEERING_MODE"` // "one-at-a-time" (default) or "all"
SubTurn SubTurnConfig `json:"subturn" envPrefix:"PICOCLAW_AGENTS_DEFAULTS_SUBTURN_"`
ToolFeedback ToolFeedbackConfig `json:"tool_feedback,omitempty"`
SplitOnMarker bool `json:"split_on_marker" env:"PICOCLAW_AGENTS_DEFAULTS_SPLIT_ON_MARKER"` // split messages on <|[SPLIT]|> marker
SplitOnMarker bool `json:"split_on_marker" env:"PICOCLAW_AGENTS_DEFAULTS_SPLIT_ON_MARKER"` // split messages on <|[SPLIT]|> marker
ContextManager string `json:"context_manager,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_CONTEXT_MANAGER"`
ContextManagerConfig json.RawMessage `json:"context_manager_config,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_CONTEXT_MANAGER_CONFIG"`
}
const DefaultMaxMediaSize = 20 * 1024 * 1024 // 20 MB

View file

@ -185,6 +185,13 @@ func DefaultConfig() *Config {
APIBase: "https://api.deepseek.com/v1",
},
// Venice AI - https://venice.ai
{
ModelName: "venice-uncensored",
Model: "venice/venice-uncensored",
APIBase: "https://api.venice.ai/api/v1",
},
// Google Gemini - https://ai.google.dev/
{
ModelName: "gemini-2.0-flash",
@ -335,6 +342,13 @@ func DefaultConfig() *Config {
APIBase: "http://localhost:8000/v1",
},
// LM Studio (local) - http://localhost:1234
{
ModelName: "lmstudio-local",
Model: "lmstudio/openai/gpt-oss-20b",
APIBase: "http://localhost:1234/v1",
},
// Azure OpenAI - https://portal.azure.com
// model_name is a user-friendly alias; the model field's path after "azure/" is your deployment name
{

View file

@ -2,12 +2,15 @@ package logger
import (
"fmt"
"io"
"os"
"path/filepath"
"runtime/debug"
"time"
)
var panicWriter io.WriteCloser
func InitPanic(filePath string) (func(), error) {
if err := os.MkdirAll(filepath.Dir(filePath), 0o755); err != nil {
return nil, fmt.Errorf("failed to create log directory: %w", err)
@ -16,21 +19,36 @@ func InitPanic(filePath string) (func(), error) {
if writer == nil {
return nil, fmt.Errorf("failed to create log file: %s", filePath)
}
if panicWriter != nil {
_ = panicWriter.Close()
}
panicWriter = writer
return func() {
defer writer.Close()
defer func() {
writer.Close()
panicWriter = nil
}()
if err := recover(); err != nil {
now := time.Now().Format("2006-01-02 15:04:05")
stack := debug.Stack()
logMsg := "\n\n====================\n[" + now + "] PANIC OCCURRED: " + fmt.Sprintf(
"%v",
err,
) + "\n" + string(
stack,
)
writer.Write([]byte(logMsg))
RecoverPanicNoExit(err)
os.Exit(1)
}
}, nil
}
func RecoverPanicNoExit(err any) {
if panicWriter == nil {
Errorf("panicWriter is nil, should not happen")
return
}
now := time.Now().Format("2006-01-02 15:04:05")
stack := debug.Stack()
logMsg := "\n\n====================\n[" + now + "] PANIC OCCURRED: " + fmt.Sprintf(
"%v",
err,
) + "\n" + string(
stack,
)
panicWriter.Write([]byte(logMsg))
}

View file

@ -24,6 +24,7 @@ type protocolMeta struct {
var protocolMetaByName = map[string]protocolMeta{
"openai": {defaultAPIBase: "https://api.openai.com/v1"},
"venice": {defaultAPIBase: "https://api.venice.ai/api/v1"},
"openrouter": {defaultAPIBase: "https://openrouter.ai/api/v1"},
"litellm": {defaultAPIBase: "http://localhost:4000/v1"},
"lmstudio": {defaultAPIBase: "http://localhost:1234/v1", emptyAPIKeyAllowed: true},
@ -209,7 +210,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
}
return provider, modelID, nil
case "litellm", "lmstudio", "openrouter", "groq", "zhipu", "gemini", "nvidia",
case "litellm", "lmstudio", "openrouter", "groq", "zhipu", "gemini", "nvidia", "venice",
"ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras",
"vivgrid", "volcengine", "vllm", "qwen", "qwen-intl", "qwen-international", "dashscope-intl",
"qwen-us", "dashscope-us", "mistral", "avian", "longcat", "modelscope", "novita",

View file

@ -112,6 +112,7 @@ func TestCreateProviderFromConfig_DefaultAPIBase(t *testing.T) {
protocol string
}{
{"openai", "openai"},
{"venice", "venice"},
{"groq", "groq"},
{"novita", "novita"},
{"openrouter", "openrouter"},
@ -160,6 +161,12 @@ func TestGetDefaultAPIBase_LMStudio(t *testing.T) {
}
}
func TestGetDefaultAPIBase_Venice(t *testing.T) {
if got := getDefaultAPIBase("venice"); got != "https://api.venice.ai/api/v1" {
t.Fatalf("getDefaultAPIBase(%q) = %q, want %q", "venice", got, "https://api.venice.ai/api/v1")
}
}
func TestCreateProviderFromConfig_LiteLLM(t *testing.T) {
cfg := &config.ModelConfig{
ModelName: "test-litellm",
@ -362,6 +369,28 @@ func TestCreateProviderFromConfig_Mimo(t *testing.T) {
}
}
func TestCreateProviderFromConfig_Venice(t *testing.T) {
cfg := &config.ModelConfig{
ModelName: "test-venice",
Model: "venice/venice-uncensored",
}
cfg.SetAPIKey("test-key")
provider, modelID, err := CreateProviderFromConfig(cfg)
if err != nil {
t.Fatalf("CreateProviderFromConfig() error = %v", err)
}
if provider == nil {
t.Fatal("CreateProviderFromConfig() returned nil provider")
}
if modelID != "venice-uncensored" {
t.Errorf("modelID = %q, want %q", modelID, "venice-uncensored")
}
if _, ok := provider.(*HTTPProvider); !ok {
t.Fatalf("expected *HTTPProvider, got %T", provider)
}
}
func TestGetDefaultAPIBase_Mimo(t *testing.T) {
if got := getDefaultAPIBase("mimo"); got != "https://api.xiaomimimo.com/v1" {
t.Fatalf("getDefaultAPIBase(%q) = %q, want %q", "mimo", got, "https://api.xiaomimimo.com/v1")

View file

@ -44,6 +44,7 @@ const defaultRequestTimeout = common.DefaultRequestTimeout
var stripModelPrefixProviders = map[string]struct{}{
"litellm": {},
"venice": {},
"moonshot": {},
"nvidia": {},
"groq": {},

View file

@ -479,6 +479,11 @@ func TestProviderChat_StripsKnownProviderPrefixes(t *testing.T) {
input: "lmstudio/openai/gpt-oss-20b",
wantModel: "openai/gpt-oss-20b",
},
{
name: "strips venice prefix",
input: "venice/venice-uncensored",
wantModel: "venice-uncensored",
},
{
name: "strips deepseek prefix",
input: "deepseek/deepseek-chat",
@ -587,6 +592,9 @@ func TestNormalizeModel_UsesAPIBase(t *testing.T) {
if got := normalizeModel("lmstudio/openai/gpt-oss-20b", "http://localhost:1234/v1"); got != "openai/gpt-oss-20b" {
t.Fatalf("normalizeModel(lmstudio) = %q, want %q", got, "openai/gpt-oss-20b")
}
if got := normalizeModel("venice/venice-uncensored", "https://api.venice.ai/api/v1"); got != "venice-uncensored" {
t.Fatalf("normalizeModel(venice) = %q, want %q", got, "venice-uncensored")
}
if got := normalizeModel("openrouter/auto", "https://openrouter.ai/api/v1"); got != "openrouter/auto" {
t.Fatalf("normalizeModel(openrouter) = %q, want %q", got, "openrouter/auto")
}

163
pkg/tools/load_image.go Normal file
View file

@ -0,0 +1,163 @@
package tools
import (
"context"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/media"
)
// LoadImageTool loads a local image file into the MediaStore and returns a
// media:// reference. The agent loop's resolveMediaRefs will then base64-encode
// it and attach it as an image_url part in the next LLM request, enabling
// vision on local files — the same pipeline used when a user sends an image
// through a chat channel.
//
// This is intentionally different from SendFileTool:
// - SendFileTool → MediaResult + WithResponseHandled() → sends file to user, ends turn
// - LoadImageTool → plain ToolResult with media:// in ForLLM → LLM sees the image next turn
type LoadImageTool struct {
workspace string
restrict bool
maxFileSize int
mediaStore media.MediaStore
allowPaths []*regexp.Regexp
defaultChannel string
defaultChatID string
}
func NewLoadImageTool(
workspace string,
restrict bool,
maxFileSize int,
store media.MediaStore,
allowPaths ...[]*regexp.Regexp,
) *LoadImageTool {
if maxFileSize <= 0 {
maxFileSize = config.DefaultMaxMediaSize
}
var patterns []*regexp.Regexp
if len(allowPaths) > 0 {
patterns = allowPaths[0]
}
return &LoadImageTool{
workspace: workspace,
restrict: restrict,
maxFileSize: maxFileSize,
mediaStore: store,
allowPaths: patterns,
}
}
func (t *LoadImageTool) Name() string { return "load_image" }
func (t *LoadImageTool) Description() string {
return "Load a local image file so you can analyze its contents with vision. " +
"Supported formats: JPEG, PNG, GIF, WebP, BMP. " +
"After calling this tool, describe or analyze the image in your next response."
}
func (t *LoadImageTool) Parameters() map[string]any {
return map[string]any{
"type": "object",
"properties": map[string]any{
"path": map[string]any{
"type": "string",
"description": "Path to the local image file. Relative paths are resolved from workspace.",
},
},
"required": []string{"path"},
}
}
func (t *LoadImageTool) SetContext(channel, chatID string) {
t.defaultChannel = channel
t.defaultChatID = chatID
}
func (t *LoadImageTool) SetMediaStore(store media.MediaStore) {
t.mediaStore = store
}
func (t *LoadImageTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
path, _ := args["path"].(string)
if strings.TrimSpace(path) == "" {
return ErrorResult("path is required")
}
// Prefer context-injected channel/chatID (set by ExecuteWithContext), fall back to SetContext values.
channel := ToolChannel(ctx)
if channel == "" {
channel = t.defaultChannel
}
chatID := ToolChatID(ctx)
if chatID == "" {
chatID = t.defaultChatID
}
if channel == "" || chatID == "" {
return ErrorResult("no target channel/chat available")
}
if t.mediaStore == nil {
return ErrorResult("media store not configured")
}
resolved, err := validatePathWithAllowPaths(path, t.workspace, t.restrict, t.allowPaths)
if err != nil {
return ErrorResult(fmt.Sprintf("invalid path: %v", err))
}
info, err := os.Stat(resolved)
if err != nil {
return ErrorResult(fmt.Sprintf("file not found: %v", err))
}
if info.IsDir() {
return ErrorResult("path is a directory, expected an image file")
}
if info.Size() > int64(t.maxFileSize) {
return ErrorResult(fmt.Sprintf(
"file too large: %d bytes (max %d bytes)", info.Size(), t.maxFileSize,
))
}
// Detect MIME type — reuse the helper already in send_file.go
mediaType := detectMediaType(resolved)
if !strings.HasPrefix(mediaType, "image/") {
return ErrorResult(fmt.Sprintf(
"file does not appear to be an image (detected type: %s)", mediaType,
))
}
filename := filepath.Base(resolved)
scope := fmt.Sprintf("tool:load_image:%s:%s", channel, chatID)
ref, err := t.mediaStore.Store(resolved, media.MediaMeta{
Filename: filename,
ContentType: mediaType,
Source: "tool:load_image",
CleanupPolicy: media.CleanupPolicyForgetOnly,
}, scope)
if err != nil {
return ErrorResult(fmt.Sprintf("failed to register image in media store: %v", err))
}
// Build the tool result text. The media:// ref will be picked up by
// resolveMediaRefs in loop_media.go and converted to a base64 data URL
// before the next LLM call, exactly like channel-received images.
msg := fmt.Sprintf("Image loaded: %s\n[image: %s]", filename, ref)
return &ToolResult{
ForLLM: msg,
ForUser: fmt.Sprintf("Loaded image: %s", filename),
// Media refs inside ForLLM are resolved by resolveMediaRefs in the
// agent loop before the next LLM call. Do NOT use MediaResult here —
// that would send the file to the user channel instead.
Media: []string{ref},
}
}

View file

@ -0,0 +1,174 @@
package tools
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/media"
"github.com/sipeed/picoclaw/pkg/providers"
)
func TestLoadImage_PathRequired(t *testing.T) {
tool := NewLoadImageTool("/tmp", false, 0, nil)
ctx := WithToolContext(context.Background(), "test", "chat1")
result := tool.Execute(ctx, map[string]any{})
if !result.IsError {
t.Fatal("expected error for missing path")
}
}
func TestLoadImage_NilMediaStore(t *testing.T) {
tool := NewLoadImageTool("/tmp", false, 0, nil)
ctx := WithToolContext(context.Background(), "test", "chat1")
result := tool.Execute(ctx, map[string]any{"path": "test.png"})
if !result.IsError || result.ForLLM != "media store not configured" {
t.Fatalf("expected media store error, got: %s", result.ForLLM)
}
}
func TestLoadImage_NoChannelContext(t *testing.T) {
store := media.NewFileMediaStore()
tool := NewLoadImageTool("/tmp", false, 0, store)
// No WithToolContext — should fail
result := tool.Execute(context.Background(), map[string]any{"path": "test.png"})
if !result.IsError || result.ForLLM != "no target channel/chat available" {
t.Fatalf("expected channel error, got: %s", result.ForLLM)
}
}
func TestLoadImage_NonImageFile(t *testing.T) {
dir := t.TempDir()
txtFile := filepath.Join(dir, "readme.txt")
os.WriteFile(txtFile, []byte("hello"), 0o644)
store := media.NewFileMediaStore()
tool := NewLoadImageTool(dir, false, 0, store)
ctx := WithToolContext(context.Background(), "test", "chat1")
result := tool.Execute(ctx, map[string]any{"path": txtFile})
if !result.IsError {
t.Fatal("expected error for non-image file")
}
}
func TestLoadImage_DefaultMaxSize(t *testing.T) {
tool := NewLoadImageTool("/tmp", false, 0, nil)
if tool.maxFileSize != config.DefaultMaxMediaSize {
t.Errorf("expected default max size %d, got %d", config.DefaultMaxMediaSize, tool.maxFileSize)
}
}
func TestLoadImage_FileTooLarge(t *testing.T) {
dir := t.TempDir()
bigFile := filepath.Join(dir, "big.png")
// Create a file with PNG header but exceeding max size
data := make([]byte, 1024)
copy(data, []byte{0x89, 0x50, 0x4E, 0x47}) // PNG magic bytes
os.WriteFile(bigFile, data, 0o644)
store := media.NewFileMediaStore()
tool := NewLoadImageTool(dir, false, 512, store) // maxSize = 512
ctx := WithToolContext(context.Background(), "test", "chat1")
result := tool.Execute(ctx, map[string]any{"path": bigFile})
if !result.IsError {
t.Fatal("expected error for oversized file")
}
}
func TestSubagentManager_SetMediaResolver_StoresResolver(t *testing.T) {
manager := NewSubagentManager(nil, "gpt-test", "/tmp")
called := false
manager.SetMediaResolver(func(msgs []providers.Message) []providers.Message {
called = true
return msgs
})
manager.mu.RLock()
got := manager.mediaResolver
manager.mu.RUnlock()
if got == nil {
t.Fatal("expected mediaResolver to be set")
}
if called {
t.Fatal("resolver should not be called during SetMediaResolver")
}
}
func TestLoadImage_SuccessPath(t *testing.T) {
dir := t.TempDir()
// Create a minimal valid PNG file (8-byte signature + minimal IHDR + IEND).
// The PNG spec requires the 8-byte magic header: 0x89 P N G \r \n 0x1a \n
pngSignature := []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}
// IHDR chunk: length(13) + "IHDR" + 1x1 px, 8-bit RGB, no interlace + CRC
ihdr := []byte{
0x00, 0x00, 0x00, 0x0D, // chunk length = 13
0x49, 0x48, 0x44, 0x52, // "IHDR"
0x00, 0x00, 0x00, 0x01, // width = 1
0x00, 0x00, 0x00, 0x01, // height = 1
0x08, // bit depth = 8
0x02, // color type = RGB
0x00, 0x00, 0x00, // compression, filter, interlace
0x90, 0x77, 0x53, 0xDE, // CRC (valid for this IHDR)
}
// IEND chunk
iend := []byte{
0x00, 0x00, 0x00, 0x00, // chunk length = 0
0x49, 0x45, 0x4E, 0x44, // "IEND"
0xAE, 0x42, 0x60, 0x82, // CRC
}
pngData := make([]byte, 0, len(pngSignature)+len(ihdr)+len(iend))
pngData = append(pngData, pngSignature...)
pngData = append(pngData, ihdr...)
pngData = append(pngData, iend...)
imgPath := filepath.Join(dir, "test_image.png")
if err := os.WriteFile(imgPath, pngData, 0o644); err != nil {
t.Fatalf("failed to create test PNG: %v", err)
}
store := media.NewFileMediaStore()
tool := NewLoadImageTool(dir, false, 0, store)
ctx := WithToolContext(context.Background(), "test", "chat1")
result := tool.Execute(ctx, map[string]any{"path": imgPath})
// 1. Must not be an error
if result.IsError {
t.Fatalf("expected success, got error: %s", result.ForLLM)
}
// 2. Media must contain exactly one media:// ref
if len(result.Media) != 1 {
t.Fatalf("expected 1 media ref, got %d", len(result.Media))
}
if !strings.HasPrefix(result.Media[0], "media://") {
t.Errorf("expected media ref to start with 'media://', got: %s", result.Media[0])
}
// 3. ForLLM must contain the [image: marker
if !strings.Contains(result.ForLLM, "[image:") {
t.Errorf("expected ForLLM to contain '[image:' marker, got: %s", result.ForLLM)
}
// 4. ForLLM should also contain the media:// ref
if !strings.Contains(result.ForLLM, result.Media[0]) {
t.Errorf("expected ForLLM to contain media ref %q, got: %s", result.Media[0], result.ForLLM)
}
// 5. Verify the ref is resolvable in the store
resolved, err := store.Resolve(result.Media[0])
if err != nil {
t.Fatalf("media ref not resolvable: %v", err)
}
if resolved != imgPath {
t.Errorf("expected resolved path %q, got %q", imgPath, resolved)
}
}

View file

@ -228,6 +228,7 @@ func (r *ToolRegistry) ExecuteWithContext(
func() {
defer func() {
if re := recover(); re != nil {
logger.RecoverPanicNoExit(re)
errMsg := fmt.Sprintf("Tool '%s' crashed with panic: %v", name, re)
logger.ErrorCF("tool", "Tool execution panic recovered",
map[string]any{

View file

@ -67,6 +67,12 @@ type SubagentManager struct {
hasTemperature bool
nextID int
spawner SpawnSubTurnFunc
// mediaResolver resolves media:// refs in tool-loop messages before
// each LLM call in the legacy RunToolLoop fallback path.
// This lets subagents reuse the same media handling behavior as the
// main agent loop without importing pkg/agent and creating a cycle.
mediaResolver func([]providers.Message) []providers.Message
}
func NewSubagentManager(
@ -90,6 +96,17 @@ func (sm *SubagentManager) SetSpawner(spawner SpawnSubTurnFunc) {
sm.spawner = spawner
}
// SetMediaResolver injects a message preprocessor that resolves media:// refs
// into LLM-ready content before each tool-loop iteration.
// This is only used by the legacy RunToolLoop fallback path.
func (sm *SubagentManager) SetMediaResolver(
resolver func([]providers.Message) []providers.Message,
) {
sm.mu.Lock()
defer sm.mu.Unlock()
sm.mediaResolver = resolver
}
// SetLLMOptions sets max tokens and temperature for subagent LLM calls.
func (sm *SubagentManager) SetLLMOptions(maxTokens int, temperature float64) {
sm.mu.Lock()
@ -177,6 +194,7 @@ func (sm *SubagentManager) runTask(
temperature := sm.temperature
hasMaxTokens := sm.hasMaxTokens
hasTemperature := sm.hasTemperature
mediaResolver := sm.mediaResolver
sm.mu.RUnlock()
var result *ToolResult
@ -223,6 +241,7 @@ After completing the task, provide a clear summary of what was done.`
Tools: tools,
MaxIterations: maxIter,
LLMOptions: llmOptions,
MediaResolver: mediaResolver,
}, messages, task.OriginChannel, task.OriginChatID)
if err == nil {

View file

@ -24,6 +24,11 @@ type ToolLoopConfig struct {
Tools *ToolRegistry
MaxIterations int
LLMOptions map[string]any
// MediaResolver resolves media:// refs in messages before each LLM call.
// This is optional and is mainly used by subagent legacy fallback execution
// so subagents can reuse the same multimodal media handling as the main loop.
MediaResolver func(messages []providers.Message) []providers.Message
}
// ToolLoopResult contains the result of running the tool loop.
@ -63,8 +68,27 @@ func RunToolLoop(
if llmOpts == nil {
llmOpts = map[string]any{}
}
// 3. Call LLM
response, err := config.Provider.Chat(ctx, messages, providerToolDefs, config.Model, llmOpts)
// 3. Resolve media:// refs and Call LLM.
// Tools like load_image produce media:// refs in their result messages.
// Without this step, the LLM would receive raw "media://uuid" strings
// instead of base64-encoded image data URLs.
//
// We build a separate callMessages slice so that:
// (a) the resolver output is used for the LLM call only,
// (b) the original `messages` slice keeps the unresolved refs for
// subsequent iterations — the resolver is idempotent but working
// on the original avoids double-encoding issues.
//
// On iteration 1 the initial user messages typically have no media://
// refs (they come from plain text), so this is effectively a no-op;
// it becomes relevant from iteration 2 onward when tool results may
// contain media refs.
callMessages := messages
if config.MediaResolver != nil && iteration > 1 {
callMessages = config.MediaResolver(messages)
}
response, err := config.Provider.Chat(ctx, callMessages, providerToolDefs, config.Model, llmOpts)
if err != nil {
logger.ErrorCF("toolloop", "LLM call failed",
map[string]any{
@ -161,11 +185,15 @@ func RunToolLoop(
for _, r := range results {
contentForLLM := r.result.ContentForLLM()
messages = append(messages, providers.Message{
toolMsg := providers.Message{
Role: "tool",
Content: contentForLLM,
ToolCallID: r.tc.ID,
})
}
if len(r.result.Media) > 0 && !r.result.ResponseHandled {
toolMsg.Media = append(toolMsg.Media, r.result.Media...)
}
messages = append(messages, toolMsg)
}
}

707
pkg/updater/updater.go Normal file
View file

@ -0,0 +1,707 @@
package updater
import (
"archive/tar"
"archive/zip"
"compress/gzip"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"regexp"
"runtime"
"strings"
"time"
"github.com/minio/selfupdate"
"github.com/spf13/cobra"
"github.com/sipeed/picoclaw/pkg/config"
)
// httpClient is a shared HTTP client used for release checks and downloads.
// The Timeout value applies to the entire HTTP request: dialing, TLS
// handshake, redirects, and reading the response body. It is NOT only
// a connection (dial) timeout. To control lower-level timeouts (dial,
// TLS handshake, response header wait), supply a custom Transport with
// an appropriately configured net.Dialer.
var httpClient = &http.Client{Timeout: 2 * time.Minute}
// DownloadAndExtractRelease downloads a release archive (or uses a direct
// asset URL) and extracts it to a temporary directory. It returns the
// extraction directory on success. If releaseURL is empty, the latest
// release of the current project is used. platform/arch can be used to
// select the correct asset (e.g. "linux", "amd64").
func DownloadAndExtractRelease(releaseURL, platform, arch string) (string, error) {
assetURL, checksum, err := findAssetInfo(releaseURL, platform, arch)
if err != nil {
return "", err
}
// Download asset to temp file. Use the asset URL extension so
// extractArchive can detect the archive format (zip/tar.gz/tar).
tmpPattern := "picoclaw-release-*"
if u, perr := url.Parse(assetURL); perr == nil {
base := filepath.Base(u.Path)
lbase := strings.ToLower(base)
switch {
case strings.HasSuffix(lbase, ".zip"):
tmpPattern += ".zip"
case strings.HasSuffix(lbase, ".tar.gz") || strings.HasSuffix(lbase, ".tgz"):
tmpPattern += ".tar.gz"
case strings.HasSuffix(lbase, ".tar"):
tmpPattern += ".tar"
default:
tmpPattern += ".archive"
}
} else {
tmpPattern += ".archive"
}
tmpFile, err := os.CreateTemp("", tmpPattern)
if err != nil {
return "", err
}
tmpPath := tmpFile.Name()
defer tmpFile.Close()
resp, err := httpClient.Get(assetURL)
if err != nil {
os.Remove(tmpPath)
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
os.Remove(tmpPath)
return "", fmt.Errorf("failed to download asset: status %d", resp.StatusCode)
}
// Stream download while computing SHA256 to avoid a second download.
// Also show a simple progress line to stderr so users see activity.
h := sha256.New()
pw := &progressWriter{total: resp.ContentLength}
mw := io.MultiWriter(tmpFile, h, pw)
if _, err = io.Copy(mw, resp.Body); err != nil {
_ = os.Remove(tmpPath)
return "", err
}
// ensure final progress line ends with newline
pw.Finish()
// verify checksum if available
if checksum != "" {
got := hex.EncodeToString(h.Sum(nil))
if !strings.EqualFold(got, checksum) {
_ = os.Remove(tmpPath)
return "", fmt.Errorf("checksum mismatch: got %s expected %s", got, checksum)
}
}
// Extract
destDir, err := os.MkdirTemp("", "picoclaw-extract-*")
if err != nil {
os.Remove(tmpPath)
return "", err
}
if err := extractArchive(tmpPath, destDir); err != nil {
os.Remove(tmpPath)
os.RemoveAll(destDir)
return "", err
}
// cleanup archive file; keep extracted contents
_ = os.Remove(tmpPath)
return destDir, nil
}
// UpdateSelfFromRelease downloads the release matching the given parameters,
// extracts it and applies the binary named programName to update the
// currently running executable using minio/selfupdate.
// If releaseURL is empty, the latest release is used. If platform or arch
// is empty, runtime values are used.
func UpdateSelfFromRelease(releaseURL, platform, arch, programName string) error {
if platform == "" {
platform = runtime.GOOS
}
if arch == "" {
arch = runtime.GOARCH
}
dir, err := DownloadAndExtractRelease(releaseURL, platform, arch)
if err != nil {
return err
}
defer os.RemoveAll(dir)
binPath, err := findBinaryInDir(dir, programName)
if err != nil {
return err
}
// ensure executable bit on non-windows
if runtime.GOOS != "windows" {
_ = os.Chmod(binPath, 0o755)
}
f, err := os.Open(binPath)
if err != nil {
return err
}
defer f.Close()
// Backup current executable so we can roll back if needed.
var opts selfupdate.Options
if exePath, err := os.Executable(); err == nil {
opts.OldSavePath = exePath + ".old"
}
if err := selfupdate.Apply(f, opts); err != nil {
return fmt.Errorf("apply update: %w", err)
}
return nil
}
// UpdateSelf updates the running executable by fetching the latest release
// and applying the binary matching programName.
func UpdateSelf(programName string) error {
// By default, select the latest stable release when no explicit
// release URL is provided. Use --nightly or a custom URL to override.
return UpdateSelfFromRelease("", runtime.GOOS, runtime.GOARCH, programName)
}
// GetReleaseAPIURL returns the GitHub Releases API URL for the given repo owner.
// Example: owner="sky5454" -> https://api.github.com/repos/sky5454/picoclaw/releases/latest
func GetReleaseAPIURL(owner string) string {
return fmt.Sprintf("https://api.github.com/repos/%s/picoclaw/releases/latest", owner)
}
// GetProdReleaseAPIURL returns the production release API URL (upstream).
func GetProdReleaseAPIURL() string {
return GetReleaseAPIURL("sipeed")
}
// GetReleaseTagAPIURL returns the GitHub Releases API URL for a specific tag.
// Example: owner="sipeed", tag="nightly" -> https://api.github.com/repos/sipeed/picoclaw/releases/tags/nightly
func GetReleaseTagAPIURL(owner, tag string) string {
return fmt.Sprintf("https://api.github.com/repos/%s/picoclaw/releases/tags/%s", owner, tag)
}
// GetNightlyReleaseAPIURL returns the nightly release API URL for the production repo.
func GetNightlyReleaseAPIURL() string {
return GetReleaseTagAPIURL("sipeed", "nightly")
}
// findAssetURL resolves the appropriate asset URL for the given release
// selector. It accepts direct archive URLs as well as GitHub release URLs
// or empty (latest release for the project).
func findAssetInfo(releaseURL, platform, arch string) (string, string, error) {
// returns (assetURL, sha256ChecksumHex, error)
if looksLikeDirectAssetURL(releaseURL) {
return "", "", fmt.Errorf("no checksum found for asset %s", releaseURL)
}
apiURL := buildReleaseAPIURL(releaseURL)
if apiURL == "" {
// If caller provided an empty releaseURL, default to the
// production latest release API URL (stable release).
apiURL = GetProdReleaseAPIURL()
}
resp, err := httpClient.Get(apiURL)
if err != nil {
return "", "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", "", fmt.Errorf("failed to query releases: status %d", resp.StatusCode)
}
var data struct {
TagName string `json:"tag_name"`
Assets []struct {
Name string `json:"name"`
BrowserDownloadURL string `json:"browser_download_url"`
Digest string `json:"digest"`
} `json:"assets"`
}
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
return "", "", err
}
// Selection order: platform -> arch -> extension.
platformLower := strings.ToLower(platform)
archLower := strings.ToLower(arch)
isZip := func(name string) bool {
return strings.HasSuffix(name, ".zip")
}
isTarGz := func(name string) bool {
return strings.HasSuffix(name, ".tar.gz") || strings.HasSuffix(name, ".tgz")
}
isTar := func(name string) bool { return strings.HasSuffix(name, ".tar") }
// collect indices of assets that contain platform (if provided)
var platformIdx []int
for i, a := range data.Assets {
n := strings.ToLower(a.Name)
if platform == "" || strings.Contains(n, platformLower) {
platformIdx = append(platformIdx, i)
}
}
pickBest := func(idxs []int) (string, int, bool) {
if len(idxs) == 0 {
return "", -1, false
}
// prefer arch matches within idxs; if arch was specified but
// no arch match exists among idxs, treat as no candidate.
var archIdx []int
if arch != "" {
aliases := archAliases(archLower)
for _, i := range idxs {
n := strings.ToLower(data.Assets[i].Name)
for _, ali := range aliases {
if strings.Contains(n, ali) {
archIdx = append(archIdx, i)
break
}
}
}
if len(archIdx) == 0 {
return "", -1, false
}
}
candidates := archIdx
if len(candidates) == 0 {
candidates = idxs
}
// extension preference
if platformLower == "windows" {
// prefer .zip only
for _, i := range candidates {
if isZip(strings.ToLower(data.Assets[i].Name)) {
return data.Assets[i].BrowserDownloadURL, i, true
}
}
// if no zip found, fallthrough to first candidate
return data.Assets[candidates[0]].BrowserDownloadURL, candidates[0], true
}
// non-windows: prefer tar.gz/tgz, then tar, then zip
for _, i := range candidates {
if isTarGz(strings.ToLower(data.Assets[i].Name)) {
return data.Assets[i].BrowserDownloadURL, i, true
}
}
for _, i := range candidates {
if isTar(strings.ToLower(data.Assets[i].Name)) {
return data.Assets[i].BrowserDownloadURL, i, true
}
}
for _, i := range candidates {
if isZip(strings.ToLower(data.Assets[i].Name)) {
return data.Assets[i].BrowserDownloadURL, i, true
}
}
// fallback to first candidate
return data.Assets[candidates[0]].BrowserDownloadURL, candidates[0], true
}
// Try platform matches first
if url, idx, ok := pickBest(platformIdx); ok {
// attempt to find checksum: prefer asset digest from API if present
if d := strings.TrimSpace(data.Assets[idx].Digest); d != "" {
dLower := strings.ToLower(d)
if strings.HasPrefix(dLower, "sha256:") {
hexpart := strings.TrimPrefix(dLower, "sha256:")
return url, hexpart, nil
}
// If digest already looks like a 64-hex, return it
if ok, _ := regexp.MatchString("(?i)^[a-f0-9]{64}$", dLower); ok {
return url, dLower, nil
}
}
// Look for checksum assets and verify by computing the asset's sha256.
for j, a := range data.Assets {
n := strings.ToLower(a.Name)
if strings.Contains(n, "sha256") ||
strings.Contains(n, "sha256sum") ||
strings.Contains(n, "checksums") ||
strings.HasSuffix(n, ".sha256") ||
strings.HasSuffix(n, ".sha256sum") {
resp2, err := httpClient.Get(data.Assets[j].BrowserDownloadURL)
if err != nil {
continue
}
bs, err := io.ReadAll(resp2.Body)
resp2.Body.Close()
if err != nil {
continue
}
if h, ok := findHashInChecksumContent(bs, url); ok {
return url, h, nil
}
}
}
// No checksum found for the selected platform asset -> error
return "", "", fmt.Errorf("no checksum found for asset %s", url)
}
// No platform match — require explicit platform+arch; fail fast.
return "", "", fmt.Errorf("no release asset matching platform %q and arch %q", platform, arch)
}
func looksLikeDirectAssetURL(u string) bool {
if u == "" {
return false
}
lower := strings.ToLower(u)
if strings.HasSuffix(lower, ".zip") ||
strings.HasSuffix(lower, ".tar.gz") ||
strings.HasSuffix(lower, ".tgz") ||
strings.HasSuffix(lower, ".tar") {
return true
}
if strings.Contains(lower, "/releases/download/") {
return true
}
return false
}
func buildReleaseAPIURL(releaseURL string) string {
if releaseURL == "" {
return ""
}
if strings.Contains(releaseURL, "api.github.com") {
return releaseURL
}
u, err := url.Parse(releaseURL)
if err != nil {
return ""
}
if u.Host != "github.com" {
return ""
}
parts := strings.Split(strings.Trim(u.Path, "/"), "/")
if len(parts) < 2 {
return ""
}
owner := parts[0]
repo := parts[1]
// if tag specified
if len(parts) >= 5 && parts[2] == "releases" && parts[3] == "tag" {
tag := parts[4]
return fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/tags/%s", owner, repo, tag)
}
// default to latest
return fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/latest", owner, repo)
}
// NOTE: helper functions to compute SHA256 from URL/path were removed
// after refactoring to stream the download and verify the checksum
// during the single download to avoid double-transfer.
// findHashInChecksumContent attempts to locate a 64-hex SHA256 in the
// checksum file content that corresponds to assetURL. It returns the
// found hash (lowercase) and true, or "", false if not found.
func findHashInChecksumContent(bs []byte, assetURL string) (string, bool) {
s := strings.ToLower(string(bs))
var assetBase string
if u, err := url.Parse(assetURL); err == nil {
assetBase = strings.ToLower(filepath.Base(u.Path))
} else {
assetBase = strings.ToLower(filepath.Base(assetURL))
}
re := regexp.MustCompile(`(?i)\b([a-f0-9]{64})\b`)
// prefer a line containing the asset filename
for _, line := range strings.Split(s, "\n") {
if strings.Contains(line, assetBase) {
if m := re.FindString(line); m != "" {
return m, true
}
}
}
// fallback: if there's exactly one unique 64-hex value, return it
matches := re.FindAllString(s, -1)
uniq := map[string]struct{}{}
for _, m := range matches {
uniq[m] = struct{}{}
}
if len(uniq) == 1 {
for k := range uniq {
return k, true
}
}
return "", false
}
// progressWriter implements io.Writer and prints a simple progress
// line to stderr while bytes are written. It is intended to be used
// as one writer in an io.MultiWriter so we can stream-to-disk, compute
// the sha256, and update the progress display in a single pass.
type progressWriter struct {
total int64
written int64
last time.Time
}
func (pw *progressWriter) Write(p []byte) (int, error) {
n := len(p)
pw.written += int64(n)
now := time.Now()
if pw.last.IsZero() || now.Sub(pw.last) >= 200*time.Millisecond || (pw.total > 0 && pw.written == pw.total) {
pw.print()
pw.last = now
}
return n, nil
}
func (pw *progressWriter) print() {
if pw.total > 0 {
pct := float64(pw.written) * 100.0 / float64(pw.total)
fmt.Fprintf(os.Stderr, "\rDownloading: %s / %s (%.1f%%)", humanBytes(pw.written), humanBytes(pw.total), pct)
} else {
fmt.Fprintf(os.Stderr, "\rDownloading: %s", humanBytes(pw.written))
}
}
func (pw *progressWriter) Finish() {
pw.print()
fmt.Fprintln(os.Stderr, "")
}
func humanBytes(n int64) string {
f := float64(n)
const (
KB = 1024.0
MB = KB * 1024.0
GB = MB * 1024.0
)
switch {
case f >= GB:
return fmt.Sprintf("%.2f GB", f/GB)
case f >= MB:
return fmt.Sprintf("%.2f MB", f/MB)
case f >= KB:
return fmt.Sprintf("%.2f KB", f/KB)
default:
return fmt.Sprintf("%d B", n)
}
}
// archAliases returns common name variants for an architecture string
// so we can match release asset names like "x86_64" vs Go's "amd64".
// archAliases returns name variants for an architecture string.
// If `arch` is empty or matches the local runtime.GOARCH, prefer the
// compile-time architecture aliases provided by archAliasesForLocal
// (implemented per-architecture via build tags). For other `arch`
// values we use a small synonyms map.
func archAliases(arch string) []string {
a := strings.ToLower(arch)
if syns, ok := archSynonyms[a]; ok {
return syns
}
return []string{a}
}
var archSynonyms = map[string][]string{
"amd64": {"amd64", "x86_64", "x64"},
"x86_64": {"amd64", "x86_64", "x64"},
"x64": {"amd64", "x86_64", "x64"},
"386": {"386", "x86"},
"x86": {"386", "x86"},
"arm64": {"arm64", "aarch64"},
"aarch64": {"arm64", "aarch64"},
"arm": {"arm"},
}
func extractArchive(archivePath, destDir string) error {
lower := strings.ToLower(archivePath)
if strings.HasSuffix(lower, ".zip") {
return extractZip(archivePath, destDir)
}
// treat .tar.gz and .tgz as gzip+tar
if strings.HasSuffix(lower, ".tar.gz") || strings.HasSuffix(lower, ".tgz") {
return extractTarGz(archivePath, destDir)
}
if strings.HasSuffix(lower, ".tar") {
return extractTar(archivePath, destDir)
}
// fallback: try tar.gz
return extractTarGz(archivePath, destDir)
}
func extractZip(archivePath, destDir string) error {
r, err := zip.OpenReader(archivePath)
if err != nil {
return err
}
defer r.Close()
destClean := filepath.Clean(destDir)
for _, f := range r.File {
target := filepath.Clean(filepath.Join(destClean, f.Name))
if !strings.HasPrefix(target, destClean+string(os.PathSeparator)) && target != destClean {
return fmt.Errorf("path traversal detected: %s", f.Name)
}
if f.FileInfo().IsDir() {
if err := os.MkdirAll(target, f.FileInfo().Mode()); err != nil {
return err
}
continue
}
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return err
}
rc, err := f.Open()
if err != nil {
return err
}
out, err := os.OpenFile(target, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, f.FileInfo().Mode())
if err != nil {
rc.Close()
return err
}
if _, err := io.Copy(out, rc); err != nil {
rc.Close()
out.Close()
return err
}
rc.Close()
out.Close()
}
return nil
}
func extractTarGz(archivePath, destDir string) error {
f, err := os.Open(archivePath)
if err != nil {
return err
}
defer f.Close()
gzr, err := gzip.NewReader(f)
if err != nil {
return err
}
defer gzr.Close()
tr := tar.NewReader(gzr)
return extractTarFromReader(tr, destDir)
}
func extractTar(archivePath, destDir string) error {
f, err := os.Open(archivePath)
if err != nil {
return err
}
defer f.Close()
tr := tar.NewReader(f)
return extractTarFromReader(tr, destDir)
}
// extractTarFromReader contains logic common to extracting entries from a
// tar.Reader and is used by both extractTarGz and extractTar to avoid
// duplicated code (golangci-lint: dupl).
func extractTarFromReader(tr *tar.Reader, destDir string) error {
for {
hdr, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return err
}
target := filepath.Clean(filepath.Join(filepath.Clean(destDir), hdr.Name))
if !strings.HasPrefix(target, filepath.Clean(destDir)+string(os.PathSeparator)) &&
target != filepath.Clean(destDir) {
return fmt.Errorf("path traversal detected: %s", hdr.Name)
}
switch hdr.Typeflag {
case tar.TypeDir:
if err := os.MkdirAll(target, 0o755); err != nil {
return err
}
case tar.TypeReg:
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return err
}
out, err := os.OpenFile(target, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, os.FileMode(hdr.Mode))
if err != nil {
return err
}
if _, err := io.Copy(out, tr); err != nil {
out.Close()
return err
}
out.Close()
}
}
return nil
}
func findBinaryInDir(dir, programName string) (string, error) {
wanted := []string{programName}
if runtime.GOOS == "windows" {
wanted = append([]string{programName + ".exe"}, wanted...)
} else {
// also accept programs with .exe in archives targeting windows
wanted = append(wanted, programName+".exe")
}
var found string
if err := filepath.WalkDir(dir, func(p string, d os.DirEntry, err error) error {
if err != nil || found != "" {
return err
}
if d.IsDir() {
return nil
}
base := filepath.Base(p)
for _, w := range wanted {
if base == w {
found = p
return io.EOF // use EOF to stop walking early
}
}
return nil
}); err != nil && err != io.EOF {
return "", err
}
if found == "" {
return "", fmt.Errorf("binary %q not found in archive", programName)
}
return found, nil
}
// NewUpdateCommand returns a cobra command that triggers UpdateSelfFromRelease.
func NewUpdateCommand(binaryName string) *cobra.Command {
var urlStr, platform, arch string
cmd := &cobra.Command{
Use: "update",
Short: "Check and apply updates from GitHub releases",
RunE: func(cmd *cobra.Command, args []string) error {
if platform == "" {
platform = runtime.GOOS
}
if arch == "" {
arch = runtime.GOARCH
}
fmt.Printf("Current version: %s\n", config.FormatVersion())
if err := UpdateSelfFromRelease(urlStr, platform, arch, binaryName); err != nil {
return err
}
fmt.Println("Update applied; restart to use the new version.")
return nil
},
}
cmd.Flags().StringVarP(&urlStr, "url", "u", "", "Direct URL to download release asset or release page")
cmd.Flags().StringVar(&platform, "platform", "", "Target platform (default: runtime.GOOS)")
cmd.Flags().StringVar(&arch, "arch", "", "Target arch (default: runtime.GOARCH)")
return cmd
}

View file

@ -0,0 +1,97 @@
package updater
import (
"io"
"os"
"path/filepath"
"strings"
"testing"
)
// matchesMagic checks whether the file at path looks like a platform binary
// by inspecting magic bytes (ELF for linux, MZ for windows).
func matchesMagic(path, platform string) (bool, error) {
f, err := os.Open(path)
if err != nil {
return false, err
}
defer f.Close()
buf := make([]byte, 4)
n, err := f.Read(buf)
if err != nil && err != io.EOF {
return false, err
}
if n >= 4 && buf[0] == 0x7f && buf[1] == 'E' && buf[2] == 'L' && buf[3] == 'F' {
return strings.Contains(platform, "linux"), nil
}
if n >= 2 && buf[0] == 'M' && buf[1] == 'Z' {
return strings.Contains(platform, "windows"), nil
}
return false, nil
}
// TestDownloadAndExtractRelease_RealPlatforms downloads the latest release
// asset for multiple platform/arch combos and inspects the extracted
// artifacts to ensure a binary-like file is present. This is a network test
// and is skipped in short mode.
func TestDownloadAndExtractRelease_RealPlatforms(t *testing.T) {
if testing.Short() {
t.Skip("skipping network tests in short mode")
}
combos := []struct{ platform, arch string }{
{"linux", "amd64"},
{"linux", "arm64"},
{"windows", "amd64"},
{"windows", "arm64"},
}
apiURL := GetProdReleaseAPIURL()
for _, c := range combos {
t.Run(c.platform+"_"+c.arch, func(t *testing.T) {
assetURL, checksum, err := findAssetInfo(apiURL, c.platform, c.arch)
if err != nil {
// If no checksum could be located for this asset, skip this
// combo rather than failing — we require signed/checksummed
// releases for real-network tests.
t.Skipf("skipping %s/%s: %v", c.platform, c.arch, err)
}
t.Logf("asset URL: %s checksum: %s", assetURL, checksum)
// Pass the release API URL (not the direct asset URL) so
// DownloadAndExtractRelease can locate and verify the asset.
dir, err := DownloadAndExtractRelease(apiURL, c.platform, c.arch)
if err != nil {
t.Fatalf("DownloadAndExtractRelease failed for %s/%s: %v", c.platform, c.arch, err)
}
defer os.RemoveAll(dir)
var found bool
_ = filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error {
if err != nil || d.IsDir() {
return err
}
info, err := d.Info()
if err != nil {
return err
}
if info.Size() < 64 {
return nil
}
ok, err := matchesMagic(path, c.platform)
if err != nil {
return err
}
if ok {
found = true
t.Logf("found artifact: %s (size=%d)", path, info.Size())
// continue walking to list all
}
return nil
})
if !found {
t.Fatalf("no binary-like artifact found for %s/%s", c.platform, c.arch)
}
})
}
}

View file

@ -10,6 +10,8 @@ if [ -z "$EXECUTABLE" ]; then
exit 1
fi
LAUNCHER_EXECUTABLE="picoclaw-launcher-${EXECUTABLE}"
EXECUTABLE="picoclaw-${EXECUTABLE}"
echo "executable: $EXECUTABLE"
APP_NAME="PicoClaw Launcher"
@ -33,17 +35,17 @@ mkdir -p "$APP_RESOURCES"
# Copy executable
echo "Copying executable..."
if [ -f "./web/build/${APP_EXECUTABLE}" ]; then
cp "./web/build/${APP_EXECUTABLE}" "${APP_MACOS}/"
if [ -f "./build/${LAUNCHER_EXECUTABLE}" ]; then
cp "./build/${LAUNCHER_EXECUTABLE}" "${APP_MACOS}/${APP_EXECUTABLE}"
else
echo "Error: ./web/build/${APP_EXECUTABLE} not found. Please build the web backend first."
echo "Run: make build in web dir"
echo "Error: ./build/${LAUNCHER_EXECUTABLE} not found. Please build the web backend first."
echo "Run: make build-launcher"
exit 1
fi
if [ -f "./build/picoclaw" ]; then
cp "./build/picoclaw" "${APP_MACOS}/"
if [ -f "./build/${EXECUTABLE}" ]; then
cp "./build/${EXECUTABLE}" "${APP_MACOS}/picoclaw"
else
echo "Error: ./build/picoclaw not found. Please build the main file first."
echo "Error: ./build/${EXECUTABLE} not found. Please build the main file first."
echo "Run: make build"
exit 1
fi
@ -76,10 +78,10 @@ cat > "${APP_CONTENTS}/Info.plist" << 'EOF'
<true/>
<key>NSSupportsAutomaticGraphicsSwitching</key>
<true/>
<key>LSRequiresCarbon</key>
<true/>
<key>LSUIElement</key>
<string>1</string>
<true/>
<key>LSMinimumSystemVersion</key>
<string>10.11</string>
</dict>
</plist>
EOF

View file

@ -81,6 +81,9 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
// Launcher service parameters (port/public)
h.registerLauncherConfigRoutes(mux)
// Self-update endpoint (requires dashboard auth)
h.registerUpdateRoutes(mux)
// Runtime build/version metadata
h.registerVersionRoutes(mux)

View file

@ -1,40 +1,115 @@
package api
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"net/http"
"net/url"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"sync"
"time"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/fileutil"
"github.com/sipeed/picoclaw/pkg/skills"
"github.com/sipeed/picoclaw/pkg/utils"
)
type skillSupportResponse struct {
Skills []skills.SkillInfo `json:"skills"`
Skills []skillSupportItem `json:"skills"`
}
type skillSupportItem struct {
Name string `json:"name"`
Path string `json:"path"`
Source string `json:"source"`
Description string `json:"description"`
OriginKind string `json:"origin_kind"`
RegistryName string `json:"registry_name,omitempty"`
RegistryURL string `json:"registry_url,omitempty"`
InstalledVersion string `json:"installed_version,omitempty"`
InstalledAt int64 `json:"installed_at,omitempty"`
}
type skillDetailResponse struct {
Name string `json:"name"`
Path string `json:"path"`
Source string `json:"source"`
Description string `json:"description"`
Content string `json:"content"`
skillSupportItem
Content string `json:"content"`
}
type skillSearchResultItem struct {
Score float64 `json:"score"`
Slug string `json:"slug"`
DisplayName string `json:"display_name"`
Summary string `json:"summary"`
Version string `json:"version"`
RegistryName string `json:"registry_name"`
URL string `json:"url,omitempty"`
Installed bool `json:"installed"`
InstalledName string `json:"installed_name,omitempty"`
}
type skillSearchResponse struct {
Results []skillSearchResultItem `json:"results"`
Limit int `json:"limit"`
Offset int `json:"offset"`
NextOffset int `json:"next_offset,omitempty"`
HasMore bool `json:"has_more"`
}
type installSkillRequest struct {
Slug string `json:"slug"`
Registry string `json:"registry"`
Version string `json:"version,omitempty"`
Force bool `json:"force,omitempty"`
}
type installSkillResponse struct {
Status string `json:"status"`
Slug string `json:"slug"`
Registry string `json:"registry"`
Version string `json:"version"`
Summary string `json:"summary,omitempty"`
IsSuspicious bool `json:"is_suspicious,omitempty"`
InstalledSkill *skillSupportItem `json:"skill,omitempty"`
}
type installedSkillOriginMeta struct {
Version int `json:"version"`
OriginKind string `json:"origin_kind,omitempty"`
Registry string `json:"registry,omitempty"`
Slug string `json:"slug,omitempty"`
RegistryURL string `json:"registry_url,omitempty"`
InstalledVersion string `json:"installed_version,omitempty"`
InstalledAt int64 `json:"installed_at"`
}
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)*`)
persistSkillOriginMeta = writeSkillOriginMeta
workspaceSkillWriteMu sync.Mutex
errImportedSkillExists = errors.New("skill already exists")
)
const (
maxImportedSkillSize = 1 << 20
maxRegistrySearchFanout = 1000
)
func (h *Handler) registerSkillRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/skills", h.handleListSkills)
mux.HandleFunc("GET /api/skills/{name}", h.handleGetSkill)
mux.HandleFunc("GET /api/skills/search", h.handleSearchSkills)
mux.HandleFunc("POST /api/skills/install", h.handleInstallSkill)
mux.HandleFunc("POST /api/skills/import", h.handleImportSkill)
mux.HandleFunc("DELETE /api/skills/{name}", h.handleDeleteSkill)
}
@ -46,11 +121,15 @@ func (h *Handler) handleListSkills(w http.ResponseWriter, r *http.Request) {
return
}
loader := newSkillsLoader(cfg.WorkspacePath())
items, err := buildSkillSupportItems(cfg)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to build skill list: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(skillSupportResponse{
Skills: loader.ListSkills(),
Skills: items,
})
}
@ -61,16 +140,18 @@ func (h *Handler) handleGetSkill(w http.ResponseWriter, r *http.Request) {
return
}
loader := newSkillsLoader(cfg.WorkspacePath())
skillItems, err := buildSkillSupportItems(cfg)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to build skill list: %v", err), http.StatusInternalServerError)
return
}
name := r.PathValue("name")
allSkills := loader.ListSkills()
for _, skill := range allSkills {
if skill.Name != name {
for _, skillItem := range skillItems {
if skillItem.Name != name {
continue
}
content, err := loadSkillContent(skill.Path)
content, err := loadSkillContent(skillItem.Path)
if err != nil {
http.Error(w, "Skill content not found", http.StatusNotFound)
return
@ -78,11 +159,8 @@ func (h *Handler) handleGetSkill(w http.ResponseWriter, r *http.Request) {
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,
skillSupportItem: skillItem,
Content: content,
})
return
}
@ -90,6 +168,266 @@ func (h *Handler) handleGetSkill(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Skill not found", http.StatusNotFound)
}
func (h *Handler) handleSearchSkills(w http.ResponseWriter, r *http.Request) {
cfg, loadErr := config.LoadConfig(h.configPath)
if loadErr != nil {
http.Error(w, fmt.Sprintf("Failed to load config: %v", loadErr), http.StatusInternalServerError)
return
}
if registryErr := ensureSkillRegistryToolEnabled(cfg, "find_skills"); registryErr != nil {
http.Error(w, registryErr.Error(), http.StatusBadRequest)
return
}
query := strings.TrimSpace(r.URL.Query().Get("q"))
limit := 20
if rawLimit := strings.TrimSpace(r.URL.Query().Get("limit")); rawLimit != "" {
parsedLimit, parseErr := strconv.Atoi(rawLimit)
if parseErr != nil || parsedLimit < 1 || parsedLimit > 50 {
http.Error(w, "limit must be between 1 and 50", http.StatusBadRequest)
return
}
limit = parsedLimit
}
offset := 0
if rawOffset := strings.TrimSpace(r.URL.Query().Get("offset")); rawOffset != "" {
parsedOffset, parseErr := strconv.Atoi(rawOffset)
if parseErr != nil || parsedOffset < 0 {
http.Error(w, "offset must be 0 or greater", http.StatusBadRequest)
return
}
offset = parsedOffset
}
installedSkills, err := buildOccupiedWorkspaceSkillsByDirectory(cfg)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to inspect installed skills: %v", err), http.StatusInternalServerError)
return
}
if query == "" {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(skillSearchResponse{
Results: []skillSearchResultItem{},
Limit: limit,
Offset: offset,
HasMore: false,
})
return
}
registryMgr := newSkillsRegistryManager(cfg)
searchLimit := offset + limit + 1
if searchLimit > maxRegistrySearchFanout {
searchLimit = maxRegistrySearchFanout
}
results, err := registryMgr.SearchAll(r.Context(), query, searchLimit)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to search skills: %v", err), http.StatusBadGateway)
return
}
if offset > len(results) {
offset = len(results)
}
end := offset + limit
if end > len(results) {
end = len(results)
}
pageResults := results[offset:end]
response := make([]skillSearchResultItem, 0, len(pageResults))
for _, result := range pageResults {
installedSkill, installed := installedSkills[result.Slug]
item := skillSearchResultItem{
Score: result.Score,
Slug: result.Slug,
DisplayName: result.DisplayName,
Summary: result.Summary,
Version: result.Version,
RegistryName: result.RegistryName,
URL: registrySkillURL(cfg, result.RegistryName, result.Slug),
Installed: installed,
}
if installed {
item.InstalledName = installedSkill.Name
}
response = append(response, item)
}
w.Header().Set("Content-Type", "application/json")
nextOffset := 0
hasMore := len(results) > end
if hasMore {
nextOffset = end
}
json.NewEncoder(w).Encode(skillSearchResponse{
Results: response,
Limit: limit,
Offset: offset,
NextOffset: nextOffset,
HasMore: hasMore,
})
}
func (h *Handler) handleInstallSkill(w http.ResponseWriter, r *http.Request) {
cfg, loadErr := config.LoadConfig(h.configPath)
if loadErr != nil {
http.Error(w, fmt.Sprintf("Failed to load config: %v", loadErr), http.StatusInternalServerError)
return
}
if registryErr := ensureSkillRegistryToolEnabled(cfg, "install_skill"); registryErr != nil {
http.Error(w, registryErr.Error(), http.StatusBadRequest)
return
}
var req installSkillRequest
if decodeErr := json.NewDecoder(r.Body).Decode(&req); decodeErr != nil {
http.Error(w, fmt.Sprintf("Invalid JSON: %v", decodeErr), http.StatusBadRequest)
return
}
req.Slug = strings.TrimSpace(req.Slug)
req.Registry = strings.TrimSpace(req.Registry)
req.Version = strings.TrimSpace(req.Version)
if validateErr := utils.ValidateSkillIdentifier(req.Slug); validateErr != nil {
http.Error(
w,
fmt.Sprintf("invalid slug %q: error: %s", req.Slug, validateErr.Error()),
http.StatusBadRequest,
)
return
}
if validateErr := utils.ValidateSkillIdentifier(req.Registry); validateErr != nil {
http.Error(
w,
fmt.Sprintf("invalid registry %q: error: %s", req.Registry, validateErr.Error()),
http.StatusBadRequest,
)
return
}
registryMgr := newSkillsRegistryManager(cfg)
registry := registryMgr.GetRegistry(req.Registry)
if registry == nil {
http.Error(w, fmt.Sprintf("registry %q not found", req.Registry), http.StatusBadRequest)
return
}
workspace := cfg.WorkspacePath()
skillsRoot := filepath.Join(workspace, "skills")
targetDir := filepath.Join(workspace, "skills", req.Slug)
workspaceSkillWriteMu.Lock()
defer workspaceSkillWriteMu.Unlock()
targetExists := false
if _, statErr := os.Stat(targetDir); statErr == nil {
targetExists = true
} else if !os.IsNotExist(statErr) {
http.Error(w, fmt.Sprintf("Failed to inspect install target: %v", statErr), http.StatusInternalServerError)
return
}
if !req.Force && targetExists {
http.Error(w, fmt.Sprintf("skill %q already installed at %s", req.Slug, targetDir), http.StatusConflict)
return
}
if err := os.MkdirAll(skillsRoot, 0o755); err != nil {
http.Error(w, fmt.Sprintf("Failed to create skills directory: %v", err), http.StatusInternalServerError)
return
}
stagedWorkspaceRoot, stagedTargetDir, err := createStagedSkillInstall(skillsRoot, req.Slug)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to prepare staged install: %v", err), http.StatusInternalServerError)
return
}
defer os.RemoveAll(stagedWorkspaceRoot)
result, err := registry.DownloadAndInstall(r.Context(), req.Slug, req.Version, stagedTargetDir)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to install skill: %v", err), http.StatusBadGateway)
return
}
if result.IsMalwareBlocked {
http.Error(
w,
fmt.Sprintf("skill %q is flagged as malicious and cannot be installed", req.Slug),
http.StatusForbidden,
)
return
}
if findWorkspaceSkillInfoByDirectory(stagedWorkspaceRoot, req.Slug) == nil {
http.Error(
w,
fmt.Sprintf("Failed to install skill: registry archive for %q is not a valid skill", req.Slug),
http.StatusBadGateway,
)
return
}
installedAt := time.Now().UnixMilli()
if err := persistSkillOriginMeta(stagedTargetDir, installedSkillOriginMeta{
Version: 1,
OriginKind: "third_party",
Registry: registry.Name(),
Slug: req.Slug,
RegistryURL: registrySkillURL(cfg, registry.Name(), req.Slug),
InstalledVersion: result.Version,
InstalledAt: installedAt,
}); err != nil {
http.Error(w, fmt.Sprintf("Failed to persist skill metadata: %v", err), http.StatusInternalServerError)
return
}
if err := commitStagedSkillInstall(
stagedWorkspaceRoot,
stagedTargetDir,
targetDir,
req.Force && targetExists,
); err != nil {
http.Error(w, fmt.Sprintf("Failed to activate installed skill: %v", err), http.StatusInternalServerError)
return
}
validatedSkill := findWorkspaceSkillByDirectory(cfg, req.Slug)
if validatedSkill == nil {
http.Error(
w,
fmt.Sprintf("Failed to install skill: activated archive for %q is not a valid skill", req.Slug),
http.StatusBadGateway,
)
return
}
installedSkill := &skillSupportItem{
Name: validatedSkill.Name,
Path: validatedSkill.Path,
Source: validatedSkill.Source,
Description: validatedSkill.Description,
OriginKind: "third_party",
RegistryName: registry.Name(),
RegistryURL: registrySkillURL(cfg, registry.Name(), req.Slug),
InstalledVersion: result.Version,
InstalledAt: installedAt,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(installSkillResponse{
Status: "ok",
Slug: req.Slug,
Registry: registry.Name(),
Version: result.Version,
Summary: result.Summary,
IsSuspicious: result.IsSuspicious,
InstalledSkill: installedSkill,
})
}
func (h *Handler) handleImportSkill(w http.ResponseWriter, r *http.Request) {
cfg, err := config.LoadConfig(h.configPath)
if err != nil {
@ -110,54 +448,26 @@ func (h *Handler) handleImportSkill(w http.ResponseWriter, r *http.Request) {
}
defer uploadedFile.Close()
content, err := io.ReadAll(io.LimitReader(uploadedFile, (1<<20)+1))
content, err := io.ReadAll(io.LimitReader(uploadedFile, maxImportedSkillSize+1))
if err != nil {
http.Error(w, fmt.Sprintf("Failed to read file: %v", err), http.StatusBadRequest)
return
}
if len(content) > 1<<20 {
if len(content) > maxImportedSkillSize {
http.Error(w, "file exceeds 1MB limit", http.StatusBadRequest)
return
}
workspaceSkillWriteMu.Lock()
defer workspaceSkillWriteMu.Unlock()
skillName, err := normalizeImportedSkillName(fileHeader.Filename, content)
importedSkill, statusCode, err := importUploadedSkill(cfg, fileHeader.Filename, content)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
http.Error(w, err.Error(), statusCode)
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,
})
json.NewEncoder(w).Encode(importedSkill)
}
func (h *Handler) handleDeleteSkill(w http.ResponseWriter, r *http.Request) {
@ -169,6 +479,9 @@ func (h *Handler) handleDeleteSkill(w http.ResponseWriter, r *http.Request) {
loader := newSkillsLoader(cfg.WorkspacePath())
name := r.PathValue("name")
workspaceSkillWriteMu.Lock()
defer workspaceSkillWriteMu.Unlock()
for _, skill := range loader.ListSkills() {
if skill.Name != name {
continue
@ -197,12 +510,274 @@ func newSkillsLoader(workspace string) *skills.SkillsLoader {
)
}
func newSkillsRegistryManager(cfg *config.Config) *skills.RegistryManager {
clawHubConfig := cfg.Tools.Skills.Registries.ClawHub
return skills.NewRegistryManagerFromConfig(skills.RegistryConfig{
MaxConcurrentSearches: cfg.Tools.Skills.MaxConcurrentSearches,
ClawHub: skills.ClawHubConfig{
Enabled: clawHubConfig.Enabled,
BaseURL: clawHubConfig.BaseURL,
AuthToken: clawHubConfig.AuthToken.String(),
SearchPath: clawHubConfig.SearchPath,
SkillsPath: clawHubConfig.SkillsPath,
DownloadPath: clawHubConfig.DownloadPath,
Timeout: clawHubConfig.Timeout,
MaxZipSize: clawHubConfig.MaxZipSize,
MaxResponseSize: clawHubConfig.MaxResponseSize,
},
})
}
func ensureSkillRegistryToolEnabled(cfg *config.Config, toolName string) error {
if !cfg.Tools.IsToolEnabled("skills") {
return fmt.Errorf("tools.skills is disabled")
}
if !cfg.Tools.IsToolEnabled(toolName) {
return fmt.Errorf("%s is disabled", toolName)
}
return nil
}
func buildSkillSupportItems(cfg *config.Config) ([]skillSupportItem, error) {
rawSkills := newSkillsLoader(cfg.WorkspacePath()).ListSkills()
items := make([]skillSupportItem, 0, len(rawSkills))
for _, skill := range rawSkills {
item, err := enrichSkillInfo(cfg, skill)
if err != nil {
return nil, err
}
items = append(items, item)
}
return items, nil
}
func buildWorkspaceSkillItemsByDirectory(cfg *config.Config) (map[string]skillSupportItem, error) {
result := make(map[string]skillSupportItem)
items, err := buildSkillSupportItems(cfg)
if err != nil {
return nil, err
}
for _, skill := range items {
if skill.Source != "workspace" {
continue
}
dir := filepath.Base(filepath.Dir(skill.Path))
if dir == "" {
continue
}
result[dir] = skill
}
return result, nil
}
func buildOccupiedWorkspaceSkillsByDirectory(cfg *config.Config) (map[string]skillSupportItem, error) {
result := make(map[string]skillSupportItem)
items, err := buildSkillSupportItems(cfg)
if err != nil {
return nil, err
}
for _, skill := range items {
if skill.Source != "workspace" {
continue
}
key := filepath.Base(filepath.Dir(skill.Path))
if meta, err := readInstalledSkillOriginMeta(skill.Path); err == nil && meta != nil && meta.Slug != "" {
key = meta.Slug
}
if key == "" {
continue
}
result[key] = skill
}
return result, nil
}
func findWorkspaceSkillByDirectory(cfg *config.Config, directory string) *skillSupportItem {
items, err := buildWorkspaceSkillItemsByDirectory(cfg)
if err != nil {
return nil
}
skill, ok := items[directory]
if !ok {
return nil
}
return &skill
}
func findWorkspaceSkillInfoByDirectory(workspace, directory string) *skills.SkillInfo {
loader := skills.NewSkillsLoader(workspace, "", "")
for _, skill := range loader.ListSkills() {
if skill.Source != "workspace" {
continue
}
if filepath.Base(filepath.Dir(skill.Path)) != directory {
continue
}
skillCopy := skill
return &skillCopy
}
return nil
}
func createStagedSkillInstall(skillsRoot, slug string) (string, string, error) {
stagedWorkspaceRoot, err := os.MkdirTemp(skillsRoot, "."+slug+"-install-*")
if err != nil {
return "", "", err
}
stagedTargetDir := filepath.Join(stagedWorkspaceRoot, "skills", slug)
return stagedWorkspaceRoot, stagedTargetDir, nil
}
func commitStagedSkillInstall(stagedWorkspaceRoot, stagedTargetDir, targetDir string, replaceExisting bool) error {
if !replaceExisting {
return os.Rename(stagedTargetDir, targetDir)
}
backupDir, err := reserveTempDirPath(filepath.Dir(targetDir), "."+filepath.Base(targetDir)+"-backup-*")
if err != nil {
return err
}
if err := os.Rename(targetDir, backupDir); err != nil {
return fmt.Errorf("failed to move existing skill aside: %w", err)
}
if err := os.Rename(stagedTargetDir, targetDir); err != nil {
if rollbackErr := os.Rename(backupDir, targetDir); rollbackErr != nil {
return fmt.Errorf("failed to activate replacement: %w (rollback failed: %v)", err, rollbackErr)
}
return fmt.Errorf("failed to activate replacement: %w", err)
}
_ = os.RemoveAll(backupDir)
_ = os.RemoveAll(stagedWorkspaceRoot)
return nil
}
func reserveTempDirPath(parent, pattern string) (string, error) {
tempDir, err := os.MkdirTemp(parent, pattern)
if err != nil {
return "", err
}
if err := os.Remove(tempDir); err != nil {
return "", err
}
return tempDir, nil
}
func enrichSkillInfo(cfg *config.Config, skill skills.SkillInfo) (skillSupportItem, error) {
item := skillSupportItem{
Name: skill.Name,
Path: skill.Path,
Source: skill.Source,
Description: skill.Description,
OriginKind: "builtin",
}
switch skill.Source {
case "builtin":
item.OriginKind = "builtin"
case "global":
item.OriginKind = "builtin"
case "workspace":
meta, err := readInstalledSkillOriginMeta(skill.Path)
if err == nil && meta != nil {
switch meta.OriginKind {
case "manual":
item.OriginKind = "manual"
item.InstalledAt = meta.InstalledAt
case "third_party":
item.OriginKind = "third_party"
item.RegistryName = meta.Registry
item.RegistryURL = registrySkillURLFromMeta(cfg, meta)
item.InstalledVersion = meta.InstalledVersion
item.InstalledAt = meta.InstalledAt
default:
if meta.Registry != "" || meta.Slug != "" || meta.InstalledVersion != "" {
item.OriginKind = "third_party"
item.RegistryName = meta.Registry
item.RegistryURL = registrySkillURLFromMeta(cfg, meta)
item.InstalledVersion = meta.InstalledVersion
item.InstalledAt = meta.InstalledAt
} else {
item.OriginKind = "builtin"
item.InstalledAt = meta.InstalledAt
}
}
} else {
item.OriginKind = "builtin"
}
default:
item.OriginKind = "builtin"
}
return item, nil
}
func readInstalledSkillOriginMeta(skillPath string) (*installedSkillOriginMeta, error) {
metaPath := filepath.Join(filepath.Dir(skillPath), ".skill-origin.json")
data, err := os.ReadFile(metaPath)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
var meta installedSkillOriginMeta
if err := json.Unmarshal(data, &meta); err != nil {
return nil, err
}
return &meta, nil
}
func writeSkillOriginMeta(targetDir string, meta installedSkillOriginMeta) error {
data, err := json.MarshalIndent(meta, "", " ")
if err != nil {
return err
}
return fileutil.WriteFileAtomic(filepath.Join(targetDir, ".skill-origin.json"), data, 0o600)
}
func registrySkillURL(cfg *config.Config, registryName, slug string) string {
switch registryName {
case "clawhub":
baseURL := strings.TrimRight(cfg.Tools.Skills.Registries.ClawHub.BaseURL, "/")
if baseURL == "" {
baseURL = "https://clawhub.ai"
}
return baseURL + "/skills/" + url.PathEscape(slug)
default:
return ""
}
}
func registrySkillURLFromMeta(cfg *config.Config, meta *installedSkillOriginMeta) string {
if meta == nil || meta.Slug == "" {
return ""
}
if meta.RegistryURL != "" {
return meta.RegistryURL
}
if cfg == nil || meta.Registry == "" {
return ""
}
return registrySkillURL(cfg, meta.Registry, meta.Slug)
}
func normalizeImportedSkillName(filename string, content []byte) (string, error) {
return normalizeImportedSkillNameWithHint(filename, "", content)
}
func normalizeImportedSkillNameWithHint(filename, directoryHint 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(directoryHint)
}
if raw == "" {
raw = strings.TrimSpace(strings.TrimSuffix(filepath.Base(filename), filepath.Ext(filename)))
}
@ -259,6 +834,210 @@ func normalizeImportedSkillContent(content []byte, skillName string) []byte {
return []byte(builder.String())
}
func importUploadedSkill(cfg *config.Config, filename string, content []byte) (*skillSupportItem, int, error) {
if isImportedSkillArchive(filename, content) {
return importUploadedSkillArchive(cfg, filename, content)
}
return importUploadedMarkdownSkill(cfg, filename, content)
}
func importUploadedMarkdownSkill(cfg *config.Config, filename string, content []byte) (*skillSupportItem, int, error) {
skillName, err := normalizeImportedSkillName(filename, content)
if err != nil {
return nil, http.StatusBadRequest, err
}
normalizedContent := normalizeImportedSkillContent(content, skillName)
workspace := cfg.WorkspacePath()
skillDir := filepath.Join(workspace, "skills", skillName)
skillFile := filepath.Join(skillDir, "SKILL.md")
if err := ensureWorkspaceSkillDoesNotExist(skillDir); err != nil {
return nil, statusCodeForImportedSkillWriteError(err), err
}
if err := os.MkdirAll(skillDir, 0o755); err != nil {
return nil, http.StatusInternalServerError, fmt.Errorf("Failed to create skill directory: %v", err)
}
if err := fileutil.WriteFileAtomic(skillFile, normalizedContent, 0o644); err != nil {
_ = os.RemoveAll(skillDir)
return nil, http.StatusInternalServerError, fmt.Errorf("Failed to save skill: %v", err)
}
return finalizeImportedSkill(cfg, skillDir, skillName, false)
}
func importUploadedSkillArchive(cfg *config.Config, filename string, content []byte) (*skillSupportItem, int, error) {
tmpDir, tempDirErr := os.MkdirTemp("", "picoclaw-skill-import-*")
if tempDirErr != nil {
return nil, http.StatusInternalServerError, fmt.Errorf("Failed to create temp directory: %v", tempDirErr)
}
defer os.RemoveAll(tmpDir)
archivePath := filepath.Join(tmpDir, "import.zip")
if writeErr := fileutil.WriteFileAtomic(archivePath, content, 0o600); writeErr != nil {
return nil, http.StatusInternalServerError, fmt.Errorf("Failed to stage uploaded archive: %v", writeErr)
}
extractDir := filepath.Join(tmpDir, "extract")
if extractErr := utils.ExtractZipFile(archivePath, extractDir); extractErr != nil {
return nil, http.StatusBadRequest, fmt.Errorf("invalid ZIP archive: %w", extractErr)
}
skillRoot, err := findImportedSkillRoot(extractDir)
if err != nil {
return nil, http.StatusBadRequest, err
}
skillFile := filepath.Join(skillRoot, "SKILL.md")
skillContent, err := os.ReadFile(skillFile)
if err != nil {
return nil, http.StatusBadRequest, fmt.Errorf("failed to read SKILL.md from archive: %w", err)
}
directoryHint := ""
if filepath.Clean(skillRoot) != filepath.Clean(extractDir) {
directoryHint = filepath.Base(skillRoot)
}
skillName, err := normalizeImportedSkillNameWithHint(filename, directoryHint, skillContent)
if err != nil {
return nil, http.StatusBadRequest, err
}
workspace := cfg.WorkspacePath()
skillDir := filepath.Join(workspace, "skills", skillName)
if err := ensureWorkspaceSkillDoesNotExist(skillDir); err != nil {
return nil, statusCodeForImportedSkillWriteError(err), err
}
if err := copyImportedSkillTree(skillRoot, skillDir); err != nil {
_ = os.RemoveAll(skillDir)
return nil, http.StatusInternalServerError, fmt.Errorf("Failed to save skill: %v", err)
}
normalizedContent := normalizeImportedSkillContent(skillContent, skillName)
if err := fileutil.WriteFileAtomic(filepath.Join(skillDir, "SKILL.md"), normalizedContent, 0o644); err != nil {
_ = os.RemoveAll(skillDir)
return nil, http.StatusInternalServerError, fmt.Errorf("Failed to normalize skill: %v", err)
}
return finalizeImportedSkill(cfg, skillDir, skillName, true)
}
func isImportedSkillArchive(filename string, content []byte) bool {
if strings.EqualFold(filepath.Ext(filename), ".zip") {
return true
}
return len(content) >= 4 && bytes.HasPrefix(content, []byte("PK\x03\x04"))
}
func ensureWorkspaceSkillDoesNotExist(skillDir string) error {
if _, err := os.Stat(skillDir); err == nil {
return errImportedSkillExists
} else if !os.IsNotExist(err) {
return fmt.Errorf("failed to inspect skill directory: %w", err)
}
return nil
}
func statusCodeForImportedSkillWriteError(err error) int {
if err == nil {
return http.StatusOK
}
if errors.Is(err, errImportedSkillExists) {
return http.StatusConflict
}
return http.StatusInternalServerError
}
func finalizeImportedSkill(
cfg *config.Config,
skillDir string,
skillName string,
requireValidatedSkill bool,
) (*skillSupportItem, int, error) {
if err := persistSkillOriginMeta(skillDir, installedSkillOriginMeta{
Version: 1,
OriginKind: "manual",
InstalledAt: time.Now().UnixMilli(),
}); err != nil {
_ = os.RemoveAll(skillDir)
return nil, http.StatusInternalServerError, fmt.Errorf("Failed to persist skill metadata: %v", err)
}
if importedSkill := findWorkspaceSkillByDirectory(cfg, skillName); importedSkill != nil {
return importedSkill, http.StatusOK, nil
}
if requireValidatedSkill {
_ = os.RemoveAll(skillDir)
return nil, http.StatusBadRequest, fmt.Errorf("imported archive is not a valid skill")
}
return &skillSupportItem{
Name: skillName,
Path: filepath.Join(skillDir, "SKILL.md"),
Source: "workspace",
Description: "Imported skill",
OriginKind: "manual",
}, http.StatusOK, nil
}
func findImportedSkillRoot(extractDir string) (string, error) {
skillFiles := make([]string, 0, 1)
err := filepath.WalkDir(extractDir, func(path string, d fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if d.IsDir() {
return nil
}
if d.Name() == "SKILL.md" {
skillFiles = append(skillFiles, path)
}
return nil
})
if err != nil {
return "", fmt.Errorf("failed to inspect ZIP archive: %w", err)
}
switch len(skillFiles) {
case 0:
return "", fmt.Errorf("ZIP archive must contain a SKILL.md file")
case 1:
return filepath.Dir(skillFiles[0]), nil
default:
return "", fmt.Errorf("ZIP archive must contain exactly one SKILL.md file")
}
}
func copyImportedSkillTree(srcDir, destDir string) error {
return filepath.WalkDir(srcDir, func(path string, d fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
relPath, err := filepath.Rel(srcDir, path)
if err != nil {
return err
}
if relPath == "." {
return os.MkdirAll(destDir, 0o755)
}
destPath := filepath.Join(destDir, relPath)
info, err := d.Info()
if err != nil {
return err
}
if d.IsDir() {
return os.MkdirAll(destPath, 0o755)
}
if !info.Mode().IsRegular() {
return fmt.Errorf("archive contains unsupported file %q", relPath)
}
return fileutil.CopyFile(path, destPath, info.Mode().Perm())
})
}
func extractImportedSkillMetadata(raw string) (map[string]string, string) {
matches := importedSkillFrontmatter.FindStringSubmatch(raw)
if len(matches) != 2 {

File diff suppressed because it is too large Load diff

52
web/backend/api/update.go Normal file
View file

@ -0,0 +1,52 @@
package api
import (
"encoding/json"
"net/http"
"github.com/sipeed/picoclaw/pkg/updater"
)
// registerUpdateRoutes registers the self-update endpoint.
func (h *Handler) registerUpdateRoutes(mux *http.ServeMux) {
mux.HandleFunc("/api/update", h.handleUpdate)
}
type updateRequest struct {
URL string `json:"url,omitempty"`
Binary string `json:"binary,omitempty"`
}
type updateResponse struct {
Status string `json:"status"`
Message string `json:"message,omitempty"`
}
func (h *Handler) handleUpdate(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
_ = json.NewEncoder(w).Encode(updateResponse{Status: "error", Message: "method not allowed"})
return
}
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20))
var req updateRequest
if err := dec.Decode(&req); err != nil {
w.WriteHeader(http.StatusBadRequest)
_ = json.NewEncoder(w).Encode(updateResponse{Status: "error", Message: "invalid request body"})
return
}
binary := req.Binary
if binary == "" {
binary = "picoclaw-launcher"
}
if err := updater.UpdateSelfFromRelease(req.URL, "", "", binary); err != nil {
w.WriteHeader(http.StatusInternalServerError)
_ = json.NewEncoder(w).Encode(updateResponse{Status: "error", Message: err.Error()})
return
}
_ = json.NewEncoder(w).Encode(updateResponse{Status: "ok", Message: "update applied; restart to use new version"})
}

View file

@ -71,6 +71,7 @@ func Recoverer(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if err := recover(); err != nil {
logger.RecoverPanicNoExit(err)
logger.ErrorC("http", fmt.Sprintf("panic recovered: %v\n%s", err, debug.Stack()))
http.Error(w, `{"error":"internal server error"}`, http.StatusInternalServerError)
}

View file

@ -5,22 +5,60 @@ export interface SkillSupportItem {
path: string
source: "workspace" | "global" | "builtin" | string
description: string
origin_kind: "builtin" | "third_party" | "manual" | string
registry_name?: string
registry_url?: string
installed_version?: string
installed_at?: number
}
export interface SkillDetailResponse extends SkillSupportItem {
content: string
}
export interface SkillRegistrySearchResult {
score: number
slug: string
display_name: string
summary: string
version: string
registry_name: string
url?: string
installed: boolean
installed_name?: string
}
interface SkillsResponse {
skills: SkillSupportItem[]
}
interface SkillActionResponse {
export interface SkillSearchResponse {
results: SkillRegistrySearchResult[]
limit: number
offset: number
next_offset?: number
has_more: boolean
}
type SkillActionResponse = Partial<SkillSupportItem> & {
status?: string
name?: string
path?: string
source?: string
description?: string
}
export interface InstallSkillRequest {
slug: string
registry: string
version?: string
force?: boolean
}
export interface InstallSkillResponse {
status: string
slug: string
registry: string
version: string
summary?: string
is_suspicious?: boolean
skill?: SkillSupportItem
}
async function request<T>(path: string, options?: RequestInit): Promise<T> {
@ -39,6 +77,29 @@ export async function getSkill(name: string): Promise<SkillDetailResponse> {
return request<SkillDetailResponse>(`/api/skills/${encodeURIComponent(name)}`)
}
export async function searchSkills(
query: string,
limit = 20,
offset = 0,
): Promise<SkillSearchResponse> {
const params = new URLSearchParams({
q: query,
limit: String(limit),
offset: String(offset),
})
return request<SkillSearchResponse>(`/api/skills/search?${params.toString()}`)
}
export async function installSkill(
input: InstallSkillRequest,
): Promise<InstallSkillResponse> {
return request<InstallSkillResponse>("/api/skills/install", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
})
}
export async function importSkill(file: File): Promise<SkillActionResponse> {
const formData = new FormData()
formData.set("file", file)
@ -64,15 +125,23 @@ export async function deleteSkill(name: string): Promise<SkillActionResponse> {
async function extractErrorMessage(res: Response): Promise<string> {
try {
const body = (await res.json()) as {
error?: string
errors?: string[]
const raw = await res.text()
if (raw.trim() === "") {
return `API error: ${res.status} ${res.statusText}`
}
if (Array.isArray(body.errors) && body.errors.length > 0) {
return body.errors.join("; ")
}
if (typeof body.error === "string" && body.error.trim() !== "") {
return body.error
try {
const body = JSON.parse(raw) as {
error?: string
errors?: string[]
}
if (Array.isArray(body.errors) && body.errors.length > 0) {
return body.errors.join("; ")
}
if (typeof body.error === "string" && body.error.trim() !== "") {
return body.error
}
} catch {
return raw.trim()
}
} catch {
// ignore invalid body

View file

@ -0,0 +1,51 @@
import { useTranslation } from "react-i18next"
import { PageHeader } from "@/components/page-header"
import { ResultsPanel } from "./results-panel"
import { SearchPanel } from "./search-panel"
import { useHubMarketplace } from "./use-hub-marketplace"
export function HubPage() {
const { t } = useTranslation()
const hub = useHubMarketplace()
return (
<div className="flex h-full flex-col">
<PageHeader title={t("navigation.hub")} />
<div
className="flex-1 overflow-auto px-6 py-6"
onScroll={hub.handleScroll}
>
<div className="mx-auto w-full max-w-[1000px] space-y-8">
<section className="animate-in fade-in mx-auto flex w-full flex-col items-center space-y-8 duration-300 md:duration-500">
<SearchPanel
marketQuery={hub.marketQuery}
canSearchMarketplace={hub.canSearchMarketplace}
isMarketSearchInitialLoading={hub.isMarketSearchInitialLoading}
unavailableToolMessages={hub.unavailableToolMessages}
onMarketQueryChange={hub.setMarketQuery}
onSearchSubmit={hub.handleSearchSubmit}
/>
<ResultsPanel
canSearchMarketplace={hub.canSearchMarketplace}
hasSubmittedQuery={hub.hasSubmittedQuery}
submittedQuery={hub.submittedMarketQuery}
marketResults={hub.marketResults}
marketSearchError={hub.marketSearchError}
isMarketSearchInitialLoading={hub.isMarketSearchInitialLoading}
isMarketSearchLoadingMore={hub.isMarketSearchLoadingMore}
canInstallFromMarketplace={hub.canInstallFromMarketplace}
getInstalledSkill={hub.getInstalledSkill}
isInstallPending={hub.isInstallPending}
onInstall={hub.handleInstall}
onViewInstalled={hub.handleViewInstalled}
/>
</section>
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,132 @@
import {
IconCheck,
IconFileInfo,
IconLoader2,
IconPlus,
} from "@tabler/icons-react"
import { useTranslation } from "react-i18next"
import {
type SkillRegistrySearchResult,
type SkillSupportItem,
} from "@/api/skills"
import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
export function MarketSkillCard({
result,
canInstall,
installPending,
installedSkill,
onInstall,
onViewInstalled,
}: {
result: SkillRegistrySearchResult
canInstall: boolean
installPending: boolean
installedSkill: SkillSupportItem | null
onInstall: () => void
onViewInstalled: () => void
}) {
const { t } = useTranslation()
return (
<Card
className="group relative overflow-hidden border-border/40 bg-card/40 transition-all hover:border-border/80 hover:bg-card hover:shadow-md"
size="sm"
>
{result.installed && (
<div className="absolute inset-x-0 top-0 h-1 bg-emerald-500/20" />
)}
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 flex-1 space-y-2">
<div className="mb-1 flex flex-wrap items-center gap-2">
<CardTitle className="text-base font-semibold tracking-tight">
{result.display_name || result.slug}
</CardTitle>
<span className="inline-flex items-center rounded-md bg-muted/60 px-2 py-0.5 text-[10px] font-semibold tracking-wider text-muted-foreground uppercase ring-1 ring-inset ring-border/50">
{result.registry_name}
</span>
{result.installed ? (
<span className="inline-flex items-center rounded-full bg-emerald-500/10 px-2 py-0.5 text-[10px] font-medium text-emerald-600 ring-1 ring-inset ring-emerald-500/20">
{t("pages.agent.skills.marketplace_installed")}
</span>
) : null}
</div>
<div className="font-mono text-xs text-muted-foreground opacity-80">
{result.slug}
{result.version ? (
<span className="text-muted-foreground/60">
{" "}
· v{result.version}
</span>
) : null}
</div>
<CardDescription className="mt-2 line-clamp-2 text-sm leading-relaxed">
{result.summary}
</CardDescription>
{result.url ? (
<div className="pt-1">
<a
href={result.url}
target="_blank"
rel="noreferrer"
className="inline-flex text-xs text-primary/80 transition-colors hover:text-primary hover:underline hover:underline-offset-4"
>
{result.url}
</a>
</div>
) : null}
</div>
<div className="flex shrink-0 flex-col items-end gap-2">
<Button
size="sm"
variant={result.installed ? "secondary" : "default"}
className="shadow-sm transition-all"
disabled={!canInstall || result.installed || installPending}
onClick={onInstall}
>
{installPending ? (
<IconLoader2 className="size-4 animate-spin" />
) : result.installed ? (
<IconCheck className="size-4" />
) : (
<IconPlus className="size-4" />
)}
{result.installed
? t("pages.agent.skills.marketplace_installed")
: t("pages.agent.skills.marketplace_install_action")}
</Button>
{result.installed && installedSkill ? (
<Button
variant="outline"
size="xs"
onClick={onViewInstalled}
className="w-full shadow-sm hover:bg-muted"
>
<IconFileInfo className="mr-1 size-3.5" />
{t("pages.agent.skills.marketplace_view_installed")}
</Button>
) : null}
</div>
</div>
</CardHeader>
{result.installed_name ? (
<CardContent className="pt-0 pb-4">
<div className="rounded-lg border border-emerald-500/20 bg-emerald-500/5 px-3 py-2 text-xs text-emerald-700 dark:text-emerald-400">
{t("pages.agent.skills.marketplace_installed_hint", {
name: result.installed_name,
})}
</div>
</CardContent>
) : null}
</Card>
)
}

View file

@ -0,0 +1,135 @@
import { IconLoader2, IconSearch, IconX } from "@tabler/icons-react"
import { useTranslation } from "react-i18next"
import {
type SkillRegistrySearchResult,
type SkillSupportItem,
} from "@/api/skills"
import { MarketSkillCard } from "./market-skill-card"
export function ResultsPanel({
canSearchMarketplace,
hasSubmittedQuery,
submittedQuery,
marketResults,
marketSearchError,
isMarketSearchInitialLoading,
isMarketSearchLoadingMore,
canInstallFromMarketplace,
getInstalledSkill,
isInstallPending,
onInstall,
onViewInstalled,
}: {
canSearchMarketplace: boolean
hasSubmittedQuery: boolean
submittedQuery: string
marketResults: SkillRegistrySearchResult[]
marketSearchError: unknown
isMarketSearchInitialLoading: boolean
isMarketSearchLoadingMore: boolean
canInstallFromMarketplace: boolean
getInstalledSkill: (installedName?: string) => SkillSupportItem | null
isInstallPending: (result: SkillRegistrySearchResult) => boolean
onInstall: (result: SkillRegistrySearchResult) => void
onViewInstalled: () => void
}) {
const { t } = useTranslation()
return (
<div className="mx-auto flex w-full max-w-[1000px] justify-center">
<div className="w-full">
{canSearchMarketplace && hasSubmittedQuery ? (
<div className="space-y-6">
<div className="rounded-xl border border-amber-200/80 bg-amber-50/70 px-4 py-3 text-sm text-amber-900">
<div className="font-semibold">
{t("pages.agent.skills.marketplace_notice_title")}
</div>
<div className="mt-1 leading-6">
{t("pages.agent.skills.marketplace_notice_body")}
</div>
</div>
{isMarketSearchInitialLoading ? (
<div className="border-border/40 bg-muted/10 flex min-h-[200px] flex-col items-center justify-center gap-4 rounded-xl border border-dashed">
<IconLoader2 className="text-muted-foreground/60 size-6 animate-spin" />
<span className="text-muted-foreground text-sm font-medium">
{t("pages.agent.skills.marketplace_loading_results")}
</span>
</div>
) : marketSearchError ? (
<div className="border-destructive/20 bg-destructive/5 rounded-xl border px-6 py-5">
<div className="text-destructive flex items-center gap-3">
<IconX className="size-5" />
<span className="text-sm font-medium">
{marketSearchError instanceof Error
? marketSearchError.message
: t("pages.agent.skills.marketplace_search_error")}
</span>
</div>
</div>
) : marketResults.length ? (
<div className="space-y-4">
<div className="border-border/40 flex items-center justify-between border-b pb-4">
<h3 className="text-foreground/85 text-base font-semibold">
{t("pages.agent.skills.marketplace_results_title", {
query: submittedQuery,
count: marketResults.length,
})}
</h3>
<span className="text-muted-foreground text-xs font-medium">
{t("pages.agent.skills.marketplace_results_hint")}
</span>
</div>
<div className="grid gap-4 lg:grid-cols-2">
{marketResults.map((result) => (
<MarketSkillCard
key={`${result.registry_name}:${result.slug}`}
result={result}
canInstall={canInstallFromMarketplace}
installPending={isInstallPending(result)}
installedSkill={getInstalledSkill(result.installed_name)}
onInstall={() => onInstall(result)}
onViewInstalled={onViewInstalled}
/>
))}
</div>
{isMarketSearchLoadingMore ? (
<div className="text-muted-foreground flex items-center justify-center gap-2 pt-2 text-sm">
<IconLoader2 className="size-4 animate-spin" />
<span>
{t("pages.agent.skills.marketplace_loading_more")}
</span>
</div>
) : null}
</div>
) : (
<div className="border-border/40 bg-muted/10 flex min-h-[200px] flex-col items-center justify-center gap-3 rounded-xl border border-dashed">
<IconSearch className="text-muted-foreground/50 size-6" />
<span className="text-muted-foreground text-sm font-medium">
{t("pages.agent.skills.marketplace_empty_results", {
query: submittedQuery,
})}
</span>
</div>
)}
</div>
) : !canSearchMarketplace ? (
<div className="border-border/40 bg-muted/10 flex min-h-[200px] flex-col items-center justify-center gap-3 rounded-xl border border-dashed">
<span className="text-muted-foreground text-sm font-medium">
{t("pages.agent.skills.marketplace_unavailable")}
</span>
</div>
) : (
<div className="border-border/40 bg-muted/10 flex min-h-[200px] flex-col items-center justify-center gap-3 rounded-xl border border-dashed">
<IconSearch className="text-muted-foreground/50 size-6" />
<span className="text-muted-foreground text-sm font-medium">
{t("pages.agent.skills.marketplace_idle")}
</span>
</div>
)}
</div>
</div>
)
}

View file

@ -0,0 +1,91 @@
import { IconLoader2 } from "@tabler/icons-react"
import { useTranslation } from "react-i18next"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import type { UnavailableToolMessage } from "./tool-support"
export function SearchPanel({
marketQuery,
canSearchMarketplace,
isMarketSearchInitialLoading,
unavailableToolMessages,
onMarketQueryChange,
onSearchSubmit,
}: {
marketQuery: string
canSearchMarketplace: boolean
isMarketSearchInitialLoading: boolean
unavailableToolMessages: UnavailableToolMessage[]
onMarketQueryChange: (value: string) => void
onSearchSubmit: () => void
}) {
const { t } = useTranslation()
return (
<div className="flex flex-col items-center justify-center space-y-6 py-8 text-center sm:py-12">
<div className="space-y-2">
<h2 className="text-2xl font-bold tracking-tight md:text-3xl">
{t("pages.agent.skills.marketplace_title", {
defaultValue: "Discover Skills",
})}
</h2>
<p className="text-muted-foreground max-w-[600px] text-base md:text-lg">
{t("pages.agent.skills.marketplace_description")}
</p>
</div>
<form
className="w-full max-w-2xl px-4 md:px-0"
onSubmit={(event) => {
event.preventDefault()
onSearchSubmit()
}}
>
<div className="group relative flex items-center justify-center">
<Input
value={marketQuery}
onChange={(event) => onMarketQueryChange(event.target.value)}
placeholder={t("pages.agent.skills.marketplace_search_placeholder")}
className="border-border/60 bg-background/50 hover:bg-background focus-visible:ring-primary/20 h-12 w-full rounded-full pr-20 pl-5 text-sm shadow-sm backdrop-blur-sm transition-all focus-visible:ring-2 md:min-w-[520px]"
disabled={!canSearchMarketplace}
/>
<Button
type="submit"
className="absolute top-1/2 right-1.5 h-9 -translate-y-1/2 rounded-full px-4 font-medium shadow-sm transition-all"
disabled={
!canSearchMarketplace ||
isMarketSearchInitialLoading ||
marketQuery.trim() === ""
}
>
{isMarketSearchInitialLoading ? (
<IconLoader2 className="size-4 animate-spin" />
) : (
<span>
{t("pages.agent.skills.marketplace_search_action", {
defaultValue: "Search",
})}
</span>
)}
</Button>
</div>
</form>
{unavailableToolMessages.length ? (
<div className="mx-auto flex w-full max-w-3xl flex-col gap-3 pt-2">
{unavailableToolMessages.map((item) => (
<div
key={item.key}
className="rounded-xl border border-amber-200/80 bg-amber-50/70 px-4 py-3 text-left text-sm text-amber-900"
>
<div className="font-semibold">{item.label}</div>
<div className="mt-1 leading-6">{item.message}</div>
</div>
))}
</div>
) : null}
</div>
)
}

View file

@ -0,0 +1,54 @@
import type { TFunction } from "i18next"
import type { ToolSupportItem } from "@/api/tools"
type MarketplaceTool = Pick<ToolSupportItem, "status" | "reason_code"> | undefined
export interface UnavailableToolMessage {
key: "search" | "install"
label: string
message: string
}
export function buildUnavailableToolMessages({
searchTool,
installTool,
t,
}: {
searchTool: MarketplaceTool
installTool: MarketplaceTool
t: TFunction
}): UnavailableToolMessage[] {
const searchMessage = getToolSupportMessage(searchTool, t)
const installMessage = getToolSupportMessage(installTool, t)
return [
searchMessage
? {
key: "search",
label: t("pages.agent.skills.marketplace_search_status"),
message: searchMessage,
}
: null,
installMessage
? {
key: "install",
label: t("pages.agent.skills.marketplace_install_status"),
message: installMessage,
}
: null,
].filter((item): item is UnavailableToolMessage => Boolean(item))
}
function getToolSupportMessage(
tool: MarketplaceTool,
t: TFunction,
): string | null {
if (!tool || tool.status === "enabled") {
return null
}
if (tool.reason_code) {
return `${t(`pages.agent.tools.reasons.${tool.reason_code}`)} ${t("pages.agent.skills.marketplace_status_enable_hint")}`
}
return t("pages.agent.skills.marketplace_status_disabled")
}

View file

@ -0,0 +1,211 @@
import {
useInfiniteQuery,
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query"
import { useNavigate } from "@tanstack/react-router"
import { useEffect, useRef, useState, type UIEvent } from "react"
import { useTranslation } from "react-i18next"
import { toast } from "sonner"
import {
getSkills,
installSkill,
searchSkills,
type SkillSearchResponse,
type SkillRegistrySearchResult,
type SkillSupportItem,
} from "@/api/skills"
import { getTools } from "@/api/tools"
import { buildUnavailableToolMessages } from "./tool-support"
const MARKET_SEARCH_LIMIT = 20
export function useHubMarketplace() {
const { t } = useTranslation()
const navigate = useNavigate()
const queryClient = useQueryClient()
const isLoadMoreLockedRef = useRef(false)
const [marketQuery, setMarketQuery] = useState("")
const [submittedMarketQuery, setSubmittedMarketQuery] = useState("")
const { data: skillsData } = useQuery({
queryKey: ["skills"],
queryFn: getSkills,
})
const { data: toolsData } = useQuery({
queryKey: ["tools"],
queryFn: getTools,
})
const findSkillsTool = toolsData?.tools.find(
(tool) => tool.name === "find_skills",
)
const installSkillTool = toolsData?.tools.find(
(tool) => tool.name === "install_skill",
)
const canSearchMarketplace = findSkillsTool?.status === "enabled"
const canInstallFromMarketplace = installSkillTool?.status === "enabled"
const hasSubmittedQuery = submittedMarketQuery.trim() !== ""
const isMarketSearchActive = canSearchMarketplace && hasSubmittedQuery
const {
data: marketSearchData,
isPending: isMarketSearchPending,
isFetching: isMarketSearchFetching,
isFetchingNextPage,
error: marketSearchError,
hasNextPage,
fetchNextPage,
refetch: refetchMarketSearch,
} = useInfiniteQuery({
queryKey: ["skills-marketplace", submittedMarketQuery],
initialPageParam: 0,
queryFn: ({ pageParam }) =>
searchSkills(
submittedMarketQuery,
MARKET_SEARCH_LIMIT,
Number(pageParam) || 0,
),
getNextPageParam: (lastPage: SkillSearchResponse) =>
lastPage.has_more ? lastPage.next_offset ?? undefined : undefined,
enabled: isMarketSearchActive,
staleTime: 5 * 60 * 1000,
refetchOnMount: false,
refetchOnWindowFocus: false,
})
const installMutation = useMutation({
mutationFn: installSkill,
onSuccess: (response) => {
toast.success(
t("pages.agent.skills.install_success", {
name: response.skill?.name ?? response.slug,
}),
)
void queryClient.invalidateQueries({ queryKey: ["skills"] })
void queryClient.invalidateQueries({ queryKey: ["skills-marketplace"] })
},
onError: (err) => {
toast.error(
err instanceof Error
? err.message
: t("pages.agent.skills.install_error"),
)
},
})
const allSkills = skillsData?.skills ?? []
const workspaceSkillsByName = new Map(
allSkills
.filter((skill) => skill.source === "workspace")
.map((skill) => [skill.name, skill] as const),
)
const marketResults =
marketSearchData?.pages.flatMap((page) => page.results) ?? []
const hasMoreMarketResults = hasNextPage ?? false
const isMarketSearchInitialLoading =
isMarketSearchActive &&
!marketSearchData &&
(isMarketSearchPending || isMarketSearchFetching)
const isMarketSearchLoadingMore =
isMarketSearchActive &&
Boolean(marketSearchData) &&
isFetchingNextPage
const installPendingKey =
installMutation.isPending && installMutation.variables
? `${installMutation.variables.registry}:${installMutation.variables.slug}`
: null
const unavailableToolMessages = buildUnavailableToolMessages({
searchTool: findSkillsTool,
installTool: installSkillTool,
t,
})
useEffect(() => {
if (!isFetchingNextPage) {
isLoadMoreLockedRef.current = false
}
}, [isFetchingNextPage])
const handleSearchSubmit = () => {
const nextQuery = marketQuery.trim()
if (!canSearchMarketplace || nextQuery === "") {
return
}
isLoadMoreLockedRef.current = false
if (nextQuery === submittedMarketQuery) {
void refetchMarketSearch()
return
}
setSubmittedMarketQuery(nextQuery)
}
const handleInstall = (result: SkillRegistrySearchResult) => {
installMutation.mutate({
slug: result.slug,
registry: result.registry_name,
version: result.version || undefined,
})
}
const handleViewInstalled = () => {
void navigate({ to: "/agent/skills" })
}
const handleScroll = (event: UIEvent<HTMLDivElement>) => {
if (
!isMarketSearchActive ||
!hasMoreMarketResults ||
isFetchingNextPage ||
isLoadMoreLockedRef.current
) {
return
}
const node = event.currentTarget
const remaining = node.scrollHeight - node.scrollTop - node.clientHeight
if (remaining > 240) {
return
}
isLoadMoreLockedRef.current = true
void fetchNextPage()
}
const getInstalledSkill = (installedName?: string): SkillSupportItem | null => {
if (!installedName) {
return null
}
return workspaceSkillsByName.get(installedName) ?? null
}
const isInstallPending = (result: SkillRegistrySearchResult) =>
installPendingKey === `${result.registry_name}:${result.slug}`
return {
marketQuery,
submittedMarketQuery,
canSearchMarketplace,
canInstallFromMarketplace,
marketResults,
marketSearchError,
unavailableToolMessages,
hasSubmittedQuery,
isMarketSearchInitialLoading,
isMarketSearchLoadingMore,
setMarketQuery,
handleSearchSubmit,
handleInstall,
handleViewInstalled,
handleScroll,
getInstalledSkill,
isInstallPending,
}
}

View file

@ -0,0 +1,65 @@
import type { SkillSupportItem } from "@/api/skills"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { IconLoader2, IconTrash } from "@tabler/icons-react"
import { useTranslation } from "react-i18next"
interface DeleteDialogProps {
open: boolean
skillPendingDelete: SkillSupportItem | null
isDeletePending: boolean
onOpenChange: (open: boolean) => void
onConfirm: () => void
}
export function DeleteDialog({
open,
skillPendingDelete,
isDeletePending,
onOpenChange,
onConfirm,
}: DeleteDialogProps) {
const { t } = useTranslation()
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent size="sm">
<AlertDialogHeader>
<AlertDialogTitle>
{t("pages.agent.skills.delete_title")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("pages.agent.skills.delete_description", {
name: skillPendingDelete?.name,
})}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeletePending}>
{t("common.cancel")}
</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
disabled={isDeletePending || !skillPendingDelete}
onClick={onConfirm}
>
{isDeletePending ? (
<IconLoader2 className="size-4 animate-spin" />
) : (
<IconTrash className="size-4" />
)}
{t("pages.agent.skills.delete_confirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}

View file

@ -0,0 +1,249 @@
import {
IconFileCode,
IconSparkles,
IconWorld,
IconX,
} from "@tabler/icons-react"
import type { ReactNode } from "react"
import { useTranslation } from "react-i18next"
import ReactMarkdown from "react-markdown"
import rehypeRaw from "rehype-raw"
import rehypeSanitize from "rehype-sanitize"
import remarkGfm from "remark-gfm"
import type { SkillDetailResponse, SkillSupportItem } from "@/api/skills"
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet"
import { Skeleton } from "@/components/ui/skeleton"
import { cn } from "@/lib/utils"
import { OriginBadge } from "./origin-badge"
import {
getOriginLabel,
getSkillOriginKind,
} from "./origin-utils"
import type { SkillDetailView } from "./types"
const DETAIL_VIEWS = [
"preview",
"raw",
"meta",
] as const satisfies SkillDetailView[]
interface DetailSheetProps {
open: boolean
selectedSkill: SkillSupportItem | null
selectedSkillDetail?: SkillDetailResponse
isLoading: boolean
error: unknown
detailView: SkillDetailView
onDetailViewChange: (view: SkillDetailView) => void
onOpenChange: (open: boolean) => void
}
export function DetailSheet({
open,
selectedSkill,
selectedSkillDetail,
isLoading,
error,
detailView,
onDetailViewChange,
onOpenChange,
}: DetailSheetProps) {
const { t } = useTranslation()
const activeSkillDetail = selectedSkillDetail ?? selectedSkill
const activeSkillOrigin = activeSkillDetail
? getSkillOriginKind(activeSkillDetail)
: null
const detailLineCount = selectedSkillDetail
? selectedSkillDetail.content.split("\n").length
: 0
const detailCharacterCount = selectedSkillDetail?.content.length ?? 0
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
side="right"
className="flex w-full flex-col gap-0 p-0 shadow-2xl data-[side=right]:!w-full data-[side=right]:sm:!w-[720px] data-[side=right]:sm:!max-w-[720px]"
>
<SheetHeader className="bg-muted/10 border-b px-6 py-6">
<div className="flex items-center gap-3">
<div className="bg-primary/10 string-1 ring-primary/20 text-primary flex size-10 items-center justify-center rounded-xl">
{activeSkillDetail?.origin_kind === "builtin" ? (
<IconSparkles className="size-5" />
) : activeSkillDetail?.registry_name ? (
<IconWorld className="size-5" />
) : (
<IconFileCode className="size-5" />
)}
</div>
<div className="min-w-0 flex-1 space-y-1 text-left">
<SheetTitle className="truncate text-xl font-bold tracking-tight">
{activeSkillDetail?.name || t("pages.agent.skills.viewer_title")}
</SheetTitle>
<SheetDescription className="line-clamp-2">
{activeSkillDetail?.description ||
t("pages.agent.skills.viewer_description")}
</SheetDescription>
</div>
</div>
</SheetHeader>
<div className="flex-1 overflow-x-hidden overflow-y-scroll px-6 py-6">
{isLoading ? (
<div className="space-y-6">
<Skeleton className="h-6 w-48" />
<Skeleton className="h-24 w-full rounded-xl" />
<Skeleton className="h-[400px] w-full rounded-xl" />
</div>
) : error ? (
<div className="text-destructive border-destructive/20 bg-destructive/5 flex h-40 flex-col items-center justify-center gap-3 rounded-xl border">
<IconX className="size-6 opacity-80" />
<span className="text-sm font-medium">
{t("pages.agent.skills.load_detail_error")}
</span>
</div>
) : selectedSkillDetail ? (
<div className="space-y-6">
{activeSkillOrigin === "third_party" ? (
<div className="border-border/40 bg-card/40 space-y-4 rounded-xl border p-4 shadow-sm">
<div className="flex flex-wrap items-center gap-2 px-1">
<OriginBadge
origin={activeSkillOrigin}
label={getOriginLabel(activeSkillOrigin, t)}
/>
</div>
<div className="grid gap-3 sm:grid-cols-2">
{selectedSkillDetail.registry_name ? (
<MetadataItem
label={t("pages.agent.skills.metadata.registry")}
value={selectedSkillDetail.registry_name}
/>
) : null}
{selectedSkillDetail.installed_version ? (
<MetadataItem
label={t("pages.agent.skills.metadata.version")}
value={selectedSkillDetail.installed_version}
/>
) : null}
{selectedSkillDetail.registry_url ? (
<MetadataItem
label={t("pages.agent.skills.metadata.url")}
value={
<a
href={selectedSkillDetail.registry_url}
target="_blank"
rel="noreferrer"
className="text-primary hover:text-primary/80 inline break-all underline-offset-4 hover:underline"
>
{selectedSkillDetail.registry_url}
</a>
}
mono
/>
) : null}
</div>
</div>
) : null}
<div className="border-border/70 bg-muted/20 inline-flex rounded-lg border p-1 shadow-sm">
{DETAIL_VIEWS.map((view) => (
<button
key={view}
type="button"
className={cn(
"rounded-md px-4 py-1.5 text-xs font-medium transition-all duration-200",
detailView === view
? "bg-background text-foreground ring-border/30 shadow-[0_1px_3px_rgba(0,0,0,0.1)] ring-1"
: "text-muted-foreground hover:text-foreground hover:bg-muted/50",
)}
onClick={() => onDetailViewChange(view)}
>
{t(`pages.agent.skills.detail_tabs.${view}`)}
</button>
))}
</div>
{detailView === "preview" ? (
<div className="prose prose-zinc dark:prose-invert prose-sm sm:prose-base prose-pre:rounded-xl prose-pre:border prose-pre:border-border/40 prose-pre:bg-zinc-950/90 prose-pre:shadow-sm prose-headings:tracking-tight prose-a:text-primary prose-a:no-underline hover:prose-a:underline max-w-none">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw, rehypeSanitize]}
>
{selectedSkillDetail.content}
</ReactMarkdown>
</div>
) : null}
{detailView === "raw" ? (
<div className="border-border/50 overflow-x-auto rounded-xl border bg-zinc-950 p-5 shadow-sm">
<pre className="font-mono text-[13px] leading-relaxed break-words whitespace-pre-wrap text-zinc-100/90">
<code>{selectedSkillDetail.content}</code>
</pre>
</div>
) : null}
{detailView === "meta" ? (
<div className="grid gap-4 sm:grid-cols-2">
<MetadataItem
label={t("pages.agent.skills.metadata.name")}
value={selectedSkillDetail.name}
/>
<MetadataItem
label={t("pages.agent.skills.metadata.description")}
value={
selectedSkillDetail.description ||
t("pages.agent.skills.no_description")
}
/>
<MetadataItem
label={t("pages.agent.skills.metadata.lines")}
value={String(detailLineCount)}
/>
<MetadataItem
label={t("pages.agent.skills.metadata.characters")}
value={String(detailCharacterCount)}
/>
</div>
) : null}
</div>
) : null}
</div>
</SheetContent>
</Sheet>
)
}
function MetadataItem({
label,
value,
mono = false,
}: {
label: string
value: ReactNode
mono?: boolean
}) {
return (
<div className="border-border/70 bg-muted/20 rounded-xl border px-4 py-3">
<div className="text-muted-foreground text-[11px] font-semibold tracking-[0.18em] uppercase">
{label}
</div>
<div
className={cn(
"text-foreground mt-2 text-sm leading-6 break-all",
mono && "font-mono text-xs",
)}
>
{value}
</div>
</div>
)
}

View file

@ -0,0 +1,136 @@
import {
IconLayoutGrid,
IconLayoutList,
IconSearch,
} from "@tabler/icons-react"
import { useTranslation } from "react-i18next"
import { Input } from "@/components/ui/input"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { cn } from "@/lib/utils"
import { getOriginLabel } from "./origin-utils"
import type { SkillLayoutMode, SkillSortOption } from "./types"
interface FilterBarProps {
searchQuery: string
sourceFilter: string
availableOrigins: string[]
sortOrder: SkillSortOption
layoutMode: SkillLayoutMode
onSearchQueryChange: (value: string) => void
onSourceFilterChange: (value: string) => void
onSortOrderChange: (value: SkillSortOption) => void
onLayoutModeChange: (value: SkillLayoutMode) => void
}
export function FilterBar({
searchQuery,
sourceFilter,
availableOrigins,
sortOrder,
layoutMode,
onSearchQueryChange,
onSourceFilterChange,
onSortOrderChange,
onLayoutModeChange,
}: FilterBarProps) {
const { t } = useTranslation()
return (
<div className="border-border/40 bg-muted/20 flex flex-wrap items-center gap-3 rounded-xl border p-2 shadow-sm">
<div className="relative min-w-[200px] flex-1">
<IconSearch className="text-muted-foreground absolute top-1/2 left-3 size-4 -translate-y-1/2" />
<Input
value={searchQuery}
onChange={(event) => onSearchQueryChange(event.target.value)}
placeholder={t("pages.agent.skills.search_placeholder")}
className="hover:bg-background/50 focus-visible:bg-background h-9 border-transparent bg-transparent pl-9 shadow-none focus-visible:ring-1"
/>
</div>
<div className="bg-border/60 hidden h-6 w-px sm:block" />
<Select value={sourceFilter} onValueChange={onSourceFilterChange}>
<SelectTrigger className="hover:bg-background/50 focus:bg-background h-9 w-[140px] border-transparent bg-transparent shadow-none hover:ring-1 focus:ring-1">
<SelectValue placeholder={t("pages.agent.skills.source_label")} />
</SelectTrigger>
<SelectContent align="end">
<SelectItem value="all">
{t("pages.agent.skills.origin.all")}
</SelectItem>
{availableOrigins.map((origin) => (
<SelectItem key={origin} value={origin}>
{getOriginLabel(origin, t)}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="bg-border/60 hidden h-6 w-px sm:block" />
<Select
value={sortOrder}
onValueChange={(value) => onSortOrderChange(value as SkillSortOption)}
>
<SelectTrigger className="hover:bg-background/50 focus:bg-background h-9 w-[160px] border-transparent bg-transparent shadow-none hover:ring-1 focus:ring-1">
<div className="flex items-center gap-2">
<span className="text-muted-foreground text-xs">
{t("pages.agent.skills.sort_label", {
defaultValue: "Sort by",
})}
:
</span>
<SelectValue />
</div>
</SelectTrigger>
<SelectContent align="end">
<SelectItem value="name-asc">
{t("pages.agent.skills.sort.name_asc")}
</SelectItem>
<SelectItem value="name-desc">
{t("pages.agent.skills.sort.name_desc")}
</SelectItem>
<SelectItem value="source">
{t("pages.agent.skills.sort.source")}
</SelectItem>
</SelectContent>
</Select>
<div className="bg-border/60 hidden h-6 w-px sm:block" />
<div className="bg-background/50 ring-border/20 inline-flex items-center rounded-lg p-0.5 shadow-sm ring-1">
<button
type="button"
className={cn(
"rounded-md px-2.5 py-1.5 text-xs font-medium transition-all",
layoutMode === "grouped"
? "bg-background text-foreground ring-border/30 shadow-sm ring-1"
: "text-muted-foreground hover:text-foreground",
)}
onClick={() => onLayoutModeChange("grouped")}
>
<IconLayoutList className="size-4" />
</button>
<button
type="button"
className={cn(
"rounded-md px-2.5 py-1.5 text-xs font-medium transition-all",
layoutMode === "grid"
? "bg-background text-foreground ring-border/30 shadow-sm ring-1"
: "text-muted-foreground hover:text-foreground",
)}
onClick={() => onLayoutModeChange("grid")}
>
<IconLayoutGrid className="size-4" />
</button>
</div>
</div>
)
}

View file

@ -0,0 +1,160 @@
import { IconLoader2, IconUpload, IconX } from "@tabler/icons-react"
import type { DragEvent } from "react"
import { useTranslation } from "react-i18next"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { cn } from "@/lib/utils"
interface ImportDialogProps {
open: boolean
isImportPending: boolean
isDragActive: boolean
onOpenChange: (open: boolean) => void
onImportClick: () => void
onDragEnter: (event: DragEvent<HTMLDivElement>) => void
onDragLeave: (event: DragEvent<HTMLDivElement>) => void
onDrop: (event: DragEvent<HTMLDivElement>) => void
}
export function ImportDialog({
open,
isImportPending,
isDragActive,
onOpenChange,
onImportClick,
onDragEnter,
onDragLeave,
onDrop,
}: ImportDialogProps) {
const { t } = useTranslation()
return (
<Dialog
open={open}
onOpenChange={(nextOpen) => {
if (!isImportPending) {
onOpenChange(nextOpen)
}
}}
>
<DialogContent
showCloseButton={false}
className="border-border/40 bg-card/95 max-w-[420px] gap-6 p-6 text-center shadow-lg backdrop-blur-sm focus:outline-none sm:rounded-2xl"
>
<div className="relative space-y-1 px-8">
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground hover:text-foreground absolute top-0 right-0"
onClick={() => onOpenChange(false)}
disabled={isImportPending}
aria-label={t("common.cancel")}
title={t("common.cancel")}
>
<IconX className="size-4" />
</Button>
<DialogHeader className="space-y-1 text-center">
<DialogTitle className="text-lg font-semibold tracking-tight">
{t("pages.agent.skills.dropzone_title")}
</DialogTitle>
<DialogDescription className="text-muted-foreground text-sm">
{t("pages.agent.skills.dropzone_description")}
</DialogDescription>
</DialogHeader>
</div>
<SkillImportPanel
isDragActive={isDragActive}
isImportPending={isImportPending}
onDragEnter={onDragEnter}
onDragLeave={onDragLeave}
onDrop={onDrop}
onImportClick={onImportClick}
/>
</DialogContent>
</Dialog>
)
}
function SkillImportPanel({
isDragActive,
isImportPending,
onDragEnter,
onDragLeave,
onDrop,
onImportClick,
}: {
isDragActive: boolean
isImportPending: boolean
onDragEnter: (event: DragEvent<HTMLDivElement>) => void
onDragLeave: (event: DragEvent<HTMLDivElement>) => void
onDrop: (event: DragEvent<HTMLDivElement>) => void
onImportClick: () => void
}) {
const { t } = useTranslation()
return (
<div className="space-y-4">
<div
className={cn(
"flex min-h-48 cursor-pointer flex-col items-center justify-center gap-3 rounded-xl border-2 border-dashed px-4 py-6 text-center transition-all duration-300",
isDragActive
? "border-primary bg-primary/10 scale-[1.02]"
: "border-border/60 bg-muted/30 hover:bg-muted/50 hover:border-primary/50",
isImportPending && "pointer-events-none opacity-50",
)}
onClick={() => {
if (!isImportPending) {
onImportClick()
}
}}
onDragEnter={onDragEnter}
onDragLeave={onDragLeave}
onDragOver={(event) => event.preventDefault()}
onDrop={onDrop}
>
<div
className={cn(
"mb-2 rounded-full p-3 transition-colors duration-300",
isDragActive
? "bg-primary text-primary-foreground shadow-sm"
: "bg-background text-muted-foreground ring-border/50 shadow-sm ring-1",
)}
>
<IconUpload className="size-6" />
</div>
<div className="space-y-1">
<div className="text-foreground text-sm font-semibold tracking-tight">
{isDragActive
? t("pages.agent.skills.dropzone_active")
: t("pages.agent.skills.dropzone_label")}
</div>
<p className="text-muted-foreground mx-auto hidden max-w-[270px] text-xs leading-relaxed sm:block">
{isDragActive
? t("pages.agent.skills.dropzone_release")
: t("pages.agent.skills.import_constraints")}
</p>
</div>
<Button
variant="secondary"
size="sm"
className="pointer-events-none mt-2 h-8 shadow-sm"
disabled={isImportPending}
>
{isImportPending ? (
<IconLoader2 className="mr-1.5 size-3.5 animate-spin" />
) : null}
{t("pages.agent.skills.import")}
</Button>
</div>
</div>
)
}

View file

@ -0,0 +1,46 @@
import {
IconFileCode,
IconFolder,
IconSparkles,
IconWorld,
} from "@tabler/icons-react"
import { cn } from "@/lib/utils"
import { getOriginBadgeClasses } from "./origin-utils"
export function OriginBadge({
origin,
label,
}: {
origin: string
label: string
}) {
return (
<span
className={cn(
"inline-flex items-center gap-1 rounded-full px-2 py-1 text-[11px] font-semibold",
getOriginBadgeClasses(origin),
)}
>
<OriginIcon origin={origin} />
{label}
</span>
)
}
export function OriginIcon({ origin }: { origin: string }) {
if (origin === "builtin") {
return <IconSparkles className="size-3.5" />
}
if (origin === "third_party") {
return <IconWorld className="size-3.5" />
}
if (origin === "manual") {
return <IconFolder className="size-3.5" />
}
if (origin === "all") {
return <IconFileCode className="size-4" />
}
return <IconFileCode className="size-3.5" />
}

View file

@ -0,0 +1,86 @@
import type { TFunction } from "i18next"
import type { SkillSupportItem } from "@/api/skills"
import type { SkillSortOption } from "./types"
const KNOWN_ORIGIN_ORDER = ["builtin", "third_party", "manual"]
export function compareSkills(
left: SkillSupportItem,
right: SkillSupportItem,
sortOrder: SkillSortOption,
) {
if (sortOrder === "source") {
const sourceDelta = compareOriginOrder(
getSkillOriginKind(left),
getSkillOriginKind(right),
)
if (sourceDelta !== 0) return sourceDelta
return left.name.localeCompare(right.name)
}
if (sortOrder === "name-desc") {
return right.name.localeCompare(left.name)
}
return left.name.localeCompare(right.name)
}
export function sortOrigins(origins: string[]) {
return [...origins].sort(compareOriginOrder)
}
export function getSkillOriginKind(skill: SkillSupportItem) {
const origin = skill.origin_kind || skill.source
return origin === "global" ? "builtin" : origin
}
export function getOriginLabel(origin: string, t: TFunction) {
if (origin === "builtin" || origin === "third_party" || origin === "manual") {
return t(`pages.agent.skills.origin.${origin}`)
}
if (origin === "all") {
return t("pages.agent.skills.origin.all")
}
return origin
}
export function getOriginAccentClasses(origin: string) {
if (origin === "manual") {
return "bg-emerald-100 text-emerald-700"
}
if (origin === "third_party") {
return "bg-sky-100 text-sky-700"
}
if (origin === "builtin") {
return "bg-amber-100 text-amber-700"
}
return "bg-muted text-muted-foreground"
}
export function getOriginBadgeClasses(origin: string) {
if (origin === "manual") {
return "bg-emerald-100 text-emerald-700"
}
if (origin === "third_party") {
return "bg-sky-100 text-sky-700"
}
if (origin === "builtin") {
return "bg-amber-100 text-amber-700"
}
return "bg-muted text-muted-foreground"
}
function compareOriginOrder(left: string, right: string) {
const leftIndex = KNOWN_ORIGIN_ORDER.indexOf(left)
const rightIndex = KNOWN_ORIGIN_ORDER.indexOf(right)
if (leftIndex !== -1 || rightIndex !== -1) {
if (leftIndex === -1) return 1
if (rightIndex === -1) return -1
return leftIndex - rightIndex
}
return left.localeCompare(right)
}

View file

@ -0,0 +1,27 @@
import { Skeleton } from "@/components/ui/skeleton"
export function PageSkeleton() {
return (
<div className="mt-4 space-y-8">
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
{[1, 2, 3, 4].map((index) => (
<Skeleton
key={index}
className="border-border/40 h-24 w-full rounded-xl border"
/>
))}
</div>
<div className="space-y-4">
<div className="flex items-center justify-between">
<Skeleton className="h-8 w-48" />
</div>
<Skeleton className="h-14 w-full rounded-xl" />
<div className="grid gap-4 pt-4 lg:grid-cols-2">
{[1, 2, 3, 4].map((index) => (
<Skeleton key={index} className="h-36 w-full rounded-xl" />
))}
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,84 @@
import { IconFileInfo, IconTrash } from "@tabler/icons-react"
import { useTranslation } from "react-i18next"
import type { SkillSupportItem } from "@/api/skills"
import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
interface SkillCardProps {
skill: SkillSupportItem
onView: () => void
onDelete: () => void
}
export function SkillCard({ skill, onView, onDelete }: SkillCardProps) {
const { t } = useTranslation()
return (
<Card
className="group border-border/40 bg-card/40 hover:bg-card hover:border-border/80 relative overflow-hidden transition-all hover:shadow-md"
size="sm"
>
<div className="via-primary/10 absolute inset-x-0 top-0 h-1 bg-gradient-to-r from-transparent to-transparent opacity-0 transition-opacity duration-500 group-hover:opacity-100" />
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 flex-1 space-y-2">
<div className="flex flex-wrap items-center gap-2">
<CardTitle className="text-base font-semibold tracking-tight">
{skill.name}
</CardTitle>
{skill.registry_name ? (
<span className="bg-muted/60 text-muted-foreground ring-border/50 inline-flex items-center rounded-md px-2 py-0.5 text-[10px] font-semibold tracking-wider uppercase ring-1 ring-inset">
{skill.registry_name}
</span>
) : null}
</div>
<CardDescription className="line-clamp-2 text-sm leading-relaxed">
{skill.description || t("pages.agent.skills.no_description")}
</CardDescription>
</div>
<div className="flex items-center gap-1 opacity-80 transition-opacity group-hover:opacity-100">
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground hover:bg-muted hover:text-foreground"
onClick={onView}
title={t("pages.agent.skills.view")}
>
<IconFileInfo className="size-4" />
</Button>
{skill.source === "workspace" ? (
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
onClick={onDelete}
title={t("pages.agent.skills.delete")}
>
<IconTrash className="size-4" />
</Button>
) : null}
</div>
</div>
</CardHeader>
<CardContent>
{skill.registry_url ? (
<a
href={skill.registry_url}
target="_blank"
rel="noreferrer"
className="text-primary/80 hover:text-primary inline-flex items-center text-xs transition-colors hover:underline hover:underline-offset-4"
>
{skill.registry_url}
</a>
) : null}
</CardContent>
</Card>
)
}

View file

@ -0,0 +1,86 @@
import { IconSearch } from "@tabler/icons-react"
import { useTranslation } from "react-i18next"
import type { SkillSupportItem } from "@/api/skills"
import { OriginBadge } from "./origin-badge"
import { getOriginLabel } from "./origin-utils"
import { SkillCard } from "./skill-card"
import type { SkillGroupSection, SkillLayoutMode } from "./types"
interface SkillsListProps {
sortedSkills: SkillSupportItem[]
groupedSkills: SkillGroupSection[]
layoutMode: SkillLayoutMode
sourceFilter: string
hasActiveFilters: boolean
onViewSkill: (skill: SkillSupportItem) => void
onDeleteSkill: (skill: SkillSupportItem) => void
}
export function SkillsList({
sortedSkills,
groupedSkills,
layoutMode,
sourceFilter,
hasActiveFilters,
onViewSkill,
onDeleteSkill,
}: SkillsListProps) {
const { t } = useTranslation()
if (!sortedSkills.length) {
return (
<div className="border-border/40 bg-muted/5 flex flex-col items-center justify-center gap-3 rounded-xl border border-dashed py-16 text-center shadow-sm">
<div className="bg-muted mb-2 rounded-full p-4">
<IconSearch className="text-muted-foreground size-6" />
</div>
<h3 className="text-lg font-semibold tracking-tight">
{hasActiveFilters
? t("pages.agent.skills.no_results")
: t("pages.agent.skills.empty")}
</h3>
</div>
)
}
if (layoutMode === "grouped" && sourceFilter === "all") {
return (
<div className="space-y-6">
{groupedSkills.map((section) => (
<div key={section.origin} className="space-y-3">
<div className="flex items-center justify-between gap-3">
<OriginBadge
origin={section.origin}
label={getOriginLabel(section.origin, t)}
/>
</div>
<div className="grid gap-4 lg:grid-cols-2">
{section.skills.map((skill) => (
<SkillCard
key={`${skill.source}:${skill.name}`}
skill={skill}
onView={() => onViewSkill(skill)}
onDelete={() => onDeleteSkill(skill)}
/>
))}
</div>
</div>
))}
</div>
)
}
return (
<div className="grid gap-4 lg:grid-cols-2">
{sortedSkills.map((skill) => (
<SkillCard
key={`${skill.source}:${skill.name}`}
skill={skill}
onView={() => onViewSkill(skill)}
onDelete={() => onDeleteSkill(skill)}
/>
))}
</div>
)
}

View file

@ -0,0 +1,160 @@
import { IconLoader2, IconPlus } from "@tabler/icons-react"
import { useTranslation } from "react-i18next"
import { PageHeader } from "@/components/page-header"
import { Button } from "@/components/ui/button"
import { DeleteDialog } from "./delete-dialog"
import { DetailSheet } from "./detail-sheet"
import { FilterBar } from "./filter-bar"
import { ImportDialog } from "./import-dialog"
import { PageSkeleton } from "./page-skeleton"
import { SkillsList } from "./skills-list"
import { Stats } from "./stats"
import { useSkillsPage } from "./use-skills-page"
export function SkillsPage() {
const { t } = useTranslation()
const {
searchQuery,
sourceFilter,
sortOrder,
layoutMode,
detailView,
isDragActive,
isImportDialogOpen,
selectedSkill,
skillPendingDelete,
availableOrigins,
groupedSkills,
stats,
sortedSkills,
hasActiveFilters,
importInputRef,
selectedSkillDetail,
skillsError,
skillDetailError,
isLoading,
isSkillDetailLoading,
isImportPending,
isDeletePending,
setSearchQuery,
setSourceFilter,
setSortOrder,
setLayoutMode,
setDetailView,
openImportDialog,
handleViewSkill,
handleRequestDelete,
handleConfirmDelete,
handleImportClick,
handleImportFileChange,
handleDropZoneDragEnter,
handleDropZoneDragLeave,
handleDropZoneDrop,
handleDetailSheetOpenChange,
handleImportDialogOpenChange,
handleDeleteDialogOpenChange,
} = useSkillsPage()
return (
<div className="flex h-full flex-col">
<PageHeader
title={t("navigation.skills")}
children={
<>
<input
ref={importInputRef}
type="file"
accept=".md,.zip,text/markdown,text/plain,application/zip,application/x-zip-compressed"
className="hidden"
onChange={handleImportFileChange}
/>
<Button
variant="outline"
onClick={openImportDialog}
disabled={isImportPending}
>
{isImportPending ? (
<IconLoader2 className="size-4 animate-spin" />
) : (
<IconPlus className="size-4" />
)}
{t("pages.agent.skills.import")}
</Button>
</>
}
/>
<div className="flex-1 overflow-auto px-6 py-6">
<div className="w-full max-w-6xl space-y-8">
{isLoading ? (
<PageSkeleton />
) : skillsError ? (
<div className="text-destructive py-6 text-sm">
{t("pages.agent.load_error")}
</div>
) : (
<section className="animate-in fade-in space-y-3 duration-300 md:duration-500">
<Stats stats={stats} />
<div className="flex flex-col gap-4 py-3">
<FilterBar
searchQuery={searchQuery}
sourceFilter={sourceFilter}
availableOrigins={availableOrigins}
sortOrder={sortOrder}
layoutMode={layoutMode}
onSearchQueryChange={setSearchQuery}
onSourceFilterChange={setSourceFilter}
onSortOrderChange={setSortOrder}
onLayoutModeChange={setLayoutMode}
/>
</div>
<SkillsList
sortedSkills={sortedSkills}
groupedSkills={groupedSkills}
layoutMode={layoutMode}
sourceFilter={sourceFilter}
hasActiveFilters={hasActiveFilters}
onViewSkill={handleViewSkill}
onDeleteSkill={handleRequestDelete}
/>
</section>
)}
</div>
</div>
<DetailSheet
open={selectedSkill !== null}
selectedSkill={selectedSkill}
selectedSkillDetail={selectedSkillDetail}
isLoading={isSkillDetailLoading}
error={skillDetailError}
detailView={detailView}
onDetailViewChange={setDetailView}
onOpenChange={handleDetailSheetOpenChange}
/>
<ImportDialog
open={isImportDialogOpen}
isImportPending={isImportPending}
isDragActive={isDragActive}
onOpenChange={handleImportDialogOpenChange}
onImportClick={handleImportClick}
onDragEnter={handleDropZoneDragEnter}
onDragLeave={handleDropZoneDragLeave}
onDrop={handleDropZoneDrop}
/>
<DeleteDialog
open={skillPendingDelete !== null}
skillPendingDelete={skillPendingDelete}
isDeletePending={isDeletePending}
onOpenChange={handleDeleteDialogOpenChange}
onConfirm={handleConfirmDelete}
/>
</div>
)
}

View file

@ -0,0 +1,39 @@
import { Card, CardContent } from "@/components/ui/card"
import { cn } from "@/lib/utils"
import { OriginIcon } from "./origin-badge"
import { getOriginAccentClasses } from "./origin-utils"
import type { SkillStatItem } from "./types"
export function Stats({ stats }: { stats: SkillStatItem[] }) {
return (
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
{stats.map((stat) => (
<Card
key={stat.key}
size="sm"
className="border-border/40 bg-card/40 hover:bg-card gap-3 shadow-sm transition-all hover:shadow-md"
>
<CardContent className="flex items-center justify-between pt-4">
<div className="space-y-1">
<div className="text-muted-foreground text-[11px] font-semibold tracking-wider uppercase">
{stat.label}
</div>
<div className="text-2xl font-bold tracking-tight">
{stat.count}
</div>
</div>
<div
className={cn(
"rounded-xl p-2.5 shadow-sm ring-1 ring-white/10 ring-inset",
getOriginAccentClasses(stat.origin),
)}
>
<OriginIcon origin={stat.origin} />
</div>
</CardContent>
</Card>
))}
</div>
)
}

View file

@ -0,0 +1,17 @@
import type { SkillSupportItem } from "@/api/skills"
export type SkillSortOption = "name-asc" | "name-desc" | "source"
export type SkillLayoutMode = "grouped" | "grid"
export type SkillDetailView = "preview" | "raw" | "meta"
export interface SkillGroupSection {
origin: string
skills: SkillSupportItem[]
}
export interface SkillStatItem {
key: string
origin: string
label: string
count: number
}

View file

@ -0,0 +1,336 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import {
type ChangeEvent,
type DragEvent,
startTransition,
useDeferredValue,
useMemo,
useRef,
useState,
} from "react"
import { useTranslation } from "react-i18next"
import { toast } from "sonner"
import {
type SkillSupportItem,
deleteSkill,
getSkill,
getSkills,
importSkill,
} from "@/api/skills"
import {
compareSkills,
getOriginLabel,
getSkillOriginKind,
sortOrigins,
} from "./origin-utils"
import type {
SkillDetailView,
SkillGroupSection,
SkillLayoutMode,
SkillSortOption,
SkillStatItem,
} from "./types"
const MAX_IMPORT_FILE_SIZE = 1 << 20
export function useSkillsPage() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const importInputRef = useRef<HTMLInputElement | null>(null)
const dragDepthRef = useRef(0)
const [searchQuery, setSearchQuery] = useState("")
const deferredSearchQuery = useDeferredValue(searchQuery)
const [sourceFilter, setSourceFilter] = useState("all")
const [sortOrder, setSortOrder] = useState<SkillSortOption>("name-asc")
const [layoutMode, setLayoutMode] = useState<SkillLayoutMode>("grouped")
const [detailView, setDetailView] = useState<SkillDetailView>("preview")
const [isDragActive, setIsDragActive] = useState(false)
const [isImportDialogOpen, setIsImportDialogOpen] = useState(false)
const [selectedSkill, setSelectedSkill] = useState<SkillSupportItem | null>(
null,
)
const [skillPendingDelete, setSkillPendingDelete] =
useState<SkillSupportItem | null>(null)
const skillsQuery = useQuery({
queryKey: ["skills"],
queryFn: getSkills,
})
const skillDetailQuery = useQuery({
queryKey: ["skills", selectedSkill?.name],
queryFn: () => getSkill(selectedSkill!.name),
enabled: selectedSkill !== null,
})
const importMutation = useMutation({
mutationFn: async (file: File) => importSkill(file),
onSuccess: (importedSkill) => {
toast.success(t("pages.agent.skills.import_success"))
startTransition(() => {
setIsImportDialogOpen(false)
setDetailView("preview")
if (importedSkill.name) {
setSelectedSkill({
name: importedSkill.name,
path: importedSkill.path ?? "",
source: importedSkill.source ?? "workspace",
description: importedSkill.description ?? "",
origin_kind: importedSkill.origin_kind ?? "manual",
registry_name: importedSkill.registry_name,
registry_url: importedSkill.registry_url,
installed_version: importedSkill.installed_version,
installed_at: importedSkill.installed_at,
})
}
})
void queryClient.invalidateQueries({ queryKey: ["skills"] })
},
onError: (err) => {
toast.error(
err instanceof Error
? err.message
: t("pages.agent.skills.import_error"),
)
},
})
const deleteMutation = useMutation({
mutationFn: async (name: string) => deleteSkill(name),
onSuccess: (_, deletedName) => {
toast.success(t("pages.agent.skills.delete_success"))
setSkillPendingDelete(null)
if (
selectedSkill?.name === deletedName &&
selectedSkill.source === "workspace"
) {
setSelectedSkill(null)
}
void queryClient.invalidateQueries({ queryKey: ["skills"] })
},
onError: (err) => {
toast.error(
err instanceof Error
? err.message
: t("pages.agent.skills.delete_error"),
)
},
})
const allSkills = useMemo(
() => skillsQuery.data?.skills ?? [],
[skillsQuery.data?.skills],
)
const normalizedSearchQuery = deferredSearchQuery.trim().toLowerCase()
const availableOrigins = useMemo(
() =>
sortOrigins([
...new Set(allSkills.map((skill) => getSkillOriginKind(skill))),
]),
[allSkills],
)
const filteredSkills = useMemo(() => {
return allSkills.filter((skill) => {
const matchesSource =
sourceFilter === "all"
? true
: getSkillOriginKind(skill) === sourceFilter
if (!matchesSource) return false
if (normalizedSearchQuery === "") return true
const searchTarget =
`${skill.name} ${skill.description} ${skill.registry_name ?? ""}`.toLowerCase()
return searchTarget.includes(normalizedSearchQuery)
})
}, [allSkills, normalizedSearchQuery, sourceFilter])
const sortedSkills = useMemo(
() => [...filteredSkills].sort((left, right) => compareSkills(left, right, sortOrder)),
[filteredSkills, sortOrder],
)
const groupedSkills = useMemo<SkillGroupSection[]>(
() =>
availableOrigins
.map((origin) => ({
origin,
skills: sortedSkills.filter(
(skill) => getSkillOriginKind(skill) === origin,
),
}))
.filter((section) => section.skills.length > 0),
[availableOrigins, sortedSkills],
)
const stats = useMemo<SkillStatItem[]>(
() => [
{
key: "all",
origin: "all",
label: t("pages.agent.skills.summary.total"),
count: allSkills.length,
},
...availableOrigins.map((origin) => ({
key: origin,
origin,
label: getOriginLabel(origin, t),
count: allSkills.filter((skill) => getSkillOriginKind(skill) === origin)
.length,
})),
],
[allSkills, availableOrigins, t],
)
const hasActiveFilters =
normalizedSearchQuery !== "" || sourceFilter !== "all"
const handleImportClick = () => {
importInputRef.current?.click()
}
const handleViewSkill = (skill: SkillSupportItem) => {
setDetailView("preview")
setSelectedSkill(skill)
}
const handleRequestDelete = (skill: SkillSupportItem) => {
setSkillPendingDelete(skill)
}
const handleConfirmDelete = () => {
if (skillPendingDelete) {
deleteMutation.mutate(skillPendingDelete.name)
}
}
const handleDetailSheetOpenChange = (open: boolean) => {
if (!open) {
setSelectedSkill(null)
}
}
const handleImportDialogOpenChange = (open: boolean) => {
if (!importMutation.isPending) {
setIsImportDialogOpen(open)
}
}
const handleDeleteDialogOpenChange = (open: boolean) => {
if (!open) {
setSkillPendingDelete(null)
}
}
const validateImportFile = (file: File) => {
const fileName = file.name.toLowerCase()
const isMarkdownFile =
fileName.endsWith(".md") ||
file.type === "text/markdown" ||
file.type === "text/plain" ||
file.type === ""
const isZipFile =
fileName.endsWith(".zip") ||
file.type === "application/zip" ||
file.type === "application/x-zip-compressed"
if (!isMarkdownFile && !isZipFile) {
return t("pages.agent.skills.import_invalid_type")
}
if (file.size > MAX_IMPORT_FILE_SIZE) {
return t("pages.agent.skills.import_invalid_size")
}
return null
}
const handleImportFile = (file: File) => {
const validationMessage = validateImportFile(file)
if (validationMessage) {
toast.error(validationMessage)
return
}
importMutation.mutate(file)
}
const handleImportFileChange = (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
if (!file) return
handleImportFile(file)
event.target.value = ""
}
const resetDragState = () => {
dragDepthRef.current = 0
setIsDragActive(false)
}
const handleDropZoneDragEnter = (event: DragEvent<HTMLDivElement>) => {
event.preventDefault()
dragDepthRef.current += 1
setIsDragActive(true)
}
const handleDropZoneDragLeave = (event: DragEvent<HTMLDivElement>) => {
event.preventDefault()
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1)
if (dragDepthRef.current === 0) {
setIsDragActive(false)
}
}
const handleDropZoneDrop = (event: DragEvent<HTMLDivElement>) => {
event.preventDefault()
const file = event.dataTransfer.files?.[0]
resetDragState()
if (!file) return
handleImportFile(file)
}
return {
searchQuery,
sourceFilter,
sortOrder,
layoutMode,
detailView,
isDragActive,
isImportDialogOpen,
selectedSkill,
skillPendingDelete,
availableOrigins,
groupedSkills,
stats,
sortedSkills,
hasActiveFilters,
importInputRef,
selectedSkillDetail: skillDetailQuery.data,
skillsError: skillsQuery.error,
skillDetailError: skillDetailQuery.error,
isLoading: skillsQuery.isLoading,
isSkillDetailLoading: skillDetailQuery.isLoading,
isImportPending: importMutation.isPending,
isDeletePending: deleteMutation.isPending,
setSearchQuery,
setSourceFilter,
setSortOrder,
setLayoutMode,
setDetailView,
openImportDialog: () => setIsImportDialogOpen(true),
handleViewSkill,
handleRequestDelete,
handleConfirmDelete,
handleImportClick,
handleImportFileChange,
handleDropZoneDragEnter,
handleDropZoneDragLeave,
handleDropZoneDrop,
handleDetailSheetOpenChange,
handleImportDialogOpenChange,
handleDeleteDialogOpenChange,
}
}

View file

@ -0,0 +1,288 @@
import { IconSearch } from "@tabler/icons-react"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { useMemo, useState } from "react"
import { useTranslation } from "react-i18next"
import { toast } from "sonner"
import { type ToolSupportItem, getTools, setToolEnabled } from "@/api/tools"
import { PageHeader } from "@/components/page-header"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import { Switch } from "@/components/ui/switch"
import { cn } from "@/lib/utils"
import { refreshGatewayState } from "@/store/gateway"
export function ToolsPage() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const { data, isLoading, error } = useQuery({
queryKey: ["tools"],
queryFn: getTools,
})
const [searchQuery, setSearchQuery] = useState("")
const [statusFilter, setStatusFilter] = useState("all")
const toggleMutation = useMutation({
mutationFn: async ({ name, enabled }: { name: string; enabled: boolean }) =>
setToolEnabled(name, enabled),
onSuccess: (_, variables) => {
toast.success(
variables.enabled
? t("pages.agent.tools.enable_success")
: t("pages.agent.tools.disable_success"),
)
void queryClient.invalidateQueries({ queryKey: ["tools"] })
void refreshGatewayState({ force: true })
},
onError: (err) => {
toast.error(
err instanceof Error
? err.message
: t("pages.agent.tools.toggle_error"),
)
},
})
// Filter and group tools
const { groupedTools, totalFilteredCount } = useMemo(() => {
if (!data) return { groupedTools: [], totalFilteredCount: 0 }
let count = 0
const buckets = new Map<string, ToolSupportItem[]>()
for (const item of data.tools) {
// Apply status filter
if (statusFilter !== "all" && item.status !== statusFilter) continue
// Apply search query
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase()
const matchesName = item.name.toLowerCase().includes(query)
const matchesDesc = (item.description || "")
.toLowerCase()
.includes(query)
if (!matchesName && !matchesDesc) continue
}
count++
const list = buckets.get(item.category) ?? []
list.push(item)
buckets.set(item.category, list)
}
return {
groupedTools: Array.from(buckets.entries()),
totalFilteredCount: count,
}
}, [data, searchQuery, statusFilter])
return (
<div className="bg-background flex h-full flex-col">
<PageHeader title={t("navigation.tools")} />
<div className="flex-1 overflow-auto px-6 py-6">
<div className="mx-auto w-full max-w-6xl space-y-8">
{/* Header & Description */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-end">
{/* Filters Toolbar */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="relative">
<IconSearch className="text-muted-foreground absolute top-1/2 left-2.5 size-4 -translate-y-1/2" />
<Input
type="text"
placeholder={t("pages.agent.tools.search_placeholder")}
className="w-full pl-9 sm:w-64"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-full sm:w-40">
<SelectValue
placeholder={t("pages.agent.tools.filter.all")}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
{t("pages.agent.tools.filter.all")}
</SelectItem>
<SelectItem value="enabled">
{t("pages.agent.tools.filter.enabled")}
</SelectItem>
<SelectItem value="disabled">
{t("pages.agent.tools.filter.disabled")}
</SelectItem>
<SelectItem value="blocked">
{t("pages.agent.tools.filter.blocked")}
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* Content Area */}
{error ? (
<Card className="border-destructive/50 bg-destructive/10 cursor-default">
<CardContent className="py-10 text-center">
<p className="text-destructive font-medium">
{t("pages.agent.load_error")}
</p>
</CardContent>
</Card>
) : isLoading ? (
// Skeleton Loading State
<div className="space-y-8">
{[1, 2].map((groupIndex) => (
<div key={groupIndex} className="space-y-4">
<Skeleton className="h-5 w-32" />
<div className="grid gap-4 lg:grid-cols-2">
{[1, 2, 3, 4].map((itemIndex) => (
<Card
key={itemIndex}
className="border-border/60 shadow-none"
>
<CardHeader className="pb-3">
<Skeleton className="mb-2 h-5 w-48" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
</CardHeader>
<CardContent>
<Skeleton className="mt-2 h-8 w-full rounded-md" />
</CardContent>
</Card>
))}
</div>
</div>
))}
</div>
) : totalFilteredCount === 0 ? (
// Empty State
<Card className="bg-muted/30 cursor-default border-dashed">
<CardContent className="flex flex-col items-center justify-center py-16 text-center text-sm">
<div className="bg-muted mb-4 rounded-full p-4">
<IconSearch className="text-muted-foreground size-8" />
</div>
<h3 className="mb-1 text-lg font-medium">
{data?.tools.length === 0
? t("pages.agent.tools.empty")
: t("pages.agent.tools.no_results")}
</h3>
{data?.tools.length !== 0 && (
<p className="text-muted-foreground">
Try adjusting your search criteria or status filters.
</p>
)}
</CardContent>
</Card>
) : (
// Tool Categories list
<div className="space-y-8">
{groupedTools.map(([category, items]) => (
<div key={category} className="space-y-4">
<h3 className="text-foreground text-sm font-semibold tracking-wide uppercase">
{t(`pages.agent.tools.categories.${category}`)}
</h3>
<div className="grid gap-4 lg:grid-cols-2">
{items.map((tool) => {
const reasonText = tool.reason_code
? t(`pages.agent.tools.reasons.${tool.reason_code}`)
: ""
const isPending =
toggleMutation.isPending &&
toggleMutation.variables?.name === tool.name
const isEnabled = tool.status === "enabled"
const isDisabled = tool.status === "disabled"
const isBlocked = tool.status === "blocked"
return (
<Card
key={tool.name}
className={cn(
"group cursor-default transition-colors",
isBlocked
? "border-amber-200/80 bg-amber-50/60 dark:border-amber-900/50 dark:bg-amber-950/20"
: "border-border/60",
isDisabled && "opacity-80",
)}
>
<CardHeader className="pb-3">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<CardTitle className="font-mono text-sm font-semibold break-all">
{tool.name}
</CardTitle>
<ToolStatusBadge status={tool.status} />
</div>
<CardDescription className="text-muted-foreground/80 mt-2 text-xs leading-relaxed break-words sm:text-sm">
{tool.description}
</CardDescription>
</div>
<div className="flex shrink-0 items-center pt-1 pl-2 sm:pt-0">
<Switch
checked={isEnabled}
disabled={isPending}
onCheckedChange={(checked) =>
toggleMutation.mutate({
name: tool.name,
enabled: checked,
})
}
/>
</div>
</div>
</CardHeader>
{reasonText && (
<CardContent className="pt-0 pb-4">
<div className="text-xs font-medium text-amber-700 dark:text-amber-400">
{reasonText}
</div>
</CardContent>
)}
</Card>
)
})}
</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
)
}
function ToolStatusBadge({ status }: { status: ToolSupportItem["status"] }) {
const { t } = useTranslation()
return (
<span
className={cn(
"shrink-0 rounded-full px-2 py-0.5 text-[10px] font-medium tracking-wide sm:text-[11px]",
status === "enabled" &&
"bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-400",
status === "blocked" &&
"bg-amber-100 text-amber-700 dark:bg-amber-950 dark:text-amber-400",
status === "disabled" && "bg-muted text-muted-foreground",
)}
>
{t(`pages.agent.tools.status.${status}`)}
</span>
)
}

View file

@ -6,6 +6,7 @@ import {
IconKey,
IconListDetails,
IconMessageCircle,
IconSearch,
IconSettings,
IconSparkles,
IconTools,
@ -24,14 +25,15 @@ import {
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuButton,
SidebarFooter,
SidebarMenuItem,
SidebarRail,
useSidebar,
} from "@/components/ui/sidebar"
import { useSidebarChannels } from "@/hooks/use-sidebar-channels"
@ -71,6 +73,7 @@ const baseNavGroups: Omit<NavGroup, "items">[] = [
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
const routerState = useRouterState()
const { i18n, t } = useTranslation()
const { isMobile, setOpenMobile } = useSidebar()
const currentPath = routerState.location.pathname
const {
channelItems,
@ -88,6 +91,11 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
})
const versionText = versionInfo?.version ?? t("footer.version_unknown")
const handleNavItemClick = React.useCallback(() => {
if (isMobile) {
setOpenMobile(false)
}
}, [isMobile, setOpenMobile])
const navGroups: NavGroup[] = React.useMemo(() => {
return [
@ -133,6 +141,12 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
{
...baseNavGroups[2],
items: [
{
title: "navigation.hub",
url: "/agent/hub",
icon: IconSearch,
translateTitle: true,
},
{
title: "navigation.skills",
url: "/agent/skills",
@ -199,7 +213,10 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
<SidebarMenuButton
asChild
isActive={isActive}
data-tour={item.url === "/models" ? "models-nav" : undefined}
onClick={handleNavItemClick}
data-tour={
item.url === "/models" ? "models-nav" : undefined
}
className={`h-9 px-3 ${isActive ? "bg-accent/80 text-foreground font-medium" : "text-muted-foreground hover:bg-muted/60"}`}
>
<Link to={item.url}>
@ -246,7 +263,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
</Collapsible>
))}
</SidebarContent>
<SidebarFooter className="border-t-border/30 group-data-[collapsible=icon]:hidden border-t px-3 py-2">
<SidebarFooter className="border-t-border/30 border-t px-3 py-2 group-data-[collapsible=icon]:hidden">
<div className="text-muted-foreground flex flex-col gap-0.5 text-[11px] leading-4">
<div className="truncate" title={versionText}>
<span className="text-foreground/80">{t("footer.version")}:</span>{" "}

View file

@ -1,319 +0,0 @@
import {
IconFileInfo,
IconLoader2,
IconPlus,
IconTrash,
} from "@tabler/icons-react"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { type ChangeEvent, useRef, useState } from "react"
import { useTranslation } from "react-i18next"
import ReactMarkdown from "react-markdown"
import rehypeRaw from "rehype-raw"
import rehypeSanitize from "rehype-sanitize"
import remarkGfm from "remark-gfm"
import { toast } from "sonner"
import {
type SkillSupportItem,
deleteSkill,
getSkill,
getSkills,
importSkill,
} from "@/api/skills"
import { PageHeader } from "@/components/page-header"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet"
export function SkillsPage() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const importInputRef = useRef<HTMLInputElement | null>(null)
const [selectedSkill, setSelectedSkill] = useState<SkillSupportItem | null>(
null,
)
const [skillPendingDelete, setSkillPendingDelete] =
useState<SkillSupportItem | null>(null)
const { data, isLoading, error } = useQuery({
queryKey: ["skills"],
queryFn: getSkills,
})
const {
data: selectedSkillDetail,
isLoading: isSkillDetailLoading,
error: skillDetailError,
} = useQuery({
queryKey: ["skills", selectedSkill?.name],
queryFn: () => getSkill(selectedSkill!.name),
enabled: selectedSkill !== null,
})
const importMutation = useMutation({
mutationFn: async (file: File) => importSkill(file),
onSuccess: () => {
toast.success(t("pages.agent.skills.import_success"))
void queryClient.invalidateQueries({ queryKey: ["skills"] })
},
onError: (err) => {
toast.error(
err instanceof Error
? err.message
: t("pages.agent.skills.import_error"),
)
},
})
const deleteMutation = useMutation({
mutationFn: async (name: string) => deleteSkill(name),
onSuccess: (_, deletedName) => {
toast.success(t("pages.agent.skills.delete_success"))
setSkillPendingDelete(null)
if (
selectedSkill?.name === deletedName &&
selectedSkill.source === "workspace"
) {
setSelectedSkill(null)
}
void queryClient.invalidateQueries({ queryKey: ["skills"] })
},
onError: (err) => {
toast.error(
err instanceof Error
? err.message
: t("pages.agent.skills.delete_error"),
)
},
})
const handleImportClick = () => {
importInputRef.current?.click()
}
const handleImportFileChange = (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
if (!file) return
importMutation.mutate(file)
event.target.value = ""
}
return (
<div className="flex h-full flex-col">
<PageHeader
title={t("navigation.skills")}
children={
<>
<input
ref={importInputRef}
type="file"
accept=".md,text/markdown,text/plain"
className="hidden"
onChange={handleImportFileChange}
/>
<Button
variant="outline"
onClick={handleImportClick}
disabled={importMutation.isPending}
>
{importMutation.isPending ? (
<IconLoader2 className="size-4 animate-spin" />
) : (
<IconPlus className="size-4" />
)}
{t("pages.agent.skills.import")}
</Button>
</>
}
/>
<div className="flex-1 overflow-auto px-6 py-3">
<div className="w-full max-w-6xl space-y-6">
{isLoading ? (
<div className="text-muted-foreground py-6 text-sm">
{t("labels.loading")}
</div>
) : error ? (
<div className="text-destructive py-6 text-sm">
{t("pages.agent.load_error")}
</div>
) : (
<section className="space-y-5">
<p className="text-muted-foreground text-sm">
{t("pages.agent.skills.description")}
</p>
{data?.skills.length ? (
<div className="grid gap-4 lg:grid-cols-2">
{data.skills.map((skill) => (
<Card
key={`${skill.source}:${skill.name}`}
className="border-border/60 gap-4"
size="sm"
>
<CardHeader>
<div className="flex items-start justify-between gap-3">
<div>
<CardTitle className="font-semibold">
{skill.name}
</CardTitle>
<CardDescription className="mt-3">
{skill.description ||
t("pages.agent.skills.no_description")}
</CardDescription>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground hover:text-foreground"
onClick={() => setSelectedSkill(skill)}
title={t("pages.agent.skills.view")}
>
<IconFileInfo className="size-4" />
</Button>
{skill.source === "workspace" ? (
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground hover:text-destructive"
onClick={() => setSkillPendingDelete(skill)}
title={t("pages.agent.skills.delete")}
>
<IconTrash className="size-4" />
</Button>
) : null}
</div>
</div>
</CardHeader>
<CardContent className="space-y-2">
<div className="text-muted-foreground text-[11px] tracking-[0.18em] uppercase">
{t("pages.agent.skills.path")}
</div>
<div className="bg-muted text-foreground overflow-x-auto rounded-lg px-3 py-2 font-mono text-xs leading-relaxed">
{skill.path}
</div>
</CardContent>
</Card>
))}
</div>
) : (
<Card className="border-dashed">
<CardContent className="text-muted-foreground py-10 text-center text-sm">
{t("pages.agent.skills.empty")}
</CardContent>
</Card>
)}
</section>
)}
</div>
</div>
<Sheet
open={selectedSkill !== null}
onOpenChange={(open) => {
if (!open) setSelectedSkill(null)
}}
>
<SheetContent
side="right"
className="w-full gap-0 p-0 data-[side=right]:!w-full data-[side=right]:sm:!w-[560px] data-[side=right]:sm:!max-w-[560px]"
>
<SheetHeader className="border-b px-6 py-5">
<SheetTitle>
{selectedSkill?.name || t("pages.agent.skills.viewer_title")}
</SheetTitle>
<SheetDescription>
{selectedSkill?.description ||
t("pages.agent.skills.viewer_description")}
</SheetDescription>
</SheetHeader>
<div className="flex-1 overflow-auto px-6 py-5">
{isSkillDetailLoading ? (
<div className="text-muted-foreground text-sm">
{t("pages.agent.skills.loading_detail")}
</div>
) : skillDetailError ? (
<div className="text-destructive text-sm">
{t("pages.agent.skills.load_detail_error")}
</div>
) : selectedSkillDetail ? (
<div className="space-y-5">
<div className="prose prose-sm dark:prose-invert prose-pre:rounded-lg prose-pre:border prose-pre:bg-zinc-950 prose-pre:p-3 max-w-none">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw, rehypeSanitize]}
>
{selectedSkillDetail.content}
</ReactMarkdown>
</div>
</div>
) : null}
</div>
</SheetContent>
</Sheet>
<AlertDialog
open={skillPendingDelete !== null}
onOpenChange={(open) => {
if (!open) setSkillPendingDelete(null)
}}
>
<AlertDialogContent size="sm">
<AlertDialogHeader>
<AlertDialogTitle>
{t("pages.agent.skills.delete_title")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("pages.agent.skills.delete_description", {
name: skillPendingDelete?.name,
})}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleteMutation.isPending}>
{t("common.cancel")}
</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
disabled={deleteMutation.isPending || !skillPendingDelete}
onClick={() => {
if (skillPendingDelete)
deleteMutation.mutate(skillPendingDelete.name)
}}
>
{deleteMutation.isPending ? (
<IconLoader2 className="size-4 animate-spin" />
) : (
<IconTrash className="size-4" />
)}
{t("pages.agent.skills.delete_confirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}

View file

@ -1,192 +0,0 @@
import { IconLoader2 } from "@tabler/icons-react"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { useTranslation } from "react-i18next"
import { toast } from "sonner"
import { type ToolSupportItem, getTools, setToolEnabled } from "@/api/tools"
import { PageHeader } from "@/components/page-header"
import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { cn } from "@/lib/utils"
import { refreshGatewayState } from "@/store/gateway"
export function ToolsPage() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const { data, isLoading, error } = useQuery({
queryKey: ["tools"],
queryFn: getTools,
})
const toggleMutation = useMutation({
mutationFn: async ({ name, enabled }: { name: string; enabled: boolean }) =>
setToolEnabled(name, enabled),
onSuccess: (_, variables) => {
toast.success(
variables.enabled
? t("pages.agent.tools.enable_success")
: t("pages.agent.tools.disable_success"),
)
void queryClient.invalidateQueries({ queryKey: ["tools"] })
void refreshGatewayState({ force: true })
},
onError: (err) => {
toast.error(
err instanceof Error
? err.message
: t("pages.agent.tools.toggle_error"),
)
},
})
const groupedTools = (() => {
if (!data) return [] as Array<[string, ToolSupportItem[]]>
const buckets = new Map<string, ToolSupportItem[]>()
for (const item of data.tools) {
const list = buckets.get(item.category) ?? []
list.push(item)
buckets.set(item.category, list)
}
return Array.from(buckets.entries())
})()
return (
<div className="flex h-full flex-col">
<PageHeader title={t("navigation.tools")} />
<div className="flex-1 overflow-auto px-6 py-3">
<div className="w-full max-w-6xl space-y-6">
{isLoading ? (
<div className="text-muted-foreground py-6 text-sm">
{t("labels.loading")}
</div>
) : error ? (
<div className="text-destructive py-6 text-sm">
{t("pages.agent.load_error")}
</div>
) : (
<section className="space-y-5">
<p className="text-muted-foreground mt-1 text-sm">
{t("pages.agent.tools.description")}
</p>
{data?.tools.length ? (
groupedTools.map(([category, items]) => (
<div key={category} className="space-y-3">
<div className="text-foreground/85 text-sm font-semibold tracking-wide">
{t(`pages.agent.tools.categories.${category}`)}
</div>
<div className="grid gap-4 lg:grid-cols-2">
{items.map((tool) => {
const reasonText = tool.reason_code
? t(`pages.agent.tools.reasons.${tool.reason_code}`)
: ""
const isPending =
toggleMutation.isPending &&
toggleMutation.variables?.name === tool.name
const nextEnabled = tool.status !== "enabled"
return (
<Card
key={tool.name}
className={cn(
"gap-4 border transition-colors",
tool.status === "enabled" &&
"border-emerald-200/70 bg-emerald-50/50",
tool.status === "blocked" &&
"border-amber-200/80 bg-amber-50/60",
tool.status === "disabled" &&
"border-border/60 bg-card/70",
)}
size="sm"
>
<CardHeader>
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0 flex-1">
<CardTitle className="font-mono text-sm break-all">
{tool.name}
</CardTitle>
<CardDescription className="mt-1 break-words">
{tool.description}
</CardDescription>
</div>
<div className="flex shrink-0 items-center gap-2 self-start">
<ToolStatusBadge status={tool.status} />
<Button
variant={
nextEnabled ? "default" : "outline"
}
size="sm"
disabled={isPending}
onClick={() =>
toggleMutation.mutate({
name: tool.name,
enabled: nextEnabled,
})
}
>
{isPending ? (
<IconLoader2 className="size-4 animate-spin" />
) : null}
{nextEnabled
? t("pages.agent.tools.enable")
: t("pages.agent.tools.disable")}
</Button>
</div>
</div>
</CardHeader>
<CardContent className="space-y-2">
<div className="text-muted-foreground text-xs">
{t("pages.agent.tools.config_key", {
key: tool.config_key,
})}
</div>
{reasonText ? (
<div className="text-sm text-amber-800">
{reasonText}
</div>
) : null}
</CardContent>
</Card>
)
})}
</div>
</div>
))
) : (
<Card className="border-dashed">
<CardContent className="text-muted-foreground py-10 text-center text-sm">
{t("pages.agent.tools.empty")}
</CardContent>
</Card>
)}
</section>
)}
</div>
</div>
</div>
)
}
function ToolStatusBadge({ status }: { status: ToolSupportItem["status"] }) {
const { t } = useTranslation()
return (
<span
className={cn(
"shrink-0 rounded-md px-2 py-1 text-[11px] font-semibold",
status === "enabled" && "bg-emerald-100 text-emerald-700",
status === "blocked" && "bg-amber-100 text-amber-700",
status === "disabled" && "bg-muted text-muted-foreground",
)}
>
{t(`pages.agent.tools.status.${status}`)}
</span>
)
}

View file

@ -0,0 +1,163 @@
import * as React from "react"
import { Dialog as DialogPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { IconX } from "@tabler/icons-react"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-6 rounded-xl bg-popover p-6 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-md data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close data-slot="dialog-close" asChild>
<Button
variant="ghost"
className="absolute top-4 right-4"
size="icon-sm"
>
<IconX
/>
<span className="sr-only">Close</span>
</Button>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close asChild>
<Button variant="outline">Close</Button>
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("leading-none font-medium", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn(
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

View file

@ -5,6 +5,7 @@
"models": "Models",
"credentials": "Credentials",
"agent_group": "Agent",
"hub": "Hub",
"skills": "Skills",
"tools": "Tools",
"services": "Services",
@ -398,11 +399,18 @@
"agent": {
"load_error": "Failed to load agent support information.",
"skills": {
"description": "Skills are loaded from the workspace, global PicoClaw home, and builtin directories.",
"empty": "No skills are currently available.",
"install_success": "Installed {{name}}.",
"install_error": "Failed to install skill.",
"search_placeholder": "Search by name, description, or registry",
"source_label": "Type",
"sort_label": "Sort",
"import": "Import Skill",
"import_success": "Skill imported.",
"import_error": "Failed to import skill.",
"import_invalid_type": "Only Markdown or ZIP skill files are supported.",
"import_invalid_size": "Skill file must be 1 MB or smaller.",
"import_constraints": "Import a Markdown or ZIP skill file up to 1 MB",
"view": "View",
"delete": "Delete",
"delete_title": "Delete Skill?",
@ -412,20 +420,78 @@
"delete_error": "Failed to delete skill.",
"viewer_title": "Skill Content",
"viewer_description": "Read the current effective SKILL.md content here.",
"loading_detail": "Loading skill content...",
"load_detail_error": "Failed to load skill content.",
"path": "Skill Path",
"no_description": "No description provided."
"no_description": "No description provided.",
"no_results": "No skills matched the current filters.",
"dropzone_title": "Import Into Workspace",
"dropzone_description": "Drag a skill file here or pick one from disk.",
"dropzone_label": "Drop a skill file here",
"dropzone_active": "Release to import this skill",
"dropzone_release": "The skill will be normalized and saved into the workspace skills directory.",
"marketplace_title": "Discover Skills",
"marketplace_description": "Search the skill registries and install useful skills into this workspace",
"marketplace_search_placeholder": "Search for capabilities like github, docker, database...",
"marketplace_search_action": "Search",
"marketplace_search_status": "Search Status",
"marketplace_install_status": "Install Status",
"marketplace_notice_title": "Security Notice",
"marketplace_notice_body": "Registry skills are third-party content. Review the author, page URL, instructions, and any required code or credentials before installing.",
"marketplace_status_disabled": "Disabled. Enable the corresponding tool on the Tools page first.",
"marketplace_status_enable_hint": "Enable the related tool on the Tools page first.",
"marketplace_search_error": "Failed to search registries.",
"marketplace_loading_results": "Searching skills...",
"marketplace_loading_more": "Loading more skills...",
"marketplace_results_title": "{{count}} results for “{{query}}”",
"marketplace_results_hint": "Registry results install into the current workspace.",
"marketplace_install_action": "Install",
"marketplace_installed": "Installed",
"marketplace_view_installed": "View Local",
"marketplace_installed_hint": "Already available in this workspace as “{{name}}”.",
"marketplace_empty_results": "No installable skills matched “{{query}}”.",
"marketplace_idle": "Search for a capability to discover installable skills from configured registries.",
"marketplace_unavailable": "Registry search is currently unavailable. Check the Skills tools configuration.",
"sort": {
"name_asc": "Name (A-Z)",
"name_desc": "Name (Z-A)",
"source": "Type"
},
"origin": {
"all": "All Types",
"builtin": "Builtin",
"third_party": "Third-Party",
"manual": "Manual"
},
"summary": {
"total": "Total Skills"
},
"detail_tabs": {
"preview": "Preview",
"raw": "Raw",
"meta": "Metadata"
},
"metadata": {
"name": "Name",
"description": "Description",
"registry": "Registry",
"url": "URL",
"version": "Installed Version",
"lines": "Line Count",
"characters": "Character Count"
}
},
"tools": {
"description": "This view reflects whether each agent tool is enabled, disabled, or blocked by a missing prerequisite.",
"search_placeholder": "Search tools...",
"no_results": "No tools match your criteria.",
"filter": {
"all": "All Status",
"enabled": "Enabled only",
"disabled": "Disabled only",
"blocked": "Blocked only"
},
"empty": "No tools are available.",
"enable": "Enable",
"disable": "Disable",
"enable_success": "Tool enabled.",
"disable_success": "Tool disabled.",
"toggle_error": "Failed to update tool state.",
"config_key": "Controlled by tools.{{key}}",
"status": {
"enabled": "Enabled",
"disabled": "Disabled",

View file

@ -5,6 +5,7 @@
"models": "模型",
"credentials": "凭据",
"agent_group": "智能体",
"hub": "Hub",
"skills": "技能",
"tools": "工具",
"services": "服务",
@ -398,11 +399,18 @@
"agent": {
"load_error": "加载 Agent 支持信息失败。",
"skills": {
"description": "技能会从工作区、PicoClaw 全局目录和内置目录中加载。",
"empty": "当前没有可用技能。",
"install_success": "已安装 {{name}}。",
"install_error": "安装技能失败。",
"search_placeholder": "按名称、描述或技能源搜索",
"source_label": "类型",
"sort_label": "排序",
"import": "导入技能",
"import_success": "技能导入成功。",
"import_error": "导入技能失败。",
"import_invalid_type": "仅支持导入 Markdown 或 ZIP 技能文件。",
"import_invalid_size": "技能文件大小不能超过 1 MB。",
"import_constraints": "支持导入最大 1 MB 的 Markdown 或 ZIP 文件",
"view": "查看",
"delete": "删除",
"delete_title": "删除技能?",
@ -412,20 +420,78 @@
"delete_error": "删除技能失败。",
"viewer_title": "技能内容",
"viewer_description": "这里展示当前生效的 SKILL.md 内容。",
"loading_detail": "正在加载技能内容...",
"load_detail_error": "加载技能内容失败。",
"path": "技能路径",
"no_description": "未提供描述。"
"no_description": "未提供描述。",
"no_results": "没有技能匹配当前筛选条件。",
"dropzone_title": "导入到工作区",
"dropzone_description": "将技能文件拖到这里,或从本地选择一个文件。",
"dropzone_label": "将技能文件拖到这里",
"dropzone_active": "松开即可导入该技能",
"dropzone_release": "导入后会自动规范化内容,并保存到工作区技能目录。",
"marketplace_title": "安装技能",
"marketplace_description": "搜索第三方技能源,并将技能安装到当前工作区",
"marketplace_search_placeholder": "搜索 github、docker、database 等技能",
"marketplace_search_action": "搜索",
"marketplace_search_status": "搜索状态",
"marketplace_install_status": "安装状态",
"marketplace_notice_title": "安全提示",
"marketplace_notice_body": "搜索结果中的 skills 属于第三方内容。安装前请先确认作者、页面 URL、说明文档以及它要求执行的代码或使用的凭据是否可信。",
"marketplace_status_disabled": "当前未启用,请先在工具页启用对应工具。",
"marketplace_status_enable_hint": "请先在工具页启用相关工具。",
"marketplace_search_error": "搜索技能源失败。",
"marketplace_loading_results": "正在搜索技能...",
"marketplace_loading_more": "正在加载更多技能...",
"marketplace_results_title": "“{{query}}” 共找到 {{count}} 个结果",
"marketplace_results_hint": "搜索结果会安装到当前工作区。",
"marketplace_install_action": "安装",
"marketplace_installed": "已安装",
"marketplace_view_installed": "查看本地技能",
"marketplace_installed_hint": "该技能已在当前工作区中可用,名称为「{{name}}」。",
"marketplace_empty_results": "没有找到与“{{query}}”匹配的可安装技能。",
"marketplace_idle": "输入一个关键词,搜索可安装的第三方技能。",
"marketplace_unavailable": "当前无法使用技能搜索,请检查 Skills 相关工具配置。",
"sort": {
"name_asc": "名称A-Z",
"name_desc": "名称Z-A",
"source": "按类型"
},
"origin": {
"all": "全部类型",
"builtin": "内置",
"third_party": "第三方",
"manual": "手动导入"
},
"summary": {
"total": "技能总数"
},
"detail_tabs": {
"preview": "预览",
"raw": "原始内容",
"meta": "元数据"
},
"metadata": {
"name": "名称",
"description": "描述",
"registry": "来源平台",
"url": "链接地址",
"version": "已安装版本",
"lines": "行数",
"characters": "字符数"
}
},
"tools": {
"description": "这里展示每个 Agent 工具当前是已启用、已禁用,还是被依赖条件阻塞。",
"search_placeholder": "搜索工具...",
"no_results": "没有找到符合条件的工具",
"filter": {
"all": "所有状态",
"enabled": "已启用",
"disabled": "已禁用",
"blocked": "被阻塞"
},
"empty": "当前没有可用工具。",
"enable": "启用",
"disable": "禁用",
"enable_success": "工具已启用。",
"disable_success": "工具已禁用。",
"toggle_error": "更新工具状态失败。",
"config_key": "由 tools.{{key}} 控制",
"status": {
"enabled": "已启用",
"disabled": "已禁用",

View file

@ -21,6 +21,7 @@ import { Route as ConfigRawRouteImport } from './routes/config.raw'
import { Route as ChannelsNameRouteImport } from './routes/channels/$name'
import { Route as AgentToolsRouteImport } from './routes/agent/tools'
import { Route as AgentSkillsRouteImport } from './routes/agent/skills'
import { Route as AgentHubRouteImport } from './routes/agent/hub'
const ModelsRoute = ModelsRouteImport.update({
id: '/models',
@ -82,6 +83,11 @@ const AgentSkillsRoute = AgentSkillsRouteImport.update({
path: '/skills',
getParentRoute: () => AgentRoute,
} as any)
const AgentHubRoute = AgentHubRouteImport.update({
id: '/hub',
path: '/hub',
getParentRoute: () => AgentRoute,
} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
@ -92,6 +98,7 @@ export interface FileRoutesByFullPath {
'/launcher-login': typeof LauncherLoginRoute
'/logs': typeof LogsRoute
'/models': typeof ModelsRoute
'/agent/hub': typeof AgentHubRoute
'/agent/skills': typeof AgentSkillsRoute
'/agent/tools': typeof AgentToolsRoute
'/channels/$name': typeof ChannelsNameRoute
@ -106,6 +113,7 @@ export interface FileRoutesByTo {
'/launcher-login': typeof LauncherLoginRoute
'/logs': typeof LogsRoute
'/models': typeof ModelsRoute
'/agent/hub': typeof AgentHubRoute
'/agent/skills': typeof AgentSkillsRoute
'/agent/tools': typeof AgentToolsRoute
'/channels/$name': typeof ChannelsNameRoute
@ -121,6 +129,7 @@ export interface FileRoutesById {
'/launcher-login': typeof LauncherLoginRoute
'/logs': typeof LogsRoute
'/models': typeof ModelsRoute
'/agent/hub': typeof AgentHubRoute
'/agent/skills': typeof AgentSkillsRoute
'/agent/tools': typeof AgentToolsRoute
'/channels/$name': typeof ChannelsNameRoute
@ -137,6 +146,7 @@ export interface FileRouteTypes {
| '/launcher-login'
| '/logs'
| '/models'
| '/agent/hub'
| '/agent/skills'
| '/agent/tools'
| '/channels/$name'
@ -151,6 +161,7 @@ export interface FileRouteTypes {
| '/launcher-login'
| '/logs'
| '/models'
| '/agent/hub'
| '/agent/skills'
| '/agent/tools'
| '/channels/$name'
@ -165,6 +176,7 @@ export interface FileRouteTypes {
| '/launcher-login'
| '/logs'
| '/models'
| '/agent/hub'
| '/agent/skills'
| '/agent/tools'
| '/channels/$name'
@ -268,6 +280,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AgentSkillsRouteImport
parentRoute: typeof AgentRoute
}
'/agent/hub': {
id: '/agent/hub'
path: '/hub'
fullPath: '/agent/hub'
preLoaderRoute: typeof AgentHubRouteImport
parentRoute: typeof AgentRoute
}
}
}
@ -284,11 +303,13 @@ const ChannelsRouteRouteWithChildren = ChannelsRouteRoute._addFileChildren(
)
interface AgentRouteChildren {
AgentHubRoute: typeof AgentHubRoute
AgentSkillsRoute: typeof AgentSkillsRoute
AgentToolsRoute: typeof AgentToolsRoute
}
const AgentRouteChildren: AgentRouteChildren = {
AgentHubRoute: AgentHubRoute,
AgentSkillsRoute: AgentSkillsRoute,
AgentToolsRoute: AgentToolsRoute,
}

View file

@ -15,7 +15,7 @@ function AgentLayout() {
})
if (pathname === "/agent") {
return <Navigate to="/agent/skills" />
return <Navigate to="/agent/hub" />
}
return <Outlet />

View file

@ -0,0 +1,11 @@
import { createFileRoute } from "@tanstack/react-router"
import { HubPage } from "@/components/agent/hub/hub-page"
export const Route = createFileRoute("/agent/hub")({
component: AgentHubRoute,
})
function AgentHubRoute() {
return <HubPage />
}

View file

@ -1,6 +1,6 @@
import { createFileRoute } from "@tanstack/react-router"
import { SkillsPage } from "@/components/skills/skills-page"
import { SkillsPage } from "@/components/agent/skills/skills-page"
export const Route = createFileRoute("/agent/skills")({
component: AgentSkillsRoute,

View file

@ -1,6 +1,6 @@
import { createFileRoute } from "@tanstack/react-router"
import { ToolsPage } from "@/components/tools/tools-page"
import { ToolsPage } from "@/components/agent/tools/tools-page"
export const Route = createFileRoute("/agent/tools")({
component: AgentToolsRoute,