Merge branch 'sipeed:main' into fix/google-antigravity-oauth-refresh
This commit is contained in:
commit
7e4af1d8b8
70 changed files with 7650 additions and 1016 deletions
62
.github/workflows/create_dmg.yml
vendored
Normal file
62
.github/workflows/create_dmg.yml
vendored
Normal 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
|
||||
17
Makefile
17
Makefile
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ func TestNewPicoclawCommand(t *testing.T) {
|
|||
"onboard",
|
||||
"skills",
|
||||
"status",
|
||||
"update",
|
||||
"version",
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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) |
|
||||
|
|
|
|||
|
|
@ -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
2
go.mod
|
|
@ -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
11
go.sum
|
|
@ -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
379
pkg/agent/context_legacy.go
Normal 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
|
||||
}
|
||||
89
pkg/agent/context_manager.go
Normal file
89
pkg/agent/context_manager.go
Normal 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
|
||||
}
|
||||
764
pkg/agent/context_manager_test.go
Normal file
764
pkg/agent/context_manager_test.go
Normal 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"})
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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...)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}()
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ const defaultRequestTimeout = common.DefaultRequestTimeout
|
|||
|
||||
var stripModelPrefixProviders = map[string]struct{}{
|
||||
"litellm": {},
|
||||
"venice": {},
|
||||
"moonshot": {},
|
||||
"nvidia": {},
|
||||
"groq": {},
|
||||
|
|
|
|||
|
|
@ -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
163
pkg/tools/load_image.go
Normal 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},
|
||||
}
|
||||
}
|
||||
174
pkg/tools/load_image_test.go
Normal file
174
pkg/tools/load_image_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
707
pkg/updater/updater.go
Normal 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
|
||||
}
|
||||
97
pkg/updater/updater_test.go
Normal file
97
pkg/updater/updater_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
52
web/backend/api/update.go
Normal 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"})
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
51
web/frontend/src/components/agent/hub/hub-page.tsx
Normal file
51
web/frontend/src/components/agent/hub/hub-page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
132
web/frontend/src/components/agent/hub/market-skill-card.tsx
Normal file
132
web/frontend/src/components/agent/hub/market-skill-card.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
135
web/frontend/src/components/agent/hub/results-panel.tsx
Normal file
135
web/frontend/src/components/agent/hub/results-panel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
91
web/frontend/src/components/agent/hub/search-panel.tsx
Normal file
91
web/frontend/src/components/agent/hub/search-panel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
54
web/frontend/src/components/agent/hub/tool-support.ts
Normal file
54
web/frontend/src/components/agent/hub/tool-support.ts
Normal 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")
|
||||
}
|
||||
211
web/frontend/src/components/agent/hub/use-hub-marketplace.ts
Normal file
211
web/frontend/src/components/agent/hub/use-hub-marketplace.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
65
web/frontend/src/components/agent/skills/delete-dialog.tsx
Normal file
65
web/frontend/src/components/agent/skills/delete-dialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
249
web/frontend/src/components/agent/skills/detail-sheet.tsx
Normal file
249
web/frontend/src/components/agent/skills/detail-sheet.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
136
web/frontend/src/components/agent/skills/filter-bar.tsx
Normal file
136
web/frontend/src/components/agent/skills/filter-bar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
160
web/frontend/src/components/agent/skills/import-dialog.tsx
Normal file
160
web/frontend/src/components/agent/skills/import-dialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
46
web/frontend/src/components/agent/skills/origin-badge.tsx
Normal file
46
web/frontend/src/components/agent/skills/origin-badge.tsx
Normal 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" />
|
||||
}
|
||||
86
web/frontend/src/components/agent/skills/origin-utils.ts
Normal file
86
web/frontend/src/components/agent/skills/origin-utils.ts
Normal 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)
|
||||
}
|
||||
27
web/frontend/src/components/agent/skills/page-skeleton.tsx
Normal file
27
web/frontend/src/components/agent/skills/page-skeleton.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
84
web/frontend/src/components/agent/skills/skill-card.tsx
Normal file
84
web/frontend/src/components/agent/skills/skill-card.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
86
web/frontend/src/components/agent/skills/skills-list.tsx
Normal file
86
web/frontend/src/components/agent/skills/skills-list.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
160
web/frontend/src/components/agent/skills/skills-page.tsx
Normal file
160
web/frontend/src/components/agent/skills/skills-page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
39
web/frontend/src/components/agent/skills/stats.tsx
Normal file
39
web/frontend/src/components/agent/skills/stats.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
17
web/frontend/src/components/agent/skills/types.ts
Normal file
17
web/frontend/src/components/agent/skills/types.ts
Normal 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
|
||||
}
|
||||
336
web/frontend/src/components/agent/skills/use-skills-page.ts
Normal file
336
web/frontend/src/components/agent/skills/use-skills-page.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
288
web/frontend/src/components/agent/tools/tools-page.tsx
Normal file
288
web/frontend/src/components/agent/tools/tools-page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>{" "}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
163
web/frontend/src/components/ui/dialog.tsx
Normal file
163
web/frontend/src/components/ui/dialog.tsx
Normal 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,
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "已禁用",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ function AgentLayout() {
|
|||
})
|
||||
|
||||
if (pathname === "/agent") {
|
||||
return <Navigate to="/agent/skills" />
|
||||
return <Navigate to="/agent/hub" />
|
||||
}
|
||||
|
||||
return <Outlet />
|
||||
|
|
|
|||
11
web/frontend/src/routes/agent/hub.tsx
Normal file
11
web/frontend/src/routes/agent/hub.tsx
Normal 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 />
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue