Compare commits
41 Commits
504d5746aa
...
worktree-s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
44256a511c | ||
|
|
4d8d560d1f | ||
|
|
452ff45a5f | ||
|
|
bc12f6899a | ||
|
|
8cce2283f7 | ||
|
|
15450ca895 | ||
|
|
a66b675675 | ||
|
|
d760b9ca10 | ||
|
|
a0d59b1947 | ||
|
|
900430d93e | ||
|
|
94bf387aee | ||
|
|
00a08c9f9b | ||
|
|
a99a3df9dd | ||
|
|
fec64af565 | ||
|
|
a2f8112d69 | ||
|
|
80d98b35a5 | ||
|
|
b3a31ec48b | ||
|
|
256dba49db | ||
|
|
30b2515f07 | ||
|
|
7ae6990c97 | ||
|
|
b7bc9ddcb1 | ||
|
|
a71c4138cc | ||
|
|
eed347e1a6 | ||
|
|
0d4fa96b82 | ||
|
|
4b08804aa9 | ||
|
|
8bcabbfb43 | ||
|
|
9a77fd4645 | ||
|
|
c3996573aa | ||
|
|
978dc5cdd8 | ||
|
|
b8d565a9eb | ||
|
|
ef3d4e3094 | ||
|
|
14f8d4d3ad | ||
|
|
9ee23e444c | ||
|
|
85bf47bebb | ||
|
|
b7f3d94950 | ||
|
|
d0c6319fc1 | ||
|
|
bf6d81f9c6 | ||
|
|
aa6a9cbd84 | ||
|
|
9c781f5f2a | ||
|
|
0179f947aa | ||
|
|
9981a4674e |
Binary file not shown.
Binary file not shown.
93
.dockerignore
Normal file
93
.dockerignore
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
# ============================================================
|
||||||
|
# ZCLAW SaaS Backend - Docker Ignore
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
# Build artifacts
|
||||||
|
target/
|
||||||
|
|
||||||
|
# Frontend applications (not needed for SaaS backend)
|
||||||
|
desktop/
|
||||||
|
admin/
|
||||||
|
design-system/
|
||||||
|
|
||||||
|
# Node.js
|
||||||
|
node_modules/
|
||||||
|
.pnpm-store/
|
||||||
|
bun.lock
|
||||||
|
pnpm-lock.yaml
|
||||||
|
package.json
|
||||||
|
package-lock.json
|
||||||
|
|
||||||
|
# Git
|
||||||
|
.git/
|
||||||
|
.gitignore
|
||||||
|
|
||||||
|
# IDE and editor
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
.docker/
|
||||||
|
docker-compose*.yml
|
||||||
|
Dockerfile
|
||||||
|
.dockerignore
|
||||||
|
|
||||||
|
# Documentation
|
||||||
|
docs/
|
||||||
|
*.md
|
||||||
|
!saas-config.toml
|
||||||
|
CLAUDE.md
|
||||||
|
CLAUDE*.md
|
||||||
|
|
||||||
|
# Environment files (secrets)
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
saas-env.example
|
||||||
|
|
||||||
|
# Data files
|
||||||
|
saas-data/
|
||||||
|
saas-data.db
|
||||||
|
saas-data.db-shm
|
||||||
|
saas-data.db-wal
|
||||||
|
*.db
|
||||||
|
*.db-shm
|
||||||
|
*.db-wal
|
||||||
|
|
||||||
|
# Test artifacts
|
||||||
|
tests/
|
||||||
|
test-results/
|
||||||
|
test.rs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Temporary files
|
||||||
|
tmp-screenshot.png
|
||||||
|
tmp/
|
||||||
|
temp/
|
||||||
|
*.tmp
|
||||||
|
|
||||||
|
# Claude worktree metadata
|
||||||
|
.claude/
|
||||||
|
plans/
|
||||||
|
pipelines/
|
||||||
|
scripts/
|
||||||
|
hands/
|
||||||
|
skills/
|
||||||
|
plugins/
|
||||||
|
config/
|
||||||
|
extract.js
|
||||||
|
extract_models.js
|
||||||
|
extract_privacy.js
|
||||||
|
start-all.ps1
|
||||||
|
start.ps1
|
||||||
|
start.sh
|
||||||
|
Makefile
|
||||||
|
PROGRESS.md
|
||||||
|
CHANGELOG.md
|
||||||
|
pencil-new.pen
|
||||||
149
.github/workflows/ci.yml
vendored
Normal file
149
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main, develop]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
env:
|
||||||
|
CARGO_TERM_COLOR: always
|
||||||
|
RUST_BACKTRACE: 1
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint:
|
||||||
|
name: Lint & Format Check
|
||||||
|
runs-on: windows-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install Rust
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
components: rustfmt, clippy
|
||||||
|
|
||||||
|
- name: Install pnpm
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: 9
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: pnpm
|
||||||
|
cache-dependency-path: desktop/pnpm-lock.yaml
|
||||||
|
|
||||||
|
- name: Cache Cargo
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cargo/registry
|
||||||
|
~/.cargo/git
|
||||||
|
target
|
||||||
|
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
|
||||||
|
restore-keys: ${{ runner.os }}-cargo-
|
||||||
|
|
||||||
|
- name: Check Rust formatting
|
||||||
|
working-directory: .
|
||||||
|
run: cargo fmt --check --all
|
||||||
|
|
||||||
|
- name: Rust Clippy
|
||||||
|
working-directory: .
|
||||||
|
run: cargo clippy --workspace -- -D warnings
|
||||||
|
|
||||||
|
- name: Install frontend dependencies
|
||||||
|
working-directory: desktop
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: TypeScript type check
|
||||||
|
working-directory: desktop
|
||||||
|
run: pnpm tsc --noEmit
|
||||||
|
|
||||||
|
test:
|
||||||
|
name: Test
|
||||||
|
runs-on: windows-latest
|
||||||
|
needs: lint
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install Rust
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
|
||||||
|
- name: Install pnpm
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: 9
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: pnpm
|
||||||
|
cache-dependency-path: desktop/pnpm-lock.yaml
|
||||||
|
|
||||||
|
- name: Cache Cargo
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cargo/registry
|
||||||
|
~/.cargo/git
|
||||||
|
target
|
||||||
|
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
|
||||||
|
restore-keys: ${{ runner.os }}-cargo-
|
||||||
|
|
||||||
|
- name: Run Rust tests
|
||||||
|
working-directory: .
|
||||||
|
run: cargo test --workspace
|
||||||
|
|
||||||
|
- name: Install frontend dependencies
|
||||||
|
working-directory: desktop
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Run frontend unit tests
|
||||||
|
working-directory: desktop
|
||||||
|
run: pnpm vitest run
|
||||||
|
|
||||||
|
build:
|
||||||
|
name: Build
|
||||||
|
runs-on: windows-latest
|
||||||
|
needs: test
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install Rust
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
|
||||||
|
- name: Install pnpm
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: 9
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: pnpm
|
||||||
|
cache-dependency-path: desktop/pnpm-lock.yaml
|
||||||
|
|
||||||
|
- name: Cache Cargo
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cargo/registry
|
||||||
|
~/.cargo/git
|
||||||
|
target
|
||||||
|
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
|
||||||
|
restore-keys: ${{ runner.os }}-cargo-
|
||||||
|
|
||||||
|
- name: Rust release build
|
||||||
|
working-directory: .
|
||||||
|
run: cargo build --release --workspace
|
||||||
|
|
||||||
|
- name: Install frontend dependencies
|
||||||
|
working-directory: desktop
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Frontend production build
|
||||||
|
working-directory: desktop
|
||||||
|
run: pnpm build
|
||||||
74
.github/workflows/release.yml
vendored
Normal file
74
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
name: Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
|
||||||
|
env:
|
||||||
|
CARGO_TERM_COLOR: always
|
||||||
|
RUST_BACKTRACE: 1
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
name: Build & Release
|
||||||
|
runs-on: windows-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install Rust
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
|
||||||
|
- name: Install pnpm
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: 9
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: pnpm
|
||||||
|
cache-dependency-path: desktop/pnpm-lock.yaml
|
||||||
|
|
||||||
|
- name: Cache Cargo
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cargo/registry
|
||||||
|
~/.cargo/git
|
||||||
|
target
|
||||||
|
key: ${{ runner.os }}-cargo-release-${{ hashFiles('**/Cargo.lock') }}
|
||||||
|
restore-keys: ${{ runner.os }}-cargo-
|
||||||
|
|
||||||
|
- name: Run Rust tests
|
||||||
|
working-directory: .
|
||||||
|
run: cargo test --workspace
|
||||||
|
|
||||||
|
- name: Install frontend dependencies
|
||||||
|
working-directory: desktop
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Run frontend tests
|
||||||
|
working-directory: desktop
|
||||||
|
run: pnpm vitest run
|
||||||
|
|
||||||
|
- name: Build Tauri application
|
||||||
|
uses: tauri-apps/tauri-action@v0.5
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
with:
|
||||||
|
projectPath: desktop
|
||||||
|
tagName: ${{ github.ref_name }}
|
||||||
|
releaseName: 'ZCLAW ${{ github.ref_name }}'
|
||||||
|
releaseBody: 'See the assets to download and install this version.'
|
||||||
|
releaseDraft: true
|
||||||
|
prerelease: false
|
||||||
|
|
||||||
|
- name: Upload artifacts
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: windows-installer
|
||||||
|
path: desktop/src-tauri/target/release/bundle/nsis/*.exe
|
||||||
14
.gitignore
vendored
14
.gitignore
vendored
@@ -40,9 +40,21 @@ desktop/src-tauri/binaries/
|
|||||||
*.exe
|
*.exe
|
||||||
*.pdb
|
*.pdb
|
||||||
|
|
||||||
#test
|
# Test
|
||||||
desktop/test-results/
|
desktop/test-results/
|
||||||
|
desktop/tests/e2e/test-results/
|
||||||
|
desktop/coverage/
|
||||||
.gstack/
|
.gstack/
|
||||||
.trae/
|
.trae/
|
||||||
target/debug/
|
target/debug/
|
||||||
target/release/
|
target/release/
|
||||||
|
|
||||||
|
# Coverage
|
||||||
|
coverage/
|
||||||
|
*.lcov
|
||||||
|
|
||||||
|
# Session plans
|
||||||
|
plans/
|
||||||
|
|
||||||
|
# Build artifacts
|
||||||
|
desktop/msi-smoke/
|
||||||
|
|||||||
67
CHANGELOG.md
Normal file
67
CHANGELOG.md
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to ZCLAW will be documented in this file.
|
||||||
|
|
||||||
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.1.0] - 2026-03-26
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
#### 核心功能
|
||||||
|
- 多模型 AI 对话,支持流式响应(Anthropic、OpenAI 兼容)
|
||||||
|
- Agent 分身管理(创建、配置、切换)
|
||||||
|
- Hands 自主能力(Browser、Collector、Researcher、Predictor、Lead、Clip、Twitter、Whiteboard、Slideshow、Speech、Quiz)
|
||||||
|
- 可视化工作流编辑器(React Flow)
|
||||||
|
- 技能系统(SKILL.md 定义)
|
||||||
|
- Agent Growth 记忆系统(语义提取、检索、注入)
|
||||||
|
- Pipeline 执行引擎(条件分支、并行执行)
|
||||||
|
- MCP 协议支持
|
||||||
|
- A2A 进程内通信
|
||||||
|
- OS Keyring 安全存储
|
||||||
|
- 加密聊天存储
|
||||||
|
- 离线消息队列
|
||||||
|
- 浏览器自动化
|
||||||
|
|
||||||
|
#### 安全
|
||||||
|
- Content Security Policy 启用
|
||||||
|
- Web fetch SSRF 防护
|
||||||
|
- 路径验证(default-deny 策略)
|
||||||
|
- Shell 命令白名单和危险命令黑名单
|
||||||
|
- API Key 通过 secrecy crate 保护
|
||||||
|
|
||||||
|
#### 基础设施
|
||||||
|
- GitHub Actions CI 流水线(lint、test、build)
|
||||||
|
- GitHub Actions Release 流水线(tag 触发、NSIS 安装包)
|
||||||
|
- Workspace 统一版本管理
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
- Valtio/XState 双轨状态管理层(未完成的迁移)
|
||||||
|
- Stub Channel 适配器(Telegram、Discord、Slack)
|
||||||
|
- 未使用的 Store(meshStore、personaStore)
|
||||||
|
- 不完整的 ActiveLearningPanel 和 skillMarketStore
|
||||||
|
- 未使用的 Feedback 组件目录
|
||||||
|
- Team(团队)和 Swarm(协作)功能(~8,100 行前端代码,零后端支持,Pipeline 系统已覆盖其全部能力)
|
||||||
|
- 调试日志清理(~310 处 console/println 语句)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 版本说明
|
||||||
|
|
||||||
|
### 版本号格式
|
||||||
|
|
||||||
|
- **主版本号**: 重大架构变更或不兼容的 API 修改
|
||||||
|
- **次版本号**: 向后兼容的功能新增
|
||||||
|
- **修订号**: 向后兼容的问题修复
|
||||||
|
|
||||||
|
### 变更类型
|
||||||
|
|
||||||
|
- `Added`: 新增功能
|
||||||
|
- `Changed`: 功能变更
|
||||||
|
- `Deprecated`: 即将废弃的功能
|
||||||
|
- `Removed`: 已移除的功能
|
||||||
|
- `Fixed`: 问题修复
|
||||||
|
- `Security`: 安全相关修复
|
||||||
15
CLAUDE.md
15
CLAUDE.md
@@ -36,7 +36,7 @@ ZCLAW/
|
|||||||
│ ├── zclaw-kernel/ # L4: 核心协调 (注册, 调度, 事件, 工作流)
|
│ ├── zclaw-kernel/ # L4: 核心协调 (注册, 调度, 事件, 工作流)
|
||||||
│ ├── zclaw-skills/ # 技能系统 (SKILL.md解析, 执行器)
|
│ ├── zclaw-skills/ # 技能系统 (SKILL.md解析, 执行器)
|
||||||
│ ├── zclaw-hands/ # 自主能力 (Hand/Trigger 注册管理)
|
│ ├── zclaw-hands/ # 自主能力 (Hand/Trigger 注册管理)
|
||||||
│ ├── zclaw-channels/ # 通道适配器 (Telegram, Discord, Slack)
|
│ ├── zclaw-channels/ # 通道适配器 (仅 ConsoleChannel 测试适配器)
|
||||||
│ └── zclaw-protocols/ # 协议支持 (MCP, A2A)
|
│ └── zclaw-protocols/ # 协议支持 (MCP, A2A)
|
||||||
├── desktop/ # Tauri 桌面应用
|
├── desktop/ # Tauri 桌面应用
|
||||||
│ ├── src/
|
│ ├── src/
|
||||||
@@ -175,24 +175,27 @@ Client → 负责网络通信和```
|
|||||||
| 分身管理 | ✅ 完成 | 创建、配置、切换 Agent |
|
| 分身管理 | ✅ 完成 | 创建、配置、切换 Agent |
|
||||||
| 自动化面板 | ✅ 完成 | Hands 触发、参数配置 |
|
| 自动化面板 | ✅ 完成 | Hands 触发、参数配置 |
|
||||||
| 技能市场 | 🚧 进行中 | 技能浏览和安装 |
|
| 技能市场 | 🚧 进行中 | 技能浏览和安装 |
|
||||||
| 工作流编辑 | 📋 计划中 | 多步骤任务编排 |
|
| 工作流编辑 | 🚧 进行中 | Pipeline 工作流编辑器 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. 自主能力系统 (Hands)
|
## 6. 自主能力系统 (Hands)
|
||||||
|
|
||||||
ZCLAW 提供 8 个自主能力包:
|
ZCLAW 提供 11 个自主能力包:
|
||||||
|
|
||||||
| Hand | 功能 | 状态 |
|
| Hand | 功能 | 状态 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| Browser | 浏览器自动化 | ✅ 可用 |
|
| Browser | 浏览器自动化 | ✅ 可用 |
|
||||||
| Collector | 数据收集聚合 | ✅ 可用 |
|
| Collector | 数据收集聚合 | ✅ 可用 |
|
||||||
| Researcher | 深度研究 | ✅ 可用 |
|
| Researcher | 深度研究 | ✅ 可用 |
|
||||||
| Predictor | 预测分析 | ✅ 可用 |
|
| Predictor | 预测分析 | ❌ 已禁用 (enabled=false),无 Rust 实现 |
|
||||||
| Lead | 销售线索发现 | ✅ 可用 |
|
| Lead | 销售线索发现 | ❌ 已禁用 (enabled=false),无 Rust 实现 |
|
||||||
| Trader | 交易分析 | ✅ 可用 |
|
|
||||||
| Clip | 视频处理 | ⚠️ 需 FFmpeg |
|
| Clip | 视频处理 | ⚠️ 需 FFmpeg |
|
||||||
| Twitter | Twitter 自动化 | ⚠️ 需 API Key |
|
| Twitter | Twitter 自动化 | ⚠️ 需 API Key |
|
||||||
|
| Whiteboard | 白板演示 | ✅ 可用(导出功能开发中,标注 demo) |
|
||||||
|
| Slideshow | 幻灯片生成 | ✅ 可用 |
|
||||||
|
| Speech | 语音合成 | ✅ 可用 |
|
||||||
|
| Quiz | 测验生成 | ✅ 可用 |
|
||||||
|
|
||||||
**触发 Hand 时:**
|
**触发 Hand 时:**
|
||||||
1. 检查依赖是否满足
|
1. 检查依赖是否满足
|
||||||
|
|||||||
770
Cargo.lock
generated
770
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
29
Cargo.toml
29
Cargo.toml
@@ -11,8 +11,12 @@ members = [
|
|||||||
"crates/zclaw-hands",
|
"crates/zclaw-hands",
|
||||||
"crates/zclaw-channels",
|
"crates/zclaw-channels",
|
||||||
"crates/zclaw-protocols",
|
"crates/zclaw-protocols",
|
||||||
|
"crates/zclaw-pipeline",
|
||||||
|
"crates/zclaw-growth",
|
||||||
# Desktop Application
|
# Desktop Application
|
||||||
"desktop/src-tauri",
|
"desktop/src-tauri",
|
||||||
|
# SaaS Backend
|
||||||
|
"crates/zclaw-saas",
|
||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
@@ -53,11 +57,15 @@ chrono = { version = "0.4", features = ["serde"] }
|
|||||||
uuid = { version = "1", features = ["v4", "v5", "serde"] }
|
uuid = { version = "1", features = ["v4", "v5", "serde"] }
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
sqlx = { version = "0.7", features = ["runtime-tokio", "sqlite"] }
|
sqlx = { version = "0.7", features = ["runtime-tokio", "postgres", "chrono"] }
|
||||||
|
libsqlite3-sys = { version = "0.27", features = ["bundled"] }
|
||||||
|
|
||||||
# HTTP client (for LLM drivers)
|
# HTTP client (for LLM drivers)
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls"] }
|
reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls"] }
|
||||||
|
|
||||||
|
# URL parsing
|
||||||
|
url = "2"
|
||||||
|
|
||||||
# Async trait
|
# Async trait
|
||||||
async-trait = "0.1"
|
async-trait = "0.1"
|
||||||
|
|
||||||
@@ -83,6 +91,22 @@ dirs = "6"
|
|||||||
# Regex
|
# Regex
|
||||||
regex = "1"
|
regex = "1"
|
||||||
|
|
||||||
|
# Shell parsing
|
||||||
|
shlex = "1"
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
tempfile = "3"
|
||||||
|
|
||||||
|
# SaaS dependencies
|
||||||
|
axum = { version = "0.7", features = ["macros"] }
|
||||||
|
axum-extra = { version = "0.9", features = ["typed-header"] }
|
||||||
|
tower = { version = "0.4", features = ["util"] }
|
||||||
|
tower-http = { version = "0.5", features = ["cors", "trace", "limit"] }
|
||||||
|
jsonwebtoken = "9"
|
||||||
|
argon2 = "0.5"
|
||||||
|
totp-rs = "5"
|
||||||
|
hex = "0.4"
|
||||||
|
|
||||||
# Internal crates
|
# Internal crates
|
||||||
zclaw-types = { path = "crates/zclaw-types" }
|
zclaw-types = { path = "crates/zclaw-types" }
|
||||||
zclaw-memory = { path = "crates/zclaw-memory" }
|
zclaw-memory = { path = "crates/zclaw-memory" }
|
||||||
@@ -92,6 +116,9 @@ zclaw-skills = { path = "crates/zclaw-skills" }
|
|||||||
zclaw-hands = { path = "crates/zclaw-hands" }
|
zclaw-hands = { path = "crates/zclaw-hands" }
|
||||||
zclaw-channels = { path = "crates/zclaw-channels" }
|
zclaw-channels = { path = "crates/zclaw-channels" }
|
||||||
zclaw-protocols = { path = "crates/zclaw-protocols" }
|
zclaw-protocols = { path = "crates/zclaw-protocols" }
|
||||||
|
zclaw-pipeline = { path = "crates/zclaw-pipeline" }
|
||||||
|
zclaw-growth = { path = "crates/zclaw-growth" }
|
||||||
|
zclaw-saas = { path = "crates/zclaw-saas" }
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
lto = true
|
lto = true
|
||||||
|
|||||||
83
Dockerfile
Normal file
83
Dockerfile
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
# ============================================================
|
||||||
|
# ZCLAW SaaS Backend - Multi-stage Docker Build
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
# ---- Stage 1: Builder ----
|
||||||
|
FROM rust:1.75-bookworm AS builder
|
||||||
|
|
||||||
|
# Install build dependencies for sqlx (postgres) and libsqlite3-sys (bundled)
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
pkg-config \
|
||||||
|
libssl-dev \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy workspace manifests first to leverage Docker layer caching
|
||||||
|
COPY Cargo.toml Cargo.lock ./
|
||||||
|
|
||||||
|
# Create stub source files so cargo can resolve and cache dependencies
|
||||||
|
# This avoids rebuilding dependencies when only application code changes
|
||||||
|
RUN mkdir -p crates/zclaw-saas/src \
|
||||||
|
&& echo 'fn main() {}' > crates/zclaw-saas/src/main.rs \
|
||||||
|
&& for crate in zclaw-types zclaw-memory zclaw-runtime zclaw-kernel \
|
||||||
|
zclaw-skills zclaw-hands zclaw-channels zclaw-protocols \
|
||||||
|
zclaw-pipeline zclaw-growth; do \
|
||||||
|
mkdir -p crates/$crate/src && echo '' > crates/$crate/src/lib.rs; \
|
||||||
|
done \
|
||||||
|
&& mkdir -p desktop/src-tauri/src && echo 'fn main() {}' > desktop/src-tauri/src/main.rs
|
||||||
|
|
||||||
|
# Pre-build dependencies (release profile with caching)
|
||||||
|
RUN cargo build --release --package zclaw-saas 2>/dev/null || true
|
||||||
|
|
||||||
|
# Copy actual source code (invalidates stubs, triggers recompile of app code only)
|
||||||
|
COPY crates/ crates/
|
||||||
|
COPY desktop/ desktop/
|
||||||
|
|
||||||
|
# Touch source files to invalidate the stub timestamps
|
||||||
|
RUN touch crates/zclaw-saas/src/main.rs \
|
||||||
|
&& for crate in zclaw-types zclaw-memory zclaw-runtime zclaw-kernel \
|
||||||
|
zclaw-skills zclaw-hands zclaw-channels zclaw-protocols \
|
||||||
|
zclaw-pipeline zclaw-growth; do \
|
||||||
|
touch crates/$crate/src/lib.rs 2>/dev/null || true; \
|
||||||
|
done \
|
||||||
|
&& touch desktop/src-tauri/src/main.rs 2>/dev/null || true
|
||||||
|
|
||||||
|
# Build the actual binary
|
||||||
|
RUN cargo build --release --package zclaw-saas
|
||||||
|
|
||||||
|
# ---- Stage 2: Runtime ----
|
||||||
|
FROM debian:bookworm-slim AS runtime
|
||||||
|
|
||||||
|
# Install runtime dependencies (ca-certificates for HTTPS, libgcc for Rust runtime)
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
ca-certificates \
|
||||||
|
libgcc-s \
|
||||||
|
&& rm -rf /var/lib/apt/lists/* \
|
||||||
|
&& update-ca-certificates
|
||||||
|
|
||||||
|
# Create non-root user for security
|
||||||
|
RUN groupadd --gid 1000 zclaw \
|
||||||
|
&& useradd --uid 1000 --gid zclaw --shell /bin/false zclaw
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy binary from builder
|
||||||
|
COPY --from=builder /app/target/release/zclaw-saas /app/zclaw-saas
|
||||||
|
|
||||||
|
# Copy configuration file
|
||||||
|
COPY saas-config.toml /app/saas-config.toml
|
||||||
|
|
||||||
|
# Ensure the non-root user owns the application files
|
||||||
|
RUN chown -R zclaw:zclaw /app
|
||||||
|
|
||||||
|
USER zclaw
|
||||||
|
|
||||||
|
# Expose the SaaS API port
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
# Health check endpoint (matches the saas-config.toml port)
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||||
|
CMD ["/app/zclaw-saas", "--healthcheck"] || exit 1
|
||||||
|
|
||||||
|
ENTRYPOINT ["/app/zclaw-saas"]
|
||||||
35
LICENSE
Normal file
35
LICENSE
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 ZCLAW Contributors
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Attribution Notice
|
||||||
|
==================
|
||||||
|
|
||||||
|
This software is based on and incorporates code from the OpenFang project
|
||||||
|
(https://github.com/nicepkg/openfang), which is licensed under the MIT License.
|
||||||
|
|
||||||
|
Original OpenFang Copyright:
|
||||||
|
Copyright (c) nicepkg
|
||||||
|
|
||||||
|
The OpenFang project provided the foundational architecture, security framework,
|
||||||
|
and agent runtime concepts that were adapted and extended to create ZCLAW.
|
||||||
35
Makefile
35
Makefile
@@ -1,10 +1,12 @@
|
|||||||
# ZCLAW Makefile
|
# ZCLAW Makefile
|
||||||
# Cross-platform task runner
|
# Cross-platform task runner
|
||||||
|
|
||||||
.PHONY: help start start-dev start-no-browser desktop desktop-build setup test clean
|
.PHONY: help start start-dev start-no-browser desktop desktop-build setup test clean \
|
||||||
|
saas-build saas-run saas-test saas-test-integration saas-clippy saas-migrate \
|
||||||
|
saas-docker-up saas-docker-down saas-docker-build
|
||||||
|
|
||||||
help: ## Show this help message
|
help: ## Show this help message
|
||||||
@echo "ZCLAW - OpenFang Desktop Client"
|
@echo "ZCLAW - AI Agent Desktop Client"
|
||||||
@echo ""
|
@echo ""
|
||||||
@echo "Usage: make [target]"
|
@echo "Usage: make [target]"
|
||||||
@echo ""
|
@echo ""
|
||||||
@@ -71,3 +73,32 @@ clean-deep: clean ## Deep clean (including pnpm cache)
|
|||||||
@rm -rf desktop/pnpm-lock.yaml
|
@rm -rf desktop/pnpm-lock.yaml
|
||||||
@rm -rf pnpm-lock.yaml
|
@rm -rf pnpm-lock.yaml
|
||||||
@echo "Deep clean complete. Run 'pnpm install' to reinstall."
|
@echo "Deep clean complete. Run 'pnpm install' to reinstall."
|
||||||
|
|
||||||
|
# === SaaS Backend ===
|
||||||
|
|
||||||
|
saas-build: ## Build zclaw-saas crate
|
||||||
|
@cargo build -p zclaw-saas
|
||||||
|
|
||||||
|
saas-run: ## Start SaaS backend (cargo run)
|
||||||
|
@cargo run -p zclaw-saas
|
||||||
|
|
||||||
|
saas-test: ## Run SaaS unit tests
|
||||||
|
@cargo test -p zclaw-saas -- --test-threads=1
|
||||||
|
|
||||||
|
saas-test-integration: ## Run SaaS integration tests (requires PostgreSQL)
|
||||||
|
@cargo test -p zclaw-saas -- --ignored --test-threads=1
|
||||||
|
|
||||||
|
saas-clippy: ## Run clippy on zclaw-saas
|
||||||
|
@cargo clippy -p zclaw-saas -- -D warnings
|
||||||
|
|
||||||
|
saas-migrate: ## Run database migrations
|
||||||
|
@cargo run -p zclaw-saas -- --migrate
|
||||||
|
|
||||||
|
saas-docker-up: ## Start SaaS services (PostgreSQL + backend)
|
||||||
|
@docker compose up -d
|
||||||
|
|
||||||
|
saas-docker-down: ## Stop SaaS services
|
||||||
|
@docker compose down
|
||||||
|
|
||||||
|
saas-docker-build: ## Build SaaS Docker images
|
||||||
|
@docker compose build
|
||||||
|
|||||||
72
README.md
72
README.md
@@ -1,11 +1,11 @@
|
|||||||
# ZCLAW 🦞 — OpenFang 定制版 (Tauri Desktop)
|
# ZCLAW 🦞 — ZCLAW 定制版 (Tauri Desktop)
|
||||||
|
|
||||||
基于 [OpenFang](https://openfang.sh/) —— 用 Rust 构建的 Agent 操作系统,打造中文优先的 Tauri 桌面 AI 助手。
|
基于 [ZCLAW](https://zclaw.sh/) —— 用 Rust 构建的 Agent 操作系统,打造中文优先的 Tauri 桌面 AI 助手。
|
||||||
|
|
||||||
## 核心定位
|
## 核心定位
|
||||||
|
|
||||||
```
|
```
|
||||||
OpenFang Kernel (Rust 执行引擎)
|
ZCLAW Kernel (Rust 执行引擎)
|
||||||
↕ WebSocket / HTTP API
|
↕ WebSocket / HTTP API
|
||||||
ZCLAW Tauri App (桌面 UI)
|
ZCLAW Tauri App (桌面 UI)
|
||||||
+ 中文模型 Provider (GLM/Qwen/Kimi/MiniMax/DeepSeek)
|
+ 中文模型 Provider (GLM/Qwen/Kimi/MiniMax/DeepSeek)
|
||||||
@@ -16,11 +16,11 @@ ZCLAW Tauri App (桌面 UI)
|
|||||||
+ 自定义 Skills
|
+ 自定义 Skills
|
||||||
```
|
```
|
||||||
|
|
||||||
## 为什么选择 OpenFang?
|
## 为什么选择 ZCLAW?
|
||||||
|
|
||||||
相比 OpenClaw,OpenFang 提供了更强的性能和更丰富的功能:
|
相比 ZCLAW,ZCLAW 提供了更强的性能和更丰富的功能:
|
||||||
|
|
||||||
| 特性 | OpenFang | OpenClaw |
|
| 特性 | ZCLAW | ZCLAW |
|
||||||
|------|----------|----------|
|
|------|----------|----------|
|
||||||
| **开发语言** | Rust | TypeScript |
|
| **开发语言** | Rust | TypeScript |
|
||||||
| **冷启动** | < 200ms | ~6s |
|
| **冷启动** | < 200ms | ~6s |
|
||||||
@@ -30,11 +30,11 @@ ZCLAW Tauri App (桌面 UI)
|
|||||||
| **渠道适配器** | 40 个 | 13 个 |
|
| **渠道适配器** | 40 个 | 13 个 |
|
||||||
| **LLM 提供商** | 27 个 | ~10 个 |
|
| **LLM 提供商** | 27 个 | ~10 个 |
|
||||||
|
|
||||||
**详细对比**:[OpenFang 架构概览](https://wurang.net/posts/openfang-intro/)
|
**详细对比**:[ZCLAW 架构概览](https://wurang.net/posts/zclaw-intro/)
|
||||||
|
|
||||||
## 功能特色
|
## 功能特色
|
||||||
|
|
||||||
- **基于 OpenFang**: 生产级 Agent 操作系统,16 层安全防护,WASM 沙箱
|
- **基于 ZCLAW**: 生产级 Agent 操作系统,16 层安全防护,WASM 沙箱
|
||||||
- **7 个自主 Hands**: Browser/Researcher/Collector/Predictor/Lead/Clip/Twitter - 预构建的"数字员工"
|
- **7 个自主 Hands**: Browser/Researcher/Collector/Predictor/Lead/Clip/Twitter - 预构建的"数字员工"
|
||||||
- **中文模型**: 智谱 GLM-4、通义千问、Kimi、MiniMax、DeepSeek (OpenAI 兼容 API)
|
- **中文模型**: 智谱 GLM-4、通义千问、Kimi、MiniMax、DeepSeek (OpenAI 兼容 API)
|
||||||
- **40+ 渠道**: 飞书、钉钉、Telegram、Discord、Slack、微信等
|
- **40+ 渠道**: 飞书、钉钉、Telegram、Discord、Slack、微信等
|
||||||
@@ -47,10 +47,10 @@ ZCLAW Tauri App (桌面 UI)
|
|||||||
|
|
||||||
| 层级 | 技术 |
|
| 层级 | 技术 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| **执行引擎** | OpenFang Kernel (Rust, http://127.0.0.1:50051) |
|
| **执行引擎** | ZCLAW Kernel (Rust, http://127.0.0.1:50051) |
|
||||||
| **桌面壳** | Tauri 2.0 (Rust + React 19) |
|
| **桌面壳** | Tauri 2.0 (Rust + React 19) |
|
||||||
| **前端** | React 19 + TailwindCSS + Zustand + Lucide Icons |
|
| **前端** | React 19 + TailwindCSS + Zustand + Lucide Icons |
|
||||||
| **通信协议** | OpenFang API (REST/WS/SSE) + OpenAI 兼容 API |
|
| **通信协议** | ZCLAW API (REST/WS/SSE) + OpenAI 兼容 API |
|
||||||
| **安全** | WASM 沙箱 + Merkle 审计追踪 + Ed25519 签名 |
|
| **安全** | WASM 沙箱 + Merkle 审计追踪 + Ed25519 签名 |
|
||||||
|
|
||||||
## 项目结构
|
## 项目结构
|
||||||
@@ -61,7 +61,7 @@ ZClaw/
|
|||||||
│ ├── src/
|
│ ├── src/
|
||||||
│ │ ├── components/ # UI 组件
|
│ │ ├── components/ # UI 组件
|
||||||
│ │ ├── store/ # Zustand 状态管理
|
│ │ ├── store/ # Zustand 状态管理
|
||||||
│ │ └── lib/gateway-client.ts # OpenFang API 客户端
|
│ │ └── lib/gateway-client.ts # ZCLAW API 客户端
|
||||||
│ └── src-tauri/ # Rust 后端
|
│ └── src-tauri/ # Rust 后端
|
||||||
│
|
│
|
||||||
├── skills/ # 自定义技能 (SKILL.md)
|
├── skills/ # 自定义技能 (SKILL.md)
|
||||||
@@ -71,14 +71,14 @@ ZClaw/
|
|||||||
├── hands/ # 自定义 Hands (HAND.toml)
|
├── hands/ # 自定义 Hands (HAND.toml)
|
||||||
│ └── custom-automation/ # 自定义自动化任务
|
│ └── custom-automation/ # 自定义自动化任务
|
||||||
│
|
│
|
||||||
├── config/ # OpenFang 默认配置
|
├── config/ # ZCLAW 默认配置
|
||||||
│ ├── config.toml # 主配置文件
|
│ ├── config.toml # 主配置文件
|
||||||
│ ├── SOUL.md # Agent 人格
|
│ ├── SOUL.md # Agent 人格
|
||||||
│ └── AGENTS.md # Agent 指令
|
│ └── AGENTS.md # Agent 指令
|
||||||
│
|
│
|
||||||
├── docs/
|
├── docs/
|
||||||
│ ├── setup/ # 设置指南
|
│ ├── setup/ # 设置指南
|
||||||
│ │ ├── OPENFANG-SETUP.md # OpenFang 配置指南
|
│ │ ├── ZCLAW-SETUP.md # ZCLAW 配置指南
|
||||||
│ │ └── chinese-models.md # 中文模型配置
|
│ │ └── chinese-models.md # 中文模型配置
|
||||||
│ ├── architecture-v2.md # 架构设计
|
│ ├── architecture-v2.md # 架构设计
|
||||||
│ └── deviation-analysis.md # 偏离分析报告
|
│ └── deviation-analysis.md # 偏离分析报告
|
||||||
@@ -88,20 +88,20 @@ ZClaw/
|
|||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
|
|
||||||
### 1. 安装 OpenFang
|
### 1. 安装 ZCLAW
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Windows (PowerShell)
|
# Windows (PowerShell)
|
||||||
iwr -useb https://openfang.sh/install.ps1 | iex
|
iwr -useb https://zclaw.sh/install.ps1 | iex
|
||||||
|
|
||||||
# macOS / Linux
|
# macOS / Linux
|
||||||
curl -fsSL https://openfang.sh/install.sh | bash
|
curl -fsSL https://zclaw.sh/install.sh | bash
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. 初始化配置
|
### 2. 初始化配置
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
openfang init
|
zclaw init
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3. 配置 API Key
|
### 3. 配置 API Key
|
||||||
@@ -121,8 +121,8 @@ export DEEPSEEK_API_KEY="your-deepseek-key" # DeepSeek
|
|||||||
### 4. 启动服务
|
### 4. 启动服务
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 启动 OpenFang Kernel
|
# 启动 ZCLAW Kernel
|
||||||
openfang start
|
zclaw start
|
||||||
|
|
||||||
# 在另一个终端启动 ZCLAW 桌面应用
|
# 在另一个终端启动 ZCLAW 桌面应用
|
||||||
git clone https://github.com/xxx/ZClaw.git
|
git clone https://github.com/xxx/ZClaw.git
|
||||||
@@ -134,16 +134,16 @@ cd desktop && pnpm tauri dev
|
|||||||
### 5. 验证安装
|
### 5. 验证安装
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 检查 OpenFang 状态
|
# 检查 ZCLAW 状态
|
||||||
openfang status
|
zclaw status
|
||||||
|
|
||||||
# 运行健康检查
|
# 运行健康检查
|
||||||
openfang doctor
|
zclaw doctor
|
||||||
```
|
```
|
||||||
|
|
||||||
## OpenFang Hands (自主能力)
|
## ZCLAW Hands (自主能力)
|
||||||
|
|
||||||
OpenFang 内置 7 个预构建的自主能力包,每个 Hand 都是一个具备完整工作流的"数字员工":
|
ZCLAW 内置 7 个预构建的自主能力包,每个 Hand 都是一个具备完整工作流的"数字员工":
|
||||||
|
|
||||||
| Hand | 功能 | 状态 |
|
| Hand | 功能 | 状态 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
@@ -170,36 +170,36 @@ OpenFang 内置 7 个预构建的自主能力包,每个 Hand 都是一个具
|
|||||||
## 文档
|
## 文档
|
||||||
|
|
||||||
### 设置指南
|
### 设置指南
|
||||||
- [OpenFang Kernel 配置指南](docs/setup/OPENFANG-SETUP.md) - 安装、配置、常见问题
|
- [ZCLAW Kernel 配置指南](docs/setup/ZCLAW-SETUP.md) - 安装、配置、常见问题
|
||||||
- [中文模型配置指南](docs/setup/chinese-models.md) - API Key 获取、模型选择、多模型配置
|
- [中文模型配置指南](docs/setup/chinese-models.md) - API Key 获取、模型选择、多模型配置
|
||||||
|
|
||||||
### 架构设计
|
### 架构设计
|
||||||
- [架构设计](docs/architecture-v2.md) — 完整的 v2 架构方案
|
- [架构设计](docs/architecture-v2.md) — 完整的 v2 架构方案
|
||||||
- [偏离分析](docs/deviation-analysis.md) — 与 QClaw/AutoClaw/OpenClaw 对标分析
|
- [偏离分析](docs/deviation-analysis.md) — 与 QClaw/AutoClaw/ZCLAW 对标分析
|
||||||
|
|
||||||
### 外部资源
|
### 外部资源
|
||||||
- [OpenFang 官方文档](https://openfang.sh/)
|
- [ZCLAW 官方文档](https://zclaw.sh/)
|
||||||
- [OpenFang GitHub](https://github.com/RightNow-AI/openfang)
|
- [ZCLAW GitHub](https://github.com/RightNow-AI/zclaw)
|
||||||
- [OpenFang 架构概览](https://wurang.net/posts/openfang-intro/)
|
- [ZCLAW 架构概览](https://wurang.net/posts/zclaw-intro/)
|
||||||
|
|
||||||
## 对标参考
|
## 对标参考
|
||||||
|
|
||||||
| 产品 | 基于 | IM 渠道 | 桌面框架 | 安全层数 |
|
| 产品 | 基于 | IM 渠道 | 桌面框架 | 安全层数 |
|
||||||
|------|------|---------|----------|----------|
|
|------|------|---------|----------|----------|
|
||||||
| **QClaw** (腾讯) | OpenClaw | 微信 + QQ | Electron | 3 |
|
| **QClaw** (腾讯) | ZCLAW | 微信 + QQ | Electron | 3 |
|
||||||
| **AutoClaw** (智谱) | OpenClaw | 飞书 | 自研 | 3 |
|
| **AutoClaw** (智谱) | ZCLAW | 飞书 | 自研 | 3 |
|
||||||
| **ZCLAW** (本项目) | OpenFang | 飞书 + 钉钉 + 40+ | Tauri 2.0 | 16 |
|
| **ZCLAW** (本项目) | ZCLAW | 飞书 + 钉钉 + 40+ | Tauri 2.0 | 16 |
|
||||||
|
|
||||||
## 从 OpenClaw 迁移
|
## 从 ZCLAW 迁移
|
||||||
|
|
||||||
如果你之前使用 OpenClaw,可以一键迁移:
|
如果你之前使用 ZCLAW,可以一键迁移:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 迁移所有内容:代理、记忆、技能、配置
|
# 迁移所有内容:代理、记忆、技能、配置
|
||||||
openfang migrate --from openclaw
|
zclaw migrate --from zclaw
|
||||||
|
|
||||||
# 先试运行查看变更
|
# 先试运行查看变更
|
||||||
openfang migrate --from openclaw --dry-run
|
zclaw migrate --from zclaw --dry-run
|
||||||
```
|
```
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|||||||
4
admin/.gitignore
vendored
Normal file
4
admin/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
.next/
|
||||||
|
node_modules/
|
||||||
|
.env.local
|
||||||
|
.env*.local
|
||||||
5
admin/next-env.d.ts
vendored
Normal file
5
admin/next-env.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
/// <reference types="next" />
|
||||||
|
/// <reference types="next/image-types/global" />
|
||||||
|
|
||||||
|
// NOTE: This file should not be edited
|
||||||
|
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
|
||||||
44
admin/next.config.js
Normal file
44
admin/next.config.js
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
async headers() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
source: '/(.*)',
|
||||||
|
headers: [
|
||||||
|
{
|
||||||
|
key: 'X-Frame-Options',
|
||||||
|
value: 'DENY',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'X-Content-Type-Options',
|
||||||
|
value: 'nosniff',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'Referrer-Policy',
|
||||||
|
value: 'strict-origin-when-cross-origin',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'Content-Security-Policy',
|
||||||
|
value: [
|
||||||
|
"default-src 'self'",
|
||||||
|
"script-src 'self' 'unsafe-eval' 'unsafe-inline'",
|
||||||
|
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
|
||||||
|
"font-src 'self' https://fonts.gstatic.com",
|
||||||
|
"img-src 'self' data: blob:",
|
||||||
|
"connect-src 'self'",
|
||||||
|
"frame-ancestors 'none'",
|
||||||
|
"base-uri 'self'",
|
||||||
|
"form-action 'self'",
|
||||||
|
].join('; '),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'Permissions-Policy',
|
||||||
|
value: 'camera=(), microphone=(), geolocation=()',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = nextConfig
|
||||||
38
admin/package.json
Normal file
38
admin/package.json
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"name": "zclaw-admin",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "next lint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-dialog": "^1.1.14",
|
||||||
|
"@radix-ui/react-select": "^2.2.5",
|
||||||
|
"@radix-ui/react-separator": "^1.1.7",
|
||||||
|
"@radix-ui/react-switch": "^1.2.5",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.12",
|
||||||
|
"@radix-ui/react-tooltip": "^1.2.7",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-react": "^0.484.0",
|
||||||
|
"next": "14.2.29",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"recharts": "^2.15.3",
|
||||||
|
"sonner": "^2.0.7",
|
||||||
|
"tailwind-merge": "^3.0.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^20.17.19",
|
||||||
|
"@types/react": "^18.3.18",
|
||||||
|
"@types/react-dom": "^18.3.5",
|
||||||
|
"autoprefixer": "^10.4.20",
|
||||||
|
"postcss": "^8.5.3",
|
||||||
|
"tailwindcss": "^3.4.17",
|
||||||
|
"typescript": "^5.7.3"
|
||||||
|
},
|
||||||
|
"packageManager": "pnpm@10.30.2"
|
||||||
|
}
|
||||||
2185
admin/pnpm-lock.yaml
generated
Normal file
2185
admin/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
6
admin/postcss.config.js
Normal file
6
admin/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
400
admin/src/app/(dashboard)/accounts/page.tsx
Normal file
400
admin/src/app/(dashboard)/accounts/page.tsx
Normal file
@@ -0,0 +1,400 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
|
import {
|
||||||
|
Search,
|
||||||
|
Plus,
|
||||||
|
Loader2,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Pencil,
|
||||||
|
Ban,
|
||||||
|
CheckCircle2,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
DialogDescription,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
import { api } from '@/lib/api-client'
|
||||||
|
import { ApiRequestError } from '@/lib/api-client'
|
||||||
|
import { formatDate } from '@/lib/utils'
|
||||||
|
import type { AccountPublic } from '@/lib/types'
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20
|
||||||
|
|
||||||
|
const roleLabels: Record<string, string> = {
|
||||||
|
super_admin: '超级管理员',
|
||||||
|
admin: '管理员',
|
||||||
|
user: '普通用户',
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusColors: Record<string, 'success' | 'destructive' | 'warning'> = {
|
||||||
|
active: 'success',
|
||||||
|
disabled: 'destructive',
|
||||||
|
suspended: 'warning',
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusLabels: Record<string, string> = {
|
||||||
|
active: '正常',
|
||||||
|
disabled: '已禁用',
|
||||||
|
suspended: '已暂停',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AccountsPage() {
|
||||||
|
const [accounts, setAccounts] = useState<AccountPublic[]>([])
|
||||||
|
const [total, setTotal] = useState(0)
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
|
||||||
|
// 搜索 debounce: 输入后 300ms 再触发请求
|
||||||
|
const [debouncedSearchState, setDebouncedSearchState] = useState('')
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => setDebouncedSearchState(search), 300)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}, [search])
|
||||||
|
const [roleFilter, setRoleFilter] = useState<string>('all')
|
||||||
|
const [statusFilter, setStatusFilter] = useState<string>('all')
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
// 编辑 Dialog
|
||||||
|
const [editTarget, setEditTarget] = useState<AccountPublic | null>(null)
|
||||||
|
const [editForm, setEditForm] = useState({ display_name: '', email: '', role: 'user' })
|
||||||
|
const [editSaving, setEditSaving] = useState(false)
|
||||||
|
|
||||||
|
// 确认 Dialog
|
||||||
|
const [confirmTarget, setConfirmTarget] = useState<{ id: string; action: string; status: string } | null>(null)
|
||||||
|
const [confirmSaving, setConfirmSaving] = useState(false)
|
||||||
|
|
||||||
|
const fetchAccounts = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const params: Record<string, unknown> = { page, page_size: PAGE_SIZE }
|
||||||
|
if (debouncedSearchState.trim()) params.search = debouncedSearchState.trim()
|
||||||
|
if (roleFilter !== 'all') params.role = roleFilter
|
||||||
|
if (statusFilter !== 'all') params.status = statusFilter
|
||||||
|
|
||||||
|
const res = await api.accounts.list(params)
|
||||||
|
setAccounts(res.items)
|
||||||
|
setTotal(res.total)
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiRequestError) {
|
||||||
|
setError(err.body.message)
|
||||||
|
} else {
|
||||||
|
setError('加载失败')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [page, debouncedSearchState, roleFilter, statusFilter])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchAccounts()
|
||||||
|
}, [fetchAccounts])
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
|
||||||
|
|
||||||
|
function openEditDialog(account: AccountPublic) {
|
||||||
|
setEditTarget(account)
|
||||||
|
setEditForm({
|
||||||
|
display_name: account.display_name,
|
||||||
|
email: account.email,
|
||||||
|
role: account.role,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleEditSave() {
|
||||||
|
if (!editTarget) return
|
||||||
|
setEditSaving(true)
|
||||||
|
try {
|
||||||
|
await api.accounts.update(editTarget.id, {
|
||||||
|
display_name: editForm.display_name,
|
||||||
|
email: editForm.email,
|
||||||
|
role: editForm.role as AccountPublic['role'],
|
||||||
|
})
|
||||||
|
setEditTarget(null)
|
||||||
|
fetchAccounts()
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiRequestError) {
|
||||||
|
setError(err.body.message)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setEditSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openConfirmDialog(account: AccountPublic) {
|
||||||
|
const newStatus = account.status === 'active' ? 'disabled' : 'active'
|
||||||
|
setConfirmTarget({
|
||||||
|
id: account.id,
|
||||||
|
action: newStatus === 'disabled' ? '禁用' : '启用',
|
||||||
|
status: newStatus,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleConfirmSave() {
|
||||||
|
if (!confirmTarget) return
|
||||||
|
setConfirmSaving(true)
|
||||||
|
try {
|
||||||
|
await api.accounts.updateStatus(confirmTarget.id, {
|
||||||
|
status: confirmTarget.status as AccountPublic['status'],
|
||||||
|
})
|
||||||
|
setConfirmTarget(null)
|
||||||
|
fetchAccounts()
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiRequestError) {
|
||||||
|
setError(err.body.message)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setConfirmSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 搜索和筛选 */}
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<div className="relative flex-1 min-w-[200px] max-w-sm">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="搜索用户名 / 邮箱 / 显示名..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => { setSearch(e.target.value); setPage(1) }}
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Select value={roleFilter} onValueChange={(v) => { setRoleFilter(v); setPage(1) }}>
|
||||||
|
<SelectTrigger className="w-[140px]">
|
||||||
|
<SelectValue placeholder="角色筛选" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">全部角色</SelectItem>
|
||||||
|
<SelectItem value="super_admin">超级管理员</SelectItem>
|
||||||
|
<SelectItem value="admin">管理员</SelectItem>
|
||||||
|
<SelectItem value="user">普通用户</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Select value={statusFilter} onValueChange={(v) => { setStatusFilter(v); setPage(1) }}>
|
||||||
|
<SelectTrigger className="w-[140px]">
|
||||||
|
<SelectValue placeholder="状态筛选" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">全部状态</SelectItem>
|
||||||
|
<SelectItem value="active">正常</SelectItem>
|
||||||
|
<SelectItem value="disabled">已禁用</SelectItem>
|
||||||
|
<SelectItem value="suspended">已暂停</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 错误提示 */}
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-md bg-destructive/10 border border-destructive/20 px-4 py-3 text-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
<button onClick={() => setError('')} className="ml-2 underline cursor-pointer">
|
||||||
|
关闭
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 表格 */}
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex h-64 items-center justify-center">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : accounts.length === 0 ? (
|
||||||
|
<div className="flex h-64 items-center justify-center text-muted-foreground text-sm">
|
||||||
|
暂无数据
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>用户名</TableHead>
|
||||||
|
<TableHead>邮箱</TableHead>
|
||||||
|
<TableHead>显示名</TableHead>
|
||||||
|
<TableHead>角色</TableHead>
|
||||||
|
<TableHead>状态</TableHead>
|
||||||
|
<TableHead>创建时间</TableHead>
|
||||||
|
<TableHead className="text-right">操作</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{accounts.map((account) => (
|
||||||
|
<TableRow key={account.id}>
|
||||||
|
<TableCell className="font-medium">{account.username}</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">{account.email}</TableCell>
|
||||||
|
<TableCell>{account.display_name || '-'}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={account.role === 'super_admin' ? 'default' : account.role === 'admin' ? 'info' : 'secondary'}>
|
||||||
|
{roleLabels[account.role] || account.role}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={statusColors[account.status] || 'secondary'}>
|
||||||
|
<span className="mr-1 inline-block h-1.5 w-1.5 rounded-full bg-current" />
|
||||||
|
{statusLabels[account.status] || account.status}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||||
|
{formatDate(account.created_at)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<div className="flex items-center justify-end gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => openEditDialog(account)}
|
||||||
|
title="编辑"
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => openConfirmDialog(account)}
|
||||||
|
title={account.status === 'active' ? '禁用' : '启用'}
|
||||||
|
>
|
||||||
|
{account.status === 'active' ? (
|
||||||
|
<Ban className="h-4 w-4 text-destructive" />
|
||||||
|
) : (
|
||||||
|
<CheckCircle2 className="h-4 w-4 text-green-400" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
|
||||||
|
{/* 分页 */}
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
第 {page} 页 / 共 {totalPages} 页 ({total} 条)
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={page <= 1}
|
||||||
|
onClick={() => setPage(page - 1)}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4 mr-1" />
|
||||||
|
上一页
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
onClick={() => setPage(page + 1)}
|
||||||
|
>
|
||||||
|
下一页
|
||||||
|
<ChevronRight className="h-4 w-4 ml-1" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 编辑 Dialog */}
|
||||||
|
<Dialog open={!!editTarget} onOpenChange={() => setEditTarget(null)}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>编辑账号</DialogTitle>
|
||||||
|
<DialogDescription>修改账号信息</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>显示名</Label>
|
||||||
|
<Input
|
||||||
|
value={editForm.display_name}
|
||||||
|
onChange={(e) => setEditForm({ ...editForm, display_name: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>邮箱</Label>
|
||||||
|
<Input
|
||||||
|
type="email"
|
||||||
|
value={editForm.email}
|
||||||
|
onChange={(e) => setEditForm({ ...editForm, email: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>角色</Label>
|
||||||
|
<Select value={editForm.role} onValueChange={(v) => setEditForm({ ...editForm, role: v })}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="user">普通用户</SelectItem>
|
||||||
|
<SelectItem value="admin">管理员</SelectItem>
|
||||||
|
<SelectItem value="super_admin">超级管理员</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setEditTarget(null)}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleEditSave} disabled={editSaving}>
|
||||||
|
{editSaving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* 确认 Dialog */}
|
||||||
|
<Dialog open={!!confirmTarget} onOpenChange={() => setConfirmTarget(null)}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>确认{confirmTarget?.action}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
确定要{confirmTarget?.action}该账号吗?此操作将立即生效。
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setConfirmTarget(null)}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={confirmTarget?.status === 'disabled' ? 'destructive' : 'default'}
|
||||||
|
onClick={handleConfirmSave}
|
||||||
|
disabled={confirmSaving}
|
||||||
|
>
|
||||||
|
{confirmSaving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
确认{confirmTarget?.action}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
351
admin/src/app/(dashboard)/api-keys/page.tsx
Normal file
351
admin/src/app/(dashboard)/api-keys/page.tsx
Normal file
@@ -0,0 +1,351 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
|
import {
|
||||||
|
Plus,
|
||||||
|
Loader2,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Trash2,
|
||||||
|
Copy,
|
||||||
|
Check,
|
||||||
|
AlertTriangle,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
DialogDescription,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import { api } from '@/lib/api-client'
|
||||||
|
import { ApiRequestError } from '@/lib/api-client'
|
||||||
|
import { formatDate } from '@/lib/utils'
|
||||||
|
import type { TokenInfo } from '@/lib/types'
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20
|
||||||
|
|
||||||
|
const allPermissions = [
|
||||||
|
{ key: 'chat', label: '对话' },
|
||||||
|
{ key: 'relay', label: '中转' },
|
||||||
|
{ key: 'admin', label: '管理' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function ApiKeysPage() {
|
||||||
|
const [tokens, setTokens] = useState<TokenInfo[]>([])
|
||||||
|
const [total, setTotal] = useState(0)
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
// 创建 Dialog
|
||||||
|
const [createOpen, setCreateOpen] = useState(false)
|
||||||
|
const [createForm, setCreateForm] = useState({ name: '', expires_days: '', permissions: ['chat'] as string[] })
|
||||||
|
const [creating, setCreating] = useState(false)
|
||||||
|
|
||||||
|
// 创建成功显示 token
|
||||||
|
const [createdToken, setCreatedToken] = useState<TokenInfo | null>(null)
|
||||||
|
const [copied, setCopied] = useState(false)
|
||||||
|
|
||||||
|
// 撤销确认
|
||||||
|
const [revokeTarget, setRevokeTarget] = useState<TokenInfo | null>(null)
|
||||||
|
const [revoking, setRevoking] = useState(false)
|
||||||
|
|
||||||
|
const fetchTokens = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const res = await api.tokens.list({ page, page_size: PAGE_SIZE })
|
||||||
|
setTokens(res.items)
|
||||||
|
setTotal(res.total)
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiRequestError) setError(err.body.message)
|
||||||
|
else setError('加载失败')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [page])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchTokens()
|
||||||
|
}, [fetchTokens])
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
|
||||||
|
|
||||||
|
function togglePermission(perm: string) {
|
||||||
|
setCreateForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
permissions: prev.permissions.includes(perm)
|
||||||
|
? prev.permissions.filter((p) => p !== perm)
|
||||||
|
: [...prev.permissions, perm],
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreate() {
|
||||||
|
if (!createForm.name.trim() || createForm.permissions.length === 0) return
|
||||||
|
setCreating(true)
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
name: createForm.name.trim(),
|
||||||
|
expires_days: createForm.expires_days ? parseInt(createForm.expires_days, 10) : undefined,
|
||||||
|
permissions: createForm.permissions,
|
||||||
|
}
|
||||||
|
const res = await api.tokens.create(payload)
|
||||||
|
setCreateOpen(false)
|
||||||
|
setCreatedToken(res)
|
||||||
|
setCreateForm({ name: '', expires_days: '', permissions: ['chat'] })
|
||||||
|
fetchTokens()
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiRequestError) setError(err.body.message)
|
||||||
|
} finally {
|
||||||
|
setCreating(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRevoke() {
|
||||||
|
if (!revokeTarget) return
|
||||||
|
setRevoking(true)
|
||||||
|
try {
|
||||||
|
await api.tokens.revoke(revokeTarget.id)
|
||||||
|
setRevokeTarget(null)
|
||||||
|
fetchTokens()
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiRequestError) setError(err.body.message)
|
||||||
|
} finally {
|
||||||
|
setRevoking(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyToken() {
|
||||||
|
if (!createdToken?.token) return
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(createdToken.token)
|
||||||
|
setCopied(true)
|
||||||
|
setTimeout(() => setCopied(false), 2000)
|
||||||
|
} catch {
|
||||||
|
// Fallback
|
||||||
|
const textarea = document.createElement('textarea')
|
||||||
|
textarea.value = createdToken.token
|
||||||
|
document.body.appendChild(textarea)
|
||||||
|
textarea.select()
|
||||||
|
document.execCommand('copy')
|
||||||
|
document.body.removeChild(textarea)
|
||||||
|
setCopied(true)
|
||||||
|
setTimeout(() => setCopied(false), 2000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div />
|
||||||
|
<Button onClick={() => setCreateOpen(true)}>
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
新建密钥
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-md bg-destructive/10 border border-destructive/20 px-4 py-3 text-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
<button onClick={() => setError('')} className="ml-2 underline cursor-pointer">关闭</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex h-64 items-center justify-center">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : tokens.length === 0 ? (
|
||||||
|
<div className="flex h-64 items-center justify-center text-muted-foreground text-sm">
|
||||||
|
暂无数据
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>名称</TableHead>
|
||||||
|
<TableHead>前缀</TableHead>
|
||||||
|
<TableHead>权限</TableHead>
|
||||||
|
<TableHead>最后使用</TableHead>
|
||||||
|
<TableHead>过期时间</TableHead>
|
||||||
|
<TableHead>创建时间</TableHead>
|
||||||
|
<TableHead className="text-right">操作</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{tokens.map((t) => (
|
||||||
|
<TableRow key={t.id}>
|
||||||
|
<TableCell className="font-medium">{t.name}</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||||
|
{t.token_prefix}...
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{t.permissions.map((p) => (
|
||||||
|
<Badge key={p} variant="outline" className="text-xs">
|
||||||
|
{p}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||||
|
{t.last_used_at ? formatDate(t.last_used_at) : '未使用'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||||
|
{t.expires_at ? formatDate(t.expires_at) : '永不过期'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||||
|
{formatDate(t.created_at)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => setRevokeTarget(t)} title="撤销">
|
||||||
|
<Trash2 className="h-4 w-4 text-destructive" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
第 {page} 页 / 共 {totalPages} 页 ({total} 条)
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => setPage(page - 1)}>
|
||||||
|
<ChevronLeft className="h-4 w-4 mr-1" />
|
||||||
|
上一页
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" disabled={page >= totalPages} onClick={() => setPage(page + 1)}>
|
||||||
|
下一页
|
||||||
|
<ChevronRight className="h-4 w-4 ml-1" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 创建 Dialog */}
|
||||||
|
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>新建 API 密钥</DialogTitle>
|
||||||
|
<DialogDescription>创建新的 API 密钥用于接口调用</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>名称 *</Label>
|
||||||
|
<Input
|
||||||
|
value={createForm.name}
|
||||||
|
onChange={(e) => setCreateForm({ ...createForm, name: e.target.value })}
|
||||||
|
placeholder="例如: 生产环境"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>过期天数 (留空则永不过期)</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={createForm.expires_days}
|
||||||
|
onChange={(e) => setCreateForm({ ...createForm, expires_days: e.target.value })}
|
||||||
|
placeholder="365"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>权限 *</Label>
|
||||||
|
<div className="flex flex-wrap gap-3 mt-1">
|
||||||
|
{allPermissions.map((perm) => (
|
||||||
|
<label
|
||||||
|
key={perm.key}
|
||||||
|
className="flex items-center gap-2 cursor-pointer"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={createForm.permissions.includes(perm.key)}
|
||||||
|
onChange={() => togglePermission(perm.key)}
|
||||||
|
className="h-4 w-4 rounded border-input bg-transparent accent-primary cursor-pointer"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-foreground">{perm.label}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setCreateOpen(false)}>取消</Button>
|
||||||
|
<Button onClick={handleCreate} disabled={creating || !createForm.name.trim() || createForm.permissions.length === 0}>
|
||||||
|
{creating && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
创建
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* 创建成功 Dialog */}
|
||||||
|
<Dialog open={!!createdToken} onOpenChange={() => setCreatedToken(null)}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<AlertTriangle className="h-5 w-5 text-yellow-400" />
|
||||||
|
密钥已创建
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
请立即复制并安全保存此密钥,关闭后将无法再次查看完整密钥。
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="rounded-md bg-muted p-4">
|
||||||
|
<p className="text-xs text-muted-foreground mb-2">完整密钥</p>
|
||||||
|
<p className="font-mono text-sm break-all text-foreground">
|
||||||
|
{createdToken?.token}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md bg-yellow-500/10 border border-yellow-500/20 p-3 text-sm text-yellow-400">
|
||||||
|
此密钥仅显示一次。请确保已保存到安全的位置。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button onClick={copyToken} variant="outline">
|
||||||
|
{copied ? <Check className="h-4 w-4 mr-2" /> : <Copy className="h-4 w-4 mr-2" />}
|
||||||
|
{copied ? '已复制' : '复制密钥'}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => setCreatedToken(null)}>我已保存</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* 撤销确认 */}
|
||||||
|
<Dialog open={!!revokeTarget} onOpenChange={() => setRevokeTarget(null)}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>确认撤销</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
确定要撤销密钥 "{revokeTarget?.name}" 吗?使用此密钥的应用将立即失去访问权限。
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setRevokeTarget(null)}>取消</Button>
|
||||||
|
<Button variant="destructive" onClick={handleRevoke} disabled={revoking}>
|
||||||
|
{revoking && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
撤销
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
283
admin/src/app/(dashboard)/config/page.tsx
Normal file
283
admin/src/app/(dashboard)/config/page.tsx
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
|
import {
|
||||||
|
Loader2,
|
||||||
|
Pencil,
|
||||||
|
RotateCcw,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
DialogDescription,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
|
import { api } from '@/lib/api-client'
|
||||||
|
import { ApiRequestError } from '@/lib/api-client'
|
||||||
|
import type { ConfigItem } from '@/lib/types'
|
||||||
|
|
||||||
|
const sourceLabels: Record<string, string> = {
|
||||||
|
default: '默认值',
|
||||||
|
env: '环境变量',
|
||||||
|
db: '数据库',
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceVariants: Record<string, 'secondary' | 'info' | 'default'> = {
|
||||||
|
default: 'secondary',
|
||||||
|
env: 'info',
|
||||||
|
db: 'default',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ConfigPage() {
|
||||||
|
const [configs, setConfigs] = useState<ConfigItem[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [activeTab, setActiveTab] = useState('all')
|
||||||
|
|
||||||
|
// 编辑 Dialog
|
||||||
|
const [editTarget, setEditTarget] = useState<ConfigItem | null>(null)
|
||||||
|
const [editValue, setEditValue] = useState('')
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
|
const fetchConfigs = useCallback(async (category?: string) => {
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const params: Record<string, unknown> = {}
|
||||||
|
if (category && category !== 'all') params.category = category
|
||||||
|
const res = await api.config.list(params)
|
||||||
|
setConfigs(res)
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiRequestError) setError(err.body.message)
|
||||||
|
else setError('加载失败')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchConfigs(activeTab)
|
||||||
|
}, [fetchConfigs, activeTab])
|
||||||
|
|
||||||
|
function openEditDialog(config: ConfigItem) {
|
||||||
|
setEditTarget(config)
|
||||||
|
setEditValue(config.current_value !== undefined ? String(config.current_value) : '')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
if (!editTarget) return
|
||||||
|
// 表单验证
|
||||||
|
if (editValue.trim() === '') {
|
||||||
|
setError('配置值不能为空')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (editTarget.value_type === 'number' && isNaN(Number(editValue))) {
|
||||||
|
setError('请输入有效的数字')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (editTarget.value_type === 'boolean' && editValue !== 'true' && editValue !== 'false') {
|
||||||
|
setError('布尔值只能为 true 或 false')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
let parsedValue: string | number | boolean = editValue
|
||||||
|
if (editTarget.value_type === 'number') {
|
||||||
|
parsedValue = parseFloat(editValue) || 0
|
||||||
|
} else if (editTarget.value_type === 'boolean') {
|
||||||
|
parsedValue = editValue === 'true'
|
||||||
|
}
|
||||||
|
await api.config.update(editTarget.id, { current_value: parsedValue })
|
||||||
|
setEditTarget(null)
|
||||||
|
fetchConfigs(activeTab)
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiRequestError) setError(err.body.message)
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatValue(value: unknown): string {
|
||||||
|
if (value === undefined || value === null) return '-'
|
||||||
|
if (typeof value === 'boolean') return value ? 'true' : 'false'
|
||||||
|
return String(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const categories = ['all', 'auth', 'relay', 'model', 'system']
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 分类 Tabs */}
|
||||||
|
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||||
|
<TabsList>
|
||||||
|
{categories.map((cat) => (
|
||||||
|
<TabsTrigger key={cat} value={cat}>
|
||||||
|
{cat === 'all' ? '全部' : cat}
|
||||||
|
</TabsTrigger>
|
||||||
|
))}
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-md bg-destructive/10 border border-destructive/20 px-4 py-3 text-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
<button onClick={() => setError('')} className="ml-2 underline cursor-pointer">关闭</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex h-64 items-center justify-center">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : configs.length === 0 ? (
|
||||||
|
<div className="flex h-64 items-center justify-center text-muted-foreground text-sm">
|
||||||
|
暂无配置项
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>分类</TableHead>
|
||||||
|
<TableHead>Key</TableHead>
|
||||||
|
<TableHead>当前值</TableHead>
|
||||||
|
<TableHead>默认值</TableHead>
|
||||||
|
<TableHead>来源</TableHead>
|
||||||
|
<TableHead>需重启</TableHead>
|
||||||
|
<TableHead>描述</TableHead>
|
||||||
|
<TableHead className="text-right">操作</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{configs.map((config) => (
|
||||||
|
<TableRow key={config.id}>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="outline">{config.category}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-sm">{config.key_path}</TableCell>
|
||||||
|
<TableCell className="font-mono text-sm max-w-[200px] truncate">
|
||||||
|
{formatValue(config.current_value)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs text-muted-foreground max-w-[200px] truncate">
|
||||||
|
{formatValue(config.default_value)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={sourceVariants[config.source] || 'secondary'}>
|
||||||
|
{sourceLabels[config.source] || config.source}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{config.requires_restart ? (
|
||||||
|
<Badge variant="warning">是</Badge>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">否</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground max-w-[250px] truncate">
|
||||||
|
{config.description || '-'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => openEditDialog(config)} title="编辑">
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 编辑 Dialog */}
|
||||||
|
<Dialog open={!!editTarget} onOpenChange={() => setEditTarget(null)}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>编辑配置</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
修改 {editTarget?.key_path} 的值
|
||||||
|
{editTarget?.requires_restart && (
|
||||||
|
<span className="block mt-1 text-yellow-400 text-xs">
|
||||||
|
注意: 修改此配置需要重启服务才能生效
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Key</Label>
|
||||||
|
<Input value={editTarget?.key_path || ''} disabled />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>类型</Label>
|
||||||
|
<Input value={editTarget?.value_type || ''} disabled />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>
|
||||||
|
新值 {editTarget?.default_value !== undefined && (
|
||||||
|
<span className="text-xs text-muted-foreground ml-2">
|
||||||
|
(默认: {formatValue(editTarget.default_value)})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Label>
|
||||||
|
{editTarget?.value_type === 'boolean' ? (
|
||||||
|
<Select value={editValue} onValueChange={setEditValue}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="true">true</SelectItem>
|
||||||
|
<SelectItem value="false">false</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
type={editTarget?.value_type === 'number' ? 'number' : 'text'}
|
||||||
|
value={editValue}
|
||||||
|
onChange={(e) => setEditValue(e.target.value)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
if (editTarget?.default_value !== undefined) {
|
||||||
|
setEditValue(String(editTarget.default_value))
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RotateCcw className="h-4 w-4 mr-2" />
|
||||||
|
恢复默认
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" onClick={() => setEditTarget(null)}>取消</Button>
|
||||||
|
<Button onClick={handleSave} disabled={saving}>
|
||||||
|
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
125
admin/src/app/(dashboard)/devices/page.tsx
Normal file
125
admin/src/app/(dashboard)/devices/page.tsx
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Monitor, Loader2, RefreshCw } from 'lucide-react'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import {
|
||||||
|
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
||||||
|
} from '@/components/ui/table'
|
||||||
|
import { api } from '@/lib/api-client'
|
||||||
|
import { ApiRequestError } from '@/lib/api-client'
|
||||||
|
import type { DeviceInfo } from '@/lib/types'
|
||||||
|
|
||||||
|
function formatRelativeTime(dateStr: string): string {
|
||||||
|
const now = Date.now()
|
||||||
|
const then = new Date(dateStr).getTime()
|
||||||
|
const diffMs = now - then
|
||||||
|
const diffMin = Math.floor(diffMs / 60000)
|
||||||
|
const diffHour = Math.floor(diffMs / 3600000)
|
||||||
|
const diffDay = Math.floor(diffMs / 86400000)
|
||||||
|
|
||||||
|
if (diffMin < 1) return '刚刚'
|
||||||
|
if (diffMin < 60) return `${diffMin} 分钟前`
|
||||||
|
if (diffHour < 24) return `${diffHour} 小时前`
|
||||||
|
return `${diffDay} 天前`
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOnline(lastSeen: string): boolean {
|
||||||
|
return Date.now() - new Date(lastSeen).getTime() < 5 * 60 * 1000
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DevicesPage() {
|
||||||
|
const [devices, setDevices] = useState<DeviceInfo[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
async function fetchDevices() {
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const res = await api.devices.list()
|
||||||
|
setDevices(res)
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiRequestError) setError(err.body.message)
|
||||||
|
else setError('加载失败')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => { fetchDevices() }, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-lg font-semibold text-foreground">设备管理</h2>
|
||||||
|
<button
|
||||||
|
onClick={fetchDevices}
|
||||||
|
disabled={loading}
|
||||||
|
className="flex items-center gap-2 rounded-md border border-border px-3 py-1.5 text-sm text-muted-foreground hover:bg-muted hover:text-foreground transition-colors cursor-pointer disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
|
||||||
|
刷新
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-md bg-destructive/10 border border-destructive/20 px-4 py-3 text-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading && !devices.length ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : devices.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
||||||
|
<Monitor className="h-10 w-10 mb-3" />
|
||||||
|
<p className="text-sm">暂无已注册设备</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-md border border-border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>设备名称</TableHead>
|
||||||
|
<TableHead>平台</TableHead>
|
||||||
|
<TableHead>版本</TableHead>
|
||||||
|
<TableHead>状态</TableHead>
|
||||||
|
<TableHead>最后活跃</TableHead>
|
||||||
|
<TableHead>注册时间</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{devices.map((d) => (
|
||||||
|
<TableRow key={d.id}>
|
||||||
|
<TableCell className="font-medium">
|
||||||
|
{d.device_name || d.device_id}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="secondary">{d.platform || 'unknown'}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">
|
||||||
|
{d.app_version || '-'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={isOnline(d.last_seen_at) ? 'success' : 'outline'}>
|
||||||
|
{isOnline(d.last_seen_at) ? '在线' : '离线'}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">
|
||||||
|
{formatRelativeTime(d.last_seen_at)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground text-xs">
|
||||||
|
{new Date(d.created_at).toLocaleString('zh-CN')}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
305
admin/src/app/(dashboard)/layout.tsx
Normal file
305
admin/src/app/(dashboard)/layout.tsx
Normal file
@@ -0,0 +1,305 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect, type ReactNode } from 'react'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { usePathname, useRouter } from 'next/navigation'
|
||||||
|
import {
|
||||||
|
LayoutDashboard,
|
||||||
|
Users,
|
||||||
|
Server,
|
||||||
|
Cpu,
|
||||||
|
Key,
|
||||||
|
BarChart3,
|
||||||
|
ArrowLeftRight,
|
||||||
|
Settings,
|
||||||
|
FileText,
|
||||||
|
LogOut,
|
||||||
|
ChevronLeft,
|
||||||
|
Menu,
|
||||||
|
Bell,
|
||||||
|
UserCog,
|
||||||
|
ShieldCheck,
|
||||||
|
Monitor,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { AuthGuard, useAuth } from '@/components/auth-guard'
|
||||||
|
import { logout } from '@/lib/auth'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ href: '/', label: '仪表盘', icon: LayoutDashboard, permission: null },
|
||||||
|
{ href: '/accounts', label: '账号管理', icon: Users, permission: 'account:admin' },
|
||||||
|
{ href: '/providers', label: '服务商', icon: Server, permission: 'model:admin' },
|
||||||
|
{ href: '/models', label: '模型管理', icon: Cpu, permission: 'model:admin' },
|
||||||
|
{ href: '/api-keys', label: 'API 密钥', icon: Key, permission: null },
|
||||||
|
{ href: '/usage', label: '用量统计', icon: BarChart3, permission: null },
|
||||||
|
{ href: '/relay', label: '中转任务', icon: ArrowLeftRight, permission: 'relay:admin' },
|
||||||
|
{ href: '/config', label: '系统配置', icon: Settings, permission: 'admin:full' },
|
||||||
|
{ href: '/logs', label: '操作日志', icon: FileText, permission: 'admin:full' },
|
||||||
|
{ href: '/profile', label: '个人设置', icon: UserCog, permission: null },
|
||||||
|
{ href: '/security', label: '安全设置', icon: ShieldCheck, permission: null },
|
||||||
|
{ href: '/devices', label: '设备管理', icon: Monitor, permission: null },
|
||||||
|
]
|
||||||
|
|
||||||
|
function Sidebar({
|
||||||
|
collapsed,
|
||||||
|
onToggle,
|
||||||
|
mobileOpen,
|
||||||
|
onMobileClose,
|
||||||
|
}: {
|
||||||
|
collapsed: boolean
|
||||||
|
onToggle: () => void
|
||||||
|
mobileOpen: boolean
|
||||||
|
onMobileClose: () => void
|
||||||
|
}) {
|
||||||
|
const pathname = usePathname()
|
||||||
|
const router = useRouter()
|
||||||
|
const { account } = useAuth()
|
||||||
|
|
||||||
|
// 路由变化时关闭移动端菜单
|
||||||
|
useEffect(() => {
|
||||||
|
onMobileClose()
|
||||||
|
}, [pathname, onMobileClose])
|
||||||
|
|
||||||
|
function handleLogout() {
|
||||||
|
logout()
|
||||||
|
router.replace('/login')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* 移动端 overlay */}
|
||||||
|
{mobileOpen && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-40 bg-black/50 lg:hidden"
|
||||||
|
onClick={onMobileClose}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<aside
|
||||||
|
className={cn(
|
||||||
|
'fixed left-0 top-0 z-50 flex h-screen flex-col border-r border-border bg-card transition-all duration-300',
|
||||||
|
collapsed ? 'w-16' : 'w-64',
|
||||||
|
'lg:z-40',
|
||||||
|
mobileOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Logo */}
|
||||||
|
<div className="flex h-14 items-center border-b border-border px-4">
|
||||||
|
<Link href="/" className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<div className="flex h-8 w-8 items-center justify-center rounded-md bg-primary text-primary-foreground font-bold text-sm">
|
||||||
|
Z
|
||||||
|
</div>
|
||||||
|
{!collapsed && (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-sm font-bold text-foreground">ZCLAW</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground">Admin</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 导航 */}
|
||||||
|
<nav className="flex-1 overflow-y-auto scrollbar-thin py-2 px-2">
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{navItems
|
||||||
|
.filter((item) => {
|
||||||
|
if (!item.permission) return true
|
||||||
|
if (!account) return false
|
||||||
|
// super_admin 拥有所有权限
|
||||||
|
if (account.role === 'super_admin') return true
|
||||||
|
return account.permissions?.includes(item.permission) ?? false
|
||||||
|
})
|
||||||
|
.map((item) => {
|
||||||
|
const isActive =
|
||||||
|
item.href === '/'
|
||||||
|
? pathname === '/'
|
||||||
|
: pathname.startsWith(item.href)
|
||||||
|
const Icon = item.icon
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li key={item.href}>
|
||||||
|
<Link
|
||||||
|
href={item.href}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors duration-200 cursor-pointer',
|
||||||
|
isActive
|
||||||
|
? 'bg-muted text-green-400'
|
||||||
|
: 'text-muted-foreground hover:bg-muted hover:text-foreground',
|
||||||
|
collapsed && 'justify-center px-2',
|
||||||
|
)}
|
||||||
|
title={collapsed ? item.label : undefined}
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4 shrink-0" />
|
||||||
|
{!collapsed && <span>{item.label}</span>}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* 底部折叠按钮 */}
|
||||||
|
<div className="border-t border-border p-2">
|
||||||
|
<button
|
||||||
|
onClick={onToggle}
|
||||||
|
className="flex w-full items-center justify-center rounded-md px-3 py-2 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors duration-200 cursor-pointer"
|
||||||
|
>
|
||||||
|
<ChevronLeft
|
||||||
|
className={cn(
|
||||||
|
'h-4 w-4 transition-transform duration-200',
|
||||||
|
collapsed && 'rotate-180',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 折叠时显示退出按钮 */}
|
||||||
|
{collapsed && (
|
||||||
|
<div className="border-t border-border p-2">
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="flex w-full items-center justify-center rounded-md px-3 py-2 text-muted-foreground hover:bg-muted hover:text-destructive transition-colors duration-200 cursor-pointer"
|
||||||
|
title="退出登录"
|
||||||
|
>
|
||||||
|
<LogOut className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 用户信息 */}
|
||||||
|
{!collapsed && (
|
||||||
|
<div className="border-t border-border p-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-medium text-foreground">
|
||||||
|
{account?.display_name?.[0] || account?.username?.[0] || 'A'}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="truncate text-sm font-medium text-foreground">
|
||||||
|
{account?.display_name || account?.username || 'Admin'}
|
||||||
|
</p>
|
||||||
|
<p className="truncate text-xs text-muted-foreground">
|
||||||
|
{account?.role || 'admin'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="rounded-md p-1.5 text-muted-foreground hover:bg-muted hover:text-destructive transition-colors duration-200 cursor-pointer"
|
||||||
|
title="退出登录"
|
||||||
|
>
|
||||||
|
<LogOut className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Header({ children }: { children?: ReactNode }) {
|
||||||
|
const pathname = usePathname()
|
||||||
|
const currentNav = navItems.find(
|
||||||
|
(item) =>
|
||||||
|
item.href === '/'
|
||||||
|
? pathname === '/'
|
||||||
|
: pathname.startsWith(item.href),
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="sticky top-0 z-30 flex h-14 items-center border-b border-border bg-background/80 backdrop-blur-sm px-6">
|
||||||
|
{/* 移动端菜单按钮 */}
|
||||||
|
{children}
|
||||||
|
|
||||||
|
{/* 页面标题 */}
|
||||||
|
<h1 className="text-lg font-semibold text-foreground">
|
||||||
|
{currentNav?.label || '仪表盘'}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div className="ml-auto flex items-center gap-2">
|
||||||
|
{/* 通知 */}
|
||||||
|
<button
|
||||||
|
className="relative rounded-md p-2 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors duration-200 cursor-pointer"
|
||||||
|
title="通知"
|
||||||
|
>
|
||||||
|
<Bell className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MobileMenuButton({ onClick }: { onClick: () => void }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
className="mr-3 rounded-md p-2 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors duration-200 lg:hidden cursor-pointer"
|
||||||
|
>
|
||||||
|
<Menu className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 路由级权限守卫:隐藏导航项但用户直接访问 URL 时拦截 */
|
||||||
|
function PageGuard({ children }: { children: ReactNode }) {
|
||||||
|
const pathname = usePathname()
|
||||||
|
const router = useRouter()
|
||||||
|
const { account } = useAuth()
|
||||||
|
|
||||||
|
const matchedNav = navItems.find((item) =>
|
||||||
|
item.href === '/' ? pathname === '/' : pathname.startsWith(item.href),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (matchedNav?.permission && account) {
|
||||||
|
if (account.role !== 'super_admin' && !(account.permissions?.includes(matchedNav.permission) ?? false)) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-1 items-center justify-center">
|
||||||
|
<div className="text-center space-y-3">
|
||||||
|
<p className="text-lg font-medium text-muted-foreground">权限不足</p>
|
||||||
|
<p className="text-sm text-muted-foreground">您没有访问「{matchedNav.label}」的权限</p>
|
||||||
|
<button
|
||||||
|
onClick={() => router.replace('/')}
|
||||||
|
className="text-sm text-primary hover:underline cursor-pointer"
|
||||||
|
>
|
||||||
|
返回仪表盘
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DashboardLayout({ children }: { children: ReactNode }) {
|
||||||
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
|
||||||
|
const [mobileOpen, setMobileOpen] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthGuard>
|
||||||
|
<PageGuard>
|
||||||
|
<div className="flex min-h-screen">
|
||||||
|
<Sidebar
|
||||||
|
collapsed={sidebarCollapsed}
|
||||||
|
onToggle={() => setSidebarCollapsed(!sidebarCollapsed)}
|
||||||
|
mobileOpen={mobileOpen}
|
||||||
|
onMobileClose={() => setMobileOpen(false)}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex flex-1 flex-col transition-all duration-300',
|
||||||
|
'ml-0 lg:transition-all',
|
||||||
|
sidebarCollapsed ? 'lg:ml-16' : 'lg:ml-64',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Header>
|
||||||
|
<MobileMenuButton onClick={() => setMobileOpen(true)} />
|
||||||
|
</Header>
|
||||||
|
<main className="flex-1 overflow-auto p-6 scrollbar-thin">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PageGuard>
|
||||||
|
</AuthGuard>
|
||||||
|
)
|
||||||
|
}
|
||||||
436
admin/src/app/(dashboard)/models/page.tsx
Normal file
436
admin/src/app/(dashboard)/models/page.tsx
Normal file
@@ -0,0 +1,436 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
|
import {
|
||||||
|
Plus,
|
||||||
|
Loader2,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Pencil,
|
||||||
|
Trash2,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Switch } from '@/components/ui/switch'
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
DialogDescription,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
import { api } from '@/lib/api-client'
|
||||||
|
import { ApiRequestError } from '@/lib/api-client'
|
||||||
|
import { formatNumber } from '@/lib/utils'
|
||||||
|
import type { Model, Provider } from '@/lib/types'
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20
|
||||||
|
|
||||||
|
interface ModelForm {
|
||||||
|
provider_id: string
|
||||||
|
model_id: string
|
||||||
|
alias: string
|
||||||
|
context_window: string
|
||||||
|
max_output_tokens: string
|
||||||
|
supports_streaming: boolean
|
||||||
|
supports_vision: boolean
|
||||||
|
enabled: boolean
|
||||||
|
pricing_input: string
|
||||||
|
pricing_output: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyForm: ModelForm = {
|
||||||
|
provider_id: '',
|
||||||
|
model_id: '',
|
||||||
|
alias: '',
|
||||||
|
context_window: '4096',
|
||||||
|
max_output_tokens: '4096',
|
||||||
|
supports_streaming: true,
|
||||||
|
supports_vision: false,
|
||||||
|
enabled: true,
|
||||||
|
pricing_input: '',
|
||||||
|
pricing_output: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ModelsPage() {
|
||||||
|
const [models, setModels] = useState<Model[]>([])
|
||||||
|
const [providers, setProviders] = useState<Provider[]>([])
|
||||||
|
const [total, setTotal] = useState(0)
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [providerFilter, setProviderFilter] = useState<string>('all')
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
// Dialog
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false)
|
||||||
|
const [editTarget, setEditTarget] = useState<Model | null>(null)
|
||||||
|
const [form, setForm] = useState<ModelForm>(emptyForm)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
|
// 删除
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<Model | null>(null)
|
||||||
|
const [deleting, setDeleting] = useState(false)
|
||||||
|
|
||||||
|
const fetchModels = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const params: Record<string, unknown> = { page, page_size: PAGE_SIZE }
|
||||||
|
if (providerFilter !== 'all') params.provider_id = providerFilter
|
||||||
|
const res = await api.models.list(params)
|
||||||
|
setModels(res.items)
|
||||||
|
setTotal(res.total)
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiRequestError) setError(err.body.message)
|
||||||
|
else setError('加载失败')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [page, providerFilter])
|
||||||
|
|
||||||
|
const fetchProviders = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await api.providers.list()
|
||||||
|
setProviders(res)
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchModels()
|
||||||
|
fetchProviders()
|
||||||
|
}, [fetchModels, fetchProviders])
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
|
||||||
|
|
||||||
|
const providerMap = new Map(providers.map((p) => [p.id, p.display_name || p.name]))
|
||||||
|
|
||||||
|
function openCreateDialog() {
|
||||||
|
setEditTarget(null)
|
||||||
|
setForm(emptyForm)
|
||||||
|
setDialogOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditDialog(model: Model) {
|
||||||
|
setEditTarget(model)
|
||||||
|
setForm({
|
||||||
|
provider_id: model.provider_id,
|
||||||
|
model_id: model.model_id,
|
||||||
|
alias: model.alias,
|
||||||
|
context_window: model.context_window.toString(),
|
||||||
|
max_output_tokens: model.max_output_tokens.toString(),
|
||||||
|
supports_streaming: model.supports_streaming,
|
||||||
|
supports_vision: model.supports_vision,
|
||||||
|
enabled: model.enabled,
|
||||||
|
pricing_input: model.pricing_input.toString(),
|
||||||
|
pricing_output: model.pricing_output.toString(),
|
||||||
|
})
|
||||||
|
setDialogOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
if (!form.model_id.trim() || !form.provider_id) return
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
provider_id: form.provider_id,
|
||||||
|
model_id: form.model_id.trim(),
|
||||||
|
alias: form.alias.trim(),
|
||||||
|
context_window: parseInt(form.context_window, 10) || 4096,
|
||||||
|
max_output_tokens: parseInt(form.max_output_tokens, 10) || 4096,
|
||||||
|
supports_streaming: form.supports_streaming,
|
||||||
|
supports_vision: form.supports_vision,
|
||||||
|
enabled: form.enabled,
|
||||||
|
pricing_input: parseFloat(form.pricing_input) || 0,
|
||||||
|
pricing_output: parseFloat(form.pricing_output) || 0,
|
||||||
|
}
|
||||||
|
if (editTarget) {
|
||||||
|
await api.models.update(editTarget.id, payload)
|
||||||
|
} else {
|
||||||
|
await api.models.create(payload)
|
||||||
|
}
|
||||||
|
setDialogOpen(false)
|
||||||
|
fetchModels()
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiRequestError) setError(err.body.message)
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete() {
|
||||||
|
if (!deleteTarget) return
|
||||||
|
setDeleting(true)
|
||||||
|
try {
|
||||||
|
await api.models.delete(deleteTarget.id)
|
||||||
|
setDeleteTarget(null)
|
||||||
|
fetchModels()
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiRequestError) setError(err.body.message)
|
||||||
|
} finally {
|
||||||
|
setDeleting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Select value={providerFilter} onValueChange={(v) => { setProviderFilter(v); setPage(1) }}>
|
||||||
|
<SelectTrigger className="w-[200px]">
|
||||||
|
<SelectValue placeholder="按服务商筛选" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">全部服务商</SelectItem>
|
||||||
|
{providers.map((p) => (
|
||||||
|
<SelectItem key={p.id} value={p.id}>
|
||||||
|
{p.display_name || p.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button onClick={openCreateDialog}>
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
新建模型
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-md bg-destructive/10 border border-destructive/20 px-4 py-3 text-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
<button onClick={() => setError('')} className="ml-2 underline cursor-pointer">关闭</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex h-64 items-center justify-center">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : models.length === 0 ? (
|
||||||
|
<div className="flex h-64 items-center justify-center text-muted-foreground text-sm">
|
||||||
|
暂无数据
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>模型 ID</TableHead>
|
||||||
|
<TableHead>别名</TableHead>
|
||||||
|
<TableHead>服务商</TableHead>
|
||||||
|
<TableHead>上下文窗口</TableHead>
|
||||||
|
<TableHead>最大输出</TableHead>
|
||||||
|
<TableHead>流式</TableHead>
|
||||||
|
<TableHead>视觉</TableHead>
|
||||||
|
<TableHead>启用</TableHead>
|
||||||
|
<TableHead className="text-right">操作</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{models.map((m) => (
|
||||||
|
<TableRow key={m.id}>
|
||||||
|
<TableCell className="font-mono text-sm">{m.model_id}</TableCell>
|
||||||
|
<TableCell>{m.alias || '-'}</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">
|
||||||
|
{providerMap.get(m.provider_id) || m.provider_id.slice(0, 8)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||||
|
{formatNumber(m.context_window)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||||
|
{formatNumber(m.max_output_tokens)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={m.supports_streaming ? 'success' : 'secondary'}>
|
||||||
|
{m.supports_streaming ? '是' : '否'}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={m.supports_vision ? 'success' : 'secondary'}>
|
||||||
|
{m.supports_vision ? '是' : '否'}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={m.enabled ? 'success' : 'destructive'}>
|
||||||
|
{m.enabled ? '启用' : '禁用'}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<div className="flex items-center justify-end gap-1">
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => openEditDialog(m)} title="编辑">
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => setDeleteTarget(m)} title="删除">
|
||||||
|
<Trash2 className="h-4 w-4 text-destructive" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
第 {page} 页 / 共 {totalPages} 页 ({total} 条)
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => setPage(page - 1)}>
|
||||||
|
<ChevronLeft className="h-4 w-4 mr-1" />
|
||||||
|
上一页
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" disabled={page >= totalPages} onClick={() => setPage(page + 1)}>
|
||||||
|
下一页
|
||||||
|
<ChevronRight className="h-4 w-4 ml-1" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 创建/编辑 Dialog */}
|
||||||
|
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||||
|
<DialogContent className="max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{editTarget ? '编辑模型' : '新建模型'}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{editTarget ? '修改模型配置' : '添加新的 AI 模型'}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 max-h-[60vh] overflow-y-auto scrollbar-thin pr-1">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>服务商 *</Label>
|
||||||
|
<Select value={form.provider_id} onValueChange={(v) => setForm({ ...form, provider_id: v })} disabled={!!editTarget}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="选择服务商" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{providers.map((p) => (
|
||||||
|
<SelectItem key={p.id} value={p.id}>
|
||||||
|
{p.display_name || p.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>模型 ID *</Label>
|
||||||
|
<Input
|
||||||
|
value={form.model_id}
|
||||||
|
onChange={(e) => setForm({ ...form, model_id: e.target.value })}
|
||||||
|
placeholder="gpt-4o"
|
||||||
|
disabled={!!editTarget}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>别名</Label>
|
||||||
|
<Input
|
||||||
|
value={form.alias}
|
||||||
|
onChange={(e) => setForm({ ...form, alias: e.target.value })}
|
||||||
|
placeholder="GPT-4o"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>上下文窗口</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={form.context_window}
|
||||||
|
onChange={(e) => setForm({ ...form, context_window: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>最大输出 Tokens</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={form.max_output_tokens}
|
||||||
|
onChange={(e) => setForm({ ...form, max_output_tokens: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Input 定价 ($/1M tokens)</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
value={form.pricing_input}
|
||||||
|
onChange={(e) => setForm({ ...form, pricing_input: e.target.value })}
|
||||||
|
placeholder="0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Output 定价 ($/1M tokens)</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
value={form.pricing_output}
|
||||||
|
onChange={(e) => setForm({ ...form, pricing_output: e.target.value })}
|
||||||
|
placeholder="0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-6">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Switch checked={form.supports_streaming} onCheckedChange={(v) => setForm({ ...form, supports_streaming: v })} />
|
||||||
|
<Label>流式</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Switch checked={form.supports_vision} onCheckedChange={(v) => setForm({ ...form, supports_vision: v })} />
|
||||||
|
<Label>视觉</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Switch checked={form.enabled} onCheckedChange={(v) => setForm({ ...form, enabled: v })} />
|
||||||
|
<Label>启用</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setDialogOpen(false)}>取消</Button>
|
||||||
|
<Button onClick={handleSave} disabled={saving || !form.model_id.trim() || !form.provider_id}>
|
||||||
|
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* 删除确认 */}
|
||||||
|
<Dialog open={!!deleteTarget} onOpenChange={() => setDeleteTarget(null)}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>确认删除</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
确定要删除模型 "{deleteTarget?.alias || deleteTarget?.model_id}" 吗?此操作不可撤销。
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setDeleteTarget(null)}>取消</Button>
|
||||||
|
<Button variant="destructive" onClick={handleDelete} disabled={deleting}>
|
||||||
|
{deleting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
338
admin/src/app/(dashboard)/page.tsx
Normal file
338
admin/src/app/(dashboard)/page.tsx
Normal file
@@ -0,0 +1,338 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import {
|
||||||
|
Users,
|
||||||
|
Server,
|
||||||
|
ArrowLeftRight,
|
||||||
|
Zap,
|
||||||
|
Loader2,
|
||||||
|
TrendingUp,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import {
|
||||||
|
AreaChart,
|
||||||
|
Area,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
CartesianGrid,
|
||||||
|
Tooltip,
|
||||||
|
ResponsiveContainer,
|
||||||
|
BarChart,
|
||||||
|
Bar,
|
||||||
|
Legend,
|
||||||
|
} from 'recharts'
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table'
|
||||||
|
import { api } from '@/lib/api-client'
|
||||||
|
import { formatNumber, formatDate } from '@/lib/utils'
|
||||||
|
import type {
|
||||||
|
DashboardStats,
|
||||||
|
UsageStats,
|
||||||
|
OperationLog,
|
||||||
|
} from '@/lib/types'
|
||||||
|
|
||||||
|
interface StatCardProps {
|
||||||
|
title: string
|
||||||
|
value: string | number
|
||||||
|
icon: React.ReactNode
|
||||||
|
color: string
|
||||||
|
subtitle?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatCard({ title, value, icon, color, subtitle }: StatCardProps) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">{title}</p>
|
||||||
|
<p className="mt-1 text-2xl font-bold text-foreground">{value}</p>
|
||||||
|
{subtitle && (
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">{subtitle}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`flex h-10 w-10 items-center justify-center rounded-lg ${color}`}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusBadge({ status }: { status: string }) {
|
||||||
|
const variantMap: Record<string, 'success' | 'destructive' | 'warning' | 'info' | 'secondary'> = {
|
||||||
|
active: 'success',
|
||||||
|
completed: 'success',
|
||||||
|
disabled: 'destructive',
|
||||||
|
failed: 'destructive',
|
||||||
|
processing: 'info',
|
||||||
|
queued: 'warning',
|
||||||
|
suspended: 'destructive',
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Badge variant={variantMap[status] || 'secondary'}>{status}</Badge>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DashboardPage() {
|
||||||
|
const [stats, setStats] = useState<DashboardStats | null>(null)
|
||||||
|
const [usageStats, setUsageStats] = useState<UsageStats | null>(null)
|
||||||
|
const [recentLogs, setRecentLogs] = useState<OperationLog[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchData() {
|
||||||
|
try {
|
||||||
|
const [statsRes, usageRes, logsRes] = await Promise.allSettled([
|
||||||
|
api.stats.dashboard(),
|
||||||
|
api.usage.get(),
|
||||||
|
api.logs.list({ page: 1, page_size: 5 }),
|
||||||
|
])
|
||||||
|
|
||||||
|
if (statsRes.status === 'fulfilled') setStats(statsRes.value)
|
||||||
|
if (usageRes.status === 'fulfilled') setUsageStats(usageRes.value)
|
||||||
|
if (logsRes.status === 'fulfilled') setRecentLogs(logsRes.value)
|
||||||
|
|
||||||
|
if (statsRes.status === 'rejected' && usageRes.status === 'rejected' && logsRes.status === 'rejected') {
|
||||||
|
setError('加载数据失败,请检查后端服务是否启动')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetchData()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-[60vh] items-center justify-center">
|
||||||
|
<div className="flex flex-col items-center gap-3">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||||
|
<p className="text-sm text-muted-foreground">加载中...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-[60vh] items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-destructive">{error}</p>
|
||||||
|
<button
|
||||||
|
onClick={() => window.location.reload()}
|
||||||
|
className="mt-4 text-sm text-primary hover:underline cursor-pointer"
|
||||||
|
>
|
||||||
|
重新加载
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const chartData = (usageStats?.by_day ?? []).map((r) => ({
|
||||||
|
day: r.date.slice(5), // MM-DD
|
||||||
|
请求量: r.request_count,
|
||||||
|
Input: r.input_tokens,
|
||||||
|
Output: r.output_tokens,
|
||||||
|
}))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* 统计卡片 */}
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<StatCard
|
||||||
|
title="总账号数"
|
||||||
|
value={stats?.total_accounts ?? '-'}
|
||||||
|
icon={<Users className="h-5 w-5 text-blue-400" />}
|
||||||
|
color="bg-blue-500/10"
|
||||||
|
subtitle={`活跃 ${stats?.active_accounts ?? 0}`}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title="活跃服务商"
|
||||||
|
value={stats?.active_providers ?? '-'}
|
||||||
|
icon={<Server className="h-5 w-5 text-green-400" />}
|
||||||
|
color="bg-green-500/10"
|
||||||
|
subtitle={`模型 ${stats?.active_models ?? 0}`}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title="今日请求"
|
||||||
|
value={stats?.tasks_today ?? '-'}
|
||||||
|
icon={<ArrowLeftRight className="h-5 w-5 text-purple-400" />}
|
||||||
|
color="bg-purple-500/10"
|
||||||
|
subtitle="中转任务"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title="今日 Token"
|
||||||
|
value={formatNumber((stats?.tokens_today_input ?? 0) + (stats?.tokens_today_output ?? 0))}
|
||||||
|
icon={<Zap className="h-5 w-5 text-orange-400" />}
|
||||||
|
color="bg-orange-500/10"
|
||||||
|
subtitle={`In: ${formatNumber(stats?.tokens_today_input ?? 0)} / Out: ${formatNumber(stats?.tokens_today_output ?? 0)}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 图表 */}
|
||||||
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||||
|
{/* 请求趋势 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<TrendingUp className="h-4 w-4 text-primary" />
|
||||||
|
请求趋势 (30 天)
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{chartData.length > 0 ? (
|
||||||
|
<ResponsiveContainer width="100%" height={280}>
|
||||||
|
<AreaChart data={chartData}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="colorRequests" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="5%" stopColor="#22C55E" stopOpacity={0.3} />
|
||||||
|
<stop offset="95%" stopColor="#22C55E" stopOpacity={0} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#1E293B" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="day"
|
||||||
|
tick={{ fontSize: 12, fill: '#94A3B8' }}
|
||||||
|
axisLine={{ stroke: '#1E293B' }}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tick={{ fontSize: 12, fill: '#94A3B8' }}
|
||||||
|
axisLine={{ stroke: '#1E293B' }}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: '#0F172A',
|
||||||
|
border: '1px solid #1E293B',
|
||||||
|
borderRadius: '8px',
|
||||||
|
color: '#F8FAFC',
|
||||||
|
fontSize: '12px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="请求量"
|
||||||
|
stroke="#22C55E"
|
||||||
|
fillOpacity={1}
|
||||||
|
fill="url(#colorRequests)"
|
||||||
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-[280px] items-center justify-center text-muted-foreground text-sm">
|
||||||
|
暂无数据
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Token 用量 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<Zap className="h-4 w-4 text-orange-400" />
|
||||||
|
Token 用量 (30 天)
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{chartData.length > 0 ? (
|
||||||
|
<ResponsiveContainer width="100%" height={280}>
|
||||||
|
<BarChart data={chartData}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#1E293B" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="day"
|
||||||
|
tick={{ fontSize: 12, fill: '#94A3B8' }}
|
||||||
|
axisLine={{ stroke: '#1E293B' }}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tick={{ fontSize: 12, fill: '#94A3B8' }}
|
||||||
|
axisLine={{ stroke: '#1E293B' }}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: '#0F172A',
|
||||||
|
border: '1px solid #1E293B',
|
||||||
|
borderRadius: '8px',
|
||||||
|
color: '#F8FAFC',
|
||||||
|
fontSize: '12px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Legend
|
||||||
|
wrapperStyle={{ fontSize: '12px', color: '#94A3B8' }}
|
||||||
|
/>
|
||||||
|
<Bar dataKey="Input" fill="#3B82F6" radius={[2, 2, 0, 0]} />
|
||||||
|
<Bar dataKey="Output" fill="#F97316" radius={[2, 2, 0, 0]} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-[280px] items-center justify-center text-muted-foreground text-sm">
|
||||||
|
暂无数据
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 最近操作日志 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">最近操作</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{recentLogs.length > 0 ? (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>时间</TableHead>
|
||||||
|
<TableHead>账号 ID</TableHead>
|
||||||
|
<TableHead>操作</TableHead>
|
||||||
|
<TableHead>目标类型</TableHead>
|
||||||
|
<TableHead>目标 ID</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{recentLogs.map((log) => (
|
||||||
|
<TableRow key={log.id}>
|
||||||
|
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||||
|
{formatDate(log.created_at)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs">
|
||||||
|
{log.account_id.slice(0, 8)}...
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="outline">{log.action}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">
|
||||||
|
{log.target_type}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||||
|
{log.target_id.slice(0, 8)}...
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-32 items-center justify-center text-muted-foreground text-sm">
|
||||||
|
暂无操作日志
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
154
admin/src/app/(dashboard)/profile/page.tsx
Normal file
154
admin/src/app/(dashboard)/profile/page.tsx
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { Lock, Loader2, Eye, EyeOff, Check } from 'lucide-react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
|
||||||
|
import { api } from '@/lib/api-client'
|
||||||
|
import { ApiRequestError } from '@/lib/api-client'
|
||||||
|
|
||||||
|
export default function ProfilePage() {
|
||||||
|
const [oldPassword, setOldPassword] = useState('')
|
||||||
|
const [newPassword, setNewPassword] = useState('')
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState('')
|
||||||
|
const [showOld, setShowOld] = useState(false)
|
||||||
|
const [showNew, setShowNew] = useState(false)
|
||||||
|
const [showConfirm, setShowConfirm] = useState(false)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [success, setSuccess] = useState('')
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setError('')
|
||||||
|
setSuccess('')
|
||||||
|
|
||||||
|
if (newPassword.length < 8) {
|
||||||
|
setError('新密码至少 8 个字符')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (newPassword !== confirmPassword) {
|
||||||
|
setError('两次输入的新密码不一致')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
await api.auth.changePassword({ old_password: oldPassword, new_password: newPassword })
|
||||||
|
setSuccess('密码修改成功')
|
||||||
|
setOldPassword('')
|
||||||
|
setNewPassword('')
|
||||||
|
setConfirmPassword('')
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiRequestError) {
|
||||||
|
setError(err.body.message || '修改失败')
|
||||||
|
} else {
|
||||||
|
setError('网络错误,请稍后重试')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-lg">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Lock className="h-5 w-5" />
|
||||||
|
修改密码
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>修改您的登录密码。修改后需要重新登录。</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="old-password">当前密码</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
id="old-password"
|
||||||
|
type={showOld ? 'text' : 'password'}
|
||||||
|
value={oldPassword}
|
||||||
|
onChange={(e) => setOldPassword(e.target.value)}
|
||||||
|
placeholder="请输入当前密码"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowOld(!showOld)}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground cursor-pointer"
|
||||||
|
>
|
||||||
|
{showOld ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="new-password">新密码</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
id="new-password"
|
||||||
|
type={showNew ? 'text' : 'password'}
|
||||||
|
value={newPassword}
|
||||||
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
|
placeholder="至少 8 个字符"
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowNew(!showNew)}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground cursor-pointer"
|
||||||
|
>
|
||||||
|
{showNew ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="confirm-password">确认新密码</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
id="confirm-password"
|
||||||
|
type={showConfirm ? 'text' : 'password'}
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
placeholder="再次输入新密码"
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowConfirm(!showConfirm)}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground cursor-pointer"
|
||||||
|
>
|
||||||
|
{showConfirm ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-md bg-destructive/10 border border-destructive/20 px-4 py-3 text-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{success && (
|
||||||
|
<div className="rounded-md bg-emerald-500/10 border border-emerald-500/20 px-4 py-3 text-sm text-emerald-500 flex items-center gap-2">
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
{success}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button type="submit" disabled={saving || !oldPassword || !newPassword || !confirmPassword}>
|
||||||
|
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
修改密码
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
369
admin/src/app/(dashboard)/providers/page.tsx
Normal file
369
admin/src/app/(dashboard)/providers/page.tsx
Normal file
@@ -0,0 +1,369 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
|
import {
|
||||||
|
Plus,
|
||||||
|
Loader2,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Pencil,
|
||||||
|
Trash2,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Switch } from '@/components/ui/switch'
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
DialogDescription,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
import { api } from '@/lib/api-client'
|
||||||
|
import { ApiRequestError } from '@/lib/api-client'
|
||||||
|
import { formatDate } from '@/lib/utils'
|
||||||
|
import type { Provider } from '@/lib/types'
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20
|
||||||
|
|
||||||
|
interface ProviderForm {
|
||||||
|
name: string
|
||||||
|
display_name: string
|
||||||
|
base_url: string
|
||||||
|
api_protocol: 'openai' | 'anthropic'
|
||||||
|
enabled: boolean
|
||||||
|
rate_limit_rpm: string
|
||||||
|
rate_limit_tpm: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyForm: ProviderForm = {
|
||||||
|
name: '',
|
||||||
|
display_name: '',
|
||||||
|
base_url: '',
|
||||||
|
api_protocol: 'openai',
|
||||||
|
enabled: true,
|
||||||
|
rate_limit_rpm: '',
|
||||||
|
rate_limit_tpm: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProvidersPage() {
|
||||||
|
const [providers, setProviders] = useState<Provider[]>([])
|
||||||
|
const [total, setTotal] = useState(0)
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
// 创建/编辑 Dialog
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false)
|
||||||
|
const [editTarget, setEditTarget] = useState<Provider | null>(null)
|
||||||
|
const [form, setForm] = useState<ProviderForm>(emptyForm)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
|
// 删除确认 Dialog
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<Provider | null>(null)
|
||||||
|
const [deleting, setDeleting] = useState(false)
|
||||||
|
|
||||||
|
const fetchProviders = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const res = await api.providers.list({ page, page_size: PAGE_SIZE })
|
||||||
|
setProviders(res.items)
|
||||||
|
setTotal(res.total)
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiRequestError) setError(err.body.message)
|
||||||
|
else setError('加载失败')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [page])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchProviders()
|
||||||
|
}, [fetchProviders])
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
|
||||||
|
|
||||||
|
function openCreateDialog() {
|
||||||
|
setEditTarget(null)
|
||||||
|
setForm(emptyForm)
|
||||||
|
setDialogOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditDialog(provider: Provider) {
|
||||||
|
setEditTarget(provider)
|
||||||
|
setForm({
|
||||||
|
name: provider.name,
|
||||||
|
display_name: provider.display_name,
|
||||||
|
base_url: provider.base_url,
|
||||||
|
api_protocol: provider.api_protocol,
|
||||||
|
enabled: provider.enabled,
|
||||||
|
rate_limit_rpm: provider.rate_limit_rpm?.toString() || '',
|
||||||
|
rate_limit_tpm: provider.rate_limit_tpm?.toString() || '',
|
||||||
|
})
|
||||||
|
setDialogOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
if (!form.name.trim() || !form.base_url.trim()) return
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
name: form.name.trim(),
|
||||||
|
display_name: form.display_name.trim(),
|
||||||
|
base_url: form.base_url.trim(),
|
||||||
|
api_protocol: form.api_protocol,
|
||||||
|
enabled: form.enabled,
|
||||||
|
rate_limit_rpm: form.rate_limit_rpm ? parseInt(form.rate_limit_rpm, 10) : undefined,
|
||||||
|
rate_limit_tpm: form.rate_limit_tpm ? parseInt(form.rate_limit_tpm, 10) : undefined,
|
||||||
|
}
|
||||||
|
if (editTarget) {
|
||||||
|
await api.providers.update(editTarget.id, payload)
|
||||||
|
} else {
|
||||||
|
await api.providers.create(payload)
|
||||||
|
}
|
||||||
|
setDialogOpen(false)
|
||||||
|
fetchProviders()
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiRequestError) setError(err.body.message)
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete() {
|
||||||
|
if (!deleteTarget) return
|
||||||
|
setDeleting(true)
|
||||||
|
try {
|
||||||
|
await api.providers.delete(deleteTarget.id)
|
||||||
|
setDeleteTarget(null)
|
||||||
|
fetchProviders()
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiRequestError) setError(err.body.message)
|
||||||
|
} finally {
|
||||||
|
setDeleting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 工具栏 */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div />
|
||||||
|
<Button onClick={openCreateDialog}>
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
新建服务商
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-md bg-destructive/10 border border-destructive/20 px-4 py-3 text-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
<button onClick={() => setError('')} className="ml-2 underline cursor-pointer">关闭</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex h-64 items-center justify-center">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : providers.length === 0 ? (
|
||||||
|
<div className="flex h-64 items-center justify-center text-muted-foreground text-sm">
|
||||||
|
暂无数据
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>名称</TableHead>
|
||||||
|
<TableHead>显示名</TableHead>
|
||||||
|
<TableHead>Base URL</TableHead>
|
||||||
|
<TableHead>协议</TableHead>
|
||||||
|
<TableHead>启用</TableHead>
|
||||||
|
<TableHead>RPM 限制</TableHead>
|
||||||
|
<TableHead>创建时间</TableHead>
|
||||||
|
<TableHead className="text-right">操作</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{providers.map((p) => (
|
||||||
|
<TableRow key={p.id}>
|
||||||
|
<TableCell className="font-medium">{p.name}</TableCell>
|
||||||
|
<TableCell>{p.display_name || '-'}</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs text-muted-foreground max-w-[200px] truncate">
|
||||||
|
{p.base_url}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={p.api_protocol === 'openai' ? 'default' : 'info'}>
|
||||||
|
{p.api_protocol}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={p.enabled ? 'success' : 'secondary'}>
|
||||||
|
{p.enabled ? '是' : '否'}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">
|
||||||
|
{p.rate_limit_rpm ?? '-'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||||
|
{formatDate(p.created_at)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<div className="flex items-center justify-end gap-1">
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => openEditDialog(p)} title="编辑">
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => setDeleteTarget(p)} title="删除">
|
||||||
|
<Trash2 className="h-4 w-4 text-destructive" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
第 {page} 页 / 共 {totalPages} 页 ({total} 条)
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => setPage(page - 1)}>
|
||||||
|
<ChevronLeft className="h-4 w-4 mr-1" />
|
||||||
|
上一页
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" disabled={page >= totalPages} onClick={() => setPage(page + 1)}>
|
||||||
|
下一页
|
||||||
|
<ChevronRight className="h-4 w-4 ml-1" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 创建/编辑 Dialog */}
|
||||||
|
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{editTarget ? '编辑服务商' : '新建服务商'}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{editTarget ? '修改服务商配置' : '添加新的 AI 服务商'}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 max-h-[60vh] overflow-y-auto scrollbar-thin pr-1">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>名称 *</Label>
|
||||||
|
<Input
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||||
|
placeholder="例如: openai"
|
||||||
|
disabled={!!editTarget}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>显示名</Label>
|
||||||
|
<Input
|
||||||
|
value={form.display_name}
|
||||||
|
onChange={(e) => setForm({ ...form, display_name: e.target.value })}
|
||||||
|
placeholder="例如: OpenAI"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Base URL *</Label>
|
||||||
|
<Input
|
||||||
|
value={form.base_url}
|
||||||
|
onChange={(e) => setForm({ ...form, base_url: e.target.value })}
|
||||||
|
placeholder="https://api.openai.com/v1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>API 协议</Label>
|
||||||
|
<Select value={form.api_protocol} onValueChange={(v) => setForm({ ...form, api_protocol: v as 'openai' | 'anthropic' })}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="openai">OpenAI</SelectItem>
|
||||||
|
<SelectItem value="anthropic">Anthropic</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Switch
|
||||||
|
checked={form.enabled}
|
||||||
|
onCheckedChange={(v) => setForm({ ...form, enabled: v })}
|
||||||
|
/>
|
||||||
|
<Label>启用</Label>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>RPM 限制</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={form.rate_limit_rpm}
|
||||||
|
onChange={(e) => setForm({ ...form, rate_limit_rpm: e.target.value })}
|
||||||
|
placeholder="不限"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>TPM 限制</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={form.rate_limit_tpm}
|
||||||
|
onChange={(e) => setForm({ ...form, rate_limit_tpm: e.target.value })}
|
||||||
|
placeholder="不限"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setDialogOpen(false)}>取消</Button>
|
||||||
|
<Button onClick={handleSave} disabled={saving || !form.name.trim() || !form.base_url.trim()}>
|
||||||
|
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* 删除确认 Dialog */}
|
||||||
|
<Dialog open={!!deleteTarget} onOpenChange={() => setDeleteTarget(null)}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>确认删除</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
确定要删除服务商 "{deleteTarget?.display_name || deleteTarget?.name}" 吗?此操作不可撤销。
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setDeleteTarget(null)}>取消</Button>
|
||||||
|
<Button variant="destructive" onClick={handleDelete} disabled={deleting}>
|
||||||
|
{deleting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
278
admin/src/app/(dashboard)/relay/page.tsx
Normal file
278
admin/src/app/(dashboard)/relay/page.tsx
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
|
import {
|
||||||
|
Loader2,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronUp,
|
||||||
|
RotateCcw,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table'
|
||||||
|
import { api } from '@/lib/api-client'
|
||||||
|
import { ApiRequestError } from '@/lib/api-client'
|
||||||
|
import { formatDate, formatNumber } from '@/lib/utils'
|
||||||
|
import type { RelayTask } from '@/lib/types'
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20
|
||||||
|
|
||||||
|
const statusVariants: Record<string, 'success' | 'info' | 'warning' | 'destructive' | 'secondary'> = {
|
||||||
|
queued: 'warning',
|
||||||
|
processing: 'info',
|
||||||
|
completed: 'success',
|
||||||
|
failed: 'destructive',
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusLabels: Record<string, string> = {
|
||||||
|
queued: '排队中',
|
||||||
|
processing: '处理中',
|
||||||
|
completed: '已完成',
|
||||||
|
failed: '失败',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RelayPage() {
|
||||||
|
const [tasks, setTasks] = useState<RelayTask[]>([])
|
||||||
|
const [total, setTotal] = useState(0)
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [statusFilter, setStatusFilter] = useState<string>('all')
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [expandedId, setExpandedId] = useState<string | null>(null)
|
||||||
|
const [retryingId, setRetryingId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const fetchTasks = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const params: Record<string, unknown> = { page, page_size: PAGE_SIZE }
|
||||||
|
if (statusFilter !== 'all') params.status = statusFilter
|
||||||
|
const res = await api.relay.list(params)
|
||||||
|
setTasks(res.items)
|
||||||
|
setTotal(res.total)
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiRequestError) setError(err.body.message)
|
||||||
|
else setError('加载失败')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [page, statusFilter])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchTasks()
|
||||||
|
}, [fetchTasks])
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
|
||||||
|
|
||||||
|
function toggleExpand(id: string) {
|
||||||
|
setExpandedId((prev) => (prev === id ? null : id))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRetry(taskId: string, e: React.MouseEvent) {
|
||||||
|
e.stopPropagation()
|
||||||
|
setRetryingId(taskId)
|
||||||
|
try {
|
||||||
|
await api.relay.retry(taskId)
|
||||||
|
fetchTasks()
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiRequestError) setError(err.body.message)
|
||||||
|
else setError('重试失败')
|
||||||
|
} finally {
|
||||||
|
setRetryingId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 筛选 */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Select value={statusFilter} onValueChange={(v) => { setStatusFilter(v); setPage(1) }}>
|
||||||
|
<SelectTrigger className="w-[140px]">
|
||||||
|
<SelectValue placeholder="状态筛选" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">全部状态</SelectItem>
|
||||||
|
<SelectItem value="queued">排队中</SelectItem>
|
||||||
|
<SelectItem value="processing">处理中</SelectItem>
|
||||||
|
<SelectItem value="completed">已完成</SelectItem>
|
||||||
|
<SelectItem value="failed">失败</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-md bg-destructive/10 border border-destructive/20 px-4 py-3 text-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
<button onClick={() => setError('')} className="ml-2 underline cursor-pointer">关闭</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex h-64 items-center justify-center">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : tasks.length === 0 ? (
|
||||||
|
<div className="flex h-64 items-center justify-center text-muted-foreground text-sm">
|
||||||
|
暂无数据
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-8" />
|
||||||
|
<TableHead>任务 ID</TableHead>
|
||||||
|
<TableHead>模型</TableHead>
|
||||||
|
<TableHead>状态</TableHead>
|
||||||
|
<TableHead>优先级</TableHead>
|
||||||
|
<TableHead>重试次数</TableHead>
|
||||||
|
<TableHead>Input Tokens</TableHead>
|
||||||
|
<TableHead>Output Tokens</TableHead>
|
||||||
|
<TableHead>错误信息</TableHead>
|
||||||
|
<TableHead>创建时间</TableHead>
|
||||||
|
<TableHead className="text-right">操作</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{tasks.map((task) => (
|
||||||
|
<>
|
||||||
|
<TableRow key={task.id} className="cursor-pointer" onClick={() => toggleExpand(task.id)}>
|
||||||
|
<TableCell>
|
||||||
|
{expandedId === task.id ? (
|
||||||
|
<ChevronUp className="h-4 w-4 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs">
|
||||||
|
{task.id.slice(0, 8)}...
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs">
|
||||||
|
{task.model_id}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={statusVariants[task.status] || 'secondary'}>
|
||||||
|
{statusLabels[task.status] || task.status}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">{task.priority}</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">{task.attempt_count}</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||||
|
{formatNumber(task.input_tokens)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||||
|
{formatNumber(task.output_tokens)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="max-w-[200px] truncate text-xs text-destructive">
|
||||||
|
{task.error_message || '-'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||||
|
{formatDate(task.created_at)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
{task.status === 'failed' && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={(e) => handleRetry(task.id, e)}
|
||||||
|
disabled={retryingId === task.id}
|
||||||
|
title="重试"
|
||||||
|
>
|
||||||
|
{retryingId === task.id ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<RotateCcw className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
{expandedId === task.id && (
|
||||||
|
<TableRow key={`${task.id}-detail`}>
|
||||||
|
<TableCell colSpan={11} className="bg-muted/20 px-8 py-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">任务 ID</p>
|
||||||
|
<p className="font-mono text-xs">{task.id}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">账号 ID</p>
|
||||||
|
<p className="font-mono text-xs">{task.account_id}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">服务商 ID</p>
|
||||||
|
<p className="font-mono text-xs">{task.provider_id}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">模型 ID</p>
|
||||||
|
<p className="font-mono text-xs">{task.model_id}</p>
|
||||||
|
</div>
|
||||||
|
{task.queued_at && (
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">排队时间</p>
|
||||||
|
<p className="font-mono text-xs">{formatDate(task.queued_at)}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{task.started_at && (
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">开始时间</p>
|
||||||
|
<p className="font-mono text-xs">{formatDate(task.started_at)}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{task.completed_at && (
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">完成时间</p>
|
||||||
|
<p className="font-mono text-xs">{formatDate(task.completed_at)}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{task.error_message && (
|
||||||
|
<div className="col-span-2">
|
||||||
|
<p className="text-muted-foreground">错误信息</p>
|
||||||
|
<p className="text-xs text-destructive mt-1">{task.error_message}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
第 {page} 页 / 共 {totalPages} 页 ({total} 条)
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => setPage(page - 1)}>
|
||||||
|
<ChevronLeft className="h-4 w-4 mr-1" />
|
||||||
|
上一页
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" disabled={page >= totalPages} onClick={() => setPage(page + 1)}>
|
||||||
|
下一页
|
||||||
|
<ChevronRight className="h-4 w-4 ml-1" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
203
admin/src/app/(dashboard)/security/page.tsx
Normal file
203
admin/src/app/(dashboard)/security/page.tsx
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { ShieldCheck, Loader2, Eye, EyeOff, QrCode, Key, AlertTriangle } from 'lucide-react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { api } from '@/lib/api-client'
|
||||||
|
import { useAuth } from '@/components/auth-guard'
|
||||||
|
import { ApiRequestError } from '@/lib/api-client'
|
||||||
|
|
||||||
|
export default function SecurityPage() {
|
||||||
|
const { account } = useAuth()
|
||||||
|
const totpEnabled = account?.totp_enabled ?? false
|
||||||
|
|
||||||
|
// Setup state
|
||||||
|
const [step, setStep] = useState<'idle' | 'verify' | 'done'>('idle')
|
||||||
|
const [otpauthUri, setOtpauthUri] = useState('')
|
||||||
|
const [secret, setSecret] = useState('')
|
||||||
|
const [verifyCode, setVerifyCode] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
// Disable state
|
||||||
|
const [disablePassword, setDisablePassword] = useState('')
|
||||||
|
const [showDisablePassword, setShowDisablePassword] = useState(false)
|
||||||
|
const [disabling, setDisabling] = useState(false)
|
||||||
|
|
||||||
|
async function handleSetup() {
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const res = await api.auth.totpSetup()
|
||||||
|
setOtpauthUri(res.otpauth_uri)
|
||||||
|
setSecret(res.secret)
|
||||||
|
setStep('verify')
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiRequestError) setError(err.body.message || '获取密钥失败')
|
||||||
|
else setError('网络错误')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleVerify() {
|
||||||
|
if (verifyCode.length !== 6) {
|
||||||
|
setError('请输入 6 位验证码')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
await api.auth.totpVerify({ code: verifyCode })
|
||||||
|
setStep('done')
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiRequestError) setError(err.body.message || '验证失败')
|
||||||
|
else setError('网络错误')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDisable() {
|
||||||
|
if (!disablePassword) {
|
||||||
|
setError('请输入密码以确认禁用')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setDisabling(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
await api.auth.totpDisable({ password: disablePassword })
|
||||||
|
setDisablePassword('')
|
||||||
|
window.location.reload()
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiRequestError) setError(err.body.message || '禁用失败')
|
||||||
|
else setError('网络错误')
|
||||||
|
} finally {
|
||||||
|
setDisabling(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-lg space-y-6">
|
||||||
|
{/* TOTP 状态 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<ShieldCheck className="h-5 w-5" />
|
||||||
|
双因素认证 (TOTP)
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
使用 Google Authenticator 等应用生成一次性验证码,增强账号安全。
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<span className="text-sm text-muted-foreground">当前状态:</span>
|
||||||
|
<Badge variant={totpEnabled ? 'success' : 'secondary'}>
|
||||||
|
{totpEnabled ? '已启用' : '未启用'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-md bg-destructive/10 border border-destructive/20 px-4 py-3 text-sm text-destructive mb-4">
|
||||||
|
{error}
|
||||||
|
<button onClick={() => setError('')} className="ml-2 underline cursor-pointer">关闭</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 未启用: 设置流程 */}
|
||||||
|
{!totpEnabled && step === 'idle' && (
|
||||||
|
<Button onClick={handleSetup} disabled={loading}>
|
||||||
|
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
<Key className="mr-2 h-4 w-4" />
|
||||||
|
启用双因素认证
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!totpEnabled && step === 'verify' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="rounded-md border border-border p-4 space-y-3">
|
||||||
|
<div className="flex items-center gap-2 text-sm font-medium">
|
||||||
|
<QrCode className="h-4 w-4" />
|
||||||
|
步骤 1: 扫描二维码或手动输入密钥
|
||||||
|
</div>
|
||||||
|
<div className="bg-muted rounded-md p-3 font-mono text-xs break-all">
|
||||||
|
{otpauthUri}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-xs text-muted-foreground">手动输入密钥:</p>
|
||||||
|
<p className="font-mono text-sm font-medium select-all">{secret}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>
|
||||||
|
步骤 2: 输入 6 位验证码
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
value={verifyCode}
|
||||||
|
onChange={(e) => setVerifyCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||||
|
placeholder="请输入应用中显示的 6 位数字"
|
||||||
|
maxLength={6}
|
||||||
|
className="font-mono tracking-widest text-center"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" onClick={() => { setStep('idle'); setVerifyCode('') }}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleVerify} disabled={loading || verifyCode.length !== 6}>
|
||||||
|
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
验证并启用
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!totpEnabled && step === 'done' && (
|
||||||
|
<div className="rounded-md bg-emerald-500/10 border border-emerald-500/20 p-4 text-sm text-emerald-500">
|
||||||
|
双因素认证已成功启用。下次登录时需要输入验证码。
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 已启用: 禁用流程 */}
|
||||||
|
{totpEnabled && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="rounded-md bg-amber-500/10 border border-amber-500/20 p-3 flex items-start gap-2 text-sm text-amber-600">
|
||||||
|
<AlertTriangle className="h-4 w-4 mt-0.5 shrink-0" />
|
||||||
|
<span>禁用双因素认证会降低账号安全性,建议仅在必要时操作。</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>输入当前密码以确认禁用</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
type={showDisablePassword ? 'text' : 'password'}
|
||||||
|
value={disablePassword}
|
||||||
|
onChange={(e) => setDisablePassword(e.target.value)}
|
||||||
|
placeholder="请输入当前密码"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowDisablePassword(!showDisablePassword)}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground cursor-pointer"
|
||||||
|
>
|
||||||
|
{showDisablePassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button variant="destructive" onClick={handleDisable} disabled={disabling || !disablePassword}>
|
||||||
|
{disabling && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
禁用双因素认证
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
234
admin/src/app/(dashboard)/usage/page.tsx
Normal file
234
admin/src/app/(dashboard)/usage/page.tsx
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
|
import { Loader2, Zap } from 'lucide-react'
|
||||||
|
import {
|
||||||
|
LineChart,
|
||||||
|
Line,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
CartesianGrid,
|
||||||
|
Tooltip,
|
||||||
|
ResponsiveContainer,
|
||||||
|
BarChart,
|
||||||
|
Bar,
|
||||||
|
Legend,
|
||||||
|
} from 'recharts'
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
import { api } from '@/lib/api-client'
|
||||||
|
import { ApiRequestError } from '@/lib/api-client'
|
||||||
|
import { formatNumber } from '@/lib/utils'
|
||||||
|
import type { UsageStats } from '@/lib/types'
|
||||||
|
|
||||||
|
export default function UsagePage() {
|
||||||
|
const [days, setDays] = useState(7)
|
||||||
|
const [usageStats, setUsageStats] = useState<UsageStats | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
const fetchData = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const from = new Date()
|
||||||
|
from.setDate(from.getDate() - days)
|
||||||
|
const fromStr = from.toISOString().slice(0, 10)
|
||||||
|
const res = await api.usage.get({ from: fromStr })
|
||||||
|
setUsageStats(res)
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiRequestError) setError(err.body.message)
|
||||||
|
else setError('加载数据失败')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [days])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData()
|
||||||
|
}, [fetchData])
|
||||||
|
|
||||||
|
const byDay = usageStats?.by_day ?? []
|
||||||
|
|
||||||
|
const lineChartData = byDay.map((r) => ({
|
||||||
|
day: r.date.slice(5),
|
||||||
|
Input: r.input_tokens,
|
||||||
|
Output: r.output_tokens,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const barChartData = (usageStats?.by_model ?? []).map((r) => ({
|
||||||
|
model: r.model_id,
|
||||||
|
请求量: r.request_count,
|
||||||
|
Input: r.input_tokens,
|
||||||
|
Output: r.output_tokens,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const totalInput = byDay.reduce((s, r) => s + r.input_tokens, 0)
|
||||||
|
const totalOutput = byDay.reduce((s, r) => s + r.output_tokens, 0)
|
||||||
|
const totalRequests = byDay.reduce((s, r) => s + r.request_count, 0)
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-[60vh] items-center justify-center">
|
||||||
|
<div className="flex flex-col items-center gap-3">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||||
|
<p className="text-sm text-muted-foreground">加载中...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-[60vh] items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-destructive">{error}</p>
|
||||||
|
<button onClick={() => fetchData()} className="mt-4 text-sm text-primary hover:underline cursor-pointer">
|
||||||
|
重新加载
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* 时间范围 */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-sm text-muted-foreground">时间范围:</span>
|
||||||
|
<Select value={String(days)} onValueChange={(v) => setDays(Number(v))}>
|
||||||
|
<SelectTrigger className="w-[140px]">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="7">最近 7 天</SelectItem>
|
||||||
|
<SelectItem value="30">最近 30 天</SelectItem>
|
||||||
|
<SelectItem value="90">最近 90 天</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 汇总统计 */}
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<p className="text-sm text-muted-foreground">总请求数</p>
|
||||||
|
<p className="mt-1 text-2xl font-bold text-foreground">
|
||||||
|
{formatNumber(totalRequests)}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<p className="text-sm text-muted-foreground">Input Tokens</p>
|
||||||
|
<p className="mt-1 text-2xl font-bold text-blue-400">
|
||||||
|
{formatNumber(totalInput)}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<p className="text-sm text-muted-foreground">Output Tokens</p>
|
||||||
|
<p className="mt-1 text-2xl font-bold text-orange-400">
|
||||||
|
{formatNumber(totalOutput)}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Token 用量趋势 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<Zap className="h-4 w-4 text-primary" />
|
||||||
|
Token 用量趋势
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{lineChartData.length > 0 ? (
|
||||||
|
<ResponsiveContainer width="100%" height={320}>
|
||||||
|
<LineChart data={lineChartData}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#1E293B" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="day"
|
||||||
|
tick={{ fontSize: 12, fill: '#94A3B8' }}
|
||||||
|
axisLine={{ stroke: '#1E293B' }}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tick={{ fontSize: 12, fill: '#94A3B8' }}
|
||||||
|
axisLine={{ stroke: '#1E293B' }}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: '#0F172A',
|
||||||
|
border: '1px solid #1E293B',
|
||||||
|
borderRadius: '8px',
|
||||||
|
color: '#F8FAFC',
|
||||||
|
fontSize: '12px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Legend wrapperStyle={{ fontSize: '12px', color: '#94A3B8' }} />
|
||||||
|
<Line type="monotone" dataKey="Input" stroke="#3B82F6" strokeWidth={2} dot={false} />
|
||||||
|
<Line type="monotone" dataKey="Output" stroke="#F97316" strokeWidth={2} dot={false} />
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-[320px] items-center justify-center text-muted-foreground text-sm">
|
||||||
|
暂无数据
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 按模型分布 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">按模型分布</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{barChartData.length > 0 ? (
|
||||||
|
<ResponsiveContainer width="100%" height={320}>
|
||||||
|
<BarChart data={barChartData} layout="vertical">
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#1E293B" />
|
||||||
|
<XAxis
|
||||||
|
type="number"
|
||||||
|
tick={{ fontSize: 12, fill: '#94A3B8' }}
|
||||||
|
axisLine={{ stroke: '#1E293B' }}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
type="category"
|
||||||
|
dataKey="model"
|
||||||
|
tick={{ fontSize: 12, fill: '#94A3B8' }}
|
||||||
|
axisLine={{ stroke: '#1E293B' }}
|
||||||
|
width={120}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: '#0F172A',
|
||||||
|
border: '1px solid #1E293B',
|
||||||
|
borderRadius: '8px',
|
||||||
|
color: '#F8FAFC',
|
||||||
|
fontSize: '12px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Legend wrapperStyle={{ fontSize: '12px', color: '#94A3B8' }} />
|
||||||
|
<Bar dataKey="Input" fill="#3B82F6" radius={[0, 2, 2, 0]} />
|
||||||
|
<Bar dataKey="Output" fill="#F97316" radius={[0, 2, 2, 0]} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-[320px] items-center justify-center text-muted-foreground text-sm">
|
||||||
|
暂无数据
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
66
admin/src/app/globals.css
Normal file
66
admin/src/app/globals.css
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
:root {
|
||||||
|
--background: 222 47% 5%;
|
||||||
|
--foreground: 210 40% 98%;
|
||||||
|
--card: 222 47% 8%;
|
||||||
|
--card-foreground: 210 40% 98%;
|
||||||
|
--primary: 142 71% 45%;
|
||||||
|
--primary-foreground: 222 47% 5%;
|
||||||
|
--muted: 217 33% 17%;
|
||||||
|
--muted-foreground: 215 20% 65%;
|
||||||
|
--accent: 215 28% 23%;
|
||||||
|
--accent-foreground: 210 40% 98%;
|
||||||
|
--destructive: 0 84% 60%;
|
||||||
|
--destructive-foreground: 210 40% 98%;
|
||||||
|
--border: 217 33% 17%;
|
||||||
|
--input: 217 33% 17%;
|
||||||
|
--ring: 142 71% 45%;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
border-color: hsl(var(--border));
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: hsl(var(--background));
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer utilities {
|
||||||
|
.scrollbar-thin {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: hsl(var(--muted)) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar-thin::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar-thin::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar-thin::-webkit-scrollbar-thumb {
|
||||||
|
background-color: hsl(var(--muted));
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
|
||||||
|
background-color: hsl(var(--accent));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
.glass-card {
|
||||||
|
@apply bg-card/80 backdrop-blur-sm border border-border rounded-lg;
|
||||||
|
}
|
||||||
|
}
|
||||||
29
admin/src/app/layout.tsx
Normal file
29
admin/src/app/layout.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import type { Metadata } from 'next'
|
||||||
|
import { Toaster } from 'sonner'
|
||||||
|
import './globals.css'
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'ZCLAW Admin',
|
||||||
|
description: 'ZCLAW AI Agent 管理平台',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<html lang="zh-CN" className="dark">
|
||||||
|
<head>
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
</head>
|
||||||
|
<body className="min-h-screen bg-background font-sans antialiased">
|
||||||
|
{children}
|
||||||
|
<Toaster richColors position="top-right" />
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
)
|
||||||
|
}
|
||||||
218
admin/src/app/login/page.tsx
Normal file
218
admin/src/app/login/page.tsx
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, type FormEvent } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import { Lock, User, Loader2, Eye, EyeOff, ShieldCheck } from 'lucide-react'
|
||||||
|
import { api } from '@/lib/api-client'
|
||||||
|
import { login } from '@/lib/auth'
|
||||||
|
import { ApiRequestError } from '@/lib/api-client'
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const router = useRouter()
|
||||||
|
const [username, setUsername] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [showPassword, setShowPassword] = useState(false)
|
||||||
|
const [totpCode, setTotpCode] = useState('')
|
||||||
|
const [showTotp, setShowTotp] = useState(false)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
async function handleSubmit(e: FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setError('')
|
||||||
|
|
||||||
|
if (!username.trim()) {
|
||||||
|
setError('请输入用户名')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!password.trim()) {
|
||||||
|
setError('请输入密码')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await api.auth.login({
|
||||||
|
username: username.trim(),
|
||||||
|
password,
|
||||||
|
totp_code: showTotp ? totpCode.trim() || undefined : undefined,
|
||||||
|
})
|
||||||
|
login(res.token, res.account)
|
||||||
|
router.replace('/')
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiRequestError) {
|
||||||
|
// 检测 TOTP 错误码,自动显示验证码输入框
|
||||||
|
if (err.body.error === 'totp_required' || err.body.message?.includes('双因素认证') || err.body.message?.includes('TOTP')) {
|
||||||
|
setShowTotp(true)
|
||||||
|
setError(err.body.message || '此账号已启用双因素认证,请输入验证码')
|
||||||
|
} else {
|
||||||
|
setError(err.body.message || '登录失败,请检查用户名和密码')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setError('网络错误,请稍后重试')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen">
|
||||||
|
{/* 左侧品牌区域 */}
|
||||||
|
<div className="hidden lg:flex lg:w-1/2 relative overflow-hidden bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900">
|
||||||
|
{/* 装饰性背景 */}
|
||||||
|
<div className="absolute inset-0">
|
||||||
|
<div className="absolute top-1/4 left-1/4 w-96 h-96 bg-green-500/5 rounded-full blur-3xl" />
|
||||||
|
<div className="absolute bottom-1/4 right-1/4 w-64 h-64 bg-green-500/8 rounded-full blur-3xl" />
|
||||||
|
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[600px] h-[600px] border border-green-500/10 rounded-full" />
|
||||||
|
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[400px] h-[400px] border border-green-500/10 rounded-full" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 品牌内容 */}
|
||||||
|
<div className="relative z-10 flex flex-col items-center justify-center w-full p-12">
|
||||||
|
<div className="text-center">
|
||||||
|
<h1 className="text-6xl font-bold tracking-tight text-foreground mb-4">
|
||||||
|
ZCLAW
|
||||||
|
</h1>
|
||||||
|
<p className="text-xl text-muted-foreground font-light">
|
||||||
|
AI Agent 管理平台
|
||||||
|
</p>
|
||||||
|
<div className="mt-8 flex items-center justify-center gap-2">
|
||||||
|
<div className="h-px w-12 bg-green-500/50" />
|
||||||
|
<div className="w-2 h-2 rounded-full bg-green-500" />
|
||||||
|
<div className="h-px w-12 bg-green-500/50" />
|
||||||
|
</div>
|
||||||
|
<p className="mt-6 text-sm text-muted-foreground/60 max-w-sm">
|
||||||
|
统一管理 AI 服务商、模型配置、API 密钥、用量监控与系统配置
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 右侧登录表单 */}
|
||||||
|
<div className="flex w-full lg:w-1/2 items-center justify-center p-8">
|
||||||
|
<div className="w-full max-w-sm space-y-8">
|
||||||
|
{/* 移动端 Logo */}
|
||||||
|
<div className="lg:hidden text-center">
|
||||||
|
<h1 className="text-4xl font-bold tracking-tight text-foreground mb-2">
|
||||||
|
ZCLAW
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">AI Agent 管理平台</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-semibold text-foreground">登录</h2>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
|
输入您的账号信息以继续
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
{/* 用户名 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label
|
||||||
|
htmlFor="username"
|
||||||
|
className="text-sm font-medium text-foreground"
|
||||||
|
>
|
||||||
|
用户名
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<User className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<input
|
||||||
|
id="username"
|
||||||
|
type="text"
|
||||||
|
placeholder="请输入用户名"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
className="flex h-10 w-full rounded-md border border-input bg-transparent pl-10 pr-3 py-2 text-sm shadow-sm transition-colors duration-200 placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||||
|
autoComplete="username"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 密码 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label
|
||||||
|
htmlFor="password"
|
||||||
|
className="text-sm font-medium text-foreground"
|
||||||
|
>
|
||||||
|
密码
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
placeholder="请输入密码"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
className="flex h-10 w-full rounded-md border border-input bg-transparent pl-10 pr-10 py-2 text-sm shadow-sm transition-colors duration-200 placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||||
|
autoComplete="current-password"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors duration-200 cursor-pointer"
|
||||||
|
>
|
||||||
|
{showPassword ? (
|
||||||
|
<EyeOff className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* TOTP 验证码 (仅账号启用 2FA 时显示) */}
|
||||||
|
{showTotp && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label
|
||||||
|
htmlFor="totp_code"
|
||||||
|
className="text-sm font-medium text-foreground"
|
||||||
|
>
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<ShieldCheck className="h-3.5 w-3.5" />
|
||||||
|
双因素验证码
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="totp_code"
|
||||||
|
type="text"
|
||||||
|
placeholder="请输入 6 位验证码"
|
||||||
|
value={totpCode}
|
||||||
|
onChange={(e) => setTotpCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||||
|
className="flex h-10 w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm tracking-widest text-center font-mono shadow-sm transition-colors duration-200 placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||||
|
maxLength={6}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 错误信息 */}
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-md bg-destructive/10 border border-destructive/20 px-4 py-3 text-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 登录按钮 */}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="flex h-10 w-full items-center justify-center rounded-md bg-primary text-primary-foreground font-medium text-sm shadow-sm transition-colors duration-200 hover:bg-primary-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 cursor-pointer"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
登录中...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'登录'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
85
admin/src/components/auth-guard.tsx
Normal file
85
admin/src/components/auth-guard.tsx
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { createContext, useContext, useEffect, useState, useCallback, type ReactNode } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import { isAuthenticated, getAccount, logout as clearCredentials, scheduleTokenRefresh, cancelTokenRefresh, setOnSessionExpired } from '@/lib/auth'
|
||||||
|
import { api } from '@/lib/api-client'
|
||||||
|
import type { AccountPublic } from '@/lib/types'
|
||||||
|
|
||||||
|
interface AuthContextValue {
|
||||||
|
account: AccountPublic | null
|
||||||
|
loading: boolean
|
||||||
|
refresh: () => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextValue>({
|
||||||
|
account: null,
|
||||||
|
loading: true,
|
||||||
|
refresh: async () => {},
|
||||||
|
})
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
return useContext(AuthContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthGuardProps {
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuthGuard({ children }: AuthGuardProps) {
|
||||||
|
const router = useRouter()
|
||||||
|
const [account, setAccount] = useState<AccountPublic | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const me = await api.auth.me()
|
||||||
|
setAccount(me)
|
||||||
|
} catch {
|
||||||
|
clearCredentials()
|
||||||
|
router.replace('/login')
|
||||||
|
}
|
||||||
|
}, [router])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAuthenticated()) {
|
||||||
|
router.replace('/login')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 验证 token 有效性并获取最新账号信息
|
||||||
|
refresh().finally(() => setLoading(false))
|
||||||
|
}, [router, refresh])
|
||||||
|
|
||||||
|
// Set up proactive token refresh with session-expired handler
|
||||||
|
useEffect(() => {
|
||||||
|
const handleSessionExpired = () => {
|
||||||
|
clearCredentials()
|
||||||
|
router.replace('/login')
|
||||||
|
}
|
||||||
|
setOnSessionExpired(handleSessionExpired)
|
||||||
|
scheduleTokenRefresh()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelTokenRefresh()
|
||||||
|
setOnSessionExpired(null)
|
||||||
|
}
|
||||||
|
}, [router])
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen w-screen items-center justify-center bg-background">
|
||||||
|
<div className="h-8 w-8 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!account) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={{ account, loading, refresh }}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
42
admin/src/components/ui/badge.tsx
Normal file
42
admin/src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
'border-transparent bg-primary/15 text-primary',
|
||||||
|
secondary:
|
||||||
|
'border-transparent bg-muted text-muted-foreground',
|
||||||
|
destructive:
|
||||||
|
'border-transparent bg-destructive/15 text-destructive',
|
||||||
|
outline:
|
||||||
|
'text-foreground border-border',
|
||||||
|
success:
|
||||||
|
'border-transparent bg-green-500/15 text-green-400',
|
||||||
|
warning:
|
||||||
|
'border-transparent bg-yellow-500/15 text-yellow-400',
|
||||||
|
info:
|
||||||
|
'border-transparent bg-blue-500/15 text-blue-400',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
export interface BadgeProps
|
||||||
|
extends React.HTMLAttributes<HTMLDivElement>,
|
||||||
|
VariantProps<typeof badgeVariants> {}
|
||||||
|
|
||||||
|
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||||
|
return (
|
||||||
|
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants }
|
||||||
56
admin/src/components/ui/button.tsx
Normal file
56
admin/src/components/ui/button.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import * as React from 'react'
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
'bg-primary text-primary-foreground hover:bg-primary-hover shadow-sm',
|
||||||
|
secondary:
|
||||||
|
'bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground',
|
||||||
|
destructive:
|
||||||
|
'bg-destructive text-destructive-foreground hover:bg-red-600 shadow-sm',
|
||||||
|
outline:
|
||||||
|
'border border-border bg-transparent hover:bg-accent hover:text-accent-foreground',
|
||||||
|
ghost:
|
||||||
|
'hover:bg-accent hover:text-accent-foreground',
|
||||||
|
link:
|
||||||
|
'text-primary underline-offset-4 hover:underline',
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: 'h-9 px-4 py-2',
|
||||||
|
sm: 'h-8 rounded-md px-3 text-xs',
|
||||||
|
lg: 'h-10 rounded-md px-8',
|
||||||
|
icon: 'h-9 w-9',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
size: 'default',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
export interface ButtonProps
|
||||||
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
|
VariantProps<typeof buttonVariants> {}
|
||||||
|
|
||||||
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ className, variant, size, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
Button.displayName = 'Button'
|
||||||
|
|
||||||
|
export { Button, buttonVariants }
|
||||||
75
admin/src/components/ui/card.tsx
Normal file
75
admin/src/components/ui/card.tsx
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const Card = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'rounded-lg border border-border bg-card text-card-foreground shadow-sm',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Card.displayName = 'Card'
|
||||||
|
|
||||||
|
const CardHeader = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn('flex flex-col space-y-1.5 p-6', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardHeader.displayName = 'CardHeader'
|
||||||
|
|
||||||
|
const CardTitle = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLHeadingElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<h3
|
||||||
|
ref={ref}
|
||||||
|
className={cn('font-semibold leading-none tracking-tight', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardTitle.displayName = 'CardTitle'
|
||||||
|
|
||||||
|
const CardDescription = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<p
|
||||||
|
ref={ref}
|
||||||
|
className={cn('text-sm text-muted-foreground', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardDescription.displayName = 'CardDescription'
|
||||||
|
|
||||||
|
const CardContent = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
|
||||||
|
))
|
||||||
|
CardContent.displayName = 'CardContent'
|
||||||
|
|
||||||
|
const CardFooter = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn('flex items-center p-6 pt-0', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardFooter.displayName = 'CardFooter'
|
||||||
|
|
||||||
|
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||||
118
admin/src/components/ui/dialog.tsx
Normal file
118
admin/src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import * as React from 'react'
|
||||||
|
import * as DialogPrimitive from '@radix-ui/react-dialog'
|
||||||
|
import { X } from 'lucide-react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const Dialog = DialogPrimitive.Root
|
||||||
|
const DialogTrigger = DialogPrimitive.Trigger
|
||||||
|
const DialogPortal = DialogPrimitive.Portal
|
||||||
|
const DialogClose = DialogPrimitive.Close
|
||||||
|
|
||||||
|
const DialogOverlay = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'fixed inset-0 z-50 bg-black/60 backdrop-blur-sm',
|
||||||
|
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
||||||
|
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||||
|
|
||||||
|
const DialogContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<DialogPortal>
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%]',
|
||||||
|
'gap-4 border border-border bg-card p-6 shadow-lg duration-200',
|
||||||
|
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
||||||
|
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||||
|
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
||||||
|
'data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%]',
|
||||||
|
'data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]',
|
||||||
|
'rounded-lg',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPortal>
|
||||||
|
))
|
||||||
|
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const DialogHeader = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn('flex flex-col space-y-1.5 text-center sm:text-left', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
DialogHeader.displayName = 'DialogHeader'
|
||||||
|
|
||||||
|
const DialogFooter = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
DialogFooter.displayName = 'DialogFooter'
|
||||||
|
|
||||||
|
const DialogTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
ref={ref}
|
||||||
|
className={cn('text-lg font-semibold leading-none tracking-tight', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||||
|
|
||||||
|
const DialogDescription = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
ref={ref}
|
||||||
|
className={cn('text-sm text-muted-foreground', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogPortal,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogClose,
|
||||||
|
DialogTrigger,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogFooter,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
}
|
||||||
28
admin/src/components/ui/input.tsx
Normal file
28
admin/src/components/ui/input.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export interface InputProps
|
||||||
|
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||||
|
|
||||||
|
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||||
|
({ className, type, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
className={cn(
|
||||||
|
'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors duration-200',
|
||||||
|
'file:border-0 file:bg-transparent file:text-sm file:font-medium',
|
||||||
|
'placeholder:text-muted-foreground',
|
||||||
|
'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring',
|
||||||
|
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
Input.displayName = 'Input'
|
||||||
|
|
||||||
|
export { Input }
|
||||||
23
admin/src/components/ui/label.tsx
Normal file
23
admin/src/components/ui/label.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export interface LabelProps
|
||||||
|
extends React.LabelHTMLAttributes<HTMLLabelElement> {}
|
||||||
|
|
||||||
|
const Label = React.forwardRef<HTMLLabelElement, LabelProps>(
|
||||||
|
({ className, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
Label.displayName = 'Label'
|
||||||
|
|
||||||
|
export { Label }
|
||||||
100
admin/src/components/ui/select.tsx
Normal file
100
admin/src/components/ui/select.tsx
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import * as React from 'react'
|
||||||
|
import * as SelectPrimitive from '@radix-ui/react-select'
|
||||||
|
import { Check, ChevronDown } from 'lucide-react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const Select = SelectPrimitive.Root
|
||||||
|
const SelectGroup = SelectPrimitive.Group
|
||||||
|
const SelectValue = SelectPrimitive.Value
|
||||||
|
|
||||||
|
const SelectTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background',
|
||||||
|
'placeholder:text-muted-foreground',
|
||||||
|
'focus:outline-none focus:ring-1 focus:ring-ring',
|
||||||
|
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
'[&>span]:line-clamp-1',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SelectPrimitive.Icon asChild>
|
||||||
|
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||||
|
</SelectPrimitive.Icon>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
))
|
||||||
|
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
||||||
|
|
||||||
|
const SelectContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||||
|
>(({ className, children, position = 'popper', ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border border-border bg-card text-foreground shadow-md',
|
||||||
|
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
||||||
|
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||||
|
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
||||||
|
position === 'popper' &&
|
||||||
|
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
position={position}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectPrimitive.Viewport
|
||||||
|
className={cn(
|
||||||
|
'p-1',
|
||||||
|
position === 'popper' &&
|
||||||
|
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SelectPrimitive.Viewport>
|
||||||
|
</SelectPrimitive.Content>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
))
|
||||||
|
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const SelectItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'relative flex w-full cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none',
|
||||||
|
'focus:bg-accent focus:text-accent-foreground',
|
||||||
|
'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<SelectPrimitive.ItemIndicator>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
))
|
||||||
|
SelectItem.displayName = SelectPrimitive.Item.displayName
|
||||||
|
|
||||||
|
export {
|
||||||
|
Select,
|
||||||
|
SelectGroup,
|
||||||
|
SelectValue,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
}
|
||||||
30
admin/src/components/ui/separator.tsx
Normal file
30
admin/src/components/ui/separator.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import * as React from 'react'
|
||||||
|
import * as SeparatorPrimitive from '@radix-ui/react-separator'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const Separator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||||
|
>(
|
||||||
|
(
|
||||||
|
{ className, orientation = 'horizontal', decorative = true, ...props },
|
||||||
|
ref,
|
||||||
|
) => (
|
||||||
|
<SeparatorPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
decorative={decorative}
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
'shrink-0 bg-border',
|
||||||
|
orientation === 'horizontal' ? 'h-[1px] w-full' : 'h-full w-[1px]',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
)
|
||||||
|
Separator.displayName = SeparatorPrimitive.Root.displayName
|
||||||
|
|
||||||
|
export { Separator }
|
||||||
32
admin/src/components/ui/switch.tsx
Normal file
32
admin/src/components/ui/switch.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import * as React from 'react'
|
||||||
|
import * as SwitchPrimitive from '@radix-ui/react-switch'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const Switch = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SwitchPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SwitchPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SwitchPrimitive.Root
|
||||||
|
className={cn(
|
||||||
|
'peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors duration-200',
|
||||||
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background',
|
||||||
|
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
'data-[state=checked]:bg-primary data-[state=unchecked]:bg-input',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
>
|
||||||
|
<SwitchPrimitive.Thumb
|
||||||
|
className={cn(
|
||||||
|
'pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform duration-200',
|
||||||
|
'data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SwitchPrimitive.Root>
|
||||||
|
))
|
||||||
|
Switch.displayName = SwitchPrimitive.Root.displayName
|
||||||
|
|
||||||
|
export { Switch }
|
||||||
119
admin/src/components/ui/table.tsx
Normal file
119
admin/src/components/ui/table.tsx
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const Table = React.forwardRef<
|
||||||
|
HTMLTableElement,
|
||||||
|
React.HTMLAttributes<HTMLTableElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div className="relative w-full overflow-auto scrollbar-thin">
|
||||||
|
<table
|
||||||
|
ref={ref}
|
||||||
|
className={cn('w-full caption-bottom text-sm', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
Table.displayName = 'Table'
|
||||||
|
|
||||||
|
const TableHeader = React.forwardRef<
|
||||||
|
HTMLTableSectionElement,
|
||||||
|
React.HTMLAttributes<HTMLTableSectionElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<thead ref={ref} className={cn('[&_tr]:border-b', className)} {...props} />
|
||||||
|
))
|
||||||
|
TableHeader.displayName = 'TableHeader'
|
||||||
|
|
||||||
|
const TableBody = React.forwardRef<
|
||||||
|
HTMLTableSectionElement,
|
||||||
|
React.HTMLAttributes<HTMLTableSectionElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<tbody
|
||||||
|
ref={ref}
|
||||||
|
className={cn('[&_tr:last-child]:border-0', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TableBody.displayName = 'TableBody'
|
||||||
|
|
||||||
|
const TableFooter = React.forwardRef<
|
||||||
|
HTMLTableSectionElement,
|
||||||
|
React.HTMLAttributes<HTMLTableSectionElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<tfoot
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'border-t bg-muted/50 font-medium [&>tr]:last:border-b-0',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TableFooter.displayName = 'TableFooter'
|
||||||
|
|
||||||
|
const TableRow = React.forwardRef<
|
||||||
|
HTMLTableRowElement,
|
||||||
|
React.HTMLAttributes<HTMLTableRowElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<tr
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'border-b border-border transition-colors duration-200 hover:bg-muted/50',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TableRow.displayName = 'TableRow'
|
||||||
|
|
||||||
|
const TableHead = React.forwardRef<
|
||||||
|
HTMLTableCellElement,
|
||||||
|
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<th
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'h-10 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TableHead.displayName = 'TableHead'
|
||||||
|
|
||||||
|
const TableCell = React.forwardRef<
|
||||||
|
HTMLTableCellElement,
|
||||||
|
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<td
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'p-4 align-middle [&:has([role=checkbox])]:pr-0',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TableCell.displayName = 'TableCell'
|
||||||
|
|
||||||
|
const TableCaption = React.forwardRef<
|
||||||
|
HTMLTableCaptionElement,
|
||||||
|
React.HTMLAttributes<HTMLTableCaptionElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<caption
|
||||||
|
ref={ref}
|
||||||
|
className={cn('mt-4 text-sm text-muted-foreground', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TableCaption.displayName = 'TableCaption'
|
||||||
|
|
||||||
|
export {
|
||||||
|
Table,
|
||||||
|
TableHeader,
|
||||||
|
TableBody,
|
||||||
|
TableFooter,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
TableCell,
|
||||||
|
TableCaption,
|
||||||
|
}
|
||||||
57
admin/src/components/ui/tabs.tsx
Normal file
57
admin/src/components/ui/tabs.tsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import * as React from 'react'
|
||||||
|
import * as TabsPrimitive from '@radix-ui/react-tabs'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const Tabs = TabsPrimitive.Root
|
||||||
|
|
||||||
|
const TabsList = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.List>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.List
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TabsList.displayName = TabsPrimitive.List.displayName
|
||||||
|
|
||||||
|
const TabsTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all duration-200',
|
||||||
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||||
|
'disabled:pointer-events-none disabled:opacity-50',
|
||||||
|
'data-[state=active]:bg-card data-[state=active]:text-foreground data-[state=active]:shadow',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
||||||
|
|
||||||
|
const TabsContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TabsContent.displayName = TabsPrimitive.Content.displayName
|
||||||
|
|
||||||
|
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||||
31
admin/src/components/ui/tooltip.tsx
Normal file
31
admin/src/components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import * as React from 'react'
|
||||||
|
import * as TooltipPrimitive from '@radix-ui/react-tooltip'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const TooltipProvider = TooltipPrimitive.Provider
|
||||||
|
const Tooltip = TooltipPrimitive.Root
|
||||||
|
const TooltipTrigger = TooltipPrimitive.Trigger
|
||||||
|
|
||||||
|
const TooltipContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||||
|
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||||
|
<TooltipPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
'z-50 overflow-hidden rounded-md bg-card border border-border px-3 py-1.5 text-sm text-foreground shadow-md',
|
||||||
|
'animate-in fade-in-0 zoom-in-95',
|
||||||
|
'data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95',
|
||||||
|
'data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2',
|
||||||
|
'data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TooltipContent.displayName = TooltipPrimitive.Content.displayName
|
||||||
|
|
||||||
|
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||||
347
admin/src/lib/api-client.ts
Normal file
347
admin/src/lib/api-client.ts
Normal file
@@ -0,0 +1,347 @@
|
|||||||
|
// ============================================================
|
||||||
|
// ZCLAW SaaS Admin — 类型化 HTTP 客户端
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { getToken, logout, refreshToken } from './auth'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import type {
|
||||||
|
AccountPublic,
|
||||||
|
ApiError,
|
||||||
|
ConfigItem,
|
||||||
|
CreateTokenRequest,
|
||||||
|
DashboardStats,
|
||||||
|
DeviceInfo,
|
||||||
|
LoginRequest,
|
||||||
|
LoginResponse,
|
||||||
|
Model,
|
||||||
|
OperationLog,
|
||||||
|
PaginatedResponse,
|
||||||
|
Provider,
|
||||||
|
RelayTask,
|
||||||
|
TokenInfo,
|
||||||
|
UsageByModel,
|
||||||
|
UsageStats,
|
||||||
|
} from './types'
|
||||||
|
|
||||||
|
// ── 错误类 ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export class ApiRequestError extends Error {
|
||||||
|
constructor(
|
||||||
|
public status: number,
|
||||||
|
public body: ApiError,
|
||||||
|
) {
|
||||||
|
super(body.message || `Request failed with status ${status}`)
|
||||||
|
this.name = 'ApiRequestError'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 基础请求 ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
const BASE_URL = process.env.NEXT_PUBLIC_SAAS_API_URL || 'http://localhost:8080'
|
||||||
|
const API_PREFIX = '/api/v1'
|
||||||
|
|
||||||
|
async function request<T>(
|
||||||
|
method: string,
|
||||||
|
path: string,
|
||||||
|
body?: unknown,
|
||||||
|
): Promise<T> {
|
||||||
|
const token = getToken()
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
}
|
||||||
|
if (token) {
|
||||||
|
headers['Authorization'] = `Bearer ${token}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(`${BASE_URL}${API_PREFIX}${path}`, {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (res.status === 401) {
|
||||||
|
// 尝试刷新 token 后重试
|
||||||
|
try {
|
||||||
|
const newToken = await refreshToken()
|
||||||
|
headers['Authorization'] = `Bearer ${newToken}`
|
||||||
|
const retryRes = await fetch(`${BASE_URL}${API_PREFIX}${path}`, {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
})
|
||||||
|
if (retryRes.ok || retryRes.status === 204) {
|
||||||
|
return retryRes.status === 204 ? (undefined as T) : retryRes.json()
|
||||||
|
}
|
||||||
|
// 刷新成功但重试仍失败,走正常错误处理
|
||||||
|
if (!retryRes.ok) {
|
||||||
|
let errorBody: ApiError
|
||||||
|
try { errorBody = await retryRes.json() } catch { errorBody = { error: 'unknown', message: `请求失败 (${retryRes.status})` } }
|
||||||
|
throw new ApiRequestError(retryRes.status, errorBody)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 刷新失败,执行登出
|
||||||
|
}
|
||||||
|
logout()
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.location.href = '/login'
|
||||||
|
}
|
||||||
|
throw new ApiRequestError(401, { error: 'unauthorized', message: '登录已过期,请重新登录' })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
let errorBody: ApiError
|
||||||
|
try {
|
||||||
|
errorBody = await res.json()
|
||||||
|
} catch {
|
||||||
|
errorBody = { error: 'unknown', message: `请求失败 (${res.status})` }
|
||||||
|
}
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
toast.error(errorBody.message || `请求失败 (${res.status})`)
|
||||||
|
}
|
||||||
|
throw new ApiRequestError(res.status, errorBody)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 204 No Content
|
||||||
|
if (res.status === 204) {
|
||||||
|
return undefined as T
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json() as Promise<T>
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── API 客户端 ────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
// ── 认证 ──────────────────────────────────────────────
|
||||||
|
auth: {
|
||||||
|
async login(data: LoginRequest): Promise<LoginResponse> {
|
||||||
|
return request<LoginResponse>('POST', '/auth/login', data)
|
||||||
|
},
|
||||||
|
|
||||||
|
async register(data: {
|
||||||
|
username: string
|
||||||
|
password: string
|
||||||
|
email: string
|
||||||
|
display_name?: string
|
||||||
|
}): Promise<LoginResponse> {
|
||||||
|
return request<LoginResponse>('POST', '/auth/register', data)
|
||||||
|
},
|
||||||
|
|
||||||
|
async me(): Promise<AccountPublic> {
|
||||||
|
return request<AccountPublic>('GET', '/auth/me')
|
||||||
|
},
|
||||||
|
|
||||||
|
async changePassword(data: { old_password: string; new_password: string }): Promise<void> {
|
||||||
|
return request<void>('PUT', '/auth/password', data)
|
||||||
|
},
|
||||||
|
|
||||||
|
async totpSetup(): Promise<{ otpauth_uri: string; secret: string; issuer: string }> {
|
||||||
|
return request<{ otpauth_uri: string; secret: string; issuer: string }>('POST', '/auth/totp/setup')
|
||||||
|
},
|
||||||
|
|
||||||
|
async totpVerify(data: { code: string }): Promise<void> {
|
||||||
|
return request<void>('POST', '/auth/totp/verify', data)
|
||||||
|
},
|
||||||
|
|
||||||
|
async totpDisable(data: { password: string }): Promise<void> {
|
||||||
|
return request<void>('POST', '/auth/totp/disable', data)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── 账号管理 ──────────────────────────────────────────
|
||||||
|
accounts: {
|
||||||
|
async list(params?: {
|
||||||
|
page?: number
|
||||||
|
page_size?: number
|
||||||
|
search?: string
|
||||||
|
role?: string
|
||||||
|
status?: string
|
||||||
|
}): Promise<PaginatedResponse<AccountPublic>> {
|
||||||
|
const qs = buildQueryString(params)
|
||||||
|
return request<PaginatedResponse<AccountPublic>>('GET', `/accounts${qs}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
async get(id: string): Promise<AccountPublic> {
|
||||||
|
return request<AccountPublic>('GET', `/accounts/${id}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
async update(
|
||||||
|
id: string,
|
||||||
|
data: Partial<Pick<AccountPublic, 'display_name' | 'email' | 'role'>>,
|
||||||
|
): Promise<AccountPublic> {
|
||||||
|
return request<AccountPublic>('PUT', `/accounts/${id}`, data)
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateStatus(
|
||||||
|
id: string,
|
||||||
|
data: { status: AccountPublic['status'] },
|
||||||
|
): Promise<void> {
|
||||||
|
return request<void>('PATCH', `/accounts/${id}/status`, data)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── 服务商管理 ────────────────────────────────────────
|
||||||
|
providers: {
|
||||||
|
async list(params?: {
|
||||||
|
page?: number
|
||||||
|
page_size?: number
|
||||||
|
}): Promise<PaginatedResponse<Provider>> {
|
||||||
|
const qs = buildQueryString(params)
|
||||||
|
return request<PaginatedResponse<Provider>>('GET', `/providers${qs}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
async get(id: string): Promise<Provider> {
|
||||||
|
return request<Provider>('GET', `/providers/${id}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
async create(data: Partial<Omit<Provider, 'id' | 'created_at' | 'updated_at'>>): Promise<Provider> {
|
||||||
|
return request<Provider>('POST', '/providers', data)
|
||||||
|
},
|
||||||
|
|
||||||
|
async update(
|
||||||
|
id: string,
|
||||||
|
data: Partial<Omit<Provider, 'id' | 'created_at' | 'updated_at'>>,
|
||||||
|
): Promise<Provider> {
|
||||||
|
return request<Provider>('PUT', `/providers/${id}`, data)
|
||||||
|
},
|
||||||
|
|
||||||
|
async delete(id: string): Promise<void> {
|
||||||
|
return request<void>('DELETE', `/providers/${id}`)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── 模型管理 ──────────────────────────────────────────
|
||||||
|
models: {
|
||||||
|
async list(params?: {
|
||||||
|
page?: number
|
||||||
|
page_size?: number
|
||||||
|
provider_id?: string
|
||||||
|
}): Promise<PaginatedResponse<Model>> {
|
||||||
|
const qs = buildQueryString(params)
|
||||||
|
return request<PaginatedResponse<Model>>('GET', `/models${qs}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
async get(id: string): Promise<Model> {
|
||||||
|
return request<Model>('GET', `/models/${id}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
async create(data: Partial<Omit<Model, 'id'>>): Promise<Model> {
|
||||||
|
return request<Model>('POST', '/models', data)
|
||||||
|
},
|
||||||
|
|
||||||
|
async update(id: string, data: Partial<Omit<Model, 'id'>>): Promise<Model> {
|
||||||
|
return request<Model>('PUT', `/models/${id}`, data)
|
||||||
|
},
|
||||||
|
|
||||||
|
async delete(id: string): Promise<void> {
|
||||||
|
return request<void>('DELETE', `/models/${id}`)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── API 密钥 ──────────────────────────────────────────
|
||||||
|
tokens: {
|
||||||
|
async list(params?: {
|
||||||
|
page?: number
|
||||||
|
page_size?: number
|
||||||
|
}): Promise<PaginatedResponse<TokenInfo>> {
|
||||||
|
const qs = buildQueryString(params)
|
||||||
|
return request<PaginatedResponse<TokenInfo>>('GET', `/tokens${qs}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
async create(data: CreateTokenRequest): Promise<TokenInfo> {
|
||||||
|
return request<TokenInfo>('POST', '/tokens', data)
|
||||||
|
},
|
||||||
|
|
||||||
|
async revoke(id: string): Promise<void> {
|
||||||
|
return request<void>('DELETE', `/tokens/${id}`)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── 用量统计 ──────────────────────────────────────────
|
||||||
|
usage: {
|
||||||
|
async get(params?: { from?: string; to?: string; provider_id?: string; model_id?: string }): Promise<UsageStats> {
|
||||||
|
const qs = buildQueryString(params)
|
||||||
|
return request<UsageStats>('GET', `/usage${qs}`)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── 中转任务 ──────────────────────────────────────────
|
||||||
|
relay: {
|
||||||
|
async list(params?: {
|
||||||
|
page?: number
|
||||||
|
page_size?: number
|
||||||
|
status?: string
|
||||||
|
}): Promise<PaginatedResponse<RelayTask>> {
|
||||||
|
const qs = buildQueryString(params)
|
||||||
|
return request<PaginatedResponse<RelayTask>>('GET', `/relay/tasks${qs}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
async get(id: string): Promise<RelayTask> {
|
||||||
|
return request<RelayTask>('GET', `/relay/tasks/${id}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
async retry(id: string): Promise<void> {
|
||||||
|
return request<void>('POST', `/relay/tasks/${id}/retry`)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── 系统配置 ──────────────────────────────────────────
|
||||||
|
config: {
|
||||||
|
async list(params?: {
|
||||||
|
category?: string
|
||||||
|
}): Promise<ConfigItem[]> {
|
||||||
|
const qs = buildQueryString(params)
|
||||||
|
return request<ConfigItem[]>('GET', `/config/items${qs}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
async update(id: string, data: { current_value: string | number | boolean }): Promise<ConfigItem> {
|
||||||
|
return request<ConfigItem>('PUT', `/config/items/${id}`, data)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── 操作日志 ──────────────────────────────────────────
|
||||||
|
logs: {
|
||||||
|
async list(params?: {
|
||||||
|
page?: number
|
||||||
|
page_size?: number
|
||||||
|
action?: string
|
||||||
|
}): Promise<OperationLog[]> {
|
||||||
|
const qs = buildQueryString(params)
|
||||||
|
return request<OperationLog[]>('GET', `/logs/operations${qs}`)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── 仪表盘 ────────────────────────────────────────────
|
||||||
|
stats: {
|
||||||
|
async dashboard(): Promise<DashboardStats> {
|
||||||
|
return request<DashboardStats>('GET', '/stats/dashboard')
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── 设备管理 ──────────────────────────────────────────
|
||||||
|
devices: {
|
||||||
|
async list(): Promise<DeviceInfo[]> {
|
||||||
|
return request<DeviceInfo[]>('GET', '/devices')
|
||||||
|
},
|
||||||
|
async register(data: { device_id: string; device_name?: string; platform?: string; app_version?: string }) {
|
||||||
|
return request<{ ok: boolean; device_id: string }>('POST', '/devices/register', data)
|
||||||
|
},
|
||||||
|
async heartbeat(data: { device_id: string }) {
|
||||||
|
return request<{ ok: boolean }>('POST', '/devices/heartbeat', data)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 工具函数 ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
function buildQueryString(params?: Record<string, unknown>): string {
|
||||||
|
if (!params) return ''
|
||||||
|
const entries = Object.entries(params).filter(
|
||||||
|
([, v]) => v !== undefined && v !== null && v !== '',
|
||||||
|
)
|
||||||
|
if (entries.length === 0) return ''
|
||||||
|
const qs = entries
|
||||||
|
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`)
|
||||||
|
.join('&')
|
||||||
|
return `?${qs}`
|
||||||
|
}
|
||||||
216
admin/src/lib/auth.ts
Normal file
216
admin/src/lib/auth.ts
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
// ============================================================
|
||||||
|
// ZCLAW SaaS Admin — JWT Token 管理
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import type { AccountPublic, LoginResponse } from './types'
|
||||||
|
|
||||||
|
const TOKEN_KEY = 'zclaw_admin_token'
|
||||||
|
const ACCOUNT_KEY = 'zclaw_admin_account'
|
||||||
|
|
||||||
|
// ── JWT 辅助函数 ────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface JwtPayload {
|
||||||
|
exp?: number
|
||||||
|
iat?: number
|
||||||
|
sub?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode a JWT payload without verifying the signature.
|
||||||
|
* Returns the parsed JSON payload, or null if the token is malformed.
|
||||||
|
*/
|
||||||
|
function decodeJwtPayload<T = Record<string, unknown>>(token: string): T | null {
|
||||||
|
try {
|
||||||
|
const parts = token.split('.')
|
||||||
|
if (parts.length !== 3) return null
|
||||||
|
const base64 = parts[1].replace(/-/g, '+').replace(/_/g, '/')
|
||||||
|
const json = decodeURIComponent(
|
||||||
|
atob(base64)
|
||||||
|
.split('')
|
||||||
|
.map((c) => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))
|
||||||
|
.join(''),
|
||||||
|
)
|
||||||
|
return JSON.parse(json) as T
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate the delay (ms) until 80% of the token's remaining lifetime
|
||||||
|
* has elapsed. Returns null if the token is already past that point.
|
||||||
|
*/
|
||||||
|
function getRefreshDelay(exp: number): number | null {
|
||||||
|
const now = Math.floor(Date.now() / 1000)
|
||||||
|
const totalLifetime = exp - now
|
||||||
|
if (totalLifetime <= 0) return null
|
||||||
|
|
||||||
|
const refreshAt = now + Math.floor(totalLifetime * 0.8)
|
||||||
|
const delayMs = (refreshAt - now) * 1000
|
||||||
|
return delayMs > 5000 ? delayMs : 5000
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 定时刷新状态 ────────────────────────────────────────────
|
||||||
|
|
||||||
|
let refreshTimerId: ReturnType<typeof setTimeout> | null = null
|
||||||
|
let visibilityHandler: (() => void) | null = null
|
||||||
|
let sessionExpiredCallback: (() => void) | null = null
|
||||||
|
|
||||||
|
// ── 凭证操作 ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** 保存登录凭证并启动自动刷新 */
|
||||||
|
export function login(token: string, account: AccountPublic): void {
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
localStorage.setItem(TOKEN_KEY, token)
|
||||||
|
localStorage.setItem(ACCOUNT_KEY, JSON.stringify(account))
|
||||||
|
scheduleTokenRefresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 清除登录凭证并停止自动刷新 */
|
||||||
|
export function logout(): void {
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
cancelTokenRefresh()
|
||||||
|
localStorage.removeItem(TOKEN_KEY)
|
||||||
|
localStorage.removeItem(ACCOUNT_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取 JWT token */
|
||||||
|
export function getToken(): string | null {
|
||||||
|
if (typeof window === 'undefined') return null
|
||||||
|
return localStorage.getItem(TOKEN_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取当前登录用户信息 */
|
||||||
|
export function getAccount(): AccountPublic | null {
|
||||||
|
if (typeof window === 'undefined') return null
|
||||||
|
const raw = localStorage.getItem(ACCOUNT_KEY)
|
||||||
|
if (!raw) return null
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw) as AccountPublic
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 是否已认证 */
|
||||||
|
export function isAuthenticated(): boolean {
|
||||||
|
return !!getToken()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 尝试刷新 token,成功则更新 localStorage 并返回新 token */
|
||||||
|
export async function refreshToken(): Promise<string> {
|
||||||
|
const res = await fetch(
|
||||||
|
`${process.env.NEXT_PUBLIC_SAAS_API_URL || 'http://localhost:8080'}/api/v1/auth/refresh`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${getToken()}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error('Token 刷新失败')
|
||||||
|
}
|
||||||
|
const data: LoginResponse = await res.json()
|
||||||
|
login(data.token, data.account)
|
||||||
|
return data.token
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 自动刷新调度 ────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a callback invoked when the proactive token refresh fails.
|
||||||
|
* The caller should use this to trigger a logout/redirect flow.
|
||||||
|
*/
|
||||||
|
export function setOnSessionExpired(handler: (() => void) | null): void {
|
||||||
|
sessionExpiredCallback = handler
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedule a proactive token refresh at 80% of the token's remaining lifetime.
|
||||||
|
* Also registers a visibilitychange listener to re-check when the tab regains focus.
|
||||||
|
*/
|
||||||
|
export function scheduleTokenRefresh(): void {
|
||||||
|
cancelTokenRefresh()
|
||||||
|
|
||||||
|
const token = getToken()
|
||||||
|
if (!token) return
|
||||||
|
|
||||||
|
const payload = decodeJwtPayload<JwtPayload>(token)
|
||||||
|
if (!payload?.exp) return
|
||||||
|
|
||||||
|
const delay = getRefreshDelay(payload.exp)
|
||||||
|
if (delay === null) {
|
||||||
|
attemptTokenRefresh()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshTimerId = setTimeout(() => {
|
||||||
|
attemptTokenRefresh()
|
||||||
|
}, delay)
|
||||||
|
|
||||||
|
if (typeof document !== 'undefined' && !visibilityHandler) {
|
||||||
|
visibilityHandler = () => {
|
||||||
|
if (document.visibilityState === 'visible') {
|
||||||
|
checkAndRefreshToken()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('visibilitychange', visibilityHandler)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancel any pending token refresh timer and remove the visibility listener.
|
||||||
|
*/
|
||||||
|
export function cancelTokenRefresh(): void {
|
||||||
|
if (refreshTimerId !== null) {
|
||||||
|
clearTimeout(refreshTimerId)
|
||||||
|
refreshTimerId = null
|
||||||
|
}
|
||||||
|
if (visibilityHandler !== null && typeof document !== 'undefined') {
|
||||||
|
document.removeEventListener('visibilitychange', visibilityHandler)
|
||||||
|
visibilityHandler = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the current token is close to expiry and refresh if needed.
|
||||||
|
* Called on visibility change to handle clock skew / long background tabs.
|
||||||
|
*/
|
||||||
|
function checkAndRefreshToken(): void {
|
||||||
|
const token = getToken()
|
||||||
|
if (!token) return
|
||||||
|
|
||||||
|
const payload = decodeJwtPayload<JwtPayload>(token)
|
||||||
|
if (!payload?.exp) return
|
||||||
|
|
||||||
|
const now = Math.floor(Date.now() / 1000)
|
||||||
|
const remaining = payload.exp - now
|
||||||
|
|
||||||
|
if (remaining <= 0) {
|
||||||
|
attemptTokenRefresh()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const delay = getRefreshDelay(payload.exp)
|
||||||
|
if (delay !== null && delay < 60_000) {
|
||||||
|
attemptTokenRefresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempt to refresh the token. On success, the new token is persisted via
|
||||||
|
* login() which also reschedules the next refresh. On failure, invoke the
|
||||||
|
* session-expired callback.
|
||||||
|
*/
|
||||||
|
async function attemptTokenRefresh(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await refreshToken()
|
||||||
|
} catch {
|
||||||
|
cancelTokenRefresh()
|
||||||
|
if (sessionExpiredCallback) {
|
||||||
|
sessionExpiredCallback()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
193
admin/src/lib/types.ts
Normal file
193
admin/src/lib/types.ts
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
// ============================================================
|
||||||
|
// ZCLAW SaaS Admin — 全局类型定义
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/** 公共账号信息 */
|
||||||
|
export interface AccountPublic {
|
||||||
|
id: string
|
||||||
|
username: string
|
||||||
|
email: string
|
||||||
|
display_name: string
|
||||||
|
role: 'super_admin' | 'admin' | 'user'
|
||||||
|
permissions: string[]
|
||||||
|
status: 'active' | 'disabled' | 'suspended'
|
||||||
|
totp_enabled: boolean
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 登录请求 */
|
||||||
|
export interface LoginRequest {
|
||||||
|
username: string
|
||||||
|
password: string
|
||||||
|
totp_code?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 登录响应 */
|
||||||
|
export interface LoginResponse {
|
||||||
|
token: string
|
||||||
|
account: AccountPublic
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 注册请求 */
|
||||||
|
export interface RegisterRequest {
|
||||||
|
username: string
|
||||||
|
password: string
|
||||||
|
email: string
|
||||||
|
display_name?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 分页响应 */
|
||||||
|
export interface PaginatedResponse<T> {
|
||||||
|
items: T[]
|
||||||
|
total: number
|
||||||
|
page: number
|
||||||
|
page_size: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 服务商 (Provider) */
|
||||||
|
export interface Provider {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
display_name: string
|
||||||
|
base_url: string
|
||||||
|
api_protocol: 'openai' | 'anthropic'
|
||||||
|
enabled: boolean
|
||||||
|
rate_limit_rpm?: number
|
||||||
|
rate_limit_tpm?: number
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 模型 */
|
||||||
|
export interface Model {
|
||||||
|
id: string
|
||||||
|
provider_id: string
|
||||||
|
model_id: string
|
||||||
|
alias: string
|
||||||
|
context_window: number
|
||||||
|
max_output_tokens: number
|
||||||
|
supports_streaming: boolean
|
||||||
|
supports_vision: boolean
|
||||||
|
enabled: boolean
|
||||||
|
pricing_input: number
|
||||||
|
pricing_output: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** API 密钥信息 */
|
||||||
|
export interface TokenInfo {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
token_prefix: string
|
||||||
|
permissions: string[]
|
||||||
|
last_used_at?: string
|
||||||
|
expires_at?: string
|
||||||
|
created_at: string
|
||||||
|
token?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 创建 Token 请求 */
|
||||||
|
export interface CreateTokenRequest {
|
||||||
|
name: string
|
||||||
|
expires_days?: number
|
||||||
|
permissions: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 中转任务 */
|
||||||
|
export interface RelayTask {
|
||||||
|
id: string
|
||||||
|
account_id: string
|
||||||
|
provider_id: string
|
||||||
|
model_id: string
|
||||||
|
status: 'queued' | 'processing' | 'completed' | 'failed'
|
||||||
|
priority: number
|
||||||
|
attempt_count: number
|
||||||
|
input_tokens: number
|
||||||
|
output_tokens: number
|
||||||
|
error_message?: string
|
||||||
|
queued_at?: string
|
||||||
|
started_at?: string
|
||||||
|
completed_at?: string
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 用量统计 — 后端返回的完整结构 */
|
||||||
|
export interface UsageStats {
|
||||||
|
total_requests: number
|
||||||
|
total_input_tokens: number
|
||||||
|
total_output_tokens: number
|
||||||
|
by_model: UsageByModel[]
|
||||||
|
by_day: DailyUsage[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 每日用量 */
|
||||||
|
export interface DailyUsage {
|
||||||
|
date: string
|
||||||
|
request_count: number
|
||||||
|
input_tokens: number
|
||||||
|
output_tokens: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按模型用量 */
|
||||||
|
export interface UsageByModel {
|
||||||
|
provider_id: string
|
||||||
|
model_id: string
|
||||||
|
request_count: number
|
||||||
|
input_tokens: number
|
||||||
|
output_tokens: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 系统配置项 */
|
||||||
|
export interface ConfigItem {
|
||||||
|
id: string
|
||||||
|
category: string
|
||||||
|
key_path: string
|
||||||
|
value_type: 'string' | 'number' | 'boolean'
|
||||||
|
current_value?: string
|
||||||
|
default_value?: string
|
||||||
|
source: 'default' | 'env' | 'db'
|
||||||
|
description?: string
|
||||||
|
requires_restart: boolean
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 操作日志 */
|
||||||
|
export interface OperationLog {
|
||||||
|
id: number
|
||||||
|
account_id: string
|
||||||
|
action: string
|
||||||
|
target_type: string
|
||||||
|
target_id: string
|
||||||
|
details?: Record<string, unknown>
|
||||||
|
ip_address?: string
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 仪表盘统计 */
|
||||||
|
export interface DashboardStats {
|
||||||
|
total_accounts: number
|
||||||
|
active_accounts: number
|
||||||
|
tasks_today: number
|
||||||
|
active_providers: number
|
||||||
|
active_models: number
|
||||||
|
tokens_today_input: number
|
||||||
|
tokens_today_output: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 设备信息 */
|
||||||
|
export interface DeviceInfo {
|
||||||
|
id: string
|
||||||
|
device_id: string
|
||||||
|
device_name?: string
|
||||||
|
platform?: string
|
||||||
|
app_version?: string
|
||||||
|
last_seen_at: string
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** API 错误响应 */
|
||||||
|
export interface ApiError {
|
||||||
|
error: string
|
||||||
|
message: string
|
||||||
|
status?: number
|
||||||
|
}
|
||||||
34
admin/src/lib/utils.ts
Normal file
34
admin/src/lib/utils.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { type ClassValue, clsx } from 'clsx'
|
||||||
|
import { twMerge } from 'tailwind-merge'
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDate(date: string | Date): string {
|
||||||
|
const d = new Date(date)
|
||||||
|
return d.toLocaleString('zh-CN', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatNumber(n: number): string {
|
||||||
|
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
|
||||||
|
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`
|
||||||
|
return n.toLocaleString()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function maskApiKey(key?: string): string {
|
||||||
|
if (!key) return '-'
|
||||||
|
if (key.length <= 8) return '****'
|
||||||
|
return `${key.slice(0, 4)}${'*'.repeat(key.length - 8)}${key.slice(-4)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise(resolve => setTimeout(resolve, ms))
|
||||||
|
}
|
||||||
62
admin/tailwind.config.ts
Normal file
62
admin/tailwind.config.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import type { Config } from 'tailwindcss'
|
||||||
|
|
||||||
|
const config: Config = {
|
||||||
|
darkMode: 'class',
|
||||||
|
content: [
|
||||||
|
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
|
||||||
|
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
|
||||||
|
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
background: '#020617',
|
||||||
|
foreground: '#F8FAFC',
|
||||||
|
card: {
|
||||||
|
DEFAULT: '#0F172A',
|
||||||
|
foreground: '#F8FAFC',
|
||||||
|
},
|
||||||
|
primary: {
|
||||||
|
DEFAULT: '#22C55E',
|
||||||
|
foreground: '#020617',
|
||||||
|
hover: '#16A34A',
|
||||||
|
},
|
||||||
|
muted: {
|
||||||
|
DEFAULT: '#1E293B',
|
||||||
|
foreground: '#94A3B8',
|
||||||
|
},
|
||||||
|
accent: {
|
||||||
|
DEFAULT: '#334155',
|
||||||
|
foreground: '#F8FAFC',
|
||||||
|
},
|
||||||
|
destructive: {
|
||||||
|
DEFAULT: '#EF4444',
|
||||||
|
foreground: '#F8FAFC',
|
||||||
|
},
|
||||||
|
border: '#1E293B',
|
||||||
|
input: '#1E293B',
|
||||||
|
ring: '#22C55E',
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
sans: ['Inter', 'system-ui', '-apple-system', 'sans-serif'],
|
||||||
|
mono: ['JetBrains Mono', 'Fira Code', 'monospace'],
|
||||||
|
},
|
||||||
|
keyframes: {
|
||||||
|
'fade-in': {
|
||||||
|
'0%': { opacity: '0', transform: 'translateY(4px)' },
|
||||||
|
'100%': { opacity: '1', transform: 'translateY(0)' },
|
||||||
|
},
|
||||||
|
'slide-in': {
|
||||||
|
'0%': { opacity: '0', transform: 'translateX(-8px)' },
|
||||||
|
'100%': { opacity: '1', transform: 'translateX(0)' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
'fade-in': 'fade-in 0.2s ease-out',
|
||||||
|
'slide-in': 'slide-in 0.2s ease-out',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
|
export default config
|
||||||
21
admin/tsconfig.json
Normal file
21
admin/tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "es2017",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [{ "name": "next" }],
|
||||||
|
"paths": { "@/*": ["./src/*"] }
|
||||||
|
},
|
||||||
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
# ZClaw Chinese LLM Providers Configuration
|
# ZCLAW Chinese LLM Providers Configuration
|
||||||
# OpenFang TOML 格式的中文模型提供商配置
|
# ZCLAW TOML 格式的中文模型提供商配置
|
||||||
#
|
#
|
||||||
# 使用方法:
|
# 使用方法:
|
||||||
# 1. 复制此文件到 ~/.openfang/config.d/ 目录
|
# 1. 复制此文件到 ~/.zclaw/config.d/ 目录
|
||||||
# 2. 或者将内容追加到 ~/.openfang/config.toml
|
# 2. 或者将内容追加到 ~/.zclaw/config.toml
|
||||||
# 3. 设置环境变量: ZHIPU_API_KEY, QWEN_API_KEY, KIMI_API_KEY, MINIMAX_API_KEY
|
# 3. 设置环境变量: ZHIPU_API_KEY, QWEN_API_KEY, KIMI_API_KEY, MINIMAX_API_KEY
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
# ============================================================
|
# ============================================================
|
||||||
# ZClaw OpenFang Main Configuration
|
# ZCLAW Main Configuration
|
||||||
# OpenFang TOML format configuration file
|
# ZCLAW TOML format configuration file
|
||||||
# ============================================================
|
# ============================================================
|
||||||
#
|
#
|
||||||
# Usage:
|
# Usage:
|
||||||
# 1. Copy this file to ~/.openfang/config.toml
|
# 1. Copy this file to ~/.zclaw/config.toml
|
||||||
# 2. Set environment variables for API keys
|
# 2. Set environment variables for API keys
|
||||||
# 3. Import chinese-providers.toml for Chinese LLM support
|
# 3. Import chinese-providers.toml for Chinese LLM support
|
||||||
#
|
#
|
||||||
@@ -38,7 +38,7 @@ api_version = "v1"
|
|||||||
|
|
||||||
[agent.defaults]
|
[agent.defaults]
|
||||||
# Default workspace for agent operations
|
# Default workspace for agent operations
|
||||||
workspace = "~/.openfang/zclaw-workspace"
|
workspace = "~/.zclaw/zclaw-workspace"
|
||||||
|
|
||||||
# Default model for new sessions
|
# Default model for new sessions
|
||||||
default_model = "zhipu/glm-4-plus"
|
default_model = "zhipu/glm-4-plus"
|
||||||
@@ -57,7 +57,7 @@ max_sessions = 10
|
|||||||
|
|
||||||
[agent.defaults.sandbox]
|
[agent.defaults.sandbox]
|
||||||
# Sandbox root directory
|
# Sandbox root directory
|
||||||
workspace_root = "~/.openfang/zclaw-workspace"
|
workspace_root = "~/.zclaw/zclaw-workspace"
|
||||||
|
|
||||||
# Allowed shell commands (empty = all allowed)
|
# Allowed shell commands (empty = all allowed)
|
||||||
# allowed_commands = ["git", "npm", "pnpm", "cargo"]
|
# allowed_commands = ["git", "npm", "pnpm", "cargo"]
|
||||||
@@ -104,7 +104,7 @@ execution_timeout = "30m"
|
|||||||
|
|
||||||
# Audit settings
|
# Audit settings
|
||||||
audit_enabled = true
|
audit_enabled = true
|
||||||
audit_log_path = "~/.openfang/logs/hands-audit.log"
|
audit_log_path = "~/.zclaw/logs/hands-audit.log"
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# LLM Provider Configuration
|
# LLM Provider Configuration
|
||||||
@@ -166,7 +166,7 @@ burst_size = 20
|
|||||||
# Audit logging
|
# Audit logging
|
||||||
[security.audit]
|
[security.audit]
|
||||||
enabled = true
|
enabled = true
|
||||||
log_path = "~/.openfang/logs/audit.log"
|
log_path = "~/.zclaw/logs/audit.log"
|
||||||
log_format = "json"
|
log_format = "json"
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
@@ -183,7 +183,7 @@ format = "pretty"
|
|||||||
# Log file settings
|
# Log file settings
|
||||||
[logging.file]
|
[logging.file]
|
||||||
enabled = true
|
enabled = true
|
||||||
path = "~/.openfang/logs/openfang.log"
|
path = "~/.zclaw/logs/zclaw.log"
|
||||||
max_size = "10MB"
|
max_size = "10MB"
|
||||||
max_files = 5
|
max_files = 5
|
||||||
compress = true
|
compress = true
|
||||||
@@ -228,7 +228,7 @@ max_results = 10
|
|||||||
|
|
||||||
# File system tool
|
# File system tool
|
||||||
[tools.fs]
|
[tools.fs]
|
||||||
allowed_paths = ["~/.openfang/zclaw-workspace"]
|
allowed_paths = ["~/.zclaw/zclaw-workspace"]
|
||||||
max_file_size = "10MB"
|
max_file_size = "10MB"
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
@@ -237,7 +237,7 @@ max_file_size = "10MB"
|
|||||||
|
|
||||||
[workflow]
|
[workflow]
|
||||||
# Workflow storage
|
# Workflow storage
|
||||||
storage_path = "~/.openfang/workflows"
|
storage_path = "~/.zclaw/workflows"
|
||||||
|
|
||||||
# Execution settings
|
# Execution settings
|
||||||
max_steps = 100
|
max_steps = 100
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ impl Channel for ConsoleChannel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn receive(&self) -> Result<mpsc::Receiver<IncomingMessage>> {
|
async fn receive(&self) -> Result<mpsc::Receiver<IncomingMessage>> {
|
||||||
let (tx, rx) = mpsc::channel(100);
|
let (_tx, rx) = mpsc::channel(100);
|
||||||
// Console channel doesn't receive messages automatically
|
// Console channel doesn't receive messages automatically
|
||||||
// Messages would need to be injected via a separate method
|
// Messages would need to be injected via a separate method
|
||||||
Ok(rx)
|
Ok(rx)
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
//! Discord channel adapter
|
|
||||||
|
|
||||||
use async_trait::async_trait;
|
|
||||||
use std::sync::Arc;
|
|
||||||
use tokio::sync::mpsc;
|
|
||||||
use zclaw_types::Result;
|
|
||||||
|
|
||||||
use crate::{Channel, ChannelConfig, ChannelStatus, IncomingMessage, OutgoingMessage};
|
|
||||||
|
|
||||||
/// Discord channel adapter
|
|
||||||
pub struct DiscordChannel {
|
|
||||||
config: ChannelConfig,
|
|
||||||
status: Arc<tokio::sync::RwLock<ChannelStatus>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl DiscordChannel {
|
|
||||||
pub fn new(config: ChannelConfig) -> Self {
|
|
||||||
Self {
|
|
||||||
config,
|
|
||||||
status: Arc::new(tokio::sync::RwLock::new(ChannelStatus::Disconnected)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl Channel for DiscordChannel {
|
|
||||||
fn config(&self) -> &ChannelConfig {
|
|
||||||
&self.config
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn connect(&self) -> Result<()> {
|
|
||||||
let mut status = self.status.write().await;
|
|
||||||
*status = ChannelStatus::Connected;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn disconnect(&self) -> Result<()> {
|
|
||||||
let mut status = self.status.write().await;
|
|
||||||
*status = ChannelStatus::Disconnected;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn status(&self) -> ChannelStatus {
|
|
||||||
self.status.read().await.clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn send(&self, _message: OutgoingMessage) -> Result<String> {
|
|
||||||
// TODO: Implement Discord API send
|
|
||||||
Ok("discord_msg_id".to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn receive(&self) -> Result<mpsc::Receiver<IncomingMessage>> {
|
|
||||||
let (tx, rx) = mpsc::channel(100);
|
|
||||||
// TODO: Implement Discord gateway
|
|
||||||
Ok(rx)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,11 +1,5 @@
|
|||||||
//! Channel adapters
|
//! Channel adapters
|
||||||
|
|
||||||
mod telegram;
|
|
||||||
mod discord;
|
|
||||||
mod slack;
|
|
||||||
mod console;
|
mod console;
|
||||||
|
|
||||||
pub use telegram::TelegramChannel;
|
|
||||||
pub use discord::DiscordChannel;
|
|
||||||
pub use slack::SlackChannel;
|
|
||||||
pub use console::ConsoleChannel;
|
pub use console::ConsoleChannel;
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
//! Slack channel adapter
|
|
||||||
|
|
||||||
use async_trait::async_trait;
|
|
||||||
use std::sync::Arc;
|
|
||||||
use tokio::sync::mpsc;
|
|
||||||
use zclaw_types::Result;
|
|
||||||
|
|
||||||
use crate::{Channel, ChannelConfig, ChannelStatus, IncomingMessage, OutgoingMessage};
|
|
||||||
|
|
||||||
/// Slack channel adapter
|
|
||||||
pub struct SlackChannel {
|
|
||||||
config: ChannelConfig,
|
|
||||||
status: Arc<tokio::sync::RwLock<ChannelStatus>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SlackChannel {
|
|
||||||
pub fn new(config: ChannelConfig) -> Self {
|
|
||||||
Self {
|
|
||||||
config,
|
|
||||||
status: Arc::new(tokio::sync::RwLock::new(ChannelStatus::Disconnected)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl Channel for SlackChannel {
|
|
||||||
fn config(&self) -> &ChannelConfig {
|
|
||||||
&self.config
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn connect(&self) -> Result<()> {
|
|
||||||
let mut status = self.status.write().await;
|
|
||||||
*status = ChannelStatus::Connected;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn disconnect(&self) -> Result<()> {
|
|
||||||
let mut status = self.status.write().await;
|
|
||||||
*status = ChannelStatus::Disconnected;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn status(&self) -> ChannelStatus {
|
|
||||||
self.status.read().await.clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn send(&self, _message: OutgoingMessage) -> Result<String> {
|
|
||||||
// TODO: Implement Slack API send
|
|
||||||
Ok("slack_msg_ts".to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn receive(&self) -> Result<mpsc::Receiver<IncomingMessage>> {
|
|
||||||
let (tx, rx) = mpsc::channel(100);
|
|
||||||
// TODO: Implement Slack RTM/events API
|
|
||||||
Ok(rx)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
//! Telegram channel adapter
|
|
||||||
|
|
||||||
use async_trait::async_trait;
|
|
||||||
use std::sync::Arc;
|
|
||||||
use tokio::sync::mpsc;
|
|
||||||
use zclaw_types::Result;
|
|
||||||
|
|
||||||
use crate::{Channel, ChannelConfig, ChannelStatus, IncomingMessage, OutgoingMessage};
|
|
||||||
|
|
||||||
/// Telegram channel adapter
|
|
||||||
pub struct TelegramChannel {
|
|
||||||
config: ChannelConfig,
|
|
||||||
client: Option<reqwest::Client>,
|
|
||||||
status: Arc<tokio::sync::RwLock<ChannelStatus>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TelegramChannel {
|
|
||||||
pub fn new(config: ChannelConfig) -> Self {
|
|
||||||
Self {
|
|
||||||
config,
|
|
||||||
client: None,
|
|
||||||
status: Arc::new(tokio::sync::RwLock::new(ChannelStatus::Disconnected)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl Channel for TelegramChannel {
|
|
||||||
fn config(&self) -> &ChannelConfig {
|
|
||||||
&self.config
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn connect(&self) -> Result<()> {
|
|
||||||
let mut status = self.status.write().await;
|
|
||||||
*status = ChannelStatus::Connected;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn disconnect(&self) -> Result<()> {
|
|
||||||
let mut status = self.status.write().await;
|
|
||||||
*status = ChannelStatus::Disconnected;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn status(&self) -> ChannelStatus {
|
|
||||||
self.status.read().await.clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn send(&self, _message: OutgoingMessage) -> Result<String> {
|
|
||||||
// TODO: Implement Telegram API send
|
|
||||||
Ok("telegram_msg_id".to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn receive(&self) -> Result<mpsc::Receiver<IncomingMessage>> {
|
|
||||||
let (tx, rx) = mpsc::channel(100);
|
|
||||||
// TODO: Implement Telegram webhook/polling
|
|
||||||
Ok(rx)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -7,7 +7,7 @@ use std::sync::Arc;
|
|||||||
use tokio::sync::RwLock;
|
use tokio::sync::RwLock;
|
||||||
use zclaw_types::Result;
|
use zclaw_types::Result;
|
||||||
|
|
||||||
use super::{Channel, ChannelConfig, ChannelStatus, IncomingMessage, OutgoingMessage};
|
use super::{Channel, ChannelConfig, OutgoingMessage};
|
||||||
|
|
||||||
/// Channel bridge manager
|
/// Channel bridge manager
|
||||||
pub struct ChannelBridge {
|
pub struct ChannelBridge {
|
||||||
|
|||||||
41
crates/zclaw-growth/Cargo.toml
Normal file
41
crates/zclaw-growth/Cargo.toml
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
[package]
|
||||||
|
name = "zclaw-growth"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
description = "ZCLAW Agent Growth System - Memory extraction, retrieval, and prompt injection"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
# Async runtime
|
||||||
|
tokio = { workspace = true }
|
||||||
|
futures = { workspace = true }
|
||||||
|
async-trait = { workspace = true }
|
||||||
|
|
||||||
|
# Serialization
|
||||||
|
serde = { workspace = true }
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
|
||||||
|
# Error handling
|
||||||
|
thiserror = { workspace = true }
|
||||||
|
anyhow = { workspace = true }
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
tracing = { workspace = true }
|
||||||
|
|
||||||
|
# Time
|
||||||
|
chrono = { workspace = true }
|
||||||
|
|
||||||
|
# IDs
|
||||||
|
uuid = { workspace = true }
|
||||||
|
|
||||||
|
# Database
|
||||||
|
sqlx = { workspace = true }
|
||||||
|
libsqlite3-sys = { workspace = true }
|
||||||
|
|
||||||
|
# Internal crates
|
||||||
|
zclaw-types = { workspace = true }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tokio-test = "0.4"
|
||||||
372
crates/zclaw-growth/src/extractor.rs
Normal file
372
crates/zclaw-growth/src/extractor.rs
Normal file
@@ -0,0 +1,372 @@
|
|||||||
|
//! Memory Extractor - Extracts preferences, knowledge, and experience from conversations
|
||||||
|
//!
|
||||||
|
//! This module provides the `MemoryExtractor` which analyzes conversations
|
||||||
|
//! using LLM to extract valuable memories for agent growth.
|
||||||
|
|
||||||
|
use crate::types::{ExtractedMemory, ExtractionConfig, MemoryType};
|
||||||
|
use crate::viking_adapter::VikingAdapter;
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use zclaw_types::{Message, Result, SessionId};
|
||||||
|
|
||||||
|
/// Trait for LLM driver abstraction
|
||||||
|
/// This allows us to use any LLM driver implementation
|
||||||
|
#[async_trait]
|
||||||
|
pub trait LlmDriverForExtraction: Send + Sync {
|
||||||
|
/// Extract memories from conversation using LLM
|
||||||
|
async fn extract_memories(
|
||||||
|
&self,
|
||||||
|
messages: &[Message],
|
||||||
|
extraction_type: MemoryType,
|
||||||
|
) -> Result<Vec<ExtractedMemory>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Memory Extractor - extracts memories from conversations
|
||||||
|
pub struct MemoryExtractor {
|
||||||
|
/// LLM driver for extraction (optional)
|
||||||
|
llm_driver: Option<Arc<dyn LlmDriverForExtraction>>,
|
||||||
|
/// OpenViking adapter for storage
|
||||||
|
viking: Option<Arc<VikingAdapter>>,
|
||||||
|
/// Extraction configuration
|
||||||
|
config: ExtractionConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MemoryExtractor {
|
||||||
|
/// Create a new memory extractor with LLM driver
|
||||||
|
pub fn new(llm_driver: Arc<dyn LlmDriverForExtraction>) -> Self {
|
||||||
|
Self {
|
||||||
|
llm_driver: Some(llm_driver),
|
||||||
|
viking: None,
|
||||||
|
config: ExtractionConfig::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new memory extractor without LLM driver
|
||||||
|
///
|
||||||
|
/// This is useful for cases where LLM-based extraction is not needed
|
||||||
|
/// or will be set later using `with_llm_driver`
|
||||||
|
pub fn new_without_driver() -> Self {
|
||||||
|
Self {
|
||||||
|
llm_driver: None,
|
||||||
|
viking: None,
|
||||||
|
config: ExtractionConfig::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the LLM driver
|
||||||
|
pub fn with_llm_driver(mut self, driver: Arc<dyn LlmDriverForExtraction>) -> Self {
|
||||||
|
self.llm_driver = Some(driver);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create with OpenViking adapter
|
||||||
|
pub fn with_viking(mut self, viking: Arc<VikingAdapter>) -> Self {
|
||||||
|
self.viking = Some(viking);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set extraction configuration
|
||||||
|
pub fn with_config(mut self, config: ExtractionConfig) -> Self {
|
||||||
|
self.config = config;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract memories from a conversation
|
||||||
|
///
|
||||||
|
/// This method analyzes the conversation and extracts:
|
||||||
|
/// - Preferences: User's communication style, format preferences, language preferences
|
||||||
|
/// - Knowledge: User-related facts, domain knowledge, lessons learned
|
||||||
|
/// - Experience: Skill/tool usage patterns and outcomes
|
||||||
|
///
|
||||||
|
/// Returns an empty Vec if no LLM driver is configured
|
||||||
|
pub async fn extract(
|
||||||
|
&self,
|
||||||
|
messages: &[Message],
|
||||||
|
session_id: SessionId,
|
||||||
|
) -> Result<Vec<ExtractedMemory>> {
|
||||||
|
// Check if LLM driver is available
|
||||||
|
let _llm_driver = match &self.llm_driver {
|
||||||
|
Some(driver) => driver,
|
||||||
|
None => {
|
||||||
|
tracing::debug!("[MemoryExtractor] No LLM driver configured, skipping extraction");
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut results = Vec::new();
|
||||||
|
|
||||||
|
// Extract preferences if enabled
|
||||||
|
if self.config.extract_preferences {
|
||||||
|
tracing::debug!("[MemoryExtractor] Extracting preferences...");
|
||||||
|
let prefs = self.extract_preferences(messages, session_id).await?;
|
||||||
|
results.extend(prefs);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract knowledge if enabled
|
||||||
|
if self.config.extract_knowledge {
|
||||||
|
tracing::debug!("[MemoryExtractor] Extracting knowledge...");
|
||||||
|
let knowledge = self.extract_knowledge(messages, session_id).await?;
|
||||||
|
results.extend(knowledge);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract experience if enabled
|
||||||
|
if self.config.extract_experience {
|
||||||
|
tracing::debug!("[MemoryExtractor] Extracting experience...");
|
||||||
|
let experience = self.extract_experience(messages, session_id).await?;
|
||||||
|
results.extend(experience);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by confidence threshold
|
||||||
|
results.retain(|m| m.confidence >= self.config.min_confidence);
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
"[MemoryExtractor] Extracted {} memories (confidence >= {})",
|
||||||
|
results.len(),
|
||||||
|
self.config.min_confidence
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(results)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract user preferences from conversation
|
||||||
|
async fn extract_preferences(
|
||||||
|
&self,
|
||||||
|
messages: &[Message],
|
||||||
|
session_id: SessionId,
|
||||||
|
) -> Result<Vec<ExtractedMemory>> {
|
||||||
|
let llm_driver = match &self.llm_driver {
|
||||||
|
Some(driver) => driver,
|
||||||
|
None => return Ok(Vec::new()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut results = llm_driver
|
||||||
|
.extract_memories(messages, MemoryType::Preference)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Set source session
|
||||||
|
for memory in &mut results {
|
||||||
|
memory.source_session = session_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(results)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract knowledge from conversation
|
||||||
|
async fn extract_knowledge(
|
||||||
|
&self,
|
||||||
|
messages: &[Message],
|
||||||
|
session_id: SessionId,
|
||||||
|
) -> Result<Vec<ExtractedMemory>> {
|
||||||
|
let llm_driver = match &self.llm_driver {
|
||||||
|
Some(driver) => driver,
|
||||||
|
None => return Ok(Vec::new()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut results = llm_driver
|
||||||
|
.extract_memories(messages, MemoryType::Knowledge)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
for memory in &mut results {
|
||||||
|
memory.source_session = session_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(results)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract experience from conversation
|
||||||
|
async fn extract_experience(
|
||||||
|
&self,
|
||||||
|
messages: &[Message],
|
||||||
|
session_id: SessionId,
|
||||||
|
) -> Result<Vec<ExtractedMemory>> {
|
||||||
|
let llm_driver = match &self.llm_driver {
|
||||||
|
Some(driver) => driver,
|
||||||
|
None => return Ok(Vec::new()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut results = llm_driver
|
||||||
|
.extract_memories(messages, MemoryType::Experience)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
for memory in &mut results {
|
||||||
|
memory.source_session = session_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(results)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Store extracted memories to OpenViking
|
||||||
|
pub async fn store_memories(
|
||||||
|
&self,
|
||||||
|
agent_id: &str,
|
||||||
|
memories: &[ExtractedMemory],
|
||||||
|
) -> Result<usize> {
|
||||||
|
let viking = match &self.viking {
|
||||||
|
Some(v) => v,
|
||||||
|
None => {
|
||||||
|
tracing::warn!("[MemoryExtractor] No VikingAdapter configured, memories not stored");
|
||||||
|
return Ok(0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut stored = 0;
|
||||||
|
for memory in memories {
|
||||||
|
let entry = memory.to_memory_entry(agent_id);
|
||||||
|
match viking.store(&entry).await {
|
||||||
|
Ok(_) => stored += 1,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!(
|
||||||
|
"[MemoryExtractor] Failed to store memory {}: {}",
|
||||||
|
memory.category,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!("[MemoryExtractor] Stored {} memories to OpenViking", stored);
|
||||||
|
Ok(stored)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Default extraction prompts for LLM
|
||||||
|
pub mod prompts {
|
||||||
|
use crate::types::MemoryType;
|
||||||
|
|
||||||
|
/// Get the extraction prompt for a memory type
|
||||||
|
pub fn get_extraction_prompt(memory_type: MemoryType) -> &'static str {
|
||||||
|
match memory_type {
|
||||||
|
MemoryType::Preference => PREFERENCE_EXTRACTION_PROMPT,
|
||||||
|
MemoryType::Knowledge => KNOWLEDGE_EXTRACTION_PROMPT,
|
||||||
|
MemoryType::Experience => EXPERIENCE_EXTRACTION_PROMPT,
|
||||||
|
MemoryType::Session => SESSION_SUMMARY_PROMPT,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const PREFERENCE_EXTRACTION_PROMPT: &str = r#"
|
||||||
|
分析以下对话,提取用户的偏好设置。关注:
|
||||||
|
- 沟通风格偏好(简洁/详细、正式/随意)
|
||||||
|
- 回复格式偏好(列表/段落、代码块风格)
|
||||||
|
- 语言偏好
|
||||||
|
- 主题兴趣
|
||||||
|
|
||||||
|
请以 JSON 格式返回,格式如下:
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"category": "communication-style",
|
||||||
|
"content": "用户偏好简洁的回复",
|
||||||
|
"confidence": 0.9,
|
||||||
|
"keywords": ["简洁", "回复风格"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
对话内容:
|
||||||
|
"#;
|
||||||
|
|
||||||
|
const KNOWLEDGE_EXTRACTION_PROMPT: &str = r#"
|
||||||
|
分析以下对话,提取有价值的知识。关注:
|
||||||
|
- 用户相关事实(职业、项目、背景)
|
||||||
|
- 领域知识(技术栈、工具、最佳实践)
|
||||||
|
- 经验教训(成功/失败案例)
|
||||||
|
|
||||||
|
请以 JSON 格式返回,格式如下:
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"category": "user-facts",
|
||||||
|
"content": "用户是一名 Rust 开发者",
|
||||||
|
"confidence": 0.85,
|
||||||
|
"keywords": ["Rust", "开发者"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
对话内容:
|
||||||
|
"#;
|
||||||
|
|
||||||
|
const EXPERIENCE_EXTRACTION_PROMPT: &str = r#"
|
||||||
|
分析以下对话,提取技能/工具使用经验。关注:
|
||||||
|
- 使用的技能或工具
|
||||||
|
- 执行结果(成功/失败)
|
||||||
|
- 改进建议
|
||||||
|
|
||||||
|
请以 JSON 格式返回,格式如下:
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"category": "skill-browser",
|
||||||
|
"content": "浏览器技能在搜索技术文档时效果很好",
|
||||||
|
"confidence": 0.8,
|
||||||
|
"keywords": ["浏览器", "搜索", "文档"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
对话内容:
|
||||||
|
"#;
|
||||||
|
|
||||||
|
const SESSION_SUMMARY_PROMPT: &str = r#"
|
||||||
|
总结以下对话会话。关注:
|
||||||
|
- 主要话题
|
||||||
|
- 关键决策
|
||||||
|
- 未解决问题
|
||||||
|
|
||||||
|
请以 JSON 格式返回,格式如下:
|
||||||
|
{
|
||||||
|
"summary": "会话摘要内容",
|
||||||
|
"keywords": ["关键词1", "关键词2"],
|
||||||
|
"topics": ["主题1", "主题2"]
|
||||||
|
}
|
||||||
|
|
||||||
|
对话内容:
|
||||||
|
"#;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
struct MockLlmDriver;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl LlmDriverForExtraction for MockLlmDriver {
|
||||||
|
async fn extract_memories(
|
||||||
|
&self,
|
||||||
|
_messages: &[Message],
|
||||||
|
extraction_type: MemoryType,
|
||||||
|
) -> Result<Vec<ExtractedMemory>> {
|
||||||
|
Ok(vec![ExtractedMemory::new(
|
||||||
|
extraction_type,
|
||||||
|
"test-category",
|
||||||
|
"test content",
|
||||||
|
SessionId::new(),
|
||||||
|
)])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_extractor_creation() {
|
||||||
|
let driver = Arc::new(MockLlmDriver);
|
||||||
|
let extractor = MemoryExtractor::new(driver);
|
||||||
|
assert!(extractor.viking.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_extract_memories() {
|
||||||
|
let driver = Arc::new(MockLlmDriver);
|
||||||
|
let extractor = MemoryExtractor::new(driver);
|
||||||
|
let messages = vec![Message::user("Hello")];
|
||||||
|
|
||||||
|
let result = extractor
|
||||||
|
.extract(&messages, SessionId::new())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Should extract preferences, knowledge, and experience
|
||||||
|
assert!(!result.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_prompts_available() {
|
||||||
|
assert!(!prompts::get_extraction_prompt(MemoryType::Preference).is_empty());
|
||||||
|
assert!(!prompts::get_extraction_prompt(MemoryType::Knowledge).is_empty());
|
||||||
|
assert!(!prompts::get_extraction_prompt(MemoryType::Experience).is_empty());
|
||||||
|
assert!(!prompts::get_extraction_prompt(MemoryType::Session).is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
539
crates/zclaw-growth/src/injector.rs
Normal file
539
crates/zclaw-growth/src/injector.rs
Normal file
@@ -0,0 +1,539 @@
|
|||||||
|
//! Prompt Injector - Injects retrieved memories into system prompts
|
||||||
|
//!
|
||||||
|
//! This module provides the `PromptInjector` which formats and injects
|
||||||
|
//! retrieved memories into the agent's system prompt for context enhancement.
|
||||||
|
//!
|
||||||
|
//! # Formatting Options
|
||||||
|
//!
|
||||||
|
//! - `inject()` - Standard markdown format with sections
|
||||||
|
//! - `inject_compact()` - Compact format for limited token budgets
|
||||||
|
//! - `inject_json()` - JSON format for structured processing
|
||||||
|
//! - `inject_custom()` - Custom template with placeholders
|
||||||
|
|
||||||
|
use crate::types::{MemoryEntry, RetrievalConfig, RetrievalResult};
|
||||||
|
|
||||||
|
/// Output format for memory injection
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum InjectionFormat {
|
||||||
|
/// Standard markdown with sections (default)
|
||||||
|
Markdown,
|
||||||
|
/// Compact inline format
|
||||||
|
Compact,
|
||||||
|
/// JSON structured format
|
||||||
|
Json,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Prompt Injector - injects memories into system prompts
|
||||||
|
pub struct PromptInjector {
|
||||||
|
/// Retrieval configuration for token budgets
|
||||||
|
config: RetrievalConfig,
|
||||||
|
/// Output format
|
||||||
|
format: InjectionFormat,
|
||||||
|
/// Custom template (uses {{preferences}}, {{knowledge}}, {{experience}} placeholders)
|
||||||
|
custom_template: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for PromptInjector {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PromptInjector {
|
||||||
|
/// Create a new prompt injector
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
config: RetrievalConfig::default(),
|
||||||
|
format: InjectionFormat::Markdown,
|
||||||
|
custom_template: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create with custom configuration
|
||||||
|
pub fn with_config(config: RetrievalConfig) -> Self {
|
||||||
|
Self {
|
||||||
|
config,
|
||||||
|
format: InjectionFormat::Markdown,
|
||||||
|
custom_template: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the output format
|
||||||
|
pub fn with_format(mut self, format: InjectionFormat) -> Self {
|
||||||
|
self.format = format;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set a custom template for injection
|
||||||
|
///
|
||||||
|
/// Template placeholders:
|
||||||
|
/// - `{{preferences}}` - Formatted preferences section
|
||||||
|
/// - `{{knowledge}}` - Formatted knowledge section
|
||||||
|
/// - `{{experience}}` - Formatted experience section
|
||||||
|
/// - `{{all}}` - All memories combined
|
||||||
|
pub fn with_custom_template(mut self, template: impl Into<String>) -> Self {
|
||||||
|
self.custom_template = Some(template.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inject memories into a base system prompt
|
||||||
|
///
|
||||||
|
/// This method constructs an enhanced system prompt by:
|
||||||
|
/// 1. Starting with the base prompt
|
||||||
|
/// 2. Adding a "用户偏好" section if preferences exist
|
||||||
|
/// 3. Adding a "相关知识" section if knowledge exists
|
||||||
|
/// 4. Adding an "经验参考" section if experience exists
|
||||||
|
///
|
||||||
|
/// Each section respects the token budget configuration.
|
||||||
|
pub fn inject(&self, base_prompt: &str, memories: &RetrievalResult) -> String {
|
||||||
|
// If no memories, return base prompt unchanged
|
||||||
|
if memories.is_empty() {
|
||||||
|
return base_prompt.to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut result = base_prompt.to_string();
|
||||||
|
|
||||||
|
// Inject preferences section
|
||||||
|
if !memories.preferences.is_empty() {
|
||||||
|
let section = self.format_section(
|
||||||
|
"## 用户偏好",
|
||||||
|
&memories.preferences,
|
||||||
|
self.config.preference_budget,
|
||||||
|
|entry| format!("- {}", entry.content),
|
||||||
|
);
|
||||||
|
result.push_str("\n\n");
|
||||||
|
result.push_str(§ion);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inject knowledge section
|
||||||
|
if !memories.knowledge.is_empty() {
|
||||||
|
let section = self.format_section(
|
||||||
|
"## 相关知识",
|
||||||
|
&memories.knowledge,
|
||||||
|
self.config.knowledge_budget,
|
||||||
|
|entry| format!("- {}", entry.content),
|
||||||
|
);
|
||||||
|
result.push_str("\n\n");
|
||||||
|
result.push_str(§ion);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inject experience section
|
||||||
|
if !memories.experience.is_empty() {
|
||||||
|
let section = self.format_section(
|
||||||
|
"## 经验参考",
|
||||||
|
&memories.experience,
|
||||||
|
self.config.experience_budget,
|
||||||
|
|entry| format!("- {}", entry.content),
|
||||||
|
);
|
||||||
|
result.push_str("\n\n");
|
||||||
|
result.push_str(§ion);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add memory context footer
|
||||||
|
result.push_str("\n\n");
|
||||||
|
result.push_str("<!-- 以上内容基于历史对话自动提取的记忆 -->");
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format a section of memories with token budget
|
||||||
|
fn format_section<F>(
|
||||||
|
&self,
|
||||||
|
header: &str,
|
||||||
|
entries: &[MemoryEntry],
|
||||||
|
token_budget: usize,
|
||||||
|
formatter: F,
|
||||||
|
) -> String
|
||||||
|
where
|
||||||
|
F: Fn(&MemoryEntry) -> String,
|
||||||
|
{
|
||||||
|
let mut result = String::new();
|
||||||
|
result.push_str(header);
|
||||||
|
result.push('\n');
|
||||||
|
|
||||||
|
let mut used_tokens = 0;
|
||||||
|
let header_tokens = header.len() / 4;
|
||||||
|
used_tokens += header_tokens;
|
||||||
|
|
||||||
|
for entry in entries {
|
||||||
|
let line = formatter(entry);
|
||||||
|
let line_tokens = line.len() / 4;
|
||||||
|
|
||||||
|
if used_tokens + line_tokens > token_budget {
|
||||||
|
// Add truncation indicator
|
||||||
|
result.push_str("- ... (更多内容已省略)\n");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
result.push_str(&line);
|
||||||
|
result.push('\n');
|
||||||
|
used_tokens += line_tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a minimal context string for token-limited scenarios
|
||||||
|
pub fn build_minimal_context(&self, memories: &RetrievalResult) -> String {
|
||||||
|
if memories.is_empty() {
|
||||||
|
return String::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut context = String::new();
|
||||||
|
|
||||||
|
// Only include top preference
|
||||||
|
if let Some(pref) = memories.preferences.first() {
|
||||||
|
context.push_str(&format!("[偏好] {}\n", pref.content));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only include top knowledge
|
||||||
|
if let Some(knowledge) = memories.knowledge.first() {
|
||||||
|
context.push_str(&format!("[知识] {}\n", knowledge.content));
|
||||||
|
}
|
||||||
|
|
||||||
|
context
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inject memories in compact format
|
||||||
|
///
|
||||||
|
/// Compact format uses inline notation: [P] for preferences, [K] for knowledge, [E] for experience
|
||||||
|
pub fn inject_compact(&self, base_prompt: &str, memories: &RetrievalResult) -> String {
|
||||||
|
if memories.is_empty() {
|
||||||
|
return base_prompt.to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut result = base_prompt.to_string();
|
||||||
|
let mut context_parts = Vec::new();
|
||||||
|
|
||||||
|
// Add compact preferences
|
||||||
|
for entry in &memories.preferences {
|
||||||
|
context_parts.push(format!("[P] {}", entry.content));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add compact knowledge
|
||||||
|
for entry in &memories.knowledge {
|
||||||
|
context_parts.push(format!("[K] {}", entry.content));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add compact experience
|
||||||
|
for entry in &memories.experience {
|
||||||
|
context_parts.push(format!("[E] {}", entry.content));
|
||||||
|
}
|
||||||
|
|
||||||
|
if !context_parts.is_empty() {
|
||||||
|
result.push_str("\n\n[记忆上下文]\n");
|
||||||
|
result.push_str(&context_parts.join("\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inject memories as JSON structure
|
||||||
|
///
|
||||||
|
/// Returns a JSON object with preferences, knowledge, and experience arrays
|
||||||
|
pub fn inject_json(&self, base_prompt: &str, memories: &RetrievalResult) -> String {
|
||||||
|
if memories.is_empty() {
|
||||||
|
return base_prompt.to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
let preferences: Vec<_> = memories.preferences.iter()
|
||||||
|
.map(|e| serde_json::json!({
|
||||||
|
"content": e.content,
|
||||||
|
"importance": e.importance,
|
||||||
|
"keywords": e.keywords,
|
||||||
|
}))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let knowledge: Vec<_> = memories.knowledge.iter()
|
||||||
|
.map(|e| serde_json::json!({
|
||||||
|
"content": e.content,
|
||||||
|
"importance": e.importance,
|
||||||
|
"keywords": e.keywords,
|
||||||
|
}))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let experience: Vec<_> = memories.experience.iter()
|
||||||
|
.map(|e| serde_json::json!({
|
||||||
|
"content": e.content,
|
||||||
|
"importance": e.importance,
|
||||||
|
"keywords": e.keywords,
|
||||||
|
}))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let memories_json = serde_json::json!({
|
||||||
|
"preferences": preferences,
|
||||||
|
"knowledge": knowledge,
|
||||||
|
"experience": experience,
|
||||||
|
});
|
||||||
|
|
||||||
|
format!("{}\n\n[记忆上下文]\n{}", base_prompt, serde_json::to_string_pretty(&memories_json).unwrap_or_default())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inject using custom template
|
||||||
|
///
|
||||||
|
/// Template placeholders:
|
||||||
|
/// - `{{preferences}}` - Formatted preferences section
|
||||||
|
/// - `{{knowledge}}` - Formatted knowledge section
|
||||||
|
/// - `{{experience}}` - Formatted experience section
|
||||||
|
/// - `{{all}}` - All memories combined
|
||||||
|
pub fn inject_custom(&self, template: &str, memories: &RetrievalResult) -> String {
|
||||||
|
let mut result = template.to_string();
|
||||||
|
|
||||||
|
// Format each section
|
||||||
|
let prefs = if !memories.preferences.is_empty() {
|
||||||
|
memories.preferences.iter()
|
||||||
|
.map(|e| format!("- {}", e.content))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n")
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
|
||||||
|
let knowledge = if !memories.knowledge.is_empty() {
|
||||||
|
memories.knowledge.iter()
|
||||||
|
.map(|e| format!("- {}", e.content))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n")
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
|
||||||
|
let experience = if !memories.experience.is_empty() {
|
||||||
|
memories.experience.iter()
|
||||||
|
.map(|e| format!("- {}", e.content))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n")
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Combine all
|
||||||
|
let all = format!(
|
||||||
|
"用户偏好:\n{}\n\n相关知识:\n{}\n\n经验参考:\n{}",
|
||||||
|
if prefs.is_empty() { "无" } else { &prefs },
|
||||||
|
if knowledge.is_empty() { "无" } else { &knowledge },
|
||||||
|
if experience.is_empty() { "无" } else { &experience },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Replace placeholders
|
||||||
|
result = result.replace("{{preferences}}", &prefs);
|
||||||
|
result = result.replace("{{knowledge}}", &knowledge);
|
||||||
|
result = result.replace("{{experience}}", &experience);
|
||||||
|
result = result.replace("{{all}}", &all);
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inject memories using the configured format
|
||||||
|
pub fn inject_with_format(&self, base_prompt: &str, memories: &RetrievalResult) -> String {
|
||||||
|
match self.format {
|
||||||
|
InjectionFormat::Markdown => self.inject(base_prompt, memories),
|
||||||
|
InjectionFormat::Compact => self.inject_compact(base_prompt, memories),
|
||||||
|
InjectionFormat::Json => self.inject_json(base_prompt, memories),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Estimate total tokens that will be injected
|
||||||
|
pub fn estimate_injection_tokens(&self, memories: &RetrievalResult) -> usize {
|
||||||
|
let mut total = 0;
|
||||||
|
|
||||||
|
// Count preference tokens
|
||||||
|
for entry in &memories.preferences {
|
||||||
|
total += entry.estimated_tokens();
|
||||||
|
if total > self.config.preference_budget {
|
||||||
|
total = self.config.preference_budget;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count knowledge tokens
|
||||||
|
let mut knowledge_tokens = 0;
|
||||||
|
for entry in &memories.knowledge {
|
||||||
|
knowledge_tokens += entry.estimated_tokens();
|
||||||
|
if knowledge_tokens > self.config.knowledge_budget {
|
||||||
|
knowledge_tokens = self.config.knowledge_budget;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
total += knowledge_tokens;
|
||||||
|
|
||||||
|
// Count experience tokens
|
||||||
|
let mut experience_tokens = 0;
|
||||||
|
for entry in &memories.experience {
|
||||||
|
experience_tokens += entry.estimated_tokens();
|
||||||
|
if experience_tokens > self.config.experience_budget {
|
||||||
|
experience_tokens = self.config.experience_budget;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
total += experience_tokens;
|
||||||
|
|
||||||
|
total
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::types::MemoryType;
|
||||||
|
use chrono::Utc;
|
||||||
|
|
||||||
|
fn create_test_entry(content: &str) -> MemoryEntry {
|
||||||
|
MemoryEntry {
|
||||||
|
uri: "test://uri".to_string(),
|
||||||
|
memory_type: MemoryType::Preference,
|
||||||
|
content: content.to_string(),
|
||||||
|
keywords: vec![],
|
||||||
|
importance: 5,
|
||||||
|
access_count: 0,
|
||||||
|
created_at: Utc::now(),
|
||||||
|
last_accessed: Utc::now(),
|
||||||
|
overview: None,
|
||||||
|
abstract_summary: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_injector_empty_memories() {
|
||||||
|
let injector = PromptInjector::new();
|
||||||
|
let base = "You are a helpful assistant.";
|
||||||
|
let memories = RetrievalResult::default();
|
||||||
|
|
||||||
|
let result = injector.inject(base, &memories);
|
||||||
|
assert_eq!(result, base);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_injector_with_preferences() {
|
||||||
|
let injector = PromptInjector::new();
|
||||||
|
let base = "You are a helpful assistant.";
|
||||||
|
let memories = RetrievalResult {
|
||||||
|
preferences: vec![create_test_entry("User prefers concise responses")],
|
||||||
|
knowledge: vec![],
|
||||||
|
experience: vec![],
|
||||||
|
total_tokens: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = injector.inject(base, &memories);
|
||||||
|
assert!(result.contains("用户偏好"));
|
||||||
|
assert!(result.contains("User prefers concise responses"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_injector_with_all_types() {
|
||||||
|
let injector = PromptInjector::new();
|
||||||
|
let base = "You are a helpful assistant.";
|
||||||
|
|
||||||
|
let memories = RetrievalResult {
|
||||||
|
preferences: vec![create_test_entry("Prefers concise")],
|
||||||
|
knowledge: vec![create_test_entry("Knows Rust")],
|
||||||
|
experience: vec![create_test_entry("Browser skill works well")],
|
||||||
|
total_tokens: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = injector.inject(base, &memories);
|
||||||
|
assert!(result.contains("用户偏好"));
|
||||||
|
assert!(result.contains("相关知识"));
|
||||||
|
assert!(result.contains("经验参考"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_minimal_context() {
|
||||||
|
let injector = PromptInjector::new();
|
||||||
|
let memories = RetrievalResult {
|
||||||
|
preferences: vec![create_test_entry("Prefers concise")],
|
||||||
|
knowledge: vec![create_test_entry("Knows Rust")],
|
||||||
|
experience: vec![],
|
||||||
|
total_tokens: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
let context = injector.build_minimal_context(&memories);
|
||||||
|
assert!(context.contains("[偏好]"));
|
||||||
|
assert!(context.contains("[知识]"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_estimate_tokens() {
|
||||||
|
let injector = PromptInjector::new();
|
||||||
|
let memories = RetrievalResult {
|
||||||
|
preferences: vec![create_test_entry("Short text")],
|
||||||
|
knowledge: vec![],
|
||||||
|
experience: vec![],
|
||||||
|
total_tokens: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
let estimate = injector.estimate_injection_tokens(&memories);
|
||||||
|
assert!(estimate > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_inject_compact() {
|
||||||
|
let injector = PromptInjector::new();
|
||||||
|
let base = "You are a helpful assistant.";
|
||||||
|
let memories = RetrievalResult {
|
||||||
|
preferences: vec![create_test_entry("Prefers concise")],
|
||||||
|
knowledge: vec![create_test_entry("Knows Rust")],
|
||||||
|
experience: vec![],
|
||||||
|
total_tokens: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = injector.inject_compact(base, &memories);
|
||||||
|
assert!(result.contains("[P]"));
|
||||||
|
assert!(result.contains("[K]"));
|
||||||
|
assert!(result.contains("[记忆上下文]"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_inject_json() {
|
||||||
|
let injector = PromptInjector::new();
|
||||||
|
let base = "You are a helpful assistant.";
|
||||||
|
let memories = RetrievalResult {
|
||||||
|
preferences: vec![create_test_entry("Prefers concise")],
|
||||||
|
knowledge: vec![],
|
||||||
|
experience: vec![],
|
||||||
|
total_tokens: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = injector.inject_json(base, &memories);
|
||||||
|
assert!(result.contains("\"preferences\""));
|
||||||
|
assert!(result.contains("Prefers concise"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_inject_custom() {
|
||||||
|
let injector = PromptInjector::new();
|
||||||
|
let template = "Context:\n{{all}}";
|
||||||
|
let memories = RetrievalResult {
|
||||||
|
preferences: vec![create_test_entry("Prefers concise")],
|
||||||
|
knowledge: vec![create_test_entry("Knows Rust")],
|
||||||
|
experience: vec![],
|
||||||
|
total_tokens: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = injector.inject_custom(template, &memories);
|
||||||
|
assert!(result.contains("用户偏好"));
|
||||||
|
assert!(result.contains("相关知识"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_format_selection() {
|
||||||
|
let base = "Base";
|
||||||
|
|
||||||
|
let memories = RetrievalResult {
|
||||||
|
preferences: vec![create_test_entry("Test")],
|
||||||
|
knowledge: vec![],
|
||||||
|
experience: vec![],
|
||||||
|
total_tokens: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Test markdown format
|
||||||
|
let injector_md = PromptInjector::new().with_format(InjectionFormat::Markdown);
|
||||||
|
let result_md = injector_md.inject_with_format(base, &memories);
|
||||||
|
assert!(result_md.contains("## 用户偏好"));
|
||||||
|
|
||||||
|
// Test compact format
|
||||||
|
let injector_compact = PromptInjector::new().with_format(InjectionFormat::Compact);
|
||||||
|
let result_compact = injector_compact.inject_with_format(base, &memories);
|
||||||
|
assert!(result_compact.contains("[P]"));
|
||||||
|
}
|
||||||
|
}
|
||||||
143
crates/zclaw-growth/src/lib.rs
Normal file
143
crates/zclaw-growth/src/lib.rs
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
//! ZCLAW Agent Growth System
|
||||||
|
//!
|
||||||
|
//! This crate provides the agent growth functionality for ZCLAW,
|
||||||
|
//! enabling agents to learn and evolve from conversations.
|
||||||
|
//!
|
||||||
|
//! # Architecture
|
||||||
|
//!
|
||||||
|
//! The growth system consists of four main components:
|
||||||
|
//!
|
||||||
|
//! 1. **MemoryExtractor** (`extractor`) - Analyzes conversations and extracts
|
||||||
|
//! preferences, knowledge, and experience using LLM.
|
||||||
|
//!
|
||||||
|
//! 2. **MemoryRetriever** (`retriever`) - Performs semantic search over
|
||||||
|
//! stored memories to find contextually relevant information.
|
||||||
|
//!
|
||||||
|
//! 3. **PromptInjector** (`injector`) - Injects retrieved memories into
|
||||||
|
//! the system prompt with token budget control.
|
||||||
|
//!
|
||||||
|
//! 4. **GrowthTracker** (`tracker`) - Tracks growth metrics and evolution
|
||||||
|
//! over time.
|
||||||
|
//!
|
||||||
|
//! # Storage
|
||||||
|
//!
|
||||||
|
//! All memories are stored in OpenViking with a URI structure:
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! agent://{agent_id}/
|
||||||
|
//! ├── preferences/{category} - User preferences
|
||||||
|
//! ├── knowledge/{domain} - Accumulated knowledge
|
||||||
|
//! ├── experience/{skill} - Skill/tool experience
|
||||||
|
//! └── sessions/{session_id}/ - Conversation history
|
||||||
|
//! ├── raw - Original conversation (L0)
|
||||||
|
//! ├── summary - Summary (L1)
|
||||||
|
//! └── keywords - Keywords (L2)
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! # Usage
|
||||||
|
//!
|
||||||
|
//! ```rust,ignore
|
||||||
|
//! use zclaw_growth::{MemoryExtractor, MemoryRetriever, PromptInjector, VikingAdapter};
|
||||||
|
//!
|
||||||
|
//! // Create components
|
||||||
|
//! let viking = VikingAdapter::in_memory();
|
||||||
|
//! let retriever = MemoryRetriever::new(Arc::new(viking.clone()));
|
||||||
|
//! let injector = PromptInjector::new();
|
||||||
|
//!
|
||||||
|
//! // Before conversation: retrieve relevant memories
|
||||||
|
//! let memories = retriever.retrieve(&agent_id, &user_input).await?;
|
||||||
|
//!
|
||||||
|
//! // Inject into system prompt
|
||||||
|
//! let enhanced_prompt = injector.inject(&base_prompt, &memories);
|
||||||
|
//!
|
||||||
|
//! // After conversation: extract and store new memories
|
||||||
|
//! let extracted = extractor.extract(&messages, session_id).await?;
|
||||||
|
//! extractor.store_memories(&agent_id, &extracted).await?;
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
pub mod types;
|
||||||
|
pub mod extractor;
|
||||||
|
pub mod retriever;
|
||||||
|
pub mod injector;
|
||||||
|
pub mod tracker;
|
||||||
|
pub mod viking_adapter;
|
||||||
|
pub mod storage;
|
||||||
|
pub mod retrieval;
|
||||||
|
pub mod summarizer;
|
||||||
|
|
||||||
|
// Re-export main types for convenience
|
||||||
|
pub use types::{
|
||||||
|
ExtractedMemory,
|
||||||
|
ExtractionConfig,
|
||||||
|
GrowthStats,
|
||||||
|
MemoryEntry,
|
||||||
|
MemoryType,
|
||||||
|
RetrievalConfig,
|
||||||
|
RetrievalResult,
|
||||||
|
UriBuilder,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub use extractor::{LlmDriverForExtraction, MemoryExtractor};
|
||||||
|
pub use retriever::{MemoryRetriever, MemoryStats};
|
||||||
|
pub use injector::{InjectionFormat, PromptInjector};
|
||||||
|
pub use tracker::{AgentMetadata, GrowthTracker, LearningEvent};
|
||||||
|
pub use viking_adapter::{FindOptions, VikingAdapter, VikingLevel, VikingStorage};
|
||||||
|
pub use storage::SqliteStorage;
|
||||||
|
pub use retrieval::{EmbeddingClient, MemoryCache, QueryAnalyzer, SemanticScorer};
|
||||||
|
pub use summarizer::SummaryLlmDriver;
|
||||||
|
|
||||||
|
/// Growth system configuration
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct GrowthConfig {
|
||||||
|
/// Enable/disable growth system
|
||||||
|
pub enabled: bool,
|
||||||
|
/// Retrieval configuration
|
||||||
|
pub retrieval: RetrievalConfig,
|
||||||
|
/// Extraction configuration
|
||||||
|
pub extraction: ExtractionConfig,
|
||||||
|
/// Auto-extract after each conversation
|
||||||
|
pub auto_extract: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for GrowthConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
enabled: true,
|
||||||
|
retrieval: RetrievalConfig::default(),
|
||||||
|
extraction: ExtractionConfig::default(),
|
||||||
|
auto_extract: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convenience function to create a complete growth system
|
||||||
|
pub fn create_growth_system(
|
||||||
|
viking: std::sync::Arc<VikingAdapter>,
|
||||||
|
llm_driver: std::sync::Arc<dyn LlmDriverForExtraction>,
|
||||||
|
) -> (MemoryExtractor, MemoryRetriever, PromptInjector, GrowthTracker) {
|
||||||
|
let extractor = MemoryExtractor::new(llm_driver).with_viking(viking.clone());
|
||||||
|
let retriever = MemoryRetriever::new(viking.clone());
|
||||||
|
let injector = PromptInjector::new();
|
||||||
|
let tracker = GrowthTracker::new(viking);
|
||||||
|
|
||||||
|
(extractor, retriever, injector, tracker)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_growth_config_default() {
|
||||||
|
let config = GrowthConfig::default();
|
||||||
|
assert!(config.enabled);
|
||||||
|
assert!(config.auto_extract);
|
||||||
|
assert_eq!(config.retrieval.max_tokens, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_memory_type_reexport() {
|
||||||
|
let mt = MemoryType::Preference;
|
||||||
|
assert_eq!(format!("{}", mt), "preferences");
|
||||||
|
}
|
||||||
|
}
|
||||||
366
crates/zclaw-growth/src/retrieval/cache.rs
Normal file
366
crates/zclaw-growth/src/retrieval/cache.rs
Normal file
@@ -0,0 +1,366 @@
|
|||||||
|
//! Memory Cache
|
||||||
|
//!
|
||||||
|
//! Provides caching for frequently accessed memories to improve
|
||||||
|
//! retrieval performance.
|
||||||
|
|
||||||
|
use crate::types::{MemoryEntry, MemoryType};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
|
||||||
|
/// Cache entry with metadata
|
||||||
|
struct CacheEntry {
|
||||||
|
/// The memory entry
|
||||||
|
entry: MemoryEntry,
|
||||||
|
/// Last access time
|
||||||
|
last_accessed: Instant,
|
||||||
|
/// Access count
|
||||||
|
access_count: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cache key for efficient lookups (reserved for future cache optimization)
|
||||||
|
#[allow(dead_code)]
|
||||||
|
#[derive(Debug, Clone, Hash, Eq, PartialEq)]
|
||||||
|
struct CacheKey {
|
||||||
|
agent_id: String,
|
||||||
|
memory_type: MemoryType,
|
||||||
|
category: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&MemoryEntry> for CacheKey {
|
||||||
|
fn from(entry: &MemoryEntry) -> Self {
|
||||||
|
// Parse URI to extract components
|
||||||
|
let parts: Vec<&str> = entry.uri.trim_start_matches("agent://").split('/').collect();
|
||||||
|
Self {
|
||||||
|
agent_id: parts.first().unwrap_or(&"").to_string(),
|
||||||
|
memory_type: entry.memory_type,
|
||||||
|
category: parts.get(2).unwrap_or(&"").to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Memory cache configuration
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct CacheConfig {
|
||||||
|
/// Maximum number of entries
|
||||||
|
pub max_entries: usize,
|
||||||
|
/// Time-to-live for entries
|
||||||
|
pub ttl: Duration,
|
||||||
|
/// Enable/disable caching
|
||||||
|
pub enabled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for CacheConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
max_entries: 1000,
|
||||||
|
ttl: Duration::from_secs(3600), // 1 hour
|
||||||
|
enabled: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Memory cache for hot memories
|
||||||
|
pub struct MemoryCache {
|
||||||
|
/// Cache storage
|
||||||
|
cache: RwLock<HashMap<String, CacheEntry>>,
|
||||||
|
/// Configuration
|
||||||
|
config: CacheConfig,
|
||||||
|
/// Cache statistics
|
||||||
|
stats: RwLock<CacheStats>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cache statistics
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct CacheStats {
|
||||||
|
/// Total cache hits
|
||||||
|
pub hits: u64,
|
||||||
|
/// Total cache misses
|
||||||
|
pub misses: u64,
|
||||||
|
/// Total entries evicted
|
||||||
|
pub evictions: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MemoryCache {
|
||||||
|
/// Create a new memory cache
|
||||||
|
pub fn new(config: CacheConfig) -> Self {
|
||||||
|
Self {
|
||||||
|
cache: RwLock::new(HashMap::new()),
|
||||||
|
config,
|
||||||
|
stats: RwLock::new(CacheStats::default()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create with default configuration
|
||||||
|
pub fn default_config() -> Self {
|
||||||
|
Self::new(CacheConfig::default())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a memory from cache
|
||||||
|
pub async fn get(&self, uri: &str) -> Option<MemoryEntry> {
|
||||||
|
if !self.config.enabled {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut cache = self.cache.write().await;
|
||||||
|
|
||||||
|
if let Some(cached) = cache.get_mut(uri) {
|
||||||
|
// Check TTL
|
||||||
|
if cached.last_accessed.elapsed() > self.config.ttl {
|
||||||
|
cache.remove(uri);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update access metadata
|
||||||
|
cached.last_accessed = Instant::now();
|
||||||
|
cached.access_count += 1;
|
||||||
|
|
||||||
|
// Update stats
|
||||||
|
let mut stats = self.stats.write().await;
|
||||||
|
stats.hits += 1;
|
||||||
|
|
||||||
|
return Some(cached.entry.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update stats
|
||||||
|
let mut stats = self.stats.write().await;
|
||||||
|
stats.misses += 1;
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Put a memory into cache
|
||||||
|
pub async fn put(&self, entry: MemoryEntry) {
|
||||||
|
if !self.config.enabled {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut cache = self.cache.write().await;
|
||||||
|
|
||||||
|
// Check capacity and evict if necessary
|
||||||
|
if cache.len() >= self.config.max_entries {
|
||||||
|
self.evict_lru(&mut cache).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
cache.insert(
|
||||||
|
entry.uri.clone(),
|
||||||
|
CacheEntry {
|
||||||
|
entry,
|
||||||
|
last_accessed: Instant::now(),
|
||||||
|
access_count: 0,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove a memory from cache
|
||||||
|
pub async fn remove(&self, uri: &str) {
|
||||||
|
let mut cache = self.cache.write().await;
|
||||||
|
cache.remove(uri);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear the cache
|
||||||
|
pub async fn clear(&self) {
|
||||||
|
let mut cache = self.cache.write().await;
|
||||||
|
cache.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Evict least recently used entries
|
||||||
|
async fn evict_lru(&self, cache: &mut HashMap<String, CacheEntry>) {
|
||||||
|
// Find LRU entry
|
||||||
|
let lru_key = cache
|
||||||
|
.iter()
|
||||||
|
.min_by_key(|(_, v)| (v.access_count, v.last_accessed))
|
||||||
|
.map(|(k, _)| k.clone());
|
||||||
|
|
||||||
|
if let Some(key) = lru_key {
|
||||||
|
cache.remove(&key);
|
||||||
|
|
||||||
|
let mut stats = self.stats.write().await;
|
||||||
|
stats.evictions += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get cache statistics
|
||||||
|
pub async fn stats(&self) -> CacheStats {
|
||||||
|
self.stats.read().await.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get cache hit rate
|
||||||
|
pub async fn hit_rate(&self) -> f32 {
|
||||||
|
let stats = self.stats.read().await;
|
||||||
|
let total = stats.hits + stats.misses;
|
||||||
|
if total == 0 {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
stats.hits as f32 / total as f32
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get cache size
|
||||||
|
pub async fn size(&self) -> usize {
|
||||||
|
self.cache.read().await.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Warm up cache with frequently accessed entries
|
||||||
|
pub async fn warmup(&self, entries: Vec<MemoryEntry>) {
|
||||||
|
for entry in entries {
|
||||||
|
self.put(entry).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get top accessed entries (for preloading)
|
||||||
|
pub async fn get_hot_entries(&self, limit: usize) -> Vec<MemoryEntry> {
|
||||||
|
let cache = self.cache.read().await;
|
||||||
|
|
||||||
|
let mut entries: Vec<_> = cache
|
||||||
|
.values()
|
||||||
|
.map(|c| (c.access_count, c.entry.clone()))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
entries.sort_by(|a, b| b.0.cmp(&a.0));
|
||||||
|
entries.truncate(limit);
|
||||||
|
|
||||||
|
entries.into_iter().map(|(_, e)| e).collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::types::MemoryType;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_cache_put_and_get() {
|
||||||
|
let cache = MemoryCache::default_config();
|
||||||
|
let entry = MemoryEntry::new(
|
||||||
|
"test-agent",
|
||||||
|
MemoryType::Preference,
|
||||||
|
"style",
|
||||||
|
"User prefers concise responses".to_string(),
|
||||||
|
);
|
||||||
|
|
||||||
|
cache.put(entry.clone()).await;
|
||||||
|
let retrieved = cache.get(&entry.uri).await;
|
||||||
|
|
||||||
|
assert!(retrieved.is_some());
|
||||||
|
assert_eq!(retrieved.unwrap().content, "User prefers concise responses");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_cache_miss() {
|
||||||
|
let cache = MemoryCache::default_config();
|
||||||
|
let retrieved = cache.get("nonexistent").await;
|
||||||
|
|
||||||
|
assert!(retrieved.is_none());
|
||||||
|
|
||||||
|
let stats = cache.stats().await;
|
||||||
|
assert_eq!(stats.misses, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_cache_remove() {
|
||||||
|
let cache = MemoryCache::default_config();
|
||||||
|
let entry = MemoryEntry::new(
|
||||||
|
"test-agent",
|
||||||
|
MemoryType::Preference,
|
||||||
|
"style",
|
||||||
|
"test".to_string(),
|
||||||
|
);
|
||||||
|
|
||||||
|
cache.put(entry.clone()).await;
|
||||||
|
cache.remove(&entry.uri).await;
|
||||||
|
let retrieved = cache.get(&entry.uri).await;
|
||||||
|
|
||||||
|
assert!(retrieved.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_cache_clear() {
|
||||||
|
let cache = MemoryCache::default_config();
|
||||||
|
let entry = MemoryEntry::new(
|
||||||
|
"test-agent",
|
||||||
|
MemoryType::Preference,
|
||||||
|
"style",
|
||||||
|
"test".to_string(),
|
||||||
|
);
|
||||||
|
|
||||||
|
cache.put(entry).await;
|
||||||
|
cache.clear().await;
|
||||||
|
let size = cache.size().await;
|
||||||
|
|
||||||
|
assert_eq!(size, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_cache_stats() {
|
||||||
|
let cache = MemoryCache::default_config();
|
||||||
|
let entry = MemoryEntry::new(
|
||||||
|
"test-agent",
|
||||||
|
MemoryType::Preference,
|
||||||
|
"style",
|
||||||
|
"test".to_string(),
|
||||||
|
);
|
||||||
|
|
||||||
|
cache.put(entry.clone()).await;
|
||||||
|
|
||||||
|
// Hit
|
||||||
|
cache.get(&entry.uri).await;
|
||||||
|
// Miss
|
||||||
|
cache.get("nonexistent").await;
|
||||||
|
|
||||||
|
let stats = cache.stats().await;
|
||||||
|
assert_eq!(stats.hits, 1);
|
||||||
|
assert_eq!(stats.misses, 1);
|
||||||
|
|
||||||
|
let hit_rate = cache.hit_rate().await;
|
||||||
|
assert!((hit_rate - 0.5).abs() < 0.001);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_cache_eviction() {
|
||||||
|
let config = CacheConfig {
|
||||||
|
max_entries: 2,
|
||||||
|
ttl: Duration::from_secs(3600),
|
||||||
|
enabled: true,
|
||||||
|
};
|
||||||
|
let cache = MemoryCache::new(config);
|
||||||
|
|
||||||
|
let entry1 = MemoryEntry::new("test", MemoryType::Preference, "1", "1".to_string());
|
||||||
|
let entry2 = MemoryEntry::new("test", MemoryType::Preference, "2", "2".to_string());
|
||||||
|
let entry3 = MemoryEntry::new("test", MemoryType::Preference, "3", "3".to_string());
|
||||||
|
|
||||||
|
cache.put(entry1.clone()).await;
|
||||||
|
cache.put(entry2.clone()).await;
|
||||||
|
|
||||||
|
// Access entry1 to make it hot
|
||||||
|
cache.get(&entry1.uri).await;
|
||||||
|
|
||||||
|
// Add entry3, should evict entry2 (LRU)
|
||||||
|
cache.put(entry3).await;
|
||||||
|
|
||||||
|
let size = cache.size().await;
|
||||||
|
assert_eq!(size, 2);
|
||||||
|
|
||||||
|
let stats = cache.stats().await;
|
||||||
|
assert_eq!(stats.evictions, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_get_hot_entries() {
|
||||||
|
let cache = MemoryCache::default_config();
|
||||||
|
|
||||||
|
let entry1 = MemoryEntry::new("test", MemoryType::Preference, "1", "1".to_string());
|
||||||
|
let entry2 = MemoryEntry::new("test", MemoryType::Preference, "2", "2".to_string());
|
||||||
|
|
||||||
|
cache.put(entry1.clone()).await;
|
||||||
|
cache.put(entry2.clone()).await;
|
||||||
|
|
||||||
|
// Access entry1 multiple times
|
||||||
|
cache.get(&entry1.uri).await;
|
||||||
|
cache.get(&entry1.uri).await;
|
||||||
|
|
||||||
|
let hot = cache.get_hot_entries(10).await;
|
||||||
|
assert_eq!(hot.len(), 2);
|
||||||
|
// entry1 should be first (more accesses)
|
||||||
|
assert_eq!(hot[0].uri, entry1.uri);
|
||||||
|
}
|
||||||
|
}
|
||||||
14
crates/zclaw-growth/src/retrieval/mod.rs
Normal file
14
crates/zclaw-growth/src/retrieval/mod.rs
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
//! Retrieval components for ZCLAW Growth System
|
||||||
|
//!
|
||||||
|
//! This module provides advanced retrieval capabilities:
|
||||||
|
//! - `semantic`: Semantic similarity computation
|
||||||
|
//! - `query`: Query analysis and expansion
|
||||||
|
//! - `cache`: Hot memory caching
|
||||||
|
|
||||||
|
pub mod semantic;
|
||||||
|
pub mod query;
|
||||||
|
pub mod cache;
|
||||||
|
|
||||||
|
pub use semantic::{EmbeddingClient, SemanticScorer};
|
||||||
|
pub use query::QueryAnalyzer;
|
||||||
|
pub use cache::MemoryCache;
|
||||||
352
crates/zclaw-growth/src/retrieval/query.rs
Normal file
352
crates/zclaw-growth/src/retrieval/query.rs
Normal file
@@ -0,0 +1,352 @@
|
|||||||
|
//! Query Analyzer
|
||||||
|
//!
|
||||||
|
//! Provides query analysis and expansion capabilities for improved retrieval.
|
||||||
|
//! Extracts keywords, identifies intent, and generates search variations.
|
||||||
|
|
||||||
|
use crate::types::MemoryType;
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
/// Query analysis result
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct AnalyzedQuery {
|
||||||
|
/// Original query string
|
||||||
|
pub original: String,
|
||||||
|
/// Extracted keywords
|
||||||
|
pub keywords: Vec<String>,
|
||||||
|
/// Query intent
|
||||||
|
pub intent: QueryIntent,
|
||||||
|
/// Memory types to search (inferred from query)
|
||||||
|
pub target_types: Vec<MemoryType>,
|
||||||
|
/// Expanded search terms
|
||||||
|
pub expansions: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Query intent classification
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum QueryIntent {
|
||||||
|
/// Looking for preferences/settings
|
||||||
|
Preference,
|
||||||
|
/// Looking for factual knowledge
|
||||||
|
Knowledge,
|
||||||
|
/// Looking for how-to/experience
|
||||||
|
Experience,
|
||||||
|
/// General conversation
|
||||||
|
General,
|
||||||
|
/// Code-related query
|
||||||
|
Code,
|
||||||
|
/// Configuration query
|
||||||
|
Configuration,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Query analyzer
|
||||||
|
pub struct QueryAnalyzer {
|
||||||
|
/// Keywords that indicate preference queries
|
||||||
|
preference_indicators: HashSet<String>,
|
||||||
|
/// Keywords that indicate knowledge queries
|
||||||
|
knowledge_indicators: HashSet<String>,
|
||||||
|
/// Keywords that indicate experience queries
|
||||||
|
experience_indicators: HashSet<String>,
|
||||||
|
/// Keywords that indicate code queries
|
||||||
|
code_indicators: HashSet<String>,
|
||||||
|
/// Stop words to filter out
|
||||||
|
stop_words: HashSet<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl QueryAnalyzer {
|
||||||
|
/// Create a new query analyzer
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
preference_indicators: [
|
||||||
|
"prefer", "like", "want", "favorite", "favourite", "style",
|
||||||
|
"format", "language", "setting", "preference", "usually",
|
||||||
|
"typically", "always", "never", "习惯", "偏好", "喜欢", "想要",
|
||||||
|
]
|
||||||
|
.iter()
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.collect(),
|
||||||
|
knowledge_indicators: [
|
||||||
|
"what", "how", "why", "explain", "tell", "know", "learn",
|
||||||
|
"understand", "meaning", "definition", "concept", "theory",
|
||||||
|
"是什么", "怎么", "为什么", "解释", "了解", "知道",
|
||||||
|
]
|
||||||
|
.iter()
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.collect(),
|
||||||
|
experience_indicators: [
|
||||||
|
"experience", "tried", "used", "before", "last time",
|
||||||
|
"previous", "history", "remember", "recall", "when",
|
||||||
|
"经验", "尝试", "用过", "上次", "记得", "回忆",
|
||||||
|
]
|
||||||
|
.iter()
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.collect(),
|
||||||
|
code_indicators: [
|
||||||
|
"code", "function", "class", "method", "variable", "type",
|
||||||
|
"error", "bug", "fix", "implement", "refactor", "api",
|
||||||
|
"代码", "函数", "类", "方法", "变量", "错误", "修复", "实现",
|
||||||
|
]
|
||||||
|
.iter()
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.collect(),
|
||||||
|
stop_words: [
|
||||||
|
"the", "a", "an", "is", "are", "was", "were", "be", "been",
|
||||||
|
"have", "has", "had", "do", "does", "did", "will", "would",
|
||||||
|
"could", "should", "may", "might", "must", "can", "to", "of",
|
||||||
|
"in", "for", "on", "with", "at", "by", "from", "as", "and",
|
||||||
|
"or", "but", "if", "then", "else", "when", "where", "which",
|
||||||
|
"who", "whom", "whose", "this", "that", "these", "those",
|
||||||
|
]
|
||||||
|
.iter()
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Analyze a query string
|
||||||
|
pub fn analyze(&self, query: &str) -> AnalyzedQuery {
|
||||||
|
let keywords = self.extract_keywords(query);
|
||||||
|
let intent = self.classify_intent(&keywords);
|
||||||
|
let target_types = self.infer_memory_types(intent, &keywords);
|
||||||
|
let expansions = self.expand_query(&keywords);
|
||||||
|
|
||||||
|
AnalyzedQuery {
|
||||||
|
original: query.to_string(),
|
||||||
|
keywords,
|
||||||
|
intent,
|
||||||
|
target_types,
|
||||||
|
expansions,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract keywords from query
|
||||||
|
fn extract_keywords(&self, query: &str) -> Vec<String> {
|
||||||
|
query
|
||||||
|
.to_lowercase()
|
||||||
|
.split(|c: char| !c.is_alphanumeric() && !is_cjk(c))
|
||||||
|
.filter(|s| !s.is_empty() && s.len() > 1)
|
||||||
|
.filter(|s| !self.stop_words.contains(*s))
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Classify query intent
|
||||||
|
fn classify_intent(&self, keywords: &[String]) -> QueryIntent {
|
||||||
|
let mut scores = [
|
||||||
|
(QueryIntent::Preference, 0),
|
||||||
|
(QueryIntent::Knowledge, 0),
|
||||||
|
(QueryIntent::Experience, 0),
|
||||||
|
(QueryIntent::Code, 0),
|
||||||
|
];
|
||||||
|
|
||||||
|
for keyword in keywords {
|
||||||
|
if self.preference_indicators.contains(keyword) {
|
||||||
|
scores[0].1 += 2;
|
||||||
|
}
|
||||||
|
if self.knowledge_indicators.contains(keyword) {
|
||||||
|
scores[1].1 += 2;
|
||||||
|
}
|
||||||
|
if self.experience_indicators.contains(keyword) {
|
||||||
|
scores[2].1 += 2;
|
||||||
|
}
|
||||||
|
if self.code_indicators.contains(keyword) {
|
||||||
|
scores[3].1 += 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find highest scoring intent
|
||||||
|
scores.sort_by(|a, b| b.1.cmp(&a.1));
|
||||||
|
|
||||||
|
if scores[0].1 > 0 {
|
||||||
|
scores[0].0
|
||||||
|
} else {
|
||||||
|
QueryIntent::General
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Infer which memory types to search
|
||||||
|
fn infer_memory_types(&self, intent: QueryIntent, _keywords: &[String]) -> Vec<MemoryType> {
|
||||||
|
let mut types = Vec::new();
|
||||||
|
|
||||||
|
match intent {
|
||||||
|
QueryIntent::Preference => {
|
||||||
|
types.push(MemoryType::Preference);
|
||||||
|
}
|
||||||
|
QueryIntent::Knowledge | QueryIntent::Code => {
|
||||||
|
types.push(MemoryType::Knowledge);
|
||||||
|
types.push(MemoryType::Experience);
|
||||||
|
}
|
||||||
|
QueryIntent::Experience => {
|
||||||
|
types.push(MemoryType::Experience);
|
||||||
|
types.push(MemoryType::Knowledge);
|
||||||
|
}
|
||||||
|
QueryIntent::General => {
|
||||||
|
// Search all types
|
||||||
|
types.push(MemoryType::Preference);
|
||||||
|
types.push(MemoryType::Knowledge);
|
||||||
|
types.push(MemoryType::Experience);
|
||||||
|
}
|
||||||
|
QueryIntent::Configuration => {
|
||||||
|
types.push(MemoryType::Preference);
|
||||||
|
types.push(MemoryType::Knowledge);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
types
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Expand query with related terms
|
||||||
|
fn expand_query(&self, keywords: &[String]) -> Vec<String> {
|
||||||
|
let mut expansions = Vec::new();
|
||||||
|
|
||||||
|
// Add stemmed variations (simplified)
|
||||||
|
for keyword in keywords {
|
||||||
|
// Add singular/plural variations
|
||||||
|
if keyword.ends_with('s') && keyword.len() > 3 {
|
||||||
|
expansions.push(keyword[..keyword.len()-1].to_string());
|
||||||
|
} else {
|
||||||
|
expansions.push(format!("{}s", keyword));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add common synonyms (simplified)
|
||||||
|
if let Some(synonyms) = self.get_synonyms(keyword) {
|
||||||
|
expansions.extend(synonyms);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expansions
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get synonyms for a keyword (simplified)
|
||||||
|
fn get_synonyms(&self, keyword: &str) -> Option<Vec<String>> {
|
||||||
|
let synonyms: &[&str] = match keyword {
|
||||||
|
"code" => &["program", "script", "source"],
|
||||||
|
"error" => &["bug", "issue", "problem", "exception"],
|
||||||
|
"fix" => &["solve", "resolve", "repair", "patch"],
|
||||||
|
"fast" => &["quick", "speed", "performance", "efficient"],
|
||||||
|
"slow" => &["performance", "optimize", "speed"],
|
||||||
|
"help" => &["assist", "support", "guide", "aid"],
|
||||||
|
"learn" => &["study", "understand", "know", "grasp"],
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
|
||||||
|
Some(synonyms.iter().map(|s| s.to_string()).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate search queries from analyzed query
|
||||||
|
pub fn generate_search_queries(&self, analyzed: &AnalyzedQuery) -> Vec<String> {
|
||||||
|
let mut queries = vec![analyzed.original.clone()];
|
||||||
|
|
||||||
|
// Add keyword-based query
|
||||||
|
if !analyzed.keywords.is_empty() {
|
||||||
|
queries.push(analyzed.keywords.join(" "));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add expanded terms
|
||||||
|
for expansion in &analyzed.expansions {
|
||||||
|
if !expansion.is_empty() {
|
||||||
|
queries.push(expansion.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deduplicate
|
||||||
|
queries.sort();
|
||||||
|
queries.dedup();
|
||||||
|
|
||||||
|
queries
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for QueryAnalyzer {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if character is CJK
|
||||||
|
fn is_cjk(c: char) -> bool {
|
||||||
|
matches!(c,
|
||||||
|
'\u{4E00}'..='\u{9FFF}' | // CJK Unified Ideographs
|
||||||
|
'\u{3400}'..='\u{4DBF}' | // CJK Unified Ideographs Extension A
|
||||||
|
'\u{20000}'..='\u{2A6DF}' | // CJK Unified Ideographs Extension B
|
||||||
|
'\u{2A700}'..='\u{2B73F}' | // CJK Unified Ideographs Extension C
|
||||||
|
'\u{2B740}'..='\u{2B81F}' | // CJK Unified Ideographs Extension D
|
||||||
|
'\u{2B820}'..='\u{2CEAF}' | // CJK Unified Ideographs Extension E
|
||||||
|
'\u{F900}'..='\u{FAFF}' | // CJK Compatibility Ideographs
|
||||||
|
'\u{2F800}'..='\u{2FA1F}' // CJK Compatibility Ideographs Supplement
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_keywords() {
|
||||||
|
let analyzer = QueryAnalyzer::new();
|
||||||
|
let keywords = analyzer.extract_keywords("What is the Rust programming language?");
|
||||||
|
|
||||||
|
assert!(keywords.contains(&"rust".to_string()));
|
||||||
|
assert!(keywords.contains(&"programming".to_string()));
|
||||||
|
assert!(keywords.contains(&"language".to_string()));
|
||||||
|
assert!(!keywords.contains(&"the".to_string())); // stop word
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_classify_intent_preference() {
|
||||||
|
let analyzer = QueryAnalyzer::new();
|
||||||
|
let analyzed = analyzer.analyze("I prefer concise responses");
|
||||||
|
|
||||||
|
assert_eq!(analyzed.intent, QueryIntent::Preference);
|
||||||
|
assert!(analyzed.target_types.contains(&MemoryType::Preference));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_classify_intent_knowledge() {
|
||||||
|
let analyzer = QueryAnalyzer::new();
|
||||||
|
let analyzed = analyzer.analyze("Explain how async/await works in Rust");
|
||||||
|
|
||||||
|
assert_eq!(analyzed.intent, QueryIntent::Knowledge);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_classify_intent_code() {
|
||||||
|
let analyzer = QueryAnalyzer::new();
|
||||||
|
let analyzed = analyzer.analyze("Fix this error in my function");
|
||||||
|
|
||||||
|
assert_eq!(analyzed.intent, QueryIntent::Code);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_query_expansion() {
|
||||||
|
let analyzer = QueryAnalyzer::new();
|
||||||
|
let analyzed = analyzer.analyze("fix the error");
|
||||||
|
|
||||||
|
assert!(!analyzed.expansions.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_generate_search_queries() {
|
||||||
|
let analyzer = QueryAnalyzer::new();
|
||||||
|
let analyzed = analyzer.analyze("Rust programming");
|
||||||
|
let queries = analyzer.generate_search_queries(&analyzed);
|
||||||
|
|
||||||
|
assert!(queries.len() >= 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_cjk_detection() {
|
||||||
|
assert!(is_cjk('中'));
|
||||||
|
assert!(is_cjk('文'));
|
||||||
|
assert!(!is_cjk('a'));
|
||||||
|
assert!(!is_cjk('1'));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_chinese_keywords() {
|
||||||
|
let analyzer = QueryAnalyzer::new();
|
||||||
|
let keywords = analyzer.extract_keywords("我喜欢简洁的回复");
|
||||||
|
|
||||||
|
// Chinese characters should be extracted
|
||||||
|
assert!(!keywords.is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
521
crates/zclaw-growth/src/retrieval/semantic.rs
Normal file
521
crates/zclaw-growth/src/retrieval/semantic.rs
Normal file
@@ -0,0 +1,521 @@
|
|||||||
|
//! Semantic Similarity Scorer
|
||||||
|
//!
|
||||||
|
//! Provides TF-IDF based semantic similarity computation for memory retrieval.
|
||||||
|
//! This is a lightweight, dependency-free implementation suitable for
|
||||||
|
//! medium-scale memory systems.
|
||||||
|
//!
|
||||||
|
//! Supports optional embedding API integration for improved semantic search.
|
||||||
|
|
||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use crate::types::MemoryEntry;
|
||||||
|
|
||||||
|
/// Embedding client trait for API integration
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
pub trait EmbeddingClient: Send + Sync {
|
||||||
|
async fn embed(&self, text: &str) -> Result<Vec<f32>, String>;
|
||||||
|
fn is_available(&self) -> bool;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// No-op embedding client (uses TF-IDF only)
|
||||||
|
pub struct NoOpEmbeddingClient;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl EmbeddingClient for NoOpEmbeddingClient {
|
||||||
|
async fn embed(&self, _text: &str) -> Result<Vec<f32>, String> {
|
||||||
|
Err("Embedding not configured".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_available(&self) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Semantic similarity scorer using TF-IDF with optional embedding support
|
||||||
|
pub struct SemanticScorer {
|
||||||
|
/// Document frequency for IDF computation
|
||||||
|
document_frequencies: HashMap<String, usize>,
|
||||||
|
/// Total number of documents
|
||||||
|
total_documents: usize,
|
||||||
|
/// Precomputed TF-IDF vectors for entries
|
||||||
|
entry_vectors: HashMap<String, HashMap<String, f32>>,
|
||||||
|
/// Precomputed embedding vectors for entries
|
||||||
|
entry_embeddings: HashMap<String, Vec<f32>>,
|
||||||
|
/// Stop words to ignore
|
||||||
|
stop_words: HashSet<String>,
|
||||||
|
/// Optional embedding client
|
||||||
|
embedding_client: Arc<dyn EmbeddingClient>,
|
||||||
|
/// Whether to use embedding for similarity
|
||||||
|
use_embedding: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SemanticScorer {
|
||||||
|
/// Create a new semantic scorer
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
document_frequencies: HashMap::new(),
|
||||||
|
total_documents: 0,
|
||||||
|
entry_vectors: HashMap::new(),
|
||||||
|
entry_embeddings: HashMap::new(),
|
||||||
|
stop_words: Self::default_stop_words(),
|
||||||
|
embedding_client: Arc::new(NoOpEmbeddingClient),
|
||||||
|
use_embedding: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new semantic scorer with embedding client
|
||||||
|
pub fn with_embedding(client: Arc<dyn EmbeddingClient>) -> Self {
|
||||||
|
Self {
|
||||||
|
document_frequencies: HashMap::new(),
|
||||||
|
total_documents: 0,
|
||||||
|
entry_vectors: HashMap::new(),
|
||||||
|
entry_embeddings: HashMap::new(),
|
||||||
|
stop_words: Self::default_stop_words(),
|
||||||
|
embedding_client: client,
|
||||||
|
use_embedding: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set whether to use embedding for similarity
|
||||||
|
pub fn set_use_embedding(&mut self, use_embedding: bool) {
|
||||||
|
self.use_embedding = use_embedding && self.embedding_client.is_available();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if embedding is available
|
||||||
|
pub fn is_embedding_available(&self) -> bool {
|
||||||
|
self.embedding_client.is_available()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the embedding client
|
||||||
|
pub fn get_embedding_client(&self) -> Arc<dyn EmbeddingClient> {
|
||||||
|
self.embedding_client.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get default stop words
|
||||||
|
fn default_stop_words() -> HashSet<String> {
|
||||||
|
[
|
||||||
|
"the", "a", "an", "is", "are", "was", "were", "be", "been", "being",
|
||||||
|
"have", "has", "had", "do", "does", "did", "will", "would", "could",
|
||||||
|
"should", "may", "might", "must", "shall", "can", "need", "dare",
|
||||||
|
"ought", "used", "to", "of", "in", "for", "on", "with", "at", "by",
|
||||||
|
"from", "as", "into", "through", "during", "before", "after",
|
||||||
|
"above", "below", "between", "under", "again", "further", "then",
|
||||||
|
"once", "here", "there", "when", "where", "why", "how", "all",
|
||||||
|
"each", "few", "more", "most", "other", "some", "such", "no", "nor",
|
||||||
|
"not", "only", "own", "same", "so", "than", "too", "very", "just",
|
||||||
|
"and", "but", "if", "or", "because", "until", "while", "although",
|
||||||
|
"though", "after", "before", "when", "whenever", "i", "you", "he",
|
||||||
|
"she", "it", "we", "they", "what", "which", "who", "whom", "this",
|
||||||
|
"that", "these", "those", "am", "im", "youre", "hes", "shes",
|
||||||
|
"its", "were", "theyre", "ive", "youve", "weve", "theyve", "id",
|
||||||
|
"youd", "hed", "shed", "wed", "theyd", "ill", "youll", "hell",
|
||||||
|
"shell", "well", "theyll", "isnt", "arent", "wasnt", "werent",
|
||||||
|
"hasnt", "havent", "hadnt", "doesnt", "dont", "didnt", "wont",
|
||||||
|
"wouldnt", "shant", "shouldnt", "cant", "cannot", "couldnt",
|
||||||
|
"mustnt", "lets", "thats", "whos", "whats", "heres", "theres",
|
||||||
|
"whens", "wheres", "whys", "hows", "a", "b", "c", "d", "e", "f",
|
||||||
|
"g", "h", "i", "j", "k", "l", "m", "n", "o", "p", "q", "r", "s",
|
||||||
|
"t", "u", "v", "w", "x", "y", "z",
|
||||||
|
]
|
||||||
|
.iter()
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tokenize text into words
|
||||||
|
fn tokenize(text: &str) -> Vec<String> {
|
||||||
|
text.to_lowercase()
|
||||||
|
.split(|c: char| !c.is_alphanumeric())
|
||||||
|
.filter(|s| !s.is_empty() && s.len() > 1)
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove stop words from tokens
|
||||||
|
fn remove_stop_words(&self, tokens: &[String]) -> Vec<String> {
|
||||||
|
tokens
|
||||||
|
.iter()
|
||||||
|
.filter(|t| !self.stop_words.contains(*t))
|
||||||
|
.cloned()
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute term frequency for a list of tokens
|
||||||
|
fn compute_tf(tokens: &[String]) -> HashMap<String, f32> {
|
||||||
|
let mut tf = HashMap::new();
|
||||||
|
let total = tokens.len() as f32;
|
||||||
|
|
||||||
|
for token in tokens {
|
||||||
|
*tf.entry(token.clone()).or_insert(0.0) += 1.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize by total tokens
|
||||||
|
for count in tf.values_mut() {
|
||||||
|
*count /= total;
|
||||||
|
}
|
||||||
|
|
||||||
|
tf
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute IDF for a term
|
||||||
|
fn compute_idf(&self, term: &str) -> f32 {
|
||||||
|
let df = self.document_frequencies.get(term).copied().unwrap_or(0);
|
||||||
|
if df == 0 || self.total_documents == 0 {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
((self.total_documents as f32 + 1.0) / (df as f32 + 1.0)).ln() + 1.0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Index an entry for semantic search
|
||||||
|
pub fn index_entry(&mut self, entry: &MemoryEntry) {
|
||||||
|
// Tokenize content and keywords
|
||||||
|
let mut all_tokens = Self::tokenize(&entry.content);
|
||||||
|
for keyword in &entry.keywords {
|
||||||
|
all_tokens.extend(Self::tokenize(keyword));
|
||||||
|
}
|
||||||
|
all_tokens = self.remove_stop_words(&all_tokens);
|
||||||
|
|
||||||
|
// Update document frequencies
|
||||||
|
let unique_terms: HashSet<_> = all_tokens.iter().cloned().collect();
|
||||||
|
for term in &unique_terms {
|
||||||
|
*self.document_frequencies.entry(term.clone()).or_insert(0) += 1;
|
||||||
|
}
|
||||||
|
self.total_documents += 1;
|
||||||
|
|
||||||
|
// Compute TF-IDF vector
|
||||||
|
let tf = Self::compute_tf(&all_tokens);
|
||||||
|
let mut tfidf = HashMap::new();
|
||||||
|
for (term, tf_val) in tf {
|
||||||
|
let idf = self.compute_idf(&term);
|
||||||
|
tfidf.insert(term, tf_val * idf);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.entry_vectors.insert(entry.uri.clone(), tfidf);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Index an entry with embedding (async)
|
||||||
|
pub async fn index_entry_with_embedding(&mut self, entry: &MemoryEntry) {
|
||||||
|
// First do TF-IDF indexing
|
||||||
|
self.index_entry(entry);
|
||||||
|
|
||||||
|
// Then compute embedding if available
|
||||||
|
if self.use_embedding && self.embedding_client.is_available() {
|
||||||
|
let text_to_embed = if !entry.keywords.is_empty() {
|
||||||
|
format!("{} {}", entry.content, entry.keywords.join(" "))
|
||||||
|
} else {
|
||||||
|
entry.content.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
match self.embedding_client.embed(&text_to_embed).await {
|
||||||
|
Ok(embedding) => {
|
||||||
|
self.entry_embeddings.insert(entry.uri.clone(), embedding);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("[SemanticScorer] Failed to compute embedding for {}: {}", entry.uri, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove an entry from the index
|
||||||
|
pub fn remove_entry(&mut self, uri: &str) {
|
||||||
|
self.entry_vectors.remove(uri);
|
||||||
|
self.entry_embeddings.remove(uri);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute cosine similarity between two vectors
|
||||||
|
fn cosine_similarity(v1: &HashMap<String, f32>, v2: &HashMap<String, f32>) -> f32 {
|
||||||
|
if v1.is_empty() || v2.is_empty() {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find common keys
|
||||||
|
let mut dot_product = 0.0;
|
||||||
|
let mut norm1 = 0.0;
|
||||||
|
let mut norm2 = 0.0;
|
||||||
|
|
||||||
|
for (k, v) in v1 {
|
||||||
|
norm1 += v * v;
|
||||||
|
if let Some(v2_val) = v2.get(k) {
|
||||||
|
dot_product += v * v2_val;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for v in v2.values() {
|
||||||
|
norm2 += v * v;
|
||||||
|
}
|
||||||
|
|
||||||
|
let denom = (norm1 * norm2).sqrt();
|
||||||
|
if denom == 0.0 {
|
||||||
|
0.0
|
||||||
|
} else {
|
||||||
|
(dot_product / denom).clamp(0.0, 1.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get pre-computed embedding for an entry
|
||||||
|
pub fn get_entry_embedding(&self, uri: &str) -> Option<Vec<f32>> {
|
||||||
|
self.entry_embeddings.get(uri).cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute cosine similarity between two embedding vectors
|
||||||
|
pub fn cosine_similarity_embedding(v1: &[f32], v2: &[f32]) -> f32 {
|
||||||
|
if v1.is_empty() || v2.is_empty() || v1.len() != v2.len() {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut dot_product = 0.0;
|
||||||
|
let mut norm1 = 0.0;
|
||||||
|
let mut norm2 = 0.0;
|
||||||
|
|
||||||
|
for i in 0..v1.len() {
|
||||||
|
dot_product += v1[i] * v2[i];
|
||||||
|
norm1 += v1[i] * v1[i];
|
||||||
|
norm2 += v2[i] * v2[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
let denom = (norm1 * norm2).sqrt();
|
||||||
|
if denom == 0.0 {
|
||||||
|
0.0
|
||||||
|
} else {
|
||||||
|
(dot_product / denom).clamp(0.0, 1.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Score similarity between query and entry using embedding (async)
|
||||||
|
pub async fn score_similarity_with_embedding(&self, query: &str, entry: &MemoryEntry) -> f32 {
|
||||||
|
// If we have precomputed embedding for this entry and embedding is enabled
|
||||||
|
if self.use_embedding && self.embedding_client.is_available() {
|
||||||
|
if let Some(entry_embedding) = self.entry_embeddings.get(&entry.uri) {
|
||||||
|
// Compute query embedding
|
||||||
|
match self.embedding_client.embed(query).await {
|
||||||
|
Ok(query_embedding) => {
|
||||||
|
let embedding_score = Self::cosine_similarity_embedding(&query_embedding, entry_embedding);
|
||||||
|
|
||||||
|
// Also compute TF-IDF score for hybrid approach
|
||||||
|
let tfidf_score = self.score_similarity(query, entry);
|
||||||
|
|
||||||
|
// Weighted combination: 70% embedding, 30% TF-IDF
|
||||||
|
return embedding_score * 0.7 + tfidf_score * 0.3;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::debug!("[SemanticScorer] Failed to embed query: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to TF-IDF
|
||||||
|
self.score_similarity(query, entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Score similarity between query and entry
|
||||||
|
pub fn score_similarity(&self, query: &str, entry: &MemoryEntry) -> f32 {
|
||||||
|
// Tokenize query
|
||||||
|
let query_tokens = self.remove_stop_words(&Self::tokenize(query));
|
||||||
|
if query_tokens.is_empty() {
|
||||||
|
return 0.5; // Neutral score for empty query
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute query TF-IDF
|
||||||
|
let query_tf = Self::compute_tf(&query_tokens);
|
||||||
|
let mut query_vec = HashMap::new();
|
||||||
|
for (term, tf_val) in query_tf {
|
||||||
|
let idf = self.compute_idf(&term);
|
||||||
|
query_vec.insert(term, tf_val * idf);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get entry vector
|
||||||
|
let entry_vec = match self.entry_vectors.get(&entry.uri) {
|
||||||
|
Some(v) => v,
|
||||||
|
None => {
|
||||||
|
// Fall back to simple matching if not indexed
|
||||||
|
return self.fallback_similarity(&query_tokens, entry);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Compute cosine similarity
|
||||||
|
let cosine = Self::cosine_similarity(&query_vec, entry_vec);
|
||||||
|
|
||||||
|
// Combine with keyword matching for better results
|
||||||
|
let keyword_boost = self.keyword_match_score(&query_tokens, entry);
|
||||||
|
|
||||||
|
// Weighted combination
|
||||||
|
cosine * 0.7 + keyword_boost * 0.3
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fallback similarity when entry is not indexed
|
||||||
|
fn fallback_similarity(&self, query_tokens: &[String], entry: &MemoryEntry) -> f32 {
|
||||||
|
let content_lower = entry.content.to_lowercase();
|
||||||
|
let mut matches = 0;
|
||||||
|
|
||||||
|
for token in query_tokens {
|
||||||
|
if content_lower.contains(token) {
|
||||||
|
matches += 1;
|
||||||
|
}
|
||||||
|
for keyword in &entry.keywords {
|
||||||
|
if keyword.to_lowercase().contains(token) {
|
||||||
|
matches += 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(matches as f32) / (query_tokens.len() * 2).max(1) as f32
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute keyword match score
|
||||||
|
fn keyword_match_score(&self, query_tokens: &[String], entry: &MemoryEntry) -> f32 {
|
||||||
|
if entry.keywords.is_empty() {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut matches = 0;
|
||||||
|
for token in query_tokens {
|
||||||
|
for keyword in &entry.keywords {
|
||||||
|
if keyword.to_lowercase().contains(&token.to_lowercase()) {
|
||||||
|
matches += 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(matches as f32) / query_tokens.len().max(1) as f32
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear the index
|
||||||
|
pub fn clear(&mut self) {
|
||||||
|
self.document_frequencies.clear();
|
||||||
|
self.total_documents = 0;
|
||||||
|
self.entry_vectors.clear();
|
||||||
|
self.entry_embeddings.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get statistics about the index
|
||||||
|
pub fn stats(&self) -> IndexStats {
|
||||||
|
IndexStats {
|
||||||
|
total_documents: self.total_documents,
|
||||||
|
unique_terms: self.document_frequencies.len(),
|
||||||
|
indexed_entries: self.entry_vectors.len(),
|
||||||
|
embedding_entries: self.entry_embeddings.len(),
|
||||||
|
use_embedding: self.use_embedding && self.embedding_client.is_available(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for SemanticScorer {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Index statistics
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct IndexStats {
|
||||||
|
pub total_documents: usize,
|
||||||
|
pub unique_terms: usize,
|
||||||
|
pub indexed_entries: usize,
|
||||||
|
pub embedding_entries: usize,
|
||||||
|
pub use_embedding: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::types::MemoryType;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_tokenize() {
|
||||||
|
let tokens = SemanticScorer::tokenize("Hello, World! This is a test.");
|
||||||
|
assert_eq!(tokens, vec!["hello", "world", "this", "is", "test"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_stop_words_removal() {
|
||||||
|
let scorer = SemanticScorer::new();
|
||||||
|
let tokens = vec!["hello".to_string(), "the".to_string(), "world".to_string()];
|
||||||
|
let filtered = scorer.remove_stop_words(&tokens);
|
||||||
|
assert_eq!(filtered, vec!["hello", "world"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_tf_computation() {
|
||||||
|
let tokens = vec!["hello".to_string(), "hello".to_string(), "world".to_string()];
|
||||||
|
let tf = SemanticScorer::compute_tf(&tokens);
|
||||||
|
|
||||||
|
let hello_tf = tf.get("hello").unwrap();
|
||||||
|
let world_tf = tf.get("world").unwrap();
|
||||||
|
|
||||||
|
// Allow for floating point comparison
|
||||||
|
assert!((hello_tf - (2.0 / 3.0)).abs() < 0.001);
|
||||||
|
assert!((world_tf - (1.0 / 3.0)).abs() < 0.001);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_cosine_similarity() {
|
||||||
|
let mut v1 = HashMap::new();
|
||||||
|
v1.insert("a".to_string(), 1.0);
|
||||||
|
v1.insert("b".to_string(), 2.0);
|
||||||
|
|
||||||
|
let mut v2 = HashMap::new();
|
||||||
|
v2.insert("a".to_string(), 1.0);
|
||||||
|
v2.insert("b".to_string(), 2.0);
|
||||||
|
|
||||||
|
// Identical vectors should have similarity 1.0
|
||||||
|
let sim = SemanticScorer::cosine_similarity(&v1, &v2);
|
||||||
|
assert!((sim - 1.0).abs() < 0.001);
|
||||||
|
|
||||||
|
// Orthogonal vectors should have similarity 0.0
|
||||||
|
let mut v3 = HashMap::new();
|
||||||
|
v3.insert("c".to_string(), 1.0);
|
||||||
|
let sim2 = SemanticScorer::cosine_similarity(&v1, &v3);
|
||||||
|
assert!((sim2 - 0.0).abs() < 0.001);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_index_and_score() {
|
||||||
|
let mut scorer = SemanticScorer::new();
|
||||||
|
|
||||||
|
let entry1 = MemoryEntry::new(
|
||||||
|
"test",
|
||||||
|
MemoryType::Knowledge,
|
||||||
|
"rust",
|
||||||
|
"Rust is a systems programming language focused on safety and performance".to_string(),
|
||||||
|
).with_keywords(vec!["rust".to_string(), "programming".to_string(), "safety".to_string()]);
|
||||||
|
|
||||||
|
let entry2 = MemoryEntry::new(
|
||||||
|
"test",
|
||||||
|
MemoryType::Knowledge,
|
||||||
|
"python",
|
||||||
|
"Python is a high-level programming language".to_string(),
|
||||||
|
).with_keywords(vec!["python".to_string(), "programming".to_string()]);
|
||||||
|
|
||||||
|
scorer.index_entry(&entry1);
|
||||||
|
scorer.index_entry(&entry2);
|
||||||
|
|
||||||
|
// Query for Rust should score higher on entry1
|
||||||
|
let score1 = scorer.score_similarity("rust safety", &entry1);
|
||||||
|
let score2 = scorer.score_similarity("rust safety", &entry2);
|
||||||
|
|
||||||
|
assert!(score1 > score2, "Rust query should score higher on Rust entry");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_stats() {
|
||||||
|
let mut scorer = SemanticScorer::new();
|
||||||
|
|
||||||
|
let entry = MemoryEntry::new(
|
||||||
|
"test",
|
||||||
|
MemoryType::Knowledge,
|
||||||
|
"test",
|
||||||
|
"Hello world".to_string(),
|
||||||
|
);
|
||||||
|
|
||||||
|
scorer.index_entry(&entry);
|
||||||
|
let stats = scorer.stats();
|
||||||
|
|
||||||
|
assert_eq!(stats.total_documents, 1);
|
||||||
|
assert_eq!(stats.indexed_entries, 1);
|
||||||
|
assert!(stats.unique_terms > 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
348
crates/zclaw-growth/src/retriever.rs
Normal file
348
crates/zclaw-growth/src/retriever.rs
Normal file
@@ -0,0 +1,348 @@
|
|||||||
|
//! Memory Retriever - Retrieves relevant memories from OpenViking
|
||||||
|
//!
|
||||||
|
//! This module provides the `MemoryRetriever` which performs semantic search
|
||||||
|
//! over stored memories to find contextually relevant information.
|
||||||
|
//! Uses multiple retrieval strategies and intelligent reranking.
|
||||||
|
|
||||||
|
use crate::retrieval::{MemoryCache, QueryAnalyzer, SemanticScorer};
|
||||||
|
use crate::types::{MemoryEntry, MemoryType, RetrievalConfig, RetrievalResult};
|
||||||
|
use crate::viking_adapter::{FindOptions, VikingAdapter};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
use zclaw_types::{AgentId, Result};
|
||||||
|
|
||||||
|
/// Memory Retriever - retrieves relevant memories from OpenViking
|
||||||
|
pub struct MemoryRetriever {
|
||||||
|
/// OpenViking adapter
|
||||||
|
viking: Arc<VikingAdapter>,
|
||||||
|
/// Retrieval configuration
|
||||||
|
config: RetrievalConfig,
|
||||||
|
/// Semantic scorer for similarity computation
|
||||||
|
scorer: RwLock<SemanticScorer>,
|
||||||
|
/// Query analyzer
|
||||||
|
analyzer: QueryAnalyzer,
|
||||||
|
/// Memory cache
|
||||||
|
cache: MemoryCache,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MemoryRetriever {
|
||||||
|
/// Create a new memory retriever
|
||||||
|
pub fn new(viking: Arc<VikingAdapter>) -> Self {
|
||||||
|
Self {
|
||||||
|
viking,
|
||||||
|
config: RetrievalConfig::default(),
|
||||||
|
scorer: RwLock::new(SemanticScorer::new()),
|
||||||
|
analyzer: QueryAnalyzer::new(),
|
||||||
|
cache: MemoryCache::default_config(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create with custom configuration
|
||||||
|
pub fn with_config(mut self, config: RetrievalConfig) -> Self {
|
||||||
|
self.config = config;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retrieve relevant memories for a query
|
||||||
|
///
|
||||||
|
/// This method:
|
||||||
|
/// 1. Analyzes the query to determine intent and keywords
|
||||||
|
/// 2. Searches for preferences matching the query
|
||||||
|
/// 3. Searches for relevant knowledge
|
||||||
|
/// 4. Searches for applicable experience
|
||||||
|
/// 5. Reranks results using semantic similarity
|
||||||
|
/// 6. Applies token budget constraints
|
||||||
|
pub async fn retrieve(
|
||||||
|
&self,
|
||||||
|
agent_id: &AgentId,
|
||||||
|
query: &str,
|
||||||
|
) -> Result<RetrievalResult> {
|
||||||
|
tracing::debug!("[MemoryRetriever] Retrieving memories for query: {}", query);
|
||||||
|
|
||||||
|
// Analyze query
|
||||||
|
let analyzed = self.analyzer.analyze(query);
|
||||||
|
tracing::debug!(
|
||||||
|
"[MemoryRetriever] Query analysis: intent={:?}, keywords={:?}",
|
||||||
|
analyzed.intent,
|
||||||
|
analyzed.keywords
|
||||||
|
);
|
||||||
|
|
||||||
|
// Retrieve each type with budget constraints and reranking
|
||||||
|
let preferences = self
|
||||||
|
.retrieve_and_rerank(
|
||||||
|
&agent_id.to_string(),
|
||||||
|
MemoryType::Preference,
|
||||||
|
query,
|
||||||
|
&analyzed.keywords,
|
||||||
|
self.config.max_results_per_type,
|
||||||
|
self.config.preference_budget,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let knowledge = self
|
||||||
|
.retrieve_and_rerank(
|
||||||
|
&agent_id.to_string(),
|
||||||
|
MemoryType::Knowledge,
|
||||||
|
query,
|
||||||
|
&analyzed.keywords,
|
||||||
|
self.config.max_results_per_type,
|
||||||
|
self.config.knowledge_budget,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let experience = self
|
||||||
|
.retrieve_and_rerank(
|
||||||
|
&agent_id.to_string(),
|
||||||
|
MemoryType::Experience,
|
||||||
|
query,
|
||||||
|
&analyzed.keywords,
|
||||||
|
self.config.max_results_per_type / 2,
|
||||||
|
self.config.experience_budget,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let total_tokens = preferences.iter()
|
||||||
|
.chain(knowledge.iter())
|
||||||
|
.chain(experience.iter())
|
||||||
|
.map(|m| m.estimated_tokens())
|
||||||
|
.sum();
|
||||||
|
|
||||||
|
// Update cache with retrieved entries
|
||||||
|
for entry in preferences.iter().chain(knowledge.iter()).chain(experience.iter()) {
|
||||||
|
self.cache.put(entry.clone()).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
"[MemoryRetriever] Retrieved {} preferences, {} knowledge, {} experience ({} tokens)",
|
||||||
|
preferences.len(),
|
||||||
|
knowledge.len(),
|
||||||
|
experience.len(),
|
||||||
|
total_tokens
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(RetrievalResult {
|
||||||
|
preferences,
|
||||||
|
knowledge,
|
||||||
|
experience,
|
||||||
|
total_tokens,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retrieve and rerank memories by type
|
||||||
|
async fn retrieve_and_rerank(
|
||||||
|
&self,
|
||||||
|
agent_id: &str,
|
||||||
|
memory_type: MemoryType,
|
||||||
|
query: &str,
|
||||||
|
keywords: &[String],
|
||||||
|
max_results: usize,
|
||||||
|
token_budget: usize,
|
||||||
|
) -> Result<Vec<MemoryEntry>> {
|
||||||
|
// Build scope for OpenViking search
|
||||||
|
let scope = format!("agent://{}/{}", agent_id, memory_type);
|
||||||
|
|
||||||
|
// Generate search queries (original + expanded)
|
||||||
|
let analyzed_for_search = crate::retrieval::query::AnalyzedQuery {
|
||||||
|
original: query.to_string(),
|
||||||
|
keywords: keywords.to_vec(),
|
||||||
|
intent: crate::retrieval::query::QueryIntent::General,
|
||||||
|
target_types: vec![],
|
||||||
|
expansions: vec![],
|
||||||
|
};
|
||||||
|
let search_queries = self.analyzer.generate_search_queries(&analyzed_for_search);
|
||||||
|
|
||||||
|
// Search with multiple queries and deduplicate
|
||||||
|
let mut all_results = Vec::new();
|
||||||
|
let mut seen_uris = std::collections::HashSet::new();
|
||||||
|
|
||||||
|
for search_query in search_queries {
|
||||||
|
let options = FindOptions {
|
||||||
|
scope: Some(scope.clone()),
|
||||||
|
limit: Some(max_results * 2),
|
||||||
|
min_similarity: Some(self.config.min_similarity),
|
||||||
|
};
|
||||||
|
|
||||||
|
let results = self.viking.find(&search_query, options).await?;
|
||||||
|
|
||||||
|
for entry in results {
|
||||||
|
if seen_uris.insert(entry.uri.clone()) {
|
||||||
|
all_results.push(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rerank using semantic similarity
|
||||||
|
let scored = self.rerank_entries(query, all_results).await;
|
||||||
|
|
||||||
|
// Apply token budget
|
||||||
|
let mut filtered = Vec::new();
|
||||||
|
let mut used_tokens = 0;
|
||||||
|
|
||||||
|
for entry in scored {
|
||||||
|
let tokens = entry.estimated_tokens();
|
||||||
|
if used_tokens + tokens <= token_budget {
|
||||||
|
used_tokens += tokens;
|
||||||
|
filtered.push(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
if filtered.len() >= max_results {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(filtered)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rerank entries using semantic similarity
|
||||||
|
async fn rerank_entries(
|
||||||
|
&self,
|
||||||
|
query: &str,
|
||||||
|
entries: Vec<MemoryEntry>,
|
||||||
|
) -> Vec<MemoryEntry> {
|
||||||
|
if entries.is_empty() {
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut scorer = self.scorer.write().await;
|
||||||
|
|
||||||
|
// Index entries for semantic search
|
||||||
|
for entry in &entries {
|
||||||
|
scorer.index_entry(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Score each entry
|
||||||
|
let mut scored: Vec<(f32, MemoryEntry)> = entries
|
||||||
|
.into_iter()
|
||||||
|
.map(|entry| {
|
||||||
|
let score = scorer.score_similarity(query, &entry);
|
||||||
|
(score, entry)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Sort by score (descending), then by importance and access count
|
||||||
|
scored.sort_by(|a, b| {
|
||||||
|
b.0.partial_cmp(&a.0)
|
||||||
|
.unwrap_or(std::cmp::Ordering::Equal)
|
||||||
|
.then_with(|| b.1.importance.cmp(&a.1.importance))
|
||||||
|
.then_with(|| b.1.access_count.cmp(&a.1.access_count))
|
||||||
|
});
|
||||||
|
|
||||||
|
scored.into_iter().map(|(_, entry)| entry).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retrieve a specific memory by URI (with cache)
|
||||||
|
pub async fn get_by_uri(&self, uri: &str) -> Result<Option<MemoryEntry>> {
|
||||||
|
// Check cache first
|
||||||
|
if let Some(cached) = self.cache.get(uri).await {
|
||||||
|
return Ok(Some(cached));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to storage
|
||||||
|
let result = self.viking.get(uri).await?;
|
||||||
|
|
||||||
|
// Update cache
|
||||||
|
if let Some(ref entry) = result {
|
||||||
|
self.cache.put(entry.clone()).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get all memories for an agent (for debugging/admin)
|
||||||
|
pub async fn get_all_memories(&self, agent_id: &AgentId) -> Result<Vec<MemoryEntry>> {
|
||||||
|
let scope = format!("agent://{}", agent_id);
|
||||||
|
let options = FindOptions {
|
||||||
|
scope: Some(scope),
|
||||||
|
limit: None,
|
||||||
|
min_similarity: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
self.viking.find("", options).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get memory statistics for an agent
|
||||||
|
pub async fn get_stats(&self, agent_id: &AgentId) -> Result<MemoryStats> {
|
||||||
|
let all = self.get_all_memories(agent_id).await?;
|
||||||
|
|
||||||
|
let preference_count = all.iter().filter(|m| m.memory_type == MemoryType::Preference).count();
|
||||||
|
let knowledge_count = all.iter().filter(|m| m.memory_type == MemoryType::Knowledge).count();
|
||||||
|
let experience_count = all.iter().filter(|m| m.memory_type == MemoryType::Experience).count();
|
||||||
|
|
||||||
|
Ok(MemoryStats {
|
||||||
|
total_count: all.len(),
|
||||||
|
preference_count,
|
||||||
|
knowledge_count,
|
||||||
|
experience_count,
|
||||||
|
cache_hit_rate: self.cache.hit_rate().await,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear the semantic index
|
||||||
|
pub async fn clear_index(&self) {
|
||||||
|
let mut scorer = self.scorer.write().await;
|
||||||
|
scorer.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get cache statistics
|
||||||
|
pub async fn cache_stats(&self) -> (usize, f32) {
|
||||||
|
let size = self.cache.size().await;
|
||||||
|
let hit_rate = self.cache.hit_rate().await;
|
||||||
|
(size, hit_rate)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Warm up cache with hot entries
|
||||||
|
pub async fn warmup_cache(&self, agent_id: &AgentId) -> Result<usize> {
|
||||||
|
let all = self.get_all_memories(agent_id).await?;
|
||||||
|
|
||||||
|
// Sort by access count to get hot entries
|
||||||
|
let mut sorted = all;
|
||||||
|
sorted.sort_by(|a, b| b.access_count.cmp(&a.access_count));
|
||||||
|
|
||||||
|
// Take top 50 hot entries
|
||||||
|
let hot: Vec<_> = sorted.into_iter().take(50).collect();
|
||||||
|
let count = hot.len();
|
||||||
|
|
||||||
|
self.cache.warmup(hot).await;
|
||||||
|
|
||||||
|
Ok(count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Memory statistics
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct MemoryStats {
|
||||||
|
pub total_count: usize,
|
||||||
|
pub preference_count: usize,
|
||||||
|
pub knowledge_count: usize,
|
||||||
|
pub experience_count: usize,
|
||||||
|
pub cache_hit_rate: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_retrieval_config_default() {
|
||||||
|
let config = RetrievalConfig::default();
|
||||||
|
assert_eq!(config.max_tokens, 500);
|
||||||
|
assert_eq!(config.preference_budget, 200);
|
||||||
|
assert_eq!(config.knowledge_budget, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_memory_type_scope() {
|
||||||
|
let scope = format!("agent://test-agent/{}", MemoryType::Preference);
|
||||||
|
assert!(scope.contains("test-agent"));
|
||||||
|
assert!(scope.contains("preferences"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_retriever_creation() {
|
||||||
|
let viking = Arc::new(VikingAdapter::in_memory());
|
||||||
|
let retriever = MemoryRetriever::new(viking);
|
||||||
|
|
||||||
|
let stats = retriever.cache_stats().await;
|
||||||
|
assert_eq!(stats.0, 0); // Cache size should be 0
|
||||||
|
}
|
||||||
|
}
|
||||||
9
crates/zclaw-growth/src/storage/mod.rs
Normal file
9
crates/zclaw-growth/src/storage/mod.rs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
//! Storage backends for ZCLAW Growth System
|
||||||
|
//!
|
||||||
|
//! This module provides multiple storage backend implementations:
|
||||||
|
//! - `InMemoryStorage`: Fast in-memory storage for testing and development
|
||||||
|
//! - `SqliteStorage`: Persistent SQLite storage for production use
|
||||||
|
|
||||||
|
mod sqlite;
|
||||||
|
|
||||||
|
pub use sqlite::SqliteStorage;
|
||||||
666
crates/zclaw-growth/src/storage/sqlite.rs
Normal file
666
crates/zclaw-growth/src/storage/sqlite.rs
Normal file
@@ -0,0 +1,666 @@
|
|||||||
|
//! SQLite Storage Backend
|
||||||
|
//!
|
||||||
|
//! Persistent storage backend using SQLite for production use.
|
||||||
|
//! Provides efficient querying and full-text search capabilities.
|
||||||
|
|
||||||
|
use crate::retrieval::semantic::{EmbeddingClient, SemanticScorer};
|
||||||
|
use crate::types::MemoryEntry;
|
||||||
|
use crate::viking_adapter::{FindOptions, VikingStorage};
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use sqlx::sqlite::{SqlitePool, SqlitePoolOptions, SqliteRow};
|
||||||
|
use sqlx::Row;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
use zclaw_types::Result;
|
||||||
|
use zclaw_types::ZclawError;
|
||||||
|
|
||||||
|
/// SQLite storage backend with TF-IDF semantic scoring
|
||||||
|
pub struct SqliteStorage {
|
||||||
|
/// Database connection pool
|
||||||
|
pool: SqlitePool,
|
||||||
|
/// Semantic scorer for similarity computation
|
||||||
|
scorer: Arc<RwLock<SemanticScorer>>,
|
||||||
|
/// Database path (for reference)
|
||||||
|
#[allow(dead_code)]
|
||||||
|
path: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Database row structure for memory entry
|
||||||
|
struct MemoryRow {
|
||||||
|
uri: String,
|
||||||
|
memory_type: String,
|
||||||
|
content: String,
|
||||||
|
keywords: String,
|
||||||
|
importance: i32,
|
||||||
|
access_count: i32,
|
||||||
|
created_at: String,
|
||||||
|
last_accessed: String,
|
||||||
|
overview: Option<String>,
|
||||||
|
abstract_summary: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SqliteStorage {
|
||||||
|
/// Create a new SQLite storage at the given path
|
||||||
|
pub async fn new(path: impl Into<PathBuf>) -> Result<Self> {
|
||||||
|
let path = path.into();
|
||||||
|
|
||||||
|
// Ensure parent directory exists
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
if parent.to_str() != Some(":memory:") {
|
||||||
|
tokio::fs::create_dir_all(parent).await.map_err(|e| {
|
||||||
|
ZclawError::StorageError(format!("Failed to create storage directory: {}", e))
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build connection string
|
||||||
|
let db_url = if path.to_str() == Some(":memory:") {
|
||||||
|
"sqlite::memory:".to_string()
|
||||||
|
} else {
|
||||||
|
format!("sqlite:{}?mode=rwc", path.to_string_lossy())
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create connection pool
|
||||||
|
let pool = SqlitePoolOptions::new()
|
||||||
|
.max_connections(5)
|
||||||
|
.connect(&db_url)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ZclawError::StorageError(format!("Failed to connect to database: {}", e)))?;
|
||||||
|
|
||||||
|
let storage = Self {
|
||||||
|
pool,
|
||||||
|
scorer: Arc::new(RwLock::new(SemanticScorer::new())),
|
||||||
|
path,
|
||||||
|
};
|
||||||
|
|
||||||
|
storage.initialize_schema().await?;
|
||||||
|
storage.warmup_scorer().await?;
|
||||||
|
|
||||||
|
Ok(storage)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create an in-memory SQLite database (for testing)
|
||||||
|
pub async fn in_memory() -> Self {
|
||||||
|
Self::new(":memory:").await.expect("Failed to create in-memory database")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configure embedding client for semantic search
|
||||||
|
/// Replaces the current scorer with a new one that has embedding support
|
||||||
|
pub async fn configure_embedding(
|
||||||
|
&self,
|
||||||
|
client: Arc<dyn EmbeddingClient>,
|
||||||
|
) -> Result<()> {
|
||||||
|
let new_scorer = SemanticScorer::with_embedding(client);
|
||||||
|
let mut scorer = self.scorer.write().await;
|
||||||
|
*scorer = new_scorer;
|
||||||
|
|
||||||
|
tracing::info!("[SqliteStorage] Embedding client configured, re-indexing with embeddings...");
|
||||||
|
self.warmup_scorer_with_embedding().await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if embedding is available
|
||||||
|
pub async fn is_embedding_available(&self) -> bool {
|
||||||
|
let scorer = self.scorer.read().await;
|
||||||
|
scorer.is_embedding_available()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Initialize database schema with FTS5
|
||||||
|
async fn initialize_schema(&self) -> Result<()> {
|
||||||
|
// Create main memories table
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
CREATE TABLE IF NOT EXISTS memories (
|
||||||
|
uri TEXT PRIMARY KEY,
|
||||||
|
memory_type TEXT NOT NULL,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
keywords TEXT NOT NULL DEFAULT '[]',
|
||||||
|
importance INTEGER NOT NULL DEFAULT 5,
|
||||||
|
access_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
last_accessed TEXT NOT NULL
|
||||||
|
)
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ZclawError::StorageError(format!("Failed to create memories table: {}", e)))?;
|
||||||
|
|
||||||
|
// Create FTS5 virtual table for full-text search
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(
|
||||||
|
uri,
|
||||||
|
content,
|
||||||
|
keywords,
|
||||||
|
tokenize='unicode61'
|
||||||
|
)
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ZclawError::StorageError(format!("Failed to create FTS5 table: {}", e)))?;
|
||||||
|
|
||||||
|
// Create index on memory_type for filtering
|
||||||
|
sqlx::query("CREATE INDEX IF NOT EXISTS idx_memory_type ON memories(memory_type)")
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ZclawError::StorageError(format!("Failed to create index: {}", e)))?;
|
||||||
|
|
||||||
|
// Create index on importance for sorting
|
||||||
|
sqlx::query("CREATE INDEX IF NOT EXISTS idx_importance ON memories(importance DESC)")
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ZclawError::StorageError(format!("Failed to create importance index: {}", e)))?;
|
||||||
|
|
||||||
|
// Migration: add overview column (L1 summary)
|
||||||
|
let _ = sqlx::query("ALTER TABLE memories ADD COLUMN overview TEXT")
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// Migration: add abstract_summary column (L0 keywords)
|
||||||
|
let _ = sqlx::query("ALTER TABLE memories ADD COLUMN abstract_summary TEXT")
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// Create metadata table
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
CREATE TABLE IF NOT EXISTS metadata (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
json TEXT NOT NULL
|
||||||
|
)
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ZclawError::StorageError(format!("Failed to create metadata table: {}", e)))?;
|
||||||
|
|
||||||
|
tracing::info!("[SqliteStorage] Database schema initialized");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Warmup semantic scorer with existing entries
|
||||||
|
async fn warmup_scorer(&self) -> Result<()> {
|
||||||
|
let rows = sqlx::query_as::<_, MemoryRow>(
|
||||||
|
"SELECT uri, memory_type, content, keywords, importance, access_count, created_at, last_accessed, overview, abstract_summary FROM memories"
|
||||||
|
)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ZclawError::StorageError(format!("Failed to load memories for warmup: {}", e)))?;
|
||||||
|
|
||||||
|
let mut scorer = self.scorer.write().await;
|
||||||
|
for row in rows {
|
||||||
|
let entry = self.row_to_entry(&row);
|
||||||
|
scorer.index_entry(&entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
let stats = scorer.stats();
|
||||||
|
tracing::info!(
|
||||||
|
"[SqliteStorage] Warmed up scorer with {} entries, {} terms",
|
||||||
|
stats.indexed_entries,
|
||||||
|
stats.unique_terms
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Warmup semantic scorer with embedding support for existing entries
|
||||||
|
async fn warmup_scorer_with_embedding(&self) -> Result<()> {
|
||||||
|
let rows = sqlx::query_as::<_, MemoryRow>(
|
||||||
|
"SELECT uri, memory_type, content, keywords, importance, access_count, created_at, last_accessed, overview, abstract_summary FROM memories"
|
||||||
|
)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ZclawError::StorageError(format!("Failed to load memories for warmup: {}", e)))?;
|
||||||
|
|
||||||
|
let mut scorer = self.scorer.write().await;
|
||||||
|
for row in rows {
|
||||||
|
let entry = self.row_to_entry(&row);
|
||||||
|
scorer.index_entry_with_embedding(&entry).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
let stats = scorer.stats();
|
||||||
|
tracing::info!(
|
||||||
|
"[SqliteStorage] Warmed up scorer with {} entries ({} with embeddings), {} terms",
|
||||||
|
stats.indexed_entries,
|
||||||
|
stats.embedding_entries,
|
||||||
|
stats.unique_terms
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert database row to MemoryEntry
|
||||||
|
fn row_to_entry(&self, row: &MemoryRow) -> MemoryEntry {
|
||||||
|
let memory_type = crate::types::MemoryType::parse(&row.memory_type);
|
||||||
|
let keywords: Vec<String> = serde_json::from_str(&row.keywords).unwrap_or_default();
|
||||||
|
let created_at = chrono::DateTime::parse_from_rfc3339(&row.created_at)
|
||||||
|
.map(|dt| dt.with_timezone(&chrono::Utc))
|
||||||
|
.unwrap_or_else(|_| chrono::Utc::now());
|
||||||
|
let last_accessed = chrono::DateTime::parse_from_rfc3339(&row.last_accessed)
|
||||||
|
.map(|dt| dt.with_timezone(&chrono::Utc))
|
||||||
|
.unwrap_or_else(|_| chrono::Utc::now());
|
||||||
|
|
||||||
|
MemoryEntry {
|
||||||
|
uri: row.uri.clone(),
|
||||||
|
memory_type,
|
||||||
|
content: row.content.clone(),
|
||||||
|
keywords,
|
||||||
|
importance: row.importance as u8,
|
||||||
|
access_count: row.access_count as u32,
|
||||||
|
created_at,
|
||||||
|
last_accessed,
|
||||||
|
overview: row.overview.clone(),
|
||||||
|
abstract_summary: row.abstract_summary.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update access count and last accessed time
|
||||||
|
async fn touch_entry(&self, uri: &str) -> Result<()> {
|
||||||
|
let now = chrono::Utc::now().to_rfc3339();
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE memories SET access_count = access_count + 1, last_accessed = ? WHERE uri = ?"
|
||||||
|
)
|
||||||
|
.bind(&now)
|
||||||
|
.bind(uri)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ZclawError::StorageError(format!("Failed to update access count: {}", e)))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl sqlx::FromRow<'_, SqliteRow> for MemoryRow {
|
||||||
|
fn from_row(row: &SqliteRow) -> sqlx::Result<Self> {
|
||||||
|
Ok(MemoryRow {
|
||||||
|
uri: row.try_get("uri")?,
|
||||||
|
memory_type: row.try_get("memory_type")?,
|
||||||
|
content: row.try_get("content")?,
|
||||||
|
keywords: row.try_get("keywords")?,
|
||||||
|
importance: row.try_get("importance")?,
|
||||||
|
access_count: row.try_get("access_count")?,
|
||||||
|
created_at: row.try_get("created_at")?,
|
||||||
|
last_accessed: row.try_get("last_accessed")?,
|
||||||
|
overview: row.try_get("overview").ok(),
|
||||||
|
abstract_summary: row.try_get("abstract_summary").ok(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl VikingStorage for SqliteStorage {
|
||||||
|
async fn store(&self, entry: &MemoryEntry) -> Result<()> {
|
||||||
|
let keywords_json = serde_json::to_string(&entry.keywords)
|
||||||
|
.map_err(|e| ZclawError::StorageError(format!("Failed to serialize keywords: {}", e)))?;
|
||||||
|
|
||||||
|
let created_at = entry.created_at.to_rfc3339();
|
||||||
|
let last_accessed = entry.last_accessed.to_rfc3339();
|
||||||
|
let memory_type = entry.memory_type.to_string();
|
||||||
|
|
||||||
|
// Insert into main table
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
INSERT OR REPLACE INTO memories
|
||||||
|
(uri, memory_type, content, keywords, importance, access_count, created_at, last_accessed, overview, abstract_summary)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(&entry.uri)
|
||||||
|
.bind(&memory_type)
|
||||||
|
.bind(&entry.content)
|
||||||
|
.bind(&keywords_json)
|
||||||
|
.bind(entry.importance as i32)
|
||||||
|
.bind(entry.access_count as i32)
|
||||||
|
.bind(&created_at)
|
||||||
|
.bind(&last_accessed)
|
||||||
|
.bind(&entry.overview)
|
||||||
|
.bind(&entry.abstract_summary)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ZclawError::StorageError(format!("Failed to store memory: {}", e)))?;
|
||||||
|
|
||||||
|
// Update FTS index - delete old and insert new
|
||||||
|
let _ = sqlx::query("DELETE FROM memories_fts WHERE uri = ?")
|
||||||
|
.bind(&entry.uri)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let keywords_text = entry.keywords.join(" ");
|
||||||
|
let _ = sqlx::query(
|
||||||
|
r#"
|
||||||
|
INSERT INTO memories_fts (uri, content, keywords)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(&entry.uri)
|
||||||
|
.bind(&entry.content)
|
||||||
|
.bind(&keywords_text)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// Update semantic scorer (use embedding when available)
|
||||||
|
let mut scorer = self.scorer.write().await;
|
||||||
|
if scorer.is_embedding_available() {
|
||||||
|
scorer.index_entry_with_embedding(entry).await;
|
||||||
|
} else {
|
||||||
|
scorer.index_entry(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::debug!("[SqliteStorage] Stored memory: {}", entry.uri);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get(&self, uri: &str) -> Result<Option<MemoryEntry>> {
|
||||||
|
let row = sqlx::query_as::<_, MemoryRow>(
|
||||||
|
"SELECT uri, memory_type, content, keywords, importance, access_count, created_at, last_accessed, overview, abstract_summary FROM memories WHERE uri = ?"
|
||||||
|
)
|
||||||
|
.bind(uri)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ZclawError::StorageError(format!("Failed to get memory: {}", e)))?;
|
||||||
|
|
||||||
|
if let Some(row) = row {
|
||||||
|
let entry = self.row_to_entry(&row);
|
||||||
|
|
||||||
|
// Update access count
|
||||||
|
self.touch_entry(&entry.uri).await?;
|
||||||
|
|
||||||
|
Ok(Some(entry))
|
||||||
|
} else {
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn find(&self, query: &str, options: FindOptions) -> Result<Vec<MemoryEntry>> {
|
||||||
|
// Get all matching entries
|
||||||
|
let rows = if let Some(ref scope) = options.scope {
|
||||||
|
sqlx::query_as::<_, MemoryRow>(
|
||||||
|
"SELECT uri, memory_type, content, keywords, importance, access_count, created_at, last_accessed, overview, abstract_summary FROM memories WHERE uri LIKE ?"
|
||||||
|
)
|
||||||
|
.bind(format!("{}%", scope))
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ZclawError::StorageError(format!("Failed to find memories: {}", e)))?
|
||||||
|
} else {
|
||||||
|
sqlx::query_as::<_, MemoryRow>(
|
||||||
|
"SELECT uri, memory_type, content, keywords, importance, access_count, created_at, last_accessed, overview, abstract_summary FROM memories"
|
||||||
|
)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ZclawError::StorageError(format!("Failed to find memories: {}", e)))?
|
||||||
|
};
|
||||||
|
|
||||||
|
// Convert to entries and compute semantic scores
|
||||||
|
let use_embedding = {
|
||||||
|
let scorer = self.scorer.read().await;
|
||||||
|
scorer.is_embedding_available()
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut scored_entries: Vec<(f32, MemoryEntry)> = Vec::new();
|
||||||
|
|
||||||
|
for row in rows {
|
||||||
|
let entry = self.row_to_entry(&row);
|
||||||
|
|
||||||
|
// Compute semantic score: use embedding when available, fallback to TF-IDF
|
||||||
|
let semantic_score = if use_embedding {
|
||||||
|
let scorer = self.scorer.read().await;
|
||||||
|
let tfidf_score = scorer.score_similarity(query, &entry);
|
||||||
|
let entry_embedding = scorer.get_entry_embedding(&entry.uri);
|
||||||
|
drop(scorer);
|
||||||
|
|
||||||
|
match entry_embedding {
|
||||||
|
Some(entry_emb) => {
|
||||||
|
// Try embedding the query for hybrid scoring
|
||||||
|
let embedding_client = {
|
||||||
|
let scorer2 = self.scorer.read().await;
|
||||||
|
scorer2.get_embedding_client()
|
||||||
|
};
|
||||||
|
|
||||||
|
match embedding_client.embed(query).await {
|
||||||
|
Ok(query_emb) => {
|
||||||
|
let emb_score = SemanticScorer::cosine_similarity_embedding(&query_emb, &entry_emb);
|
||||||
|
// Hybrid: 70% embedding + 30% TF-IDF
|
||||||
|
emb_score * 0.7 + tfidf_score * 0.3
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
tracing::debug!("[SqliteStorage] Query embedding failed, using TF-IDF only");
|
||||||
|
tfidf_score
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => tfidf_score,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let scorer = self.scorer.read().await;
|
||||||
|
scorer.score_similarity(query, &entry)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Apply similarity threshold
|
||||||
|
if let Some(min_similarity) = options.min_similarity {
|
||||||
|
if semantic_score < min_similarity {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
scored_entries.push((semantic_score, entry));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by score (descending), then by importance and access count
|
||||||
|
scored_entries.sort_by(|a, b| {
|
||||||
|
b.0.partial_cmp(&a.0)
|
||||||
|
.unwrap_or(std::cmp::Ordering::Equal)
|
||||||
|
.then_with(|| b.1.importance.cmp(&a.1.importance))
|
||||||
|
.then_with(|| b.1.access_count.cmp(&a.1.access_count))
|
||||||
|
});
|
||||||
|
|
||||||
|
// Apply limit
|
||||||
|
if let Some(limit) = options.limit {
|
||||||
|
scored_entries.truncate(limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(scored_entries.into_iter().map(|(_, entry)| entry).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn find_by_prefix(&self, prefix: &str) -> Result<Vec<MemoryEntry>> {
|
||||||
|
let rows = sqlx::query_as::<_, MemoryRow>(
|
||||||
|
"SELECT uri, memory_type, content, keywords, importance, access_count, created_at, last_accessed, overview, abstract_summary FROM memories WHERE uri LIKE ?"
|
||||||
|
)
|
||||||
|
.bind(format!("{}%", prefix))
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ZclawError::StorageError(format!("Failed to find by prefix: {}", e)))?;
|
||||||
|
|
||||||
|
let entries = rows.iter().map(|row| self.row_to_entry(row)).collect();
|
||||||
|
|
||||||
|
Ok(entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete(&self, uri: &str) -> Result<()> {
|
||||||
|
sqlx::query("DELETE FROM memories WHERE uri = ?")
|
||||||
|
.bind(uri)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ZclawError::StorageError(format!("Failed to delete memory: {}", e)))?;
|
||||||
|
|
||||||
|
// Remove from FTS
|
||||||
|
let _ = sqlx::query("DELETE FROM memories_fts WHERE uri = ?")
|
||||||
|
.bind(uri)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// Remove from scorer
|
||||||
|
let mut scorer = self.scorer.write().await;
|
||||||
|
scorer.remove_entry(uri);
|
||||||
|
|
||||||
|
tracing::debug!("[SqliteStorage] Deleted memory: {}", uri);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn store_metadata_json(&self, key: &str, json: &str) -> Result<()> {
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
INSERT OR REPLACE INTO metadata (key, json)
|
||||||
|
VALUES (?, ?)
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(key)
|
||||||
|
.bind(json)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ZclawError::StorageError(format!("Failed to store metadata: {}", e)))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_metadata_json(&self, key: &str) -> Result<Option<String>> {
|
||||||
|
let result = sqlx::query_scalar::<_, String>("SELECT json FROM metadata WHERE key = ?")
|
||||||
|
.bind(key)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ZclawError::StorageError(format!("Failed to get metadata: {}", e)))?;
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::types::MemoryType;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_sqlite_storage_store_and_get() {
|
||||||
|
let storage = SqliteStorage::in_memory().await;
|
||||||
|
let entry = MemoryEntry::new(
|
||||||
|
"test-agent",
|
||||||
|
MemoryType::Preference,
|
||||||
|
"style",
|
||||||
|
"User prefers concise responses".to_string(),
|
||||||
|
);
|
||||||
|
|
||||||
|
storage.store(&entry).await.unwrap();
|
||||||
|
let retrieved = storage.get(&entry.uri).await.unwrap();
|
||||||
|
|
||||||
|
assert!(retrieved.is_some());
|
||||||
|
assert_eq!(retrieved.unwrap().content, "User prefers concise responses");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_sqlite_storage_semantic_search() {
|
||||||
|
let storage = SqliteStorage::in_memory().await;
|
||||||
|
|
||||||
|
// Store entries with different content
|
||||||
|
let entry1 = MemoryEntry::new(
|
||||||
|
"agent-1",
|
||||||
|
MemoryType::Knowledge,
|
||||||
|
"rust",
|
||||||
|
"Rust is a systems programming language focused on safety".to_string(),
|
||||||
|
).with_keywords(vec!["rust".to_string(), "programming".to_string(), "safety".to_string()]);
|
||||||
|
|
||||||
|
let entry2 = MemoryEntry::new(
|
||||||
|
"agent-1",
|
||||||
|
MemoryType::Knowledge,
|
||||||
|
"python",
|
||||||
|
"Python is a high-level programming language".to_string(),
|
||||||
|
).with_keywords(vec!["python".to_string(), "programming".to_string()]);
|
||||||
|
|
||||||
|
storage.store(&entry1).await.unwrap();
|
||||||
|
storage.store(&entry2).await.unwrap();
|
||||||
|
|
||||||
|
// Search for "rust safety"
|
||||||
|
let results = storage.find(
|
||||||
|
"rust safety",
|
||||||
|
FindOptions {
|
||||||
|
scope: Some("agent://agent-1".to_string()),
|
||||||
|
limit: Some(10),
|
||||||
|
min_similarity: Some(0.1),
|
||||||
|
},
|
||||||
|
).await.unwrap();
|
||||||
|
|
||||||
|
// Should find the Rust entry with higher score
|
||||||
|
assert!(!results.is_empty());
|
||||||
|
assert!(results[0].content.contains("Rust"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_sqlite_storage_delete() {
|
||||||
|
let storage = SqliteStorage::in_memory().await;
|
||||||
|
let entry = MemoryEntry::new(
|
||||||
|
"test-agent",
|
||||||
|
MemoryType::Preference,
|
||||||
|
"style",
|
||||||
|
"test".to_string(),
|
||||||
|
);
|
||||||
|
|
||||||
|
storage.store(&entry).await.unwrap();
|
||||||
|
storage.delete(&entry.uri).await.unwrap();
|
||||||
|
|
||||||
|
let retrieved = storage.get(&entry.uri).await.unwrap();
|
||||||
|
assert!(retrieved.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_persistence() {
|
||||||
|
let path = std::env::temp_dir().join("zclaw_test_memories.db");
|
||||||
|
|
||||||
|
// Clean up any existing test db
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
|
||||||
|
// Create and store
|
||||||
|
{
|
||||||
|
let storage = SqliteStorage::new(&path).await.unwrap();
|
||||||
|
let entry = MemoryEntry::new(
|
||||||
|
"persist-test",
|
||||||
|
MemoryType::Knowledge,
|
||||||
|
"test",
|
||||||
|
"This should persist".to_string(),
|
||||||
|
);
|
||||||
|
storage.store(&entry).await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reopen and verify
|
||||||
|
{
|
||||||
|
let storage = SqliteStorage::new(&path).await.unwrap();
|
||||||
|
let results = storage.find_by_prefix("agent://persist-test").await.unwrap();
|
||||||
|
assert!(!results.is_empty());
|
||||||
|
assert_eq!(results[0].content, "This should persist");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_metadata_storage() {
|
||||||
|
let storage = SqliteStorage::in_memory().await;
|
||||||
|
|
||||||
|
let json = r#"{"test": "value"}"#;
|
||||||
|
storage.store_metadata_json("test-key", json).await.unwrap();
|
||||||
|
|
||||||
|
let retrieved = storage.get_metadata_json("test-key").await.unwrap();
|
||||||
|
assert_eq!(retrieved, Some(json.to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_access_count() {
|
||||||
|
let storage = SqliteStorage::in_memory().await;
|
||||||
|
let entry = MemoryEntry::new(
|
||||||
|
"test-agent",
|
||||||
|
MemoryType::Knowledge,
|
||||||
|
"test",
|
||||||
|
"test content".to_string(),
|
||||||
|
);
|
||||||
|
|
||||||
|
storage.store(&entry).await.unwrap();
|
||||||
|
|
||||||
|
// Access multiple times
|
||||||
|
for _ in 0..3 {
|
||||||
|
let _ = storage.get(&entry.uri).await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let retrieved = storage.get(&entry.uri).await.unwrap().unwrap();
|
||||||
|
assert!(retrieved.access_count >= 3);
|
||||||
|
}
|
||||||
|
}
|
||||||
192
crates/zclaw-growth/src/summarizer.rs
Normal file
192
crates/zclaw-growth/src/summarizer.rs
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
//! Memory Summarizer - L0/L1 Summary Generation
|
||||||
|
//!
|
||||||
|
//! Provides trait and functions for generating layered summaries of memory entries:
|
||||||
|
//! - L1 Overview: 1-2 sentence summary (~200 tokens)
|
||||||
|
//! - L0 Abstract: 3-5 keywords (~100 tokens)
|
||||||
|
//!
|
||||||
|
//! The trait-based design allows zclaw-growth to remain decoupled from any
|
||||||
|
//! specific LLM implementation. The Tauri layer provides a concrete implementation.
|
||||||
|
|
||||||
|
use crate::types::MemoryEntry;
|
||||||
|
|
||||||
|
/// LLM driver for summary generation.
|
||||||
|
/// Implementations call an LLM API to produce concise summaries.
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
pub trait SummaryLlmDriver: Send + Sync {
|
||||||
|
/// Generate a short summary (1-2 sentences, ~200 tokens) for a memory entry.
|
||||||
|
async fn generate_overview(&self, entry: &MemoryEntry) -> Result<String, String>;
|
||||||
|
|
||||||
|
/// Generate keyword extraction (3-5 keywords, ~100 tokens) for a memory entry.
|
||||||
|
async fn generate_abstract(&self, entry: &MemoryEntry) -> Result<String, String>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate an L1 overview prompt for the LLM.
|
||||||
|
pub fn overview_prompt(entry: &MemoryEntry) -> String {
|
||||||
|
format!(
|
||||||
|
r#"Summarize the following memory entry in 1-2 concise sentences (in the same language as the content).
|
||||||
|
Focus on the key information. Do not add any preamble or explanation.
|
||||||
|
|
||||||
|
Memory type: {}
|
||||||
|
Category: {}
|
||||||
|
Content: {}"#,
|
||||||
|
entry.memory_type,
|
||||||
|
entry.uri.rsplit('/').next().unwrap_or("unknown"),
|
||||||
|
entry.content
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate an L0 abstract prompt for the LLM.
|
||||||
|
pub fn abstract_prompt(entry: &MemoryEntry) -> String {
|
||||||
|
format!(
|
||||||
|
r#"Extract 3-5 keywords or key phrases from the following memory entry.
|
||||||
|
Output ONLY the keywords, comma-separated, in the same language as the content.
|
||||||
|
Do not add any preamble, explanation, or numbering.
|
||||||
|
|
||||||
|
Memory type: {}
|
||||||
|
Content: {}"#,
|
||||||
|
entry.memory_type, entry.content
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate both L1 overview and L0 abstract for a memory entry.
|
||||||
|
/// Returns (overview, abstract_summary) tuple.
|
||||||
|
pub async fn generate_summaries(
|
||||||
|
driver: &dyn SummaryLlmDriver,
|
||||||
|
entry: &MemoryEntry,
|
||||||
|
) -> (Option<String>, Option<String>) {
|
||||||
|
// Generate L1 overview
|
||||||
|
let overview = match driver.generate_overview(entry).await {
|
||||||
|
Ok(text) => {
|
||||||
|
let cleaned = clean_summary(&text);
|
||||||
|
if !cleaned.is_empty() {
|
||||||
|
Some(cleaned)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::debug!("[Summarizer] Failed to generate overview for {}: {}", entry.uri, e);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Generate L0 abstract
|
||||||
|
let abstract_summary = match driver.generate_abstract(entry).await {
|
||||||
|
Ok(text) => {
|
||||||
|
let cleaned = clean_summary(&text);
|
||||||
|
if !cleaned.is_empty() {
|
||||||
|
Some(cleaned)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::debug!("[Summarizer] Failed to generate abstract for {}: {}", entry.uri, e);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
(overview, abstract_summary)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clean LLM response: strip quotes, whitespace, prefixes
|
||||||
|
fn clean_summary(text: &str) -> String {
|
||||||
|
text.trim()
|
||||||
|
.trim_start_matches('"')
|
||||||
|
.trim_end_matches('"')
|
||||||
|
.trim_start_matches('\'')
|
||||||
|
.trim_end_matches('\'')
|
||||||
|
.trim_start_matches("摘要:")
|
||||||
|
.trim_start_matches("摘要:")
|
||||||
|
.trim_start_matches("关键词:")
|
||||||
|
.trim_start_matches("关键词:")
|
||||||
|
.trim_start_matches("Overview:")
|
||||||
|
.trim_start_matches("overview:")
|
||||||
|
.trim()
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::types::MemoryType;
|
||||||
|
|
||||||
|
struct MockSummaryDriver;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl SummaryLlmDriver for MockSummaryDriver {
|
||||||
|
async fn generate_overview(&self, entry: &MemoryEntry) -> Result<String, String> {
|
||||||
|
Ok(format!("Summary of: {}", &entry.content[..entry.content.len().min(30)]))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn generate_abstract(&self, _entry: &MemoryEntry) -> Result<String, String> {
|
||||||
|
Ok("keyword1, keyword2, keyword3".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_entry(content: &str) -> MemoryEntry {
|
||||||
|
MemoryEntry::new("test-agent", MemoryType::Knowledge, "test", content.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_generate_summaries() {
|
||||||
|
let driver = MockSummaryDriver;
|
||||||
|
let entry = make_entry("This is a test memory entry about Rust programming.");
|
||||||
|
|
||||||
|
let (overview, abstract_summary) = generate_summaries(&driver, &entry).await;
|
||||||
|
|
||||||
|
assert!(overview.is_some());
|
||||||
|
assert!(abstract_summary.is_some());
|
||||||
|
assert!(overview.unwrap().contains("Summary of"));
|
||||||
|
assert!(abstract_summary.unwrap().contains("keyword1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_generate_summaries_handles_error() {
|
||||||
|
struct FailingDriver;
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl SummaryLlmDriver for FailingDriver {
|
||||||
|
async fn generate_overview(&self, _entry: &MemoryEntry) -> Result<String, String> {
|
||||||
|
Err("LLM unavailable".to_string())
|
||||||
|
}
|
||||||
|
async fn generate_abstract(&self, _entry: &MemoryEntry) -> Result<String, String> {
|
||||||
|
Err("LLM unavailable".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let driver = FailingDriver;
|
||||||
|
let entry = make_entry("test content");
|
||||||
|
|
||||||
|
let (overview, abstract_summary) = generate_summaries(&driver, &entry).await;
|
||||||
|
|
||||||
|
assert!(overview.is_none());
|
||||||
|
assert!(abstract_summary.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_clean_summary() {
|
||||||
|
assert_eq!(clean_summary("\"hello world\""), "hello world");
|
||||||
|
assert_eq!(clean_summary("摘要:你好"), "你好");
|
||||||
|
assert_eq!(clean_summary(" keyword1, keyword2 "), "keyword1, keyword2");
|
||||||
|
assert_eq!(clean_summary("Overview: something"), "something");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_overview_prompt() {
|
||||||
|
let entry = make_entry("User prefers dark mode and compact UI");
|
||||||
|
let prompt = overview_prompt(&entry);
|
||||||
|
|
||||||
|
assert!(prompt.contains("1-2 concise sentences"));
|
||||||
|
assert!(prompt.contains("User prefers dark mode"));
|
||||||
|
assert!(prompt.contains("knowledge"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_abstract_prompt() {
|
||||||
|
let entry = make_entry("Rust is a systems programming language");
|
||||||
|
let prompt = abstract_prompt(&entry);
|
||||||
|
|
||||||
|
assert!(prompt.contains("3-5 keywords"));
|
||||||
|
assert!(prompt.contains("Rust is a systems"));
|
||||||
|
}
|
||||||
|
}
|
||||||
212
crates/zclaw-growth/src/tracker.rs
Normal file
212
crates/zclaw-growth/src/tracker.rs
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
//! Growth Tracker - Tracks agent growth metrics and evolution
|
||||||
|
//!
|
||||||
|
//! This module provides the `GrowthTracker` which monitors and records
|
||||||
|
//! the evolution of an agent's capabilities and knowledge over time.
|
||||||
|
|
||||||
|
use crate::types::{GrowthStats, MemoryType};
|
||||||
|
use crate::viking_adapter::VikingAdapter;
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use zclaw_types::{AgentId, Result};
|
||||||
|
|
||||||
|
/// Growth Tracker - tracks agent growth metrics
|
||||||
|
pub struct GrowthTracker {
|
||||||
|
/// OpenViking adapter for storage
|
||||||
|
viking: Arc<VikingAdapter>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GrowthTracker {
|
||||||
|
/// Create a new growth tracker
|
||||||
|
pub fn new(viking: Arc<VikingAdapter>) -> Self {
|
||||||
|
Self { viking }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get current growth statistics for an agent
|
||||||
|
pub async fn get_stats(&self, agent_id: &AgentId) -> Result<GrowthStats> {
|
||||||
|
// Query all memories for the agent
|
||||||
|
let memories = self.viking.find_by_prefix(&format!("agent://{}", agent_id)).await?;
|
||||||
|
|
||||||
|
let mut stats = GrowthStats::default();
|
||||||
|
stats.total_memories = memories.len();
|
||||||
|
|
||||||
|
for memory in &memories {
|
||||||
|
match memory.memory_type {
|
||||||
|
MemoryType::Preference => stats.preference_count += 1,
|
||||||
|
MemoryType::Knowledge => stats.knowledge_count += 1,
|
||||||
|
MemoryType::Experience => stats.experience_count += 1,
|
||||||
|
MemoryType::Session => stats.sessions_processed += 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get last learning time from metadata
|
||||||
|
let meta: Option<AgentMetadata> = self.viking
|
||||||
|
.get_metadata(&format!("agent://{}", agent_id))
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if let Some(meta) = meta {
|
||||||
|
stats.last_learning_time = meta.last_learning_time;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(stats)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record a learning event
|
||||||
|
pub async fn record_learning(
|
||||||
|
&self,
|
||||||
|
agent_id: &AgentId,
|
||||||
|
session_id: &str,
|
||||||
|
memories_extracted: usize,
|
||||||
|
) -> Result<()> {
|
||||||
|
let event = LearningEvent {
|
||||||
|
agent_id: agent_id.to_string(),
|
||||||
|
session_id: session_id.to_string(),
|
||||||
|
memories_extracted,
|
||||||
|
timestamp: Utc::now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Store learning event
|
||||||
|
self.viking
|
||||||
|
.store_metadata(
|
||||||
|
&format!("agent://{}/events/{}", agent_id, session_id),
|
||||||
|
&event,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Update last learning time
|
||||||
|
self.viking
|
||||||
|
.store_metadata(
|
||||||
|
&format!("agent://{}", agent_id),
|
||||||
|
&AgentMetadata {
|
||||||
|
last_learning_time: Some(Utc::now()),
|
||||||
|
total_learning_events: None, // Will be computed
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
"[GrowthTracker] Recorded learning event: agent={}, session={}, memories={}",
|
||||||
|
agent_id,
|
||||||
|
session_id,
|
||||||
|
memories_extracted
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get growth timeline for an agent
|
||||||
|
pub async fn get_timeline(&self, agent_id: &AgentId) -> Result<Vec<LearningEvent>> {
|
||||||
|
let memories = self
|
||||||
|
.viking
|
||||||
|
.find_by_prefix(&format!("agent://{}/events/", agent_id))
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Parse events from stored memory content
|
||||||
|
let mut timeline = Vec::new();
|
||||||
|
for memory in memories {
|
||||||
|
if let Ok(event) = serde_json::from_str::<LearningEvent>(&memory.content) {
|
||||||
|
timeline.push(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by timestamp descending
|
||||||
|
timeline.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
|
||||||
|
|
||||||
|
Ok(timeline)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculate growth velocity (memories per day)
|
||||||
|
pub async fn get_growth_velocity(&self, agent_id: &AgentId) -> Result<f64> {
|
||||||
|
let timeline = self.get_timeline(agent_id).await?;
|
||||||
|
|
||||||
|
if timeline.is_empty() {
|
||||||
|
return Ok(0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get first and last event
|
||||||
|
let first = timeline.iter().min_by_key(|e| e.timestamp);
|
||||||
|
let last = timeline.iter().max_by_key(|e| e.timestamp);
|
||||||
|
|
||||||
|
match (first, last) {
|
||||||
|
(Some(first), Some(last)) => {
|
||||||
|
let days = (last.timestamp - first.timestamp).num_days().max(1) as f64;
|
||||||
|
let total_memories: usize = timeline.iter().map(|e| e.memories_extracted).sum();
|
||||||
|
Ok(total_memories as f64 / days)
|
||||||
|
}
|
||||||
|
_ => Ok(0.0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get memory distribution by category
|
||||||
|
pub async fn get_memory_distribution(
|
||||||
|
&self,
|
||||||
|
agent_id: &AgentId,
|
||||||
|
) -> Result<HashMap<String, usize>> {
|
||||||
|
let memories = self.viking.find_by_prefix(&format!("agent://{}", agent_id)).await?;
|
||||||
|
|
||||||
|
let mut distribution = HashMap::new();
|
||||||
|
for memory in memories {
|
||||||
|
*distribution.entry(memory.memory_type.to_string()).or_insert(0) += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(distribution)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Learning event record
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct LearningEvent {
|
||||||
|
/// Agent ID
|
||||||
|
pub agent_id: String,
|
||||||
|
/// Session ID where learning occurred
|
||||||
|
pub session_id: String,
|
||||||
|
/// Number of memories extracted
|
||||||
|
pub memories_extracted: usize,
|
||||||
|
/// Event timestamp
|
||||||
|
pub timestamp: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Agent metadata stored in OpenViking
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct AgentMetadata {
|
||||||
|
/// Last learning time
|
||||||
|
pub last_learning_time: Option<DateTime<Utc>>,
|
||||||
|
/// Total learning events (computed)
|
||||||
|
pub total_learning_events: Option<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_learning_event_serialization() {
|
||||||
|
let event = LearningEvent {
|
||||||
|
agent_id: "test-agent".to_string(),
|
||||||
|
session_id: "test-session".to_string(),
|
||||||
|
memories_extracted: 5,
|
||||||
|
timestamp: Utc::now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&event).unwrap();
|
||||||
|
let parsed: LearningEvent = serde_json::from_str(&json).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(parsed.agent_id, event.agent_id);
|
||||||
|
assert_eq!(parsed.memories_extracted, event.memories_extracted);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_agent_metadata_serialization() {
|
||||||
|
let meta = AgentMetadata {
|
||||||
|
last_learning_time: Some(Utc::now()),
|
||||||
|
total_learning_events: Some(10),
|
||||||
|
};
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&meta).unwrap();
|
||||||
|
let parsed: AgentMetadata = serde_json::from_str(&json).unwrap();
|
||||||
|
|
||||||
|
assert!(parsed.last_learning_time.is_some());
|
||||||
|
assert_eq!(parsed.total_learning_events, Some(10));
|
||||||
|
}
|
||||||
|
}
|
||||||
504
crates/zclaw-growth/src/types.rs
Normal file
504
crates/zclaw-growth/src/types.rs
Normal file
@@ -0,0 +1,504 @@
|
|||||||
|
//! Core type definitions for the ZCLAW Growth System
|
||||||
|
//!
|
||||||
|
//! This module defines the fundamental types used for memory management,
|
||||||
|
//! extraction, retrieval, and prompt injection.
|
||||||
|
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use zclaw_types::SessionId;
|
||||||
|
|
||||||
|
/// Memory type classification
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum MemoryType {
|
||||||
|
/// User preferences (communication style, format, language, etc.)
|
||||||
|
Preference,
|
||||||
|
/// Accumulated knowledge (user facts, domain knowledge, lessons learned)
|
||||||
|
Knowledge,
|
||||||
|
/// Skill/tool usage experience
|
||||||
|
Experience,
|
||||||
|
/// Conversation session history
|
||||||
|
Session,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for MemoryType {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
MemoryType::Preference => write!(f, "preferences"),
|
||||||
|
MemoryType::Knowledge => write!(f, "knowledge"),
|
||||||
|
MemoryType::Experience => write!(f, "experience"),
|
||||||
|
MemoryType::Session => write!(f, "sessions"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::str::FromStr for MemoryType {
|
||||||
|
type Err = String;
|
||||||
|
|
||||||
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||||
|
match s.to_lowercase().as_str() {
|
||||||
|
"preferences" | "preference" => Ok(MemoryType::Preference),
|
||||||
|
"knowledge" => Ok(MemoryType::Knowledge),
|
||||||
|
"experience" => Ok(MemoryType::Experience),
|
||||||
|
"sessions" | "session" => Ok(MemoryType::Session),
|
||||||
|
_ => Err(format!("Unknown memory type: {}", s)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MemoryType {
|
||||||
|
/// Parse memory type from string (returns Knowledge as default)
|
||||||
|
pub fn parse(s: &str) -> Self {
|
||||||
|
s.parse().unwrap_or(MemoryType::Knowledge)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Memory entry stored in OpenViking
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct MemoryEntry {
|
||||||
|
/// URI in OpenViking format: agent://{agent_id}/{type}/{category}
|
||||||
|
pub uri: String,
|
||||||
|
/// Type of memory
|
||||||
|
pub memory_type: MemoryType,
|
||||||
|
/// Memory content
|
||||||
|
pub content: String,
|
||||||
|
/// Keywords for semantic search
|
||||||
|
pub keywords: Vec<String>,
|
||||||
|
/// Importance score (1-10)
|
||||||
|
pub importance: u8,
|
||||||
|
/// Number of times accessed
|
||||||
|
pub access_count: u32,
|
||||||
|
/// Creation timestamp
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
/// Last access timestamp
|
||||||
|
pub last_accessed: DateTime<Utc>,
|
||||||
|
/// L1 overview: 1-2 sentence summary (~200 tokens)
|
||||||
|
pub overview: Option<String>,
|
||||||
|
/// L0 abstract: 3-5 keywords (~100 tokens)
|
||||||
|
pub abstract_summary: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MemoryEntry {
|
||||||
|
/// Create a new memory entry
|
||||||
|
pub fn new(
|
||||||
|
agent_id: &str,
|
||||||
|
memory_type: MemoryType,
|
||||||
|
category: &str,
|
||||||
|
content: String,
|
||||||
|
) -> Self {
|
||||||
|
let uri = format!("agent://{}/{}/{}", agent_id, memory_type, category);
|
||||||
|
Self {
|
||||||
|
uri,
|
||||||
|
memory_type,
|
||||||
|
content,
|
||||||
|
keywords: Vec::new(),
|
||||||
|
importance: 5,
|
||||||
|
access_count: 0,
|
||||||
|
created_at: Utc::now(),
|
||||||
|
last_accessed: Utc::now(),
|
||||||
|
overview: None,
|
||||||
|
abstract_summary: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add keywords to the memory entry
|
||||||
|
pub fn with_keywords(mut self, keywords: Vec<String>) -> Self {
|
||||||
|
self.keywords = keywords;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set importance score
|
||||||
|
pub fn with_importance(mut self, importance: u8) -> Self {
|
||||||
|
self.importance = importance.min(10).max(1);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set L1 overview summary
|
||||||
|
pub fn with_overview(mut self, overview: impl Into<String>) -> Self {
|
||||||
|
self.overview = Some(overview.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set L0 abstract summary
|
||||||
|
pub fn with_abstract_summary(mut self, abstract_summary: impl Into<String>) -> Self {
|
||||||
|
self.abstract_summary = Some(abstract_summary.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mark as accessed
|
||||||
|
pub fn touch(&mut self) {
|
||||||
|
self.access_count += 1;
|
||||||
|
self.last_accessed = Utc::now();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Estimate token count (roughly 4 characters per token for mixed content)
|
||||||
|
/// More accurate estimation considering Chinese characters (1.5 tokens avg)
|
||||||
|
pub fn estimated_tokens(&self) -> usize {
|
||||||
|
let char_count = self.content.chars().count();
|
||||||
|
let cjk_count = self.content.chars().filter(|c| is_cjk(*c)).count();
|
||||||
|
let non_cjk_count = char_count - cjk_count;
|
||||||
|
|
||||||
|
// CJK: ~1.5 tokens per char, non-CJK: ~0.25 tokens per char
|
||||||
|
(cjk_count as f32 * 1.5 + non_cjk_count as f32 * 0.25).ceil() as usize
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extracted memory from conversation analysis
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ExtractedMemory {
|
||||||
|
/// Type of extracted memory
|
||||||
|
pub memory_type: MemoryType,
|
||||||
|
/// Category within the memory type
|
||||||
|
pub category: String,
|
||||||
|
/// Memory content
|
||||||
|
pub content: String,
|
||||||
|
/// Extraction confidence (0.0 - 1.0)
|
||||||
|
pub confidence: f32,
|
||||||
|
/// Source session ID
|
||||||
|
pub source_session: SessionId,
|
||||||
|
/// Keywords extracted
|
||||||
|
pub keywords: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ExtractedMemory {
|
||||||
|
/// Create a new extracted memory
|
||||||
|
pub fn new(
|
||||||
|
memory_type: MemoryType,
|
||||||
|
category: impl Into<String>,
|
||||||
|
content: impl Into<String>,
|
||||||
|
source_session: SessionId,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
memory_type,
|
||||||
|
category: category.into(),
|
||||||
|
content: content.into(),
|
||||||
|
confidence: 0.8,
|
||||||
|
source_session,
|
||||||
|
keywords: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set confidence score
|
||||||
|
pub fn with_confidence(mut self, confidence: f32) -> Self {
|
||||||
|
self.confidence = confidence.clamp(0.0, 1.0);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add keywords
|
||||||
|
pub fn with_keywords(mut self, keywords: Vec<String>) -> Self {
|
||||||
|
self.keywords = keywords;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert to MemoryEntry for storage
|
||||||
|
pub fn to_memory_entry(&self, agent_id: &str) -> MemoryEntry {
|
||||||
|
MemoryEntry::new(agent_id, self.memory_type, &self.category, self.content.clone())
|
||||||
|
.with_keywords(self.keywords.clone())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retrieval configuration
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct RetrievalConfig {
|
||||||
|
/// Total token budget for retrieved memories
|
||||||
|
pub max_tokens: usize,
|
||||||
|
/// Token budget for preferences
|
||||||
|
pub preference_budget: usize,
|
||||||
|
/// Token budget for knowledge
|
||||||
|
pub knowledge_budget: usize,
|
||||||
|
/// Token budget for experience
|
||||||
|
pub experience_budget: usize,
|
||||||
|
/// Minimum similarity threshold (0.0 - 1.0)
|
||||||
|
pub min_similarity: f32,
|
||||||
|
/// Maximum number of results per type
|
||||||
|
pub max_results_per_type: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if character is CJK
|
||||||
|
fn is_cjk(c: char) -> bool {
|
||||||
|
matches!(c,
|
||||||
|
'\u{4E00}'..='\u{9FFF}' | // CJK Unified Ideographs
|
||||||
|
'\u{3400}'..='\u{4DBF}' | // CJK Unified Ideographs Extension A
|
||||||
|
'\u{20000}'..='\u{2A6DF}' | // CJK Unified Ideographs Extension B
|
||||||
|
'\u{F900}'..='\u{FAFF}' | // CJK Compatibility Ideographs
|
||||||
|
'\u{3040}'..='\u{309F}' | // Hiragana
|
||||||
|
'\u{30A0}'..='\u{30FF}' | // Katakana
|
||||||
|
'\u{AC00}'..='\u{D7AF}' // Hangul
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for RetrievalConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
max_tokens: 500,
|
||||||
|
preference_budget: 200,
|
||||||
|
knowledge_budget: 200,
|
||||||
|
experience_budget: 100,
|
||||||
|
min_similarity: 0.7,
|
||||||
|
max_results_per_type: 5,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RetrievalConfig {
|
||||||
|
/// Create a config with custom token budget
|
||||||
|
pub fn with_budget(max_tokens: usize) -> Self {
|
||||||
|
let pref = (max_tokens as f32 * 0.4) as usize;
|
||||||
|
let knowledge = (max_tokens as f32 * 0.4) as usize;
|
||||||
|
let exp = max_tokens.saturating_sub(pref).saturating_sub(knowledge);
|
||||||
|
|
||||||
|
Self {
|
||||||
|
max_tokens,
|
||||||
|
preference_budget: pref,
|
||||||
|
knowledge_budget: knowledge,
|
||||||
|
experience_budget: exp,
|
||||||
|
min_similarity: 0.7,
|
||||||
|
max_results_per_type: 5,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retrieval result containing memories by type
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct RetrievalResult {
|
||||||
|
/// Retrieved preferences
|
||||||
|
pub preferences: Vec<MemoryEntry>,
|
||||||
|
/// Retrieved knowledge
|
||||||
|
pub knowledge: Vec<MemoryEntry>,
|
||||||
|
/// Retrieved experience
|
||||||
|
pub experience: Vec<MemoryEntry>,
|
||||||
|
/// Total tokens used
|
||||||
|
pub total_tokens: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RetrievalResult {
|
||||||
|
/// Check if result is empty
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.preferences.is_empty()
|
||||||
|
&& self.knowledge.is_empty()
|
||||||
|
&& self.experience.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get total memory count
|
||||||
|
pub fn total_count(&self) -> usize {
|
||||||
|
self.preferences.len() + self.knowledge.len() + self.experience.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculate total tokens from entries
|
||||||
|
pub fn calculate_tokens(&self) -> usize {
|
||||||
|
let tokens: usize = self.preferences.iter()
|
||||||
|
.chain(self.knowledge.iter())
|
||||||
|
.chain(self.experience.iter())
|
||||||
|
.map(|m| m.estimated_tokens())
|
||||||
|
.sum();
|
||||||
|
tokens
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extraction configuration
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ExtractionConfig {
|
||||||
|
/// Extract preferences from conversation
|
||||||
|
pub extract_preferences: bool,
|
||||||
|
/// Extract knowledge from conversation
|
||||||
|
pub extract_knowledge: bool,
|
||||||
|
/// Extract experience from conversation
|
||||||
|
pub extract_experience: bool,
|
||||||
|
/// Minimum confidence threshold for extraction
|
||||||
|
pub min_confidence: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ExtractionConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
extract_preferences: true,
|
||||||
|
extract_knowledge: true,
|
||||||
|
extract_experience: true,
|
||||||
|
min_confidence: 0.6,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Growth statistics for an agent
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
pub struct GrowthStats {
|
||||||
|
/// Total number of memories
|
||||||
|
pub total_memories: usize,
|
||||||
|
/// Number of preferences
|
||||||
|
pub preference_count: usize,
|
||||||
|
/// Number of knowledge entries
|
||||||
|
pub knowledge_count: usize,
|
||||||
|
/// Number of experience entries
|
||||||
|
pub experience_count: usize,
|
||||||
|
/// Total sessions processed
|
||||||
|
pub sessions_processed: usize,
|
||||||
|
/// Last learning timestamp
|
||||||
|
pub last_learning_time: Option<DateTime<Utc>>,
|
||||||
|
/// Average extraction confidence
|
||||||
|
pub avg_confidence: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// OpenViking URI builder
|
||||||
|
pub struct UriBuilder;
|
||||||
|
|
||||||
|
impl UriBuilder {
|
||||||
|
/// Build a preference URI
|
||||||
|
pub fn preference(agent_id: &str, category: &str) -> String {
|
||||||
|
format!("agent://{}/preferences/{}", agent_id, category)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a knowledge URI
|
||||||
|
pub fn knowledge(agent_id: &str, domain: &str) -> String {
|
||||||
|
format!("agent://{}/knowledge/{}", agent_id, domain)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build an experience URI
|
||||||
|
pub fn experience(agent_id: &str, skill_id: &str) -> String {
|
||||||
|
format!("agent://{}/experience/{}", agent_id, skill_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a session URI
|
||||||
|
pub fn session(agent_id: &str, session_id: &str) -> String {
|
||||||
|
format!("agent://{}/sessions/{}", agent_id, session_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse agent ID from URI
|
||||||
|
pub fn parse_agent_id(uri: &str) -> Option<&str> {
|
||||||
|
uri.strip_prefix("agent://")?
|
||||||
|
.split('/')
|
||||||
|
.next()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse memory type from URI
|
||||||
|
pub fn parse_memory_type(uri: &str) -> Option<MemoryType> {
|
||||||
|
let after_agent = uri.strip_prefix("agent://")?;
|
||||||
|
let mut parts = after_agent.split('/');
|
||||||
|
parts.next()?; // Skip agent_id
|
||||||
|
|
||||||
|
match parts.next()? {
|
||||||
|
"preferences" => Some(MemoryType::Preference),
|
||||||
|
"knowledge" => Some(MemoryType::Knowledge),
|
||||||
|
"experience" => Some(MemoryType::Experience),
|
||||||
|
"sessions" => Some(MemoryType::Session),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_memory_type_display() {
|
||||||
|
assert_eq!(format!("{}", MemoryType::Preference), "preferences");
|
||||||
|
assert_eq!(format!("{}", MemoryType::Knowledge), "knowledge");
|
||||||
|
assert_eq!(format!("{}", MemoryType::Experience), "experience");
|
||||||
|
assert_eq!(format!("{}", MemoryType::Session), "sessions");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_memory_entry_creation() {
|
||||||
|
let entry = MemoryEntry::new(
|
||||||
|
"test-agent",
|
||||||
|
MemoryType::Preference,
|
||||||
|
"communication-style",
|
||||||
|
"User prefers concise responses".to_string(),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(entry.uri, "agent://test-agent/preferences/communication-style");
|
||||||
|
assert_eq!(entry.importance, 5);
|
||||||
|
assert_eq!(entry.access_count, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_memory_entry_touch() {
|
||||||
|
let mut entry = MemoryEntry::new(
|
||||||
|
"test-agent",
|
||||||
|
MemoryType::Knowledge,
|
||||||
|
"domain",
|
||||||
|
"content".to_string(),
|
||||||
|
);
|
||||||
|
|
||||||
|
entry.touch();
|
||||||
|
assert_eq!(entry.access_count, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_estimated_tokens() {
|
||||||
|
let entry = MemoryEntry::new(
|
||||||
|
"test-agent",
|
||||||
|
MemoryType::Preference,
|
||||||
|
"test",
|
||||||
|
"This is a test content that should be around 10 tokens".to_string(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// ~40 chars / 4 = ~10 tokens
|
||||||
|
assert!(entry.estimated_tokens() > 5);
|
||||||
|
assert!(entry.estimated_tokens() < 20);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_retrieval_config_default() {
|
||||||
|
let config = RetrievalConfig::default();
|
||||||
|
assert_eq!(config.max_tokens, 500);
|
||||||
|
assert_eq!(config.preference_budget, 200);
|
||||||
|
assert_eq!(config.knowledge_budget, 200);
|
||||||
|
assert_eq!(config.experience_budget, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_retrieval_config_with_budget() {
|
||||||
|
let config = RetrievalConfig::with_budget(1000);
|
||||||
|
assert_eq!(config.max_tokens, 1000);
|
||||||
|
assert!(config.preference_budget >= 350);
|
||||||
|
assert!(config.knowledge_budget >= 350);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_uri_builder() {
|
||||||
|
let pref_uri = UriBuilder::preference("agent-1", "style");
|
||||||
|
assert_eq!(pref_uri, "agent://agent-1/preferences/style");
|
||||||
|
|
||||||
|
let knowledge_uri = UriBuilder::knowledge("agent-1", "rust");
|
||||||
|
assert_eq!(knowledge_uri, "agent://agent-1/knowledge/rust");
|
||||||
|
|
||||||
|
let exp_uri = UriBuilder::experience("agent-1", "browser");
|
||||||
|
assert_eq!(exp_uri, "agent://agent-1/experience/browser");
|
||||||
|
|
||||||
|
let session_uri = UriBuilder::session("agent-1", "session-123");
|
||||||
|
assert_eq!(session_uri, "agent://agent-1/sessions/session-123");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_uri_parser() {
|
||||||
|
let uri = "agent://agent-1/preferences/style";
|
||||||
|
assert_eq!(UriBuilder::parse_agent_id(uri), Some("agent-1"));
|
||||||
|
assert_eq!(UriBuilder::parse_memory_type(uri), Some(MemoryType::Preference));
|
||||||
|
|
||||||
|
let invalid_uri = "invalid-uri";
|
||||||
|
assert!(UriBuilder::parse_agent_id(invalid_uri).is_none());
|
||||||
|
assert!(UriBuilder::parse_memory_type(invalid_uri).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_retrieval_result() {
|
||||||
|
let result = RetrievalResult::default();
|
||||||
|
assert!(result.is_empty());
|
||||||
|
assert_eq!(result.total_count(), 0);
|
||||||
|
|
||||||
|
let result = RetrievalResult {
|
||||||
|
preferences: vec![MemoryEntry::new(
|
||||||
|
"agent-1",
|
||||||
|
MemoryType::Preference,
|
||||||
|
"style",
|
||||||
|
"test".to_string(),
|
||||||
|
)],
|
||||||
|
knowledge: vec![],
|
||||||
|
experience: vec![],
|
||||||
|
total_tokens: 0,
|
||||||
|
};
|
||||||
|
assert!(!result.is_empty());
|
||||||
|
assert_eq!(result.total_count(), 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
362
crates/zclaw-growth/src/viking_adapter.rs
Normal file
362
crates/zclaw-growth/src/viking_adapter.rs
Normal file
@@ -0,0 +1,362 @@
|
|||||||
|
//! OpenViking Adapter - Interface to the OpenViking memory system
|
||||||
|
//!
|
||||||
|
//! This module provides the `VikingAdapter` which wraps the OpenViking
|
||||||
|
//! context database for storing and retrieving agent memories.
|
||||||
|
|
||||||
|
use crate::types::MemoryEntry;
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use serde::{de::DeserializeOwned, Serialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use zclaw_types::Result;
|
||||||
|
|
||||||
|
/// Search options for find operations
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct FindOptions {
|
||||||
|
/// Scope to search within (URI prefix)
|
||||||
|
pub scope: Option<String>,
|
||||||
|
/// Maximum results to return
|
||||||
|
pub limit: Option<usize>,
|
||||||
|
/// Minimum similarity threshold
|
||||||
|
pub min_similarity: Option<f32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// VikingStorage trait - core storage operations (dyn-compatible)
|
||||||
|
#[async_trait]
|
||||||
|
pub trait VikingStorage: Send + Sync {
|
||||||
|
/// Store a memory entry
|
||||||
|
async fn store(&self, entry: &MemoryEntry) -> Result<()>;
|
||||||
|
|
||||||
|
/// Get a memory entry by URI
|
||||||
|
async fn get(&self, uri: &str) -> Result<Option<MemoryEntry>>;
|
||||||
|
|
||||||
|
/// Find memories by query with options
|
||||||
|
async fn find(&self, query: &str, options: FindOptions) -> Result<Vec<MemoryEntry>>;
|
||||||
|
|
||||||
|
/// Find memories by URI prefix
|
||||||
|
async fn find_by_prefix(&self, prefix: &str) -> Result<Vec<MemoryEntry>>;
|
||||||
|
|
||||||
|
/// Delete a memory by URI
|
||||||
|
async fn delete(&self, uri: &str) -> Result<()>;
|
||||||
|
|
||||||
|
/// Store metadata as JSON string
|
||||||
|
async fn store_metadata_json(&self, key: &str, json: &str) -> Result<()>;
|
||||||
|
|
||||||
|
/// Get metadata as JSON string
|
||||||
|
async fn get_metadata_json(&self, key: &str) -> Result<Option<String>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// OpenViking adapter implementation
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct VikingAdapter {
|
||||||
|
/// Storage backend
|
||||||
|
backend: Arc<dyn VikingStorage>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl VikingAdapter {
|
||||||
|
/// Create a new Viking adapter with a storage backend
|
||||||
|
pub fn new(backend: Arc<dyn VikingStorage>) -> Self {
|
||||||
|
Self { backend }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create with in-memory storage (for testing)
|
||||||
|
pub fn in_memory() -> Self {
|
||||||
|
Self {
|
||||||
|
backend: Arc::new(InMemoryStorage::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Store a memory entry
|
||||||
|
pub async fn store(&self, entry: &MemoryEntry) -> Result<()> {
|
||||||
|
self.backend.store(entry).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a memory entry by URI
|
||||||
|
pub async fn get(&self, uri: &str) -> Result<Option<MemoryEntry>> {
|
||||||
|
self.backend.get(uri).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find memories by query
|
||||||
|
pub async fn find(&self, query: &str, options: FindOptions) -> Result<Vec<MemoryEntry>> {
|
||||||
|
self.backend.find(query, options).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find memories by URI prefix
|
||||||
|
pub async fn find_by_prefix(&self, prefix: &str) -> Result<Vec<MemoryEntry>> {
|
||||||
|
self.backend.find_by_prefix(prefix).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a memory
|
||||||
|
pub async fn delete(&self, uri: &str) -> Result<()> {
|
||||||
|
self.backend.delete(uri).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Store metadata (typed)
|
||||||
|
pub async fn store_metadata<T: Serialize>(&self, key: &str, value: &T) -> Result<()> {
|
||||||
|
let json = serde_json::to_string(value)?;
|
||||||
|
self.backend.store_metadata_json(key, &json).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get metadata (typed)
|
||||||
|
pub async fn get_metadata<T: DeserializeOwned>(&self, key: &str) -> Result<Option<T>> {
|
||||||
|
match self.backend.get_metadata_json(key).await? {
|
||||||
|
Some(json) => {
|
||||||
|
let value: T = serde_json::from_str(&json)?;
|
||||||
|
Ok(Some(value))
|
||||||
|
}
|
||||||
|
None => Ok(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// In-memory storage backend (for testing and development)
|
||||||
|
pub struct InMemoryStorage {
|
||||||
|
memories: std::sync::RwLock<HashMap<String, MemoryEntry>>,
|
||||||
|
metadata: std::sync::RwLock<HashMap<String, String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InMemoryStorage {
|
||||||
|
/// Create a new in-memory storage
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
memories: std::sync::RwLock::new(HashMap::new()),
|
||||||
|
metadata: std::sync::RwLock::new(HashMap::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for InMemoryStorage {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl VikingStorage for InMemoryStorage {
|
||||||
|
async fn store(&self, entry: &MemoryEntry) -> Result<()> {
|
||||||
|
let mut memories = self.memories.write().unwrap();
|
||||||
|
memories.insert(entry.uri.clone(), entry.clone());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get(&self, uri: &str) -> Result<Option<MemoryEntry>> {
|
||||||
|
let memories = self.memories.read().unwrap();
|
||||||
|
Ok(memories.get(uri).cloned())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn find(&self, query: &str, options: FindOptions) -> Result<Vec<MemoryEntry>> {
|
||||||
|
let memories = self.memories.read().unwrap();
|
||||||
|
|
||||||
|
let mut results: Vec<MemoryEntry> = memories
|
||||||
|
.values()
|
||||||
|
.filter(|entry| {
|
||||||
|
// Apply scope filter
|
||||||
|
if let Some(ref scope) = options.scope {
|
||||||
|
if !entry.uri.starts_with(scope) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simple text matching (in real implementation, use semantic search)
|
||||||
|
if !query.is_empty() {
|
||||||
|
let query_lower = query.to_lowercase();
|
||||||
|
let content_lower = entry.content.to_lowercase();
|
||||||
|
let keywords_match = entry.keywords.iter().any(|k| k.to_lowercase().contains(&query_lower));
|
||||||
|
|
||||||
|
content_lower.contains(&query_lower) || keywords_match
|
||||||
|
} else {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Sort by importance and access count
|
||||||
|
results.sort_by(|a, b| {
|
||||||
|
b.importance
|
||||||
|
.cmp(&a.importance)
|
||||||
|
.then_with(|| b.access_count.cmp(&a.access_count))
|
||||||
|
});
|
||||||
|
|
||||||
|
// Apply limit
|
||||||
|
if let Some(limit) = options.limit {
|
||||||
|
results.truncate(limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(results)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn find_by_prefix(&self, prefix: &str) -> Result<Vec<MemoryEntry>> {
|
||||||
|
let memories = self.memories.read().unwrap();
|
||||||
|
|
||||||
|
let results: Vec<MemoryEntry> = memories
|
||||||
|
.values()
|
||||||
|
.filter(|entry| entry.uri.starts_with(prefix))
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(results)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete(&self, uri: &str) -> Result<()> {
|
||||||
|
let mut memories = self.memories.write().unwrap();
|
||||||
|
memories.remove(uri);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn store_metadata_json(&self, key: &str, json: &str) -> Result<()> {
|
||||||
|
let mut metadata = self.metadata.write().unwrap();
|
||||||
|
metadata.insert(key.to_string(), json.to_string());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_metadata_json(&self, key: &str) -> Result<Option<String>> {
|
||||||
|
let metadata = self.metadata.read().unwrap();
|
||||||
|
Ok(metadata.get(key).cloned())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// OpenViking levels for storage
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum VikingLevel {
|
||||||
|
/// L0: Raw data (original content)
|
||||||
|
L0,
|
||||||
|
/// L1: Summarized content
|
||||||
|
L1,
|
||||||
|
/// L2: Keywords and metadata
|
||||||
|
L2,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for VikingLevel {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
VikingLevel::L0 => write!(f, "L0"),
|
||||||
|
VikingLevel::L1 => write!(f, "L1"),
|
||||||
|
VikingLevel::L2 => write!(f, "L2"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::types::MemoryType;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_in_memory_storage_store_and_get() {
|
||||||
|
let storage = InMemoryStorage::new();
|
||||||
|
let entry = MemoryEntry::new(
|
||||||
|
"test-agent",
|
||||||
|
MemoryType::Preference,
|
||||||
|
"style",
|
||||||
|
"test content".to_string(),
|
||||||
|
);
|
||||||
|
|
||||||
|
storage.store(&entry).await.unwrap();
|
||||||
|
let retrieved = storage.get(&entry.uri).await.unwrap();
|
||||||
|
|
||||||
|
assert!(retrieved.is_some());
|
||||||
|
assert_eq!(retrieved.unwrap().content, "test content");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_in_memory_storage_find() {
|
||||||
|
let storage = InMemoryStorage::new();
|
||||||
|
|
||||||
|
let entry1 = MemoryEntry::new(
|
||||||
|
"agent-1",
|
||||||
|
MemoryType::Knowledge,
|
||||||
|
"rust",
|
||||||
|
"Rust programming tips".to_string(),
|
||||||
|
);
|
||||||
|
let entry2 = MemoryEntry::new(
|
||||||
|
"agent-1",
|
||||||
|
MemoryType::Knowledge,
|
||||||
|
"python",
|
||||||
|
"Python programming tips".to_string(),
|
||||||
|
);
|
||||||
|
|
||||||
|
storage.store(&entry1).await.unwrap();
|
||||||
|
storage.store(&entry2).await.unwrap();
|
||||||
|
|
||||||
|
let results = storage
|
||||||
|
.find(
|
||||||
|
"Rust",
|
||||||
|
FindOptions {
|
||||||
|
scope: Some("agent://agent-1".to_string()),
|
||||||
|
limit: Some(10),
|
||||||
|
min_similarity: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(results.len(), 1);
|
||||||
|
assert!(results[0].content.contains("Rust"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_in_memory_storage_delete() {
|
||||||
|
let storage = InMemoryStorage::new();
|
||||||
|
let entry = MemoryEntry::new(
|
||||||
|
"test-agent",
|
||||||
|
MemoryType::Preference,
|
||||||
|
"style",
|
||||||
|
"test".to_string(),
|
||||||
|
);
|
||||||
|
|
||||||
|
storage.store(&entry).await.unwrap();
|
||||||
|
storage.delete(&entry.uri).await.unwrap();
|
||||||
|
|
||||||
|
let retrieved = storage.get(&entry.uri).await.unwrap();
|
||||||
|
assert!(retrieved.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_metadata_storage() {
|
||||||
|
let storage = InMemoryStorage::new();
|
||||||
|
|
||||||
|
#[derive(Serialize, serde::Deserialize)]
|
||||||
|
struct TestData {
|
||||||
|
value: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
let data = TestData {
|
||||||
|
value: "test".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
storage.store_metadata_json("test-key", &serde_json::to_string(&data).unwrap()).await.unwrap();
|
||||||
|
let json = storage.get_metadata_json("test-key").await.unwrap();
|
||||||
|
|
||||||
|
assert!(json.is_some());
|
||||||
|
let retrieved: TestData = serde_json::from_str(&json.unwrap()).unwrap();
|
||||||
|
assert_eq!(retrieved.value, "test");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_viking_adapter_typed_metadata() {
|
||||||
|
let adapter = VikingAdapter::in_memory();
|
||||||
|
|
||||||
|
#[derive(Serialize, serde::Deserialize)]
|
||||||
|
struct TestData {
|
||||||
|
value: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
let data = TestData {
|
||||||
|
value: "test".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
adapter.store_metadata("test-key", &data).await.unwrap();
|
||||||
|
let retrieved: Option<TestData> = adapter.get_metadata("test-key").await.unwrap();
|
||||||
|
|
||||||
|
assert!(retrieved.is_some());
|
||||||
|
assert_eq!(retrieved.unwrap().value, "test");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_viking_level_display() {
|
||||||
|
assert_eq!(format!("{}", VikingLevel::L0), "L0");
|
||||||
|
assert_eq!(format!("{}", VikingLevel::L1), "L1");
|
||||||
|
assert_eq!(format!("{}", VikingLevel::L2), "L2");
|
||||||
|
}
|
||||||
|
}
|
||||||
412
crates/zclaw-growth/tests/integration_test.rs
Normal file
412
crates/zclaw-growth/tests/integration_test.rs
Normal file
@@ -0,0 +1,412 @@
|
|||||||
|
//! Integration tests for ZCLAW Growth System
|
||||||
|
//!
|
||||||
|
//! Tests the complete flow: store → find → inject
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
use zclaw_growth::{
|
||||||
|
FindOptions, MemoryEntry, MemoryRetriever, MemoryType, PromptInjector,
|
||||||
|
RetrievalConfig, RetrievalResult, SqliteStorage, VikingAdapter,
|
||||||
|
};
|
||||||
|
use zclaw_types::AgentId;
|
||||||
|
|
||||||
|
/// Test complete memory lifecycle
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_memory_lifecycle() {
|
||||||
|
let storage = Arc::new(SqliteStorage::in_memory().await);
|
||||||
|
let adapter = Arc::new(VikingAdapter::new(storage));
|
||||||
|
|
||||||
|
// Create agent ID and use its string form for storage
|
||||||
|
let agent_id = AgentId::new();
|
||||||
|
let agent_str = agent_id.to_string();
|
||||||
|
|
||||||
|
// 1. Store a preference
|
||||||
|
let pref = MemoryEntry::new(
|
||||||
|
&agent_str,
|
||||||
|
MemoryType::Preference,
|
||||||
|
"communication-style",
|
||||||
|
"用户偏好简洁的回复,不喜欢冗长的解释".to_string(),
|
||||||
|
)
|
||||||
|
.with_keywords(vec!["简洁".to_string(), "沟通风格".to_string()])
|
||||||
|
.with_importance(8);
|
||||||
|
|
||||||
|
adapter.store(&pref).await.unwrap();
|
||||||
|
|
||||||
|
// 2. Store knowledge
|
||||||
|
let knowledge = MemoryEntry::new(
|
||||||
|
&agent_str,
|
||||||
|
MemoryType::Knowledge,
|
||||||
|
"rust-expertise",
|
||||||
|
"用户是 Rust 开发者,熟悉 async/await 和 trait 系统".to_string(),
|
||||||
|
)
|
||||||
|
.with_keywords(vec!["Rust".to_string(), "开发者".to_string()]);
|
||||||
|
|
||||||
|
adapter.store(&knowledge).await.unwrap();
|
||||||
|
|
||||||
|
// 3. Store experience
|
||||||
|
let experience = MemoryEntry::new(
|
||||||
|
&agent_str,
|
||||||
|
MemoryType::Experience,
|
||||||
|
"browser-skill",
|
||||||
|
"浏览器技能在搜索技术文档时效果很好".to_string(),
|
||||||
|
)
|
||||||
|
.with_keywords(vec!["浏览器".to_string(), "技能".to_string()]);
|
||||||
|
|
||||||
|
adapter.store(&experience).await.unwrap();
|
||||||
|
|
||||||
|
// 4. Retrieve memories - directly from adapter first
|
||||||
|
let direct_results = adapter
|
||||||
|
.find(
|
||||||
|
"Rust",
|
||||||
|
FindOptions {
|
||||||
|
scope: Some(format!("agent://{}", agent_str)),
|
||||||
|
limit: Some(10),
|
||||||
|
min_similarity: Some(0.1),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
println!("Direct find results: {:?}", direct_results.len());
|
||||||
|
|
||||||
|
let retriever = MemoryRetriever::new(adapter.clone());
|
||||||
|
// Use lower similarity threshold for testing
|
||||||
|
let config = RetrievalConfig {
|
||||||
|
min_similarity: 0.1,
|
||||||
|
..RetrievalConfig::default()
|
||||||
|
};
|
||||||
|
let retriever = retriever.with_config(config);
|
||||||
|
let result = retriever
|
||||||
|
.retrieve(&agent_id, "Rust 编程")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
println!("Knowledge results: {:?}", result.knowledge.len());
|
||||||
|
println!("Preferences results: {:?}", result.preferences.len());
|
||||||
|
println!("Experience results: {:?}", result.experience.len());
|
||||||
|
|
||||||
|
// Should find the knowledge entry
|
||||||
|
assert!(!result.knowledge.is_empty(), "Expected to find knowledge entries but found none. Direct results: {}", direct_results.len());
|
||||||
|
assert!(result.knowledge[0].content.contains("Rust"));
|
||||||
|
|
||||||
|
// 5. Inject into prompt
|
||||||
|
let injector = PromptInjector::new();
|
||||||
|
let base_prompt = "你是一个有帮助的 AI 助手。";
|
||||||
|
let enhanced = injector.inject_with_format(base_prompt, &result);
|
||||||
|
|
||||||
|
// Enhanced prompt should contain memory context
|
||||||
|
assert!(enhanced.len() > base_prompt.len());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test semantic search ranking
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_semantic_search_ranking() {
|
||||||
|
let storage = Arc::new(SqliteStorage::in_memory().await);
|
||||||
|
let adapter = Arc::new(VikingAdapter::new(storage.clone()));
|
||||||
|
|
||||||
|
// Store multiple entries with different relevance
|
||||||
|
let entries = vec![
|
||||||
|
MemoryEntry::new(
|
||||||
|
"agent-1",
|
||||||
|
MemoryType::Knowledge,
|
||||||
|
"rust-basics",
|
||||||
|
"Rust 是一门系统编程语言,注重安全性和性能".to_string(),
|
||||||
|
)
|
||||||
|
.with_keywords(vec!["Rust".to_string(), "系统编程".to_string()]),
|
||||||
|
MemoryEntry::new(
|
||||||
|
"agent-1",
|
||||||
|
MemoryType::Knowledge,
|
||||||
|
"python-basics",
|
||||||
|
"Python 是一门高级编程语言,易于学习".to_string(),
|
||||||
|
)
|
||||||
|
.with_keywords(vec!["Python".to_string(), "高级语言".to_string()]),
|
||||||
|
MemoryEntry::new(
|
||||||
|
"agent-1",
|
||||||
|
MemoryType::Knowledge,
|
||||||
|
"rust-async",
|
||||||
|
"Rust 的 async/await 语法用于异步编程".to_string(),
|
||||||
|
)
|
||||||
|
.with_keywords(vec!["Rust".to_string(), "async".to_string(), "异步".to_string()]),
|
||||||
|
];
|
||||||
|
|
||||||
|
for entry in &entries {
|
||||||
|
adapter.store(entry).await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search for "Rust 异步编程"
|
||||||
|
let results = adapter
|
||||||
|
.find(
|
||||||
|
"Rust 异步编程",
|
||||||
|
FindOptions {
|
||||||
|
scope: Some("agent://agent-1".to_string()),
|
||||||
|
limit: Some(10),
|
||||||
|
min_similarity: Some(0.1),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Rust async entry should rank highest
|
||||||
|
assert!(!results.is_empty());
|
||||||
|
assert!(results[0].content.contains("async") || results[0].content.contains("Rust"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test memory importance and access count
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_importance_and_access() {
|
||||||
|
let storage = Arc::new(SqliteStorage::in_memory().await);
|
||||||
|
let adapter = Arc::new(VikingAdapter::new(storage.clone()));
|
||||||
|
|
||||||
|
// Create entries with different importance
|
||||||
|
let high_importance = MemoryEntry::new(
|
||||||
|
"agent-1",
|
||||||
|
MemoryType::Preference,
|
||||||
|
"critical",
|
||||||
|
"这是非常重要的偏好".to_string(),
|
||||||
|
)
|
||||||
|
.with_importance(10);
|
||||||
|
|
||||||
|
let low_importance = MemoryEntry::new(
|
||||||
|
"agent-1",
|
||||||
|
MemoryType::Preference,
|
||||||
|
"minor",
|
||||||
|
"这是不太重要的偏好".to_string(),
|
||||||
|
)
|
||||||
|
.with_importance(2);
|
||||||
|
|
||||||
|
adapter.store(&high_importance).await.unwrap();
|
||||||
|
adapter.store(&low_importance).await.unwrap();
|
||||||
|
|
||||||
|
// Access the low importance one multiple times
|
||||||
|
for _ in 0..5 {
|
||||||
|
let _ = adapter.get(&low_importance.uri).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search should consider both importance and access count
|
||||||
|
let results = adapter
|
||||||
|
.find(
|
||||||
|
"偏好",
|
||||||
|
FindOptions {
|
||||||
|
scope: Some("agent://agent-1".to_string()),
|
||||||
|
limit: Some(10),
|
||||||
|
min_similarity: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(results.len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test prompt injection with token budget
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_prompt_injection_token_budget() {
|
||||||
|
let mut result = RetrievalResult::default();
|
||||||
|
|
||||||
|
// Add memories that exceed budget
|
||||||
|
for i in 0..10 {
|
||||||
|
result.preferences.push(
|
||||||
|
MemoryEntry::new(
|
||||||
|
"agent-1",
|
||||||
|
MemoryType::Preference,
|
||||||
|
&format!("pref-{}", i),
|
||||||
|
"这是一个很长的偏好描述,用于测试 token 预算控制功能。".repeat(5),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
result.total_tokens = result.calculate_tokens();
|
||||||
|
|
||||||
|
// Budget is 500 tokens by default
|
||||||
|
let injector = PromptInjector::new();
|
||||||
|
let base = "Base prompt";
|
||||||
|
let enhanced = injector.inject_with_format(base, &result);
|
||||||
|
|
||||||
|
// Should include memory context
|
||||||
|
assert!(enhanced.len() > base.len());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test metadata storage
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_metadata_operations() {
|
||||||
|
let storage = Arc::new(SqliteStorage::in_memory().await);
|
||||||
|
let adapter = Arc::new(VikingAdapter::new(storage));
|
||||||
|
|
||||||
|
// Store metadata using typed API
|
||||||
|
#[derive(serde::Serialize, serde::Deserialize, PartialEq, Debug)]
|
||||||
|
struct Config {
|
||||||
|
version: String,
|
||||||
|
auto_extract: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
let config = Config {
|
||||||
|
version: "1.0.0".to_string(),
|
||||||
|
auto_extract: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
adapter.store_metadata("agent-config", &config).await.unwrap();
|
||||||
|
|
||||||
|
// Retrieve metadata
|
||||||
|
let retrieved: Option<Config> = adapter.get_metadata("agent-config").await.unwrap();
|
||||||
|
assert!(retrieved.is_some());
|
||||||
|
|
||||||
|
let parsed = retrieved.unwrap();
|
||||||
|
assert_eq!(parsed.version, "1.0.0");
|
||||||
|
assert_eq!(parsed.auto_extract, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test memory deletion and cleanup
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_memory_deletion() {
|
||||||
|
let storage = Arc::new(SqliteStorage::in_memory().await);
|
||||||
|
let adapter = Arc::new(VikingAdapter::new(storage));
|
||||||
|
|
||||||
|
let entry = MemoryEntry::new(
|
||||||
|
"agent-1",
|
||||||
|
MemoryType::Knowledge,
|
||||||
|
"temp",
|
||||||
|
"Temporary knowledge".to_string(),
|
||||||
|
);
|
||||||
|
|
||||||
|
adapter.store(&entry).await.unwrap();
|
||||||
|
|
||||||
|
// Verify stored
|
||||||
|
let retrieved = adapter.get(&entry.uri).await.unwrap();
|
||||||
|
assert!(retrieved.is_some());
|
||||||
|
|
||||||
|
// Delete
|
||||||
|
adapter.delete(&entry.uri).await.unwrap();
|
||||||
|
|
||||||
|
// Verify deleted
|
||||||
|
let retrieved = adapter.get(&entry.uri).await.unwrap();
|
||||||
|
assert!(retrieved.is_none());
|
||||||
|
|
||||||
|
// Verify not in search results
|
||||||
|
let results = adapter
|
||||||
|
.find(
|
||||||
|
"Temporary",
|
||||||
|
FindOptions {
|
||||||
|
scope: Some("agent://agent-1".to_string()),
|
||||||
|
limit: Some(10),
|
||||||
|
min_similarity: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(results.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test cross-agent isolation
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_agent_isolation() {
|
||||||
|
let storage = Arc::new(SqliteStorage::in_memory().await);
|
||||||
|
let adapter = Arc::new(VikingAdapter::new(storage));
|
||||||
|
|
||||||
|
// Store memories for different agents
|
||||||
|
let agent1_memory = MemoryEntry::new(
|
||||||
|
"agent-1",
|
||||||
|
MemoryType::Knowledge,
|
||||||
|
"secret",
|
||||||
|
"Agent 1 的秘密信息".to_string(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let agent2_memory = MemoryEntry::new(
|
||||||
|
"agent-2",
|
||||||
|
MemoryType::Knowledge,
|
||||||
|
"secret",
|
||||||
|
"Agent 2 的秘密信息".to_string(),
|
||||||
|
);
|
||||||
|
|
||||||
|
adapter.store(&agent1_memory).await.unwrap();
|
||||||
|
adapter.store(&agent2_memory).await.unwrap();
|
||||||
|
|
||||||
|
// Agent 1 should only see its own memories
|
||||||
|
let results = adapter
|
||||||
|
.find(
|
||||||
|
"秘密",
|
||||||
|
FindOptions {
|
||||||
|
scope: Some("agent://agent-1".to_string()),
|
||||||
|
limit: Some(10),
|
||||||
|
min_similarity: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(results.len(), 1);
|
||||||
|
assert!(results[0].content.contains("Agent 1"));
|
||||||
|
|
||||||
|
// Agent 2 should only see its own memories
|
||||||
|
let results = adapter
|
||||||
|
.find(
|
||||||
|
"秘密",
|
||||||
|
FindOptions {
|
||||||
|
scope: Some("agent://agent-2".to_string()),
|
||||||
|
limit: Some(10),
|
||||||
|
min_similarity: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(results.len(), 1);
|
||||||
|
assert!(results[0].content.contains("Agent 2"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test Chinese text handling
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_chinese_text_handling() {
|
||||||
|
let storage = Arc::new(SqliteStorage::in_memory().await);
|
||||||
|
let adapter = Arc::new(VikingAdapter::new(storage));
|
||||||
|
|
||||||
|
let entry = MemoryEntry::new(
|
||||||
|
"中文测试",
|
||||||
|
MemoryType::Knowledge,
|
||||||
|
"中文知识",
|
||||||
|
"这是一个中文测试,包含关键词:人工智能、机器学习、深度学习。".to_string(),
|
||||||
|
)
|
||||||
|
.with_keywords(vec!["人工智能".to_string(), "机器学习".to_string()]);
|
||||||
|
|
||||||
|
adapter.store(&entry).await.unwrap();
|
||||||
|
|
||||||
|
// Search with Chinese query
|
||||||
|
let results = adapter
|
||||||
|
.find(
|
||||||
|
"人工智能",
|
||||||
|
FindOptions {
|
||||||
|
scope: Some("agent://中文测试".to_string()),
|
||||||
|
limit: Some(10),
|
||||||
|
min_similarity: Some(0.1),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(!results.is_empty());
|
||||||
|
assert!(results[0].content.contains("人工智能"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test find by prefix
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_find_by_prefix() {
|
||||||
|
let storage = Arc::new(SqliteStorage::in_memory().await);
|
||||||
|
let adapter = Arc::new(VikingAdapter::new(storage));
|
||||||
|
|
||||||
|
// Store multiple entries under same agent
|
||||||
|
for i in 0..5 {
|
||||||
|
let entry = MemoryEntry::new(
|
||||||
|
"agent-1",
|
||||||
|
MemoryType::Knowledge,
|
||||||
|
&format!("topic-{}", i),
|
||||||
|
format!("Content for topic {}", i),
|
||||||
|
);
|
||||||
|
adapter.store(&entry).await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find all entries for agent-1
|
||||||
|
let results = adapter
|
||||||
|
.find_by_prefix("agent://agent-1")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(results.len(), 5);
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ description = "ZCLAW Hands - autonomous capabilities"
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
zclaw-types = { workspace = true }
|
zclaw-types = { workspace = true }
|
||||||
|
zclaw-runtime = { workspace = true }
|
||||||
|
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
|
|||||||
@@ -132,8 +132,8 @@ impl BrowserHand {
|
|||||||
Self {
|
Self {
|
||||||
config: HandConfig {
|
config: HandConfig {
|
||||||
id: "browser".to_string(),
|
id: "browser".to_string(),
|
||||||
name: "Browser".to_string(),
|
name: "浏览器".to_string(),
|
||||||
description: "Web browser automation for navigation, interaction, and scraping".to_string(),
|
description: "网页浏览器自动化,支持导航、交互和数据采集".to_string(),
|
||||||
needs_approval: false,
|
needs_approval: false,
|
||||||
dependencies: vec!["webdriver".to_string()],
|
dependencies: vec!["webdriver".to_string()],
|
||||||
input_schema: Some(serde_json::json!({
|
input_schema: Some(serde_json::json!({
|
||||||
|
|||||||
@@ -170,8 +170,8 @@ impl ClipHand {
|
|||||||
Self {
|
Self {
|
||||||
config: HandConfig {
|
config: HandConfig {
|
||||||
id: "clip".to_string(),
|
id: "clip".to_string(),
|
||||||
name: "Clip".to_string(),
|
name: "视频剪辑".to_string(),
|
||||||
description: "Video processing and editing capabilities using FFmpeg".to_string(),
|
description: "使用 FFmpeg 进行视频处理和编辑".to_string(),
|
||||||
needs_approval: false,
|
needs_approval: false,
|
||||||
dependencies: vec!["ffmpeg".to_string()],
|
dependencies: vec!["ffmpeg".to_string()],
|
||||||
input_schema: Some(serde_json::json!({
|
input_schema: Some(serde_json::json!({
|
||||||
|
|||||||
@@ -113,8 +113,8 @@ impl CollectorHand {
|
|||||||
Self {
|
Self {
|
||||||
config: HandConfig {
|
config: HandConfig {
|
||||||
id: "collector".to_string(),
|
id: "collector".to_string(),
|
||||||
name: "Collector".to_string(),
|
name: "数据采集器".to_string(),
|
||||||
description: "Data collection and aggregation from web sources".to_string(),
|
description: "从网页源收集和聚合数据".to_string(),
|
||||||
needs_approval: false,
|
needs_approval: false,
|
||||||
dependencies: vec!["network".to_string()],
|
dependencies: vec!["network".to_string()],
|
||||||
input_schema: Some(serde_json::json!({
|
input_schema: Some(serde_json::json!({
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
mod whiteboard;
|
mod whiteboard;
|
||||||
mod slideshow;
|
mod slideshow;
|
||||||
mod speech;
|
mod speech;
|
||||||
mod quiz;
|
pub mod quiz;
|
||||||
mod browser;
|
mod browser;
|
||||||
mod researcher;
|
mod researcher;
|
||||||
mod collector;
|
mod collector;
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ use std::sync::Arc;
|
|||||||
use tokio::sync::RwLock;
|
use tokio::sync::RwLock;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
use zclaw_types::Result;
|
use zclaw_types::Result;
|
||||||
|
use zclaw_runtime::driver::{LlmDriver, CompletionRequest};
|
||||||
|
|
||||||
use crate::{Hand, HandConfig, HandContext, HandResult, HandStatus};
|
use crate::{Hand, HandConfig, HandContext, HandResult, HandStatus};
|
||||||
|
|
||||||
@@ -44,29 +45,242 @@ impl QuizGenerator for DefaultQuizGenerator {
|
|||||||
difficulty: &DifficultyLevel,
|
difficulty: &DifficultyLevel,
|
||||||
_question_types: &[QuestionType],
|
_question_types: &[QuestionType],
|
||||||
) -> Result<Vec<QuizQuestion>> {
|
) -> Result<Vec<QuizQuestion>> {
|
||||||
// Generate placeholder questions
|
// Generate placeholder questions with randomized correct answers
|
||||||
|
let options_pool: Vec<Vec<String>> = vec![
|
||||||
|
vec!["光合作用".into(), "呼吸作用".into(), "蒸腾作用".into(), "运输作用".into()],
|
||||||
|
vec!["牛顿".into(), "爱因斯坦".into(), "伽利略".into(), "开普勒".into()],
|
||||||
|
vec!["太平洋".into(), "大西洋".into(), "印度洋".into(), "北冰洋".into()],
|
||||||
|
vec!["DNA".into(), "RNA".into(), "蛋白质".into(), "碳水化合物".into()],
|
||||||
|
vec!["引力".into(), "电磁力".into(), "强力".into(), "弱力".into()],
|
||||||
|
];
|
||||||
|
|
||||||
Ok((0..count)
|
Ok((0..count)
|
||||||
.map(|i| QuizQuestion {
|
.map(|i| {
|
||||||
|
let pool_idx = i % options_pool.len();
|
||||||
|
let mut opts = options_pool[pool_idx].clone();
|
||||||
|
// Shuffle options to randomize correct answer position
|
||||||
|
let correct_idx = (i * 3 + 1) % opts.len();
|
||||||
|
opts.swap(0, correct_idx);
|
||||||
|
let correct = opts[0].clone();
|
||||||
|
|
||||||
|
QuizQuestion {
|
||||||
id: uuid_v4(),
|
id: uuid_v4(),
|
||||||
question_type: QuestionType::MultipleChoice,
|
question_type: QuestionType::MultipleChoice,
|
||||||
question: format!("Question {} about {}", i + 1, topic),
|
question: format!("关于{}的第{}题({}难度)", topic, i + 1, match difficulty {
|
||||||
options: Some(vec![
|
DifficultyLevel::Easy => "简单",
|
||||||
"Option A".to_string(),
|
DifficultyLevel::Medium => "中等",
|
||||||
"Option B".to_string(),
|
DifficultyLevel::Hard => "困难",
|
||||||
"Option C".to_string(),
|
DifficultyLevel::Adaptive => "自适应",
|
||||||
"Option D".to_string(),
|
}),
|
||||||
]),
|
options: Some(opts),
|
||||||
correct_answer: Answer::Single("Option A".to_string()),
|
correct_answer: Answer::Single(correct),
|
||||||
explanation: Some(format!("Explanation for question {}", i + 1)),
|
explanation: Some(format!("第{}题的详细解释", i + 1)),
|
||||||
hints: Some(vec![format!("Hint 1 for question {}", i + 1)]),
|
hints: Some(vec![format!("提示:仔细阅读关于{}的内容", topic)]),
|
||||||
points: 10.0,
|
points: 10.0,
|
||||||
difficulty: difficulty.clone(),
|
difficulty: difficulty.clone(),
|
||||||
tags: vec![topic.to_string()],
|
tags: vec![topic.to_string()],
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// LLM-powered quiz generator that produces real questions via an LLM driver.
|
||||||
|
pub struct LlmQuizGenerator {
|
||||||
|
driver: Arc<dyn LlmDriver>,
|
||||||
|
model: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LlmQuizGenerator {
|
||||||
|
pub fn new(driver: Arc<dyn LlmDriver>, model: String) -> Self {
|
||||||
|
Self { driver, model }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl QuizGenerator for LlmQuizGenerator {
|
||||||
|
async fn generate_questions(
|
||||||
|
&self,
|
||||||
|
topic: &str,
|
||||||
|
content: Option<&str>,
|
||||||
|
count: usize,
|
||||||
|
difficulty: &DifficultyLevel,
|
||||||
|
question_types: &[QuestionType],
|
||||||
|
) -> Result<Vec<QuizQuestion>> {
|
||||||
|
let difficulty_str = match difficulty {
|
||||||
|
DifficultyLevel::Easy => "简单",
|
||||||
|
DifficultyLevel::Medium => "中等",
|
||||||
|
DifficultyLevel::Hard => "困难",
|
||||||
|
DifficultyLevel::Adaptive => "中等",
|
||||||
|
};
|
||||||
|
|
||||||
|
let type_str = if question_types.is_empty() {
|
||||||
|
String::from("选择题(multiple_choice)")
|
||||||
|
} else {
|
||||||
|
question_types
|
||||||
|
.iter()
|
||||||
|
.map(|t| match t {
|
||||||
|
QuestionType::MultipleChoice => "选择题",
|
||||||
|
QuestionType::TrueFalse => "判断题",
|
||||||
|
QuestionType::FillBlank => "填空题",
|
||||||
|
QuestionType::ShortAnswer => "简答题",
|
||||||
|
QuestionType::Essay => "论述题",
|
||||||
|
_ => "选择题",
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(",")
|
||||||
|
};
|
||||||
|
|
||||||
|
let content_section = match content {
|
||||||
|
Some(c) if !c.is_empty() => format!("\n\n参考内容:\n{}", &c[..c.len().min(3000)]),
|
||||||
|
_ => String::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let content_note = if content.is_some() && content.map_or(false, |c| !c.is_empty()) {
|
||||||
|
"(基于提供的参考内容出题)"
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
};
|
||||||
|
|
||||||
|
let prompt = format!(
|
||||||
|
r#"你是一个专业的出题专家。请根据以下要求生成测验题目:
|
||||||
|
|
||||||
|
主题: {}
|
||||||
|
难度: {}
|
||||||
|
题目类型: {}
|
||||||
|
数量: {}{}
|
||||||
|
{}
|
||||||
|
|
||||||
|
请严格按照以下 JSON 格式输出,不要添加任何其他文字:
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{{
|
||||||
|
"question": "题目内容",
|
||||||
|
"options": ["选项A", "选项B", "选项C", "选项D"],
|
||||||
|
"correct_answer": "正确答案(与options中某项完全一致)",
|
||||||
|
"explanation": "答案解释",
|
||||||
|
"hint": "提示信息"
|
||||||
|
}}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
要求:
|
||||||
|
1. 题目要有实际内容,不要使用占位符
|
||||||
|
2. 正确答案必须随机分布(不要总在第一个选项)
|
||||||
|
3. 每道题的选项要有区分度,干扰项要合理
|
||||||
|
4. 解释要清晰准确
|
||||||
|
5. 直接输出 JSON,不要有 markdown 包裹"#,
|
||||||
|
topic, difficulty_str, type_str, count, content_section, content_note,
|
||||||
|
);
|
||||||
|
|
||||||
|
let request = CompletionRequest {
|
||||||
|
model: self.model.clone(),
|
||||||
|
system: Some("你是一个专业的出题专家,只输出纯JSON格式。".to_string()),
|
||||||
|
messages: vec![zclaw_types::Message::user(&prompt)],
|
||||||
|
tools: Vec::new(),
|
||||||
|
max_tokens: Some(4096),
|
||||||
|
temperature: Some(0.7),
|
||||||
|
stop: Vec::new(),
|
||||||
|
stream: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
let response = self.driver.complete(request).await.map_err(|e| {
|
||||||
|
zclaw_types::ZclawError::Internal(format!("LLM quiz generation failed: {}", e))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Extract text from response
|
||||||
|
let text: String = response
|
||||||
|
.content
|
||||||
|
.iter()
|
||||||
|
.filter_map(|block| match block {
|
||||||
|
zclaw_runtime::driver::ContentBlock::Text { text } => Some(text.clone()),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
// Parse JSON from response (handle markdown code fences)
|
||||||
|
let json_str = extract_json(&text);
|
||||||
|
|
||||||
|
let raw_questions: Vec<serde_json::Value> =
|
||||||
|
serde_json::from_str(json_str).map_err(|e| {
|
||||||
|
zclaw_types::ZclawError::Internal(format!(
|
||||||
|
"Failed to parse quiz JSON: {}. Raw: {}",
|
||||||
|
e,
|
||||||
|
&text[..text.len().min(200)]
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let questions: Vec<QuizQuestion> = raw_questions
|
||||||
|
.into_iter()
|
||||||
|
.take(count)
|
||||||
|
.map(|q| {
|
||||||
|
let options: Vec<String> = q["options"]
|
||||||
|
.as_array()
|
||||||
|
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let correct = q["correct_answer"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
QuizQuestion {
|
||||||
|
id: uuid_v4(),
|
||||||
|
question_type: QuestionType::MultipleChoice,
|
||||||
|
question: q["question"].as_str().unwrap_or("未知题目").to_string(),
|
||||||
|
options: if options.is_empty() { None } else { Some(options) },
|
||||||
|
correct_answer: Answer::Single(correct),
|
||||||
|
explanation: q["explanation"].as_str().map(String::from),
|
||||||
|
hints: q["hint"].as_str().map(|h| vec![h.to_string()]),
|
||||||
|
points: 10.0,
|
||||||
|
difficulty: difficulty.clone(),
|
||||||
|
tags: vec![topic.to_string()],
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if questions.is_empty() {
|
||||||
|
// Fallback to default if LLM returns nothing parseable
|
||||||
|
return DefaultQuizGenerator
|
||||||
|
.generate_questions(topic, content, count, difficulty, question_types)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(questions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract JSON from a string that may be wrapped in markdown code fences.
|
||||||
|
fn extract_json(text: &str) -> &str {
|
||||||
|
let trimmed = text.trim();
|
||||||
|
|
||||||
|
// Try to find ```json ... ``` block
|
||||||
|
if let Some(start) = trimmed.find("```json") {
|
||||||
|
let after_start = &trimmed[start + 7..];
|
||||||
|
if let Some(end) = after_start.find("```") {
|
||||||
|
return after_start[..end].trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to find ``` ... ``` block
|
||||||
|
if let Some(start) = trimmed.find("```") {
|
||||||
|
let after_start = &trimmed[start + 3..];
|
||||||
|
if let Some(end) = after_start.find("```") {
|
||||||
|
return after_start[..end].trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to find raw JSON array
|
||||||
|
if let Some(start) = trimmed.find('[') {
|
||||||
|
if let Some(end) = trimmed.rfind(']') {
|
||||||
|
return &trimmed[start..=end];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
trimmed
|
||||||
|
}
|
||||||
|
|
||||||
/// Quiz action types
|
/// Quiz action types
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(tag = "action", rename_all = "snake_case")]
|
#[serde(tag = "action", rename_all = "snake_case")]
|
||||||
@@ -261,8 +475,8 @@ impl QuizHand {
|
|||||||
Self {
|
Self {
|
||||||
config: HandConfig {
|
config: HandConfig {
|
||||||
id: "quiz".to_string(),
|
id: "quiz".to_string(),
|
||||||
name: "Quiz".to_string(),
|
name: "测验".to_string(),
|
||||||
description: "Generate and manage quizzes for assessment".to_string(),
|
description: "生成和管理测验题目,评估答案,提供反馈".to_string(),
|
||||||
needs_approval: false,
|
needs_approval: false,
|
||||||
dependencies: vec![],
|
dependencies: vec![],
|
||||||
input_schema: Some(serde_json::json!({
|
input_schema: Some(serde_json::json!({
|
||||||
|
|||||||
@@ -142,8 +142,8 @@ impl ResearcherHand {
|
|||||||
Self {
|
Self {
|
||||||
config: HandConfig {
|
config: HandConfig {
|
||||||
id: "researcher".to_string(),
|
id: "researcher".to_string(),
|
||||||
name: "Researcher".to_string(),
|
name: "研究员".to_string(),
|
||||||
description: "Deep research and analysis capabilities with web search and content fetching".to_string(),
|
description: "深度研究和分析能力,支持网络搜索和内容获取".to_string(),
|
||||||
needs_approval: false,
|
needs_approval: false,
|
||||||
dependencies: vec!["network".to_string()],
|
dependencies: vec!["network".to_string()],
|
||||||
input_schema: Some(serde_json::json!({
|
input_schema: Some(serde_json::json!({
|
||||||
|
|||||||
@@ -156,8 +156,8 @@ impl SlideshowHand {
|
|||||||
Self {
|
Self {
|
||||||
config: HandConfig {
|
config: HandConfig {
|
||||||
id: "slideshow".to_string(),
|
id: "slideshow".to_string(),
|
||||||
name: "Slideshow".to_string(),
|
name: "幻灯片".to_string(),
|
||||||
description: "Control presentation slides and highlights".to_string(),
|
description: "控制演示文稿的播放、导航和标注".to_string(),
|
||||||
needs_approval: false,
|
needs_approval: false,
|
||||||
dependencies: vec![],
|
dependencies: vec![],
|
||||||
input_schema: Some(serde_json::json!({
|
input_schema: Some(serde_json::json!({
|
||||||
|
|||||||
@@ -149,8 +149,8 @@ impl SpeechHand {
|
|||||||
Self {
|
Self {
|
||||||
config: HandConfig {
|
config: HandConfig {
|
||||||
id: "speech".to_string(),
|
id: "speech".to_string(),
|
||||||
name: "Speech".to_string(),
|
name: "语音合成".to_string(),
|
||||||
description: "Text-to-speech synthesis for voice output".to_string(),
|
description: "文本转语音合成输出".to_string(),
|
||||||
needs_approval: false,
|
needs_approval: false,
|
||||||
dependencies: vec![],
|
dependencies: vec![],
|
||||||
input_schema: Some(serde_json::json!({
|
input_schema: Some(serde_json::json!({
|
||||||
@@ -162,7 +162,7 @@ impl SpeechHand {
|
|||||||
"rate": { "type": "number" },
|
"rate": { "type": "number" },
|
||||||
}
|
}
|
||||||
})),
|
})),
|
||||||
tags: vec!["audio".to_string(), "tts".to_string(), "education".to_string()],
|
tags: vec!["audio".to_string(), "tts".to_string(), "education".to_string(), "demo".to_string()],
|
||||||
enabled: true,
|
enabled: true,
|
||||||
},
|
},
|
||||||
state: Arc::new(RwLock::new(SpeechState {
|
state: Arc::new(RwLock::new(SpeechState {
|
||||||
|
|||||||
@@ -205,8 +205,8 @@ impl TwitterHand {
|
|||||||
Self {
|
Self {
|
||||||
config: HandConfig {
|
config: HandConfig {
|
||||||
id: "twitter".to_string(),
|
id: "twitter".to_string(),
|
||||||
name: "Twitter".to_string(),
|
name: "Twitter 自动化".to_string(),
|
||||||
description: "Twitter/X automation capabilities for posting, searching, and managing content".to_string(),
|
description: "Twitter/X 自动化能力,发布、搜索和管理内容".to_string(),
|
||||||
needs_approval: true, // Twitter actions need approval
|
needs_approval: true, // Twitter actions need approval
|
||||||
dependencies: vec!["twitter_api_key".to_string()],
|
dependencies: vec!["twitter_api_key".to_string()],
|
||||||
input_schema: Some(serde_json::json!({
|
input_schema: Some(serde_json::json!({
|
||||||
@@ -270,7 +270,7 @@ impl TwitterHand {
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
})),
|
})),
|
||||||
tags: vec!["twitter".to_string(), "social".to_string(), "automation".to_string()],
|
tags: vec!["twitter".to_string(), "social".to_string(), "automation".to_string(), "demo".to_string()],
|
||||||
enabled: true,
|
enabled: true,
|
||||||
},
|
},
|
||||||
credentials: Arc::new(RwLock::new(None)),
|
credentials: Arc::new(RwLock::new(None)),
|
||||||
|
|||||||
@@ -180,8 +180,8 @@ impl WhiteboardHand {
|
|||||||
Self {
|
Self {
|
||||||
config: HandConfig {
|
config: HandConfig {
|
||||||
id: "whiteboard".to_string(),
|
id: "whiteboard".to_string(),
|
||||||
name: "Whiteboard".to_string(),
|
name: "白板".to_string(),
|
||||||
description: "Draw and annotate on a virtual whiteboard".to_string(),
|
description: "在虚拟白板上绘制和标注".to_string(),
|
||||||
needs_approval: false,
|
needs_approval: false,
|
||||||
dependencies: vec![],
|
dependencies: vec![],
|
||||||
input_schema: Some(serde_json::json!({
|
input_schema: Some(serde_json::json!({
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ repository.workspace = true
|
|||||||
rust-version.workspace = true
|
rust-version.workspace = true
|
||||||
description = "ZCLAW kernel - central coordinator for all subsystems"
|
description = "ZCLAW kernel - central coordinator for all subsystems"
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = []
|
||||||
|
# Enable multi-agent orchestration (Director, A2A protocol)
|
||||||
|
multi-agent = ["zclaw-protocols/a2a"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
zclaw-types = { workspace = true }
|
zclaw-types = { workspace = true }
|
||||||
zclaw-memory = { workspace = true }
|
zclaw-memory = { workspace = true }
|
||||||
@@ -20,6 +25,7 @@ tokio-stream = { workspace = true }
|
|||||||
futures = { workspace = true }
|
futures = { workspace = true }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
|
toml = { workspace = true }
|
||||||
thiserror = { workspace = true }
|
thiserror = { workspace = true }
|
||||||
uuid = { workspace = true }
|
uuid = { workspace = true }
|
||||||
chrono = { workspace = true }
|
chrono = { workspace = true }
|
||||||
|
|||||||
@@ -52,9 +52,31 @@ impl CapabilityManager {
|
|||||||
.unwrap_or(false)
|
.unwrap_or(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Validate capabilities don't exceed parent's
|
/// Validate capabilities for dangerous combinations
|
||||||
|
///
|
||||||
|
/// Checks that overly broad capabilities are not combined with
|
||||||
|
/// dangerous operations. Returns an error if an unsafe combination
|
||||||
|
/// is detected.
|
||||||
pub fn validate(&self, capabilities: &[Capability]) -> Result<()> {
|
pub fn validate(&self, capabilities: &[Capability]) -> Result<()> {
|
||||||
// TODO: Implement capability validation
|
let has_tool_all = capabilities.iter().any(|c| matches!(c, Capability::ToolAll));
|
||||||
|
let has_agent_kill = capabilities.iter().any(|c| matches!(c, Capability::AgentKill { .. }));
|
||||||
|
let has_shell_wildcard = capabilities.iter().any(|c| {
|
||||||
|
matches!(c, Capability::ShellExec { pattern } if pattern == "*")
|
||||||
|
});
|
||||||
|
|
||||||
|
// ToolAll + destructive operations is dangerous
|
||||||
|
if has_tool_all && has_agent_kill {
|
||||||
|
return Err(ZclawError::SecurityError(
|
||||||
|
"ToolAll 与 AgentKill 不能同时授予".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if has_tool_all && has_shell_wildcard {
|
||||||
|
return Err(ZclawError::SecurityError(
|
||||||
|
"ToolAll 与 ShellExec(\"*\") 不能同时授予".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -157,18 +157,173 @@ impl Default for KernelConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Default skills directory (./skills relative to cwd)
|
/// Default skills directory
|
||||||
|
///
|
||||||
|
/// Discovery order:
|
||||||
|
/// 1. ZCLAW_SKILLS_DIR environment variable (if set)
|
||||||
|
/// 2. Compile-time known workspace path (CARGO_WORKSPACE_DIR or relative from manifest dir)
|
||||||
|
/// 3. Current working directory/skills (for development)
|
||||||
|
/// 4. Executable directory and multiple levels up (for packaged apps)
|
||||||
fn default_skills_dir() -> Option<std::path::PathBuf> {
|
fn default_skills_dir() -> Option<std::path::PathBuf> {
|
||||||
std::env::current_dir()
|
// 1. Check environment variable override
|
||||||
|
if let Ok(dir) = std::env::var("ZCLAW_SKILLS_DIR") {
|
||||||
|
let path = std::path::PathBuf::from(&dir);
|
||||||
|
tracing::debug!(target: "kernel_config", "ZCLAW_SKILLS_DIR env: {} (exists: {})", path.display(), path.exists());
|
||||||
|
if path.exists() {
|
||||||
|
return Some(path);
|
||||||
|
}
|
||||||
|
// Even if it doesn't exist, respect the env var
|
||||||
|
return Some(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Try compile-time known paths (works for cargo build/test)
|
||||||
|
// CARGO_MANIFEST_DIR is the crate directory (crates/zclaw-kernel)
|
||||||
|
// We need to go up to find the workspace root
|
||||||
|
let manifest_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||||
|
tracing::debug!(target: "kernel_config", "CARGO_MANIFEST_DIR: {}", manifest_dir.display());
|
||||||
|
|
||||||
|
// Go up from crates/zclaw-kernel to workspace root
|
||||||
|
if let Some(workspace_root) = manifest_dir.parent().and_then(|p| p.parent()) {
|
||||||
|
let workspace_skills = workspace_root.join("skills");
|
||||||
|
tracing::debug!(target: "kernel_config", "Workspace skills: {} (exists: {})", workspace_skills.display(), workspace_skills.exists());
|
||||||
|
if workspace_skills.exists() {
|
||||||
|
return Some(workspace_skills);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Try current working directory first (for development)
|
||||||
|
if let Ok(cwd) = std::env::current_dir() {
|
||||||
|
let cwd_skills = cwd.join("skills");
|
||||||
|
tracing::debug!(target: "kernel_config", "Checking cwd: {} (exists: {})", cwd_skills.display(), cwd_skills.exists());
|
||||||
|
if cwd_skills.exists() {
|
||||||
|
return Some(cwd_skills);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also try going up from cwd (might be in desktop/src-tauri)
|
||||||
|
let mut current = cwd.as_path();
|
||||||
|
for i in 0..6 {
|
||||||
|
if let Some(parent) = current.parent() {
|
||||||
|
let parent_skills = parent.join("skills");
|
||||||
|
tracing::debug!(target: "kernel_config", "CWD Level {}: {} (exists: {})", i, parent_skills.display(), parent_skills.exists());
|
||||||
|
if parent_skills.exists() {
|
||||||
|
return Some(parent_skills);
|
||||||
|
}
|
||||||
|
current = parent;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Try executable's directory and multiple levels up
|
||||||
|
if let Ok(exe) = std::env::current_exe() {
|
||||||
|
tracing::debug!(target: "kernel_config", "Current exe: {}", exe.display());
|
||||||
|
if let Some(exe_dir) = exe.parent().map(|p| p.to_path_buf()) {
|
||||||
|
// Same directory as exe
|
||||||
|
let exe_skills = exe_dir.join("skills");
|
||||||
|
tracing::debug!(target: "kernel_config", "Checking exe dir: {} (exists: {})", exe_skills.display(), exe_skills.exists());
|
||||||
|
if exe_skills.exists() {
|
||||||
|
return Some(exe_skills);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Go up multiple levels to handle Tauri dev builds
|
||||||
|
let mut current = exe_dir.as_path();
|
||||||
|
for i in 0..6 {
|
||||||
|
if let Some(parent) = current.parent() {
|
||||||
|
let parent_skills = parent.join("skills");
|
||||||
|
tracing::debug!(target: "kernel_config", "EXE Level {}: {} (exists: {})", i, parent_skills.display(), parent_skills.exists());
|
||||||
|
if parent_skills.exists() {
|
||||||
|
return Some(parent_skills);
|
||||||
|
}
|
||||||
|
current = parent;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Fallback to current working directory/skills (may not exist)
|
||||||
|
let fallback = std::env::current_dir()
|
||||||
.ok()
|
.ok()
|
||||||
.map(|cwd| cwd.join("skills"))
|
.map(|cwd| cwd.join("skills"));
|
||||||
|
tracing::debug!(target: "kernel_config", "Fallback to: {:?}", fallback);
|
||||||
|
fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
impl KernelConfig {
|
impl KernelConfig {
|
||||||
/// Load configuration from file
|
/// Load configuration from file.
|
||||||
|
///
|
||||||
|
/// Search order:
|
||||||
|
/// 1. Path from `ZCLAW_CONFIG` environment variable
|
||||||
|
/// 2. `~/.zclaw/config.toml`
|
||||||
|
/// 3. Fallback to `Self::default()`
|
||||||
|
///
|
||||||
|
/// Supports `${VAR_NAME}` environment variable interpolation in string values.
|
||||||
pub async fn load() -> Result<Self> {
|
pub async fn load() -> Result<Self> {
|
||||||
// TODO: Load from ~/.zclaw/config.toml
|
let config_path = Self::find_config_path();
|
||||||
Ok(Self::default())
|
|
||||||
|
match config_path {
|
||||||
|
Some(path) => {
|
||||||
|
if !path.exists() {
|
||||||
|
tracing::debug!(target: "kernel_config", "Config file not found: {:?}, using defaults", path);
|
||||||
|
return Ok(Self::default());
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!(target: "kernel_config", "Loading config from: {:?}", path);
|
||||||
|
let content = std::fs::read_to_string(&path).map_err(|e| {
|
||||||
|
zclaw_types::ZclawError::Internal(format!("Failed to read config {}: {}", path.display(), e))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let interpolated = interpolate_env_vars(&content);
|
||||||
|
let mut config: KernelConfig = toml::from_str(&interpolated).map_err(|e| {
|
||||||
|
zclaw_types::ZclawError::Internal(format!("Failed to parse config {}: {}", path.display(), e))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Resolve skills_dir if not explicitly set
|
||||||
|
if config.skills_dir.is_none() {
|
||||||
|
config.skills_dir = default_skills_dir();
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
target: "kernel_config",
|
||||||
|
model = %config.llm.model,
|
||||||
|
base_url = %config.llm.base_url,
|
||||||
|
has_api_key = !config.llm.api_key.is_empty(),
|
||||||
|
"Config loaded successfully"
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(config)
|
||||||
|
}
|
||||||
|
None => Ok(Self::default()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find the config file path.
|
||||||
|
fn find_config_path() -> Option<PathBuf> {
|
||||||
|
// 1. Environment variable override
|
||||||
|
if let Ok(path) = std::env::var("ZCLAW_CONFIG") {
|
||||||
|
return Some(PathBuf::from(path));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. ~/.zclaw/config.toml
|
||||||
|
if let Some(home) = dirs::home_dir() {
|
||||||
|
let path = home.join(".zclaw").join("config.toml");
|
||||||
|
if path.exists() {
|
||||||
|
return Some(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Project root config/config.toml (for development)
|
||||||
|
let project_config = std::env::current_dir()
|
||||||
|
.ok()
|
||||||
|
.map(|cwd| cwd.join("config").join("config.toml"))?;
|
||||||
|
|
||||||
|
if project_config.exists() {
|
||||||
|
return Some(project_config);
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create the LLM driver
|
/// Create the LLM driver
|
||||||
@@ -334,7 +489,7 @@ impl KernelConfig {
|
|||||||
Self {
|
Self {
|
||||||
database_url: default_database_url(),
|
database_url: default_database_url(),
|
||||||
llm,
|
llm,
|
||||||
skills_dir: None,
|
skills_dir: default_skills_dir(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -352,3 +507,81 @@ impl LlmConfig {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// === Environment variable interpolation ===
|
||||||
|
|
||||||
|
/// Replace `${VAR_NAME}` patterns in a string with environment variable values.
|
||||||
|
/// If the variable is not set, the pattern is left as-is.
|
||||||
|
fn interpolate_env_vars(content: &str) -> String {
|
||||||
|
let mut result = String::with_capacity(content.len());
|
||||||
|
let mut chars = content.char_indices().peekable();
|
||||||
|
|
||||||
|
while let Some((_, ch)) = chars.next() {
|
||||||
|
if ch == '$' && chars.peek().map(|(_, c)| *c == '{').unwrap_or(false) {
|
||||||
|
chars.next(); // consume '{'
|
||||||
|
|
||||||
|
let mut var_name = String::new();
|
||||||
|
|
||||||
|
while let Some((_, c)) = chars.peek() {
|
||||||
|
match c {
|
||||||
|
'}' => {
|
||||||
|
chars.next(); // consume '}'
|
||||||
|
if let Ok(value) = std::env::var(&var_name) {
|
||||||
|
result.push_str(&value);
|
||||||
|
} else {
|
||||||
|
result.push_str("${");
|
||||||
|
result.push_str(&var_name);
|
||||||
|
result.push('}');
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
var_name.push(*c);
|
||||||
|
chars.next();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle unclosed ${... at end of string
|
||||||
|
if !content[result.len()..].contains('}') && var_name.is_empty() {
|
||||||
|
// Already consumed, nothing to do
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
result.push(ch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_interpolate_env_vars_basic() {
|
||||||
|
std::env::set_var("ZCLAW_TEST_VAR", "hello");
|
||||||
|
let result = interpolate_env_vars("prefix ${ZCLAW_TEST_VAR} suffix");
|
||||||
|
assert_eq!(result, "prefix hello suffix");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_interpolate_env_vars_missing() {
|
||||||
|
let result = interpolate_env_vars("${ZCLAW_NONEXISTENT_VAR_12345}");
|
||||||
|
assert_eq!(result, "${ZCLAW_NONEXISTENT_VAR_12345}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_interpolate_env_vars_no_vars() {
|
||||||
|
let result = interpolate_env_vars("no variables here");
|
||||||
|
assert_eq!(result, "no variables here");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_interpolate_env_vars_multiple() {
|
||||||
|
std::env::set_var("ZCLAW_TEST_A", "alpha");
|
||||||
|
std::env::set_var("ZCLAW_TEST_B", "beta");
|
||||||
|
let result = interpolate_env_vars("${ZCLAW_TEST_A}-${ZCLAW_TEST_B}");
|
||||||
|
assert_eq!(result, "alpha-beta");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,11 +10,11 @@
|
|||||||
use crate::generation::{Classroom, GeneratedScene, SceneContent, SceneType, SceneAction};
|
use crate::generation::{Classroom, GeneratedScene, SceneContent, SceneType, SceneAction};
|
||||||
use super::{ExportOptions, ExportResult, Exporter, sanitize_filename};
|
use super::{ExportOptions, ExportResult, Exporter, sanitize_filename};
|
||||||
use zclaw_types::Result;
|
use zclaw_types::Result;
|
||||||
use zclaw_types::ZclawError;
|
|
||||||
|
|
||||||
/// HTML exporter
|
/// HTML exporter
|
||||||
pub struct HtmlExporter {
|
pub struct HtmlExporter {
|
||||||
/// Template name
|
/// Template name (reserved for future template support)
|
||||||
|
#[allow(dead_code)] // TODO: Implement template-based HTML export
|
||||||
template: String,
|
template: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,6 +27,7 @@ impl HtmlExporter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Create with specific template
|
/// Create with specific template
|
||||||
|
#[allow(dead_code)] // Reserved for future template support
|
||||||
pub fn with_template(template: &str) -> Self {
|
pub fn with_template(template: &str) -> Self {
|
||||||
Self {
|
Self {
|
||||||
template: template.to_string(),
|
template: template.to_string(),
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ impl MarkdownExporter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Create without front matter
|
/// Create without front matter
|
||||||
|
#[allow(dead_code)] // Reserved for future use
|
||||||
pub fn without_front_matter() -> Self {
|
pub fn without_front_matter() -> Self {
|
||||||
Self {
|
Self {
|
||||||
include_front_matter: false,
|
include_front_matter: false,
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
//! without external dependencies. For more advanced features, consider using
|
//! without external dependencies. For more advanced features, consider using
|
||||||
//! a dedicated library like `pptx-rs` or `office` crate.
|
//! a dedicated library like `pptx-rs` or `office` crate.
|
||||||
|
|
||||||
use crate::generation::{Classroom, GeneratedScene, SceneContent, SceneType, SceneAction};
|
use crate::generation::{Classroom, GeneratedScene, SceneContent, SceneAction};
|
||||||
use super::{ExportOptions, ExportResult, Exporter, sanitize_filename};
|
use super::{ExportOptions, ExportResult, Exporter, sanitize_filename};
|
||||||
use zclaw_types::{Result, ZclawError};
|
use zclaw_types::{Result, ZclawError};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
@@ -211,7 +211,7 @@ impl PptxExporter {
|
|||||||
|
|
||||||
/// Generate title slide XML
|
/// Generate title slide XML
|
||||||
fn generate_title_slide(&self, classroom: &Classroom) -> String {
|
fn generate_title_slide(&self, classroom: &Classroom) -> String {
|
||||||
let objectives = classroom.objectives.iter()
|
let _objectives = classroom.objectives.iter()
|
||||||
.map(|o| format!("- {}", o))
|
.map(|o| format!("- {}", o))
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join("\n");
|
.join("\n");
|
||||||
@@ -568,7 +568,7 @@ use zip::{ZipWriter, write::SimpleFileOptions};
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::generation::{ClassroomMetadata, TeachingStyle, DifficultyLevel};
|
use crate::generation::{ClassroomMetadata, TeachingStyle, DifficultyLevel, SceneType};
|
||||||
|
|
||||||
fn create_test_classroom() -> Classroom {
|
fn create_test_classroom() -> Classroom {
|
||||||
Classroom {
|
Classroom {
|
||||||
|
|||||||
@@ -9,9 +9,8 @@ use std::sync::Arc;
|
|||||||
use tokio::sync::RwLock;
|
use tokio::sync::RwLock;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
use futures::future::join_all;
|
use futures::future::join_all;
|
||||||
use zclaw_types::{AgentId, Result, ZclawError};
|
use zclaw_types::Result;
|
||||||
use zclaw_runtime::{LlmDriver, CompletionRequest, CompletionResponse, ContentBlock};
|
use zclaw_runtime::{LlmDriver, CompletionRequest, CompletionResponse, ContentBlock};
|
||||||
use zclaw_hands::{WhiteboardAction, SpeechAction, QuizAction};
|
|
||||||
|
|
||||||
/// Generation stage
|
/// Generation stage
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
@@ -704,47 +703,6 @@ Actions can be:
|
|||||||
self.parse_outline_from_text(&text, request)
|
self.parse_outline_from_text(&text, request)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generate scene using LLM
|
|
||||||
async fn generate_scene_with_llm(
|
|
||||||
&self,
|
|
||||||
driver: &dyn LlmDriver,
|
|
||||||
item: &OutlineItem,
|
|
||||||
order: usize,
|
|
||||||
) -> Result<GeneratedScene> {
|
|
||||||
let prompt = format!(
|
|
||||||
"Generate a detailed scene for the following outline item:\n\
|
|
||||||
Title: {}\n\
|
|
||||||
Description: {}\n\
|
|
||||||
Type: {:?}\n\
|
|
||||||
Key Points: {:?}\n\n\
|
|
||||||
Return a JSON object with:\n\
|
|
||||||
- title: scene title\n\
|
|
||||||
- content: scene content (object with relevant fields)\n\
|
|
||||||
- actions: array of actions to execute\n\
|
|
||||||
- duration_seconds: estimated duration",
|
|
||||||
item.title, item.description, item.scene_type, item.key_points
|
|
||||||
);
|
|
||||||
|
|
||||||
let llm_request = CompletionRequest {
|
|
||||||
model: "default".to_string(),
|
|
||||||
system: Some(self.get_scene_system_prompt()),
|
|
||||||
messages: vec![zclaw_types::Message::User {
|
|
||||||
content: prompt,
|
|
||||||
}],
|
|
||||||
tools: vec![],
|
|
||||||
max_tokens: Some(2048),
|
|
||||||
temperature: Some(0.7),
|
|
||||||
stop: vec![],
|
|
||||||
stream: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
let response = driver.complete(llm_request).await?;
|
|
||||||
let text = self.extract_text_from_response(&response);
|
|
||||||
|
|
||||||
// Parse scene from response
|
|
||||||
self.parse_scene_from_text(&text, item, order)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Extract text from LLM response
|
/// Extract text from LLM response
|
||||||
fn extract_text_from_response(&self, response: &CompletionResponse) -> String {
|
fn extract_text_from_response(&self, response: &CompletionResponse) -> String {
|
||||||
response.content.iter()
|
response.content.iter()
|
||||||
@@ -787,38 +745,6 @@ You MUST respond with valid JSON in this exact format:
|
|||||||
Ensure the outline is coherent and follows good pedagogical practices."#.to_string()
|
Ensure the outline is coherent and follows good pedagogical practices."#.to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get system prompt for scene generation
|
|
||||||
fn get_scene_system_prompt(&self) -> String {
|
|
||||||
r#"You are an expert educational content creator. Your task is to generate detailed teaching scenes.
|
|
||||||
|
|
||||||
When given an outline item, you will:
|
|
||||||
1. Create rich, engaging content
|
|
||||||
2. Design appropriate actions (speech, whiteboard, quiz, etc.)
|
|
||||||
3. Ensure content matches the scene type
|
|
||||||
|
|
||||||
You MUST respond with valid JSON in this exact format:
|
|
||||||
{
|
|
||||||
"title": "Scene Title",
|
|
||||||
"content": {
|
|
||||||
"description": "Detailed description",
|
|
||||||
"key_points": ["Point 1", "Point 2"],
|
|
||||||
"slides": [{"title": "...", "content": "..."}]
|
|
||||||
},
|
|
||||||
"actions": [
|
|
||||||
{"type": "speech", "text": "Welcome to...", "agent_role": "teacher"},
|
|
||||||
{"type": "whiteboard_draw_text", "x": 100, "y": 100, "text": "Key Concept"}
|
|
||||||
],
|
|
||||||
"duration_seconds": 300
|
|
||||||
}
|
|
||||||
|
|
||||||
Actions can be:
|
|
||||||
- speech: {"type": "speech", "text": "...", "agent_role": "teacher|assistant|student"}
|
|
||||||
- whiteboard_draw_text: {"type": "whiteboard_draw_text", "x": 0, "y": 0, "text": "..."}
|
|
||||||
- whiteboard_draw_shape: {"type": "whiteboard_draw_shape", "shape": "rectangle", "x": 0, "y": 0, "width": 100, "height": 50}
|
|
||||||
- quiz_show: {"type": "quiz_show", "quiz_id": "..."}
|
|
||||||
- discussion: {"type": "discussion", "topic": "..."}"#.to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parse outline from LLM response text
|
/// Parse outline from LLM response text
|
||||||
fn parse_outline_from_text(&self, text: &str, request: &GenerationRequest) -> Result<Vec<OutlineItem>> {
|
fn parse_outline_from_text(&self, text: &str, request: &GenerationRequest) -> Result<Vec<OutlineItem>> {
|
||||||
// Try to extract JSON from the response
|
// Try to extract JSON from the response
|
||||||
@@ -871,87 +797,6 @@ Actions can be:
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse scene from LLM response text
|
|
||||||
fn parse_scene_from_text(&self, text: &str, item: &OutlineItem, order: usize) -> Result<GeneratedScene> {
|
|
||||||
let json_text = self.extract_json(text);
|
|
||||||
|
|
||||||
if let Ok(scene_data) = serde_json::from_str::<serde_json::Value>(&json_text) {
|
|
||||||
let actions = self.parse_actions(&scene_data);
|
|
||||||
|
|
||||||
Ok(GeneratedScene {
|
|
||||||
id: format!("scene_{}", item.id),
|
|
||||||
outline_id: item.id.clone(),
|
|
||||||
content: SceneContent {
|
|
||||||
title: scene_data.get("title")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or(&item.title)
|
|
||||||
.to_string(),
|
|
||||||
scene_type: item.scene_type.clone(),
|
|
||||||
content: scene_data.get("content").cloned().unwrap_or(serde_json::json!({})),
|
|
||||||
actions,
|
|
||||||
duration_seconds: scene_data.get("duration_seconds")
|
|
||||||
.and_then(|v| v.as_u64())
|
|
||||||
.unwrap_or(item.duration_seconds as u64) as u32,
|
|
||||||
notes: None,
|
|
||||||
},
|
|
||||||
order,
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
// Fallback
|
|
||||||
self.generate_scene_for_item(item, order)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parse actions from scene data
|
|
||||||
fn parse_actions(&self, scene_data: &serde_json::Value) -> Vec<SceneAction> {
|
|
||||||
scene_data.get("actions")
|
|
||||||
.and_then(|v| v.as_array())
|
|
||||||
.map(|arr| {
|
|
||||||
arr.iter()
|
|
||||||
.filter_map(|action| self.parse_single_action(action))
|
|
||||||
.collect()
|
|
||||||
})
|
|
||||||
.unwrap_or_default()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parse single action
|
|
||||||
fn parse_single_action(&self, action: &serde_json::Value) -> Option<SceneAction> {
|
|
||||||
let action_type = action.get("type")?.as_str()?;
|
|
||||||
|
|
||||||
match action_type {
|
|
||||||
"speech" => Some(SceneAction::Speech {
|
|
||||||
text: action.get("text")?.as_str()?.to_string(),
|
|
||||||
agent_role: action.get("agent_role")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("teacher")
|
|
||||||
.to_string(),
|
|
||||||
}),
|
|
||||||
"whiteboard_draw_text" => Some(SceneAction::WhiteboardDrawText {
|
|
||||||
x: action.get("x")?.as_f64()?,
|
|
||||||
y: action.get("y")?.as_f64()?,
|
|
||||||
text: action.get("text")?.as_str()?.to_string(),
|
|
||||||
font_size: action.get("font_size").and_then(|v| v.as_u64()).map(|v| v as u32),
|
|
||||||
color: action.get("color").and_then(|v| v.as_str()).map(String::from),
|
|
||||||
}),
|
|
||||||
"whiteboard_draw_shape" => Some(SceneAction::WhiteboardDrawShape {
|
|
||||||
shape: action.get("shape")?.as_str()?.to_string(),
|
|
||||||
x: action.get("x")?.as_f64()?,
|
|
||||||
y: action.get("y")?.as_f64()?,
|
|
||||||
width: action.get("width")?.as_f64()?,
|
|
||||||
height: action.get("height")?.as_f64()?,
|
|
||||||
fill: action.get("fill").and_then(|v| v.as_str()).map(String::from),
|
|
||||||
}),
|
|
||||||
"quiz_show" => Some(SceneAction::QuizShow {
|
|
||||||
quiz_id: action.get("quiz_id")?.as_str()?.to_string(),
|
|
||||||
}),
|
|
||||||
"discussion" => Some(SceneAction::Discussion {
|
|
||||||
topic: action.get("topic")?.as_str()?.to_string(),
|
|
||||||
duration_seconds: action.get("duration_seconds").and_then(|v| v.as_u64()).map(|v| v as u32),
|
|
||||||
}),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Extract JSON from text (handles markdown code blocks)
|
/// Extract JSON from text (handles markdown code blocks)
|
||||||
fn extract_json(&self, text: &str) -> String {
|
fn extract_json(&self, text: &str) -> String {
|
||||||
// Try to extract from markdown code block
|
// Try to extract from markdown code block
|
||||||
@@ -1058,63 +903,6 @@ Generate {} outline items that flow logically and cover the topic comprehensivel
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generate scene for outline item (would be replaced by LLM call)
|
|
||||||
fn generate_scene_for_item(&self, item: &OutlineItem, order: usize) -> Result<GeneratedScene> {
|
|
||||||
let actions = match item.scene_type {
|
|
||||||
SceneType::Slide => vec![
|
|
||||||
SceneAction::Speech {
|
|
||||||
text: format!("Let's explore: {}", item.title),
|
|
||||||
agent_role: "teacher".to_string(),
|
|
||||||
},
|
|
||||||
SceneAction::WhiteboardDrawText {
|
|
||||||
x: 100.0,
|
|
||||||
y: 100.0,
|
|
||||||
text: item.title.clone(),
|
|
||||||
font_size: Some(32),
|
|
||||||
color: Some("#333333".to_string()),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
SceneType::Quiz => vec![
|
|
||||||
SceneAction::Speech {
|
|
||||||
text: "Now let's test your understanding.".to_string(),
|
|
||||||
agent_role: "teacher".to_string(),
|
|
||||||
},
|
|
||||||
SceneAction::QuizShow {
|
|
||||||
quiz_id: format!("quiz_{}", item.id),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
SceneType::Discussion => vec![
|
|
||||||
SceneAction::Discussion {
|
|
||||||
topic: item.title.clone(),
|
|
||||||
duration_seconds: Some(300),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
_ => vec![
|
|
||||||
SceneAction::Speech {
|
|
||||||
text: format!("Content for: {}", item.title),
|
|
||||||
agent_role: "teacher".to_string(),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(GeneratedScene {
|
|
||||||
id: format!("scene_{}", item.id),
|
|
||||||
outline_id: item.id.clone(),
|
|
||||||
content: SceneContent {
|
|
||||||
title: item.title.clone(),
|
|
||||||
scene_type: item.scene_type.clone(),
|
|
||||||
content: serde_json::json!({
|
|
||||||
"description": item.description,
|
|
||||||
"key_points": item.key_points,
|
|
||||||
}),
|
|
||||||
actions,
|
|
||||||
duration_seconds: item.duration_seconds,
|
|
||||||
notes: None,
|
|
||||||
},
|
|
||||||
order,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build classroom from components
|
/// Build classroom from components
|
||||||
fn build_classroom(
|
fn build_classroom(
|
||||||
&self,
|
&self,
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
//! Kernel - central coordinator
|
//! Kernel - central coordinator
|
||||||
|
|
||||||
|
use std::pin::Pin;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::sync::{broadcast, mpsc};
|
use tokio::sync::{broadcast, mpsc, Mutex};
|
||||||
use zclaw_types::{AgentConfig, AgentId, AgentInfo, Event, Result};
|
use zclaw_types::{AgentConfig, AgentId, AgentInfo, Event, Result};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
@@ -13,16 +14,53 @@ use crate::config::KernelConfig;
|
|||||||
use zclaw_memory::MemoryStore;
|
use zclaw_memory::MemoryStore;
|
||||||
use zclaw_runtime::{AgentLoop, LlmDriver, ToolRegistry, tool::SkillExecutor};
|
use zclaw_runtime::{AgentLoop, LlmDriver, ToolRegistry, tool::SkillExecutor};
|
||||||
use zclaw_skills::SkillRegistry;
|
use zclaw_skills::SkillRegistry;
|
||||||
use zclaw_hands::{HandRegistry, HandContext, HandResult, hands::{BrowserHand, SlideshowHand, SpeechHand, QuizHand, WhiteboardHand, ResearcherHand, CollectorHand, ClipHand, TwitterHand}};
|
use zclaw_skills::LlmCompleter;
|
||||||
|
use zclaw_hands::{HandRegistry, HandContext, HandResult, hands::{BrowserHand, SlideshowHand, SpeechHand, QuizHand, WhiteboardHand, ResearcherHand, CollectorHand, ClipHand, TwitterHand, quiz::LlmQuizGenerator}};
|
||||||
|
|
||||||
|
/// Adapter that bridges `zclaw_runtime::LlmDriver` → `zclaw_skills::LlmCompleter`
|
||||||
|
struct LlmDriverAdapter {
|
||||||
|
driver: Arc<dyn LlmDriver>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl zclaw_skills::LlmCompleter for LlmDriverAdapter {
|
||||||
|
fn complete(
|
||||||
|
&self,
|
||||||
|
prompt: &str,
|
||||||
|
) -> Pin<Box<dyn std::future::Future<Output = std::result::Result<String, String>> + Send + '_>> {
|
||||||
|
let driver = self.driver.clone();
|
||||||
|
let prompt = prompt.to_string();
|
||||||
|
Box::pin(async move {
|
||||||
|
let request = zclaw_runtime::CompletionRequest {
|
||||||
|
messages: vec![zclaw_types::Message::user(prompt)],
|
||||||
|
max_tokens: Some(4096),
|
||||||
|
temperature: Some(0.7),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let response = driver.complete(request).await
|
||||||
|
.map_err(|e| format!("LLM completion error: {}", e))?;
|
||||||
|
// Extract text from content blocks
|
||||||
|
let text: String = response.content.iter()
|
||||||
|
.filter_map(|block| match block {
|
||||||
|
zclaw_runtime::ContentBlock::Text { text } => Some(text.as_str()),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("");
|
||||||
|
Ok(text)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Skill executor implementation for Kernel
|
/// Skill executor implementation for Kernel
|
||||||
pub struct KernelSkillExecutor {
|
pub struct KernelSkillExecutor {
|
||||||
skills: Arc<SkillRegistry>,
|
skills: Arc<SkillRegistry>,
|
||||||
|
llm: Arc<dyn LlmCompleter>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl KernelSkillExecutor {
|
impl KernelSkillExecutor {
|
||||||
pub fn new(skills: Arc<SkillRegistry>) -> Self {
|
pub fn new(skills: Arc<SkillRegistry>, driver: Arc<dyn LlmDriver>) -> Self {
|
||||||
Self { skills }
|
let llm: Arc<dyn zclaw_skills::LlmCompleter> = Arc::new(LlmDriverAdapter { driver });
|
||||||
|
Self { skills, llm }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,6 +76,7 @@ impl SkillExecutor for KernelSkillExecutor {
|
|||||||
let context = zclaw_skills::SkillContext {
|
let context = zclaw_skills::SkillContext {
|
||||||
agent_id: agent_id.to_string(),
|
agent_id: agent_id.to_string(),
|
||||||
session_id: session_id.to_string(),
|
session_id: session_id.to_string(),
|
||||||
|
llm: Some(self.llm.clone()),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
let result = self.skills.execute(&zclaw_types::SkillId::new(skill_id), &context, input).await?;
|
let result = self.skills.execute(&zclaw_types::SkillId::new(skill_id), &context, input).await?;
|
||||||
@@ -53,9 +92,12 @@ pub struct Kernel {
|
|||||||
events: EventBus,
|
events: EventBus,
|
||||||
memory: Arc<MemoryStore>,
|
memory: Arc<MemoryStore>,
|
||||||
driver: Arc<dyn LlmDriver>,
|
driver: Arc<dyn LlmDriver>,
|
||||||
|
llm_completer: Arc<dyn zclaw_skills::LlmCompleter>,
|
||||||
skills: Arc<SkillRegistry>,
|
skills: Arc<SkillRegistry>,
|
||||||
skill_executor: Arc<KernelSkillExecutor>,
|
skill_executor: Arc<KernelSkillExecutor>,
|
||||||
hands: Arc<HandRegistry>,
|
hands: Arc<HandRegistry>,
|
||||||
|
trigger_manager: crate::trigger_manager::TriggerManager,
|
||||||
|
pending_approvals: Arc<Mutex<Vec<ApprovalEntry>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Kernel {
|
impl Kernel {
|
||||||
@@ -84,10 +126,12 @@ impl Kernel {
|
|||||||
|
|
||||||
// Initialize hand registry with built-in hands
|
// Initialize hand registry with built-in hands
|
||||||
let hands = Arc::new(HandRegistry::new());
|
let hands = Arc::new(HandRegistry::new());
|
||||||
|
let quiz_model = config.model().to_string();
|
||||||
|
let quiz_generator = Arc::new(LlmQuizGenerator::new(driver.clone(), quiz_model));
|
||||||
hands.register(Arc::new(BrowserHand::new())).await;
|
hands.register(Arc::new(BrowserHand::new())).await;
|
||||||
hands.register(Arc::new(SlideshowHand::new())).await;
|
hands.register(Arc::new(SlideshowHand::new())).await;
|
||||||
hands.register(Arc::new(SpeechHand::new())).await;
|
hands.register(Arc::new(SpeechHand::new())).await;
|
||||||
hands.register(Arc::new(QuizHand::new())).await;
|
hands.register(Arc::new(QuizHand::with_generator(quiz_generator))).await;
|
||||||
hands.register(Arc::new(WhiteboardHand::new())).await;
|
hands.register(Arc::new(WhiteboardHand::new())).await;
|
||||||
hands.register(Arc::new(ResearcherHand::new())).await;
|
hands.register(Arc::new(ResearcherHand::new())).await;
|
||||||
hands.register(Arc::new(CollectorHand::new())).await;
|
hands.register(Arc::new(CollectorHand::new())).await;
|
||||||
@@ -95,7 +139,14 @@ impl Kernel {
|
|||||||
hands.register(Arc::new(TwitterHand::new())).await;
|
hands.register(Arc::new(TwitterHand::new())).await;
|
||||||
|
|
||||||
// Create skill executor
|
// Create skill executor
|
||||||
let skill_executor = Arc::new(KernelSkillExecutor::new(skills.clone()));
|
let skill_executor = Arc::new(KernelSkillExecutor::new(skills.clone(), driver.clone()));
|
||||||
|
|
||||||
|
// Create LLM completer for skill system (shared with skill_executor)
|
||||||
|
let llm_completer: Arc<dyn zclaw_skills::LlmCompleter> =
|
||||||
|
Arc::new(LlmDriverAdapter { driver: driver.clone() });
|
||||||
|
|
||||||
|
// Initialize trigger manager
|
||||||
|
let trigger_manager = crate::trigger_manager::TriggerManager::new(hands.clone());
|
||||||
|
|
||||||
// Restore persisted agents
|
// Restore persisted agents
|
||||||
let persisted = memory.list_agents().await?;
|
let persisted = memory.list_agents().await?;
|
||||||
@@ -110,9 +161,12 @@ impl Kernel {
|
|||||||
events,
|
events,
|
||||||
memory,
|
memory,
|
||||||
driver,
|
driver,
|
||||||
|
llm_completer,
|
||||||
skills,
|
skills,
|
||||||
skill_executor,
|
skill_executor,
|
||||||
hands,
|
hands,
|
||||||
|
trigger_manager,
|
||||||
|
pending_approvals: Arc::new(Mutex::new(Vec::new())),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,6 +177,112 @@ impl Kernel {
|
|||||||
tools
|
tools
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build a system prompt with skill information injected
|
||||||
|
async fn build_system_prompt_with_skills(&self, base_prompt: Option<&String>) -> String {
|
||||||
|
// Get skill list asynchronously
|
||||||
|
let skills = self.skills.list().await;
|
||||||
|
|
||||||
|
let mut prompt = base_prompt
|
||||||
|
.map(|p| p.clone())
|
||||||
|
.unwrap_or_else(|| "You are a helpful AI assistant.".to_string());
|
||||||
|
|
||||||
|
// Inject skill information with categories
|
||||||
|
if !skills.is_empty() {
|
||||||
|
prompt.push_str("\n\n## Available Skills\n\n");
|
||||||
|
prompt.push_str("You have access to specialized skills. Analyze user intent and autonomously call `execute_skill` with the appropriate skill_id.\n\n");
|
||||||
|
|
||||||
|
// Group skills by category based on their ID patterns
|
||||||
|
let categories = self.categorize_skills(&skills);
|
||||||
|
|
||||||
|
for (category, category_skills) in categories {
|
||||||
|
prompt.push_str(&format!("### {}\n", category));
|
||||||
|
for skill in category_skills {
|
||||||
|
prompt.push_str(&format!(
|
||||||
|
"- **{}**: {}",
|
||||||
|
skill.id.as_str(),
|
||||||
|
skill.description
|
||||||
|
));
|
||||||
|
prompt.push('\n');
|
||||||
|
}
|
||||||
|
prompt.push('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
prompt.push_str("### When to use skills:\n");
|
||||||
|
prompt.push_str("- **IMPORTANT**: You should autonomously decide when to use skills based on your understanding of the user's intent.\n");
|
||||||
|
prompt.push_str("- Do not wait for explicit skill names - recognize the need and act.\n");
|
||||||
|
prompt.push_str("- Match user's request to the most appropriate skill's domain.\n");
|
||||||
|
prompt.push_str("- If multiple skills could apply, choose the most specialized one.\n\n");
|
||||||
|
prompt.push_str("### Example:\n");
|
||||||
|
prompt.push_str("User: \"分析腾讯财报\" → Intent: Financial analysis → Call: execute_skill(\"finance-tracker\", {...})\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
prompt
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Categorize skills into logical groups
|
||||||
|
///
|
||||||
|
/// Priority:
|
||||||
|
/// 1. Use skill's `category` field if defined in SKILL.md
|
||||||
|
/// 2. Fall back to pattern matching for backward compatibility
|
||||||
|
fn categorize_skills<'a>(&self, skills: &'a [zclaw_skills::SkillManifest]) -> Vec<(String, Vec<&'a zclaw_skills::SkillManifest>)> {
|
||||||
|
let mut categories: std::collections::HashMap<String, Vec<&zclaw_skills::SkillManifest>> = std::collections::HashMap::new();
|
||||||
|
|
||||||
|
// Fallback category patterns for skills without explicit category
|
||||||
|
let fallback_patterns = [
|
||||||
|
("开发工程", vec!["senior-developer", "frontend-developer", "backend-architect", "ai-engineer", "devops-automator", "rapid-prototyper", "lsp-index-engineer"]),
|
||||||
|
("测试质量", vec!["api-tester", "evidence-collector", "reality-checker", "performance-benchmarker", "test-results-analyzer", "accessibility-auditor", "code-review"]),
|
||||||
|
("安全合规", vec!["security-engineer", "legal-compliance-checker", "agentic-identity-trust"]),
|
||||||
|
("数据分析", vec!["analytics-reporter", "finance-tracker", "data-analysis", "sales-data-extraction-agent", "data-consolidation-agent", "report-distribution-agent"]),
|
||||||
|
("项目管理", vec!["senior-pm", "project-shepherd", "sprint-prioritizer", "experiment-tracker", "feedback-synthesizer", "trend-researcher", "agents-orchestrator"]),
|
||||||
|
("设计UX", vec!["ui-designer", "ux-architect", "ux-researcher", "visual-storyteller", "image-prompt-engineer", "whimsy-injector", "brand-guardian"]),
|
||||||
|
("内容营销", vec!["content-creator", "chinese-writing", "executive-summary-generator", "social-media-strategist"]),
|
||||||
|
("社交平台", vec!["twitter-engager", "instagram-curator", "tiktok-strategist", "reddit-community-builder", "zhihu-strategist", "xiaohongshu-specialist", "wechat-official-account", "growth-hacker", "app-store-optimizer"]),
|
||||||
|
("运营支持", vec!["studio-operations", "studio-producer", "support-responder", "workflow-optimizer", "infrastructure-maintainer", "tool-evaluator"]),
|
||||||
|
("XR/空间计算", vec!["visionos-spatial-engineer", "macos-spatial-metal-engineer", "xr-immersive-developer", "xr-interface-architect", "xr-cockpit-interaction-specialist", "terminal-integration-specialist"]),
|
||||||
|
("基础工具", vec!["web-search", "file-operations", "shell-command", "git", "translation", "feishu-docs"]),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Categorize each skill
|
||||||
|
for skill in skills {
|
||||||
|
// Priority 1: Use skill's explicit category
|
||||||
|
if let Some(ref category) = skill.category {
|
||||||
|
if !category.is_empty() {
|
||||||
|
categories.entry(category.clone()).or_default().push(skill);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Priority 2: Fallback to pattern matching
|
||||||
|
let skill_id = skill.id.as_str();
|
||||||
|
let mut categorized = false;
|
||||||
|
|
||||||
|
for (category, patterns) in &fallback_patterns {
|
||||||
|
if patterns.iter().any(|p| skill_id.contains(p) || *p == skill_id) {
|
||||||
|
categories.entry(category.to_string()).or_default().push(skill);
|
||||||
|
categorized = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Put uncategorized skills in "其他"
|
||||||
|
if !categorized {
|
||||||
|
categories.entry("其他".to_string()).or_default().push(skill);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to ordered vector
|
||||||
|
let mut result: Vec<(String, Vec<_>)> = categories.into_iter().collect();
|
||||||
|
result.sort_by(|a, b| {
|
||||||
|
// Sort by predefined order
|
||||||
|
let order = ["开发工程", "测试质量", "安全合规", "数据分析", "项目管理", "设计UX", "内容营销", "社交平台", "运营支持", "XR/空间计算", "基础工具", "其他"];
|
||||||
|
let a_idx = order.iter().position(|&x| x == a.0).unwrap_or(99);
|
||||||
|
let b_idx = order.iter().position(|&x| x == b.0).unwrap_or(99);
|
||||||
|
a_idx.cmp(&b_idx)
|
||||||
|
});
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
/// Spawn a new agent
|
/// Spawn a new agent
|
||||||
pub async fn spawn_agent(&self, config: AgentConfig) -> Result<AgentId> {
|
pub async fn spawn_agent(&self, config: AgentConfig) -> Result<AgentId> {
|
||||||
let id = config.id;
|
let id = config.id;
|
||||||
@@ -195,14 +355,12 @@ impl Kernel {
|
|||||||
.with_model(&model)
|
.with_model(&model)
|
||||||
.with_skill_executor(self.skill_executor.clone())
|
.with_skill_executor(self.skill_executor.clone())
|
||||||
.with_max_tokens(agent_config.max_tokens.unwrap_or_else(|| self.config.max_tokens()))
|
.with_max_tokens(agent_config.max_tokens.unwrap_or_else(|| self.config.max_tokens()))
|
||||||
.with_temperature(agent_config.temperature.unwrap_or_else(|| self.config.temperature()));
|
.with_temperature(agent_config.temperature.unwrap_or_else(|| self.config.temperature()))
|
||||||
|
.with_compaction_threshold(15_000); // Compact when context exceeds ~15k tokens
|
||||||
|
|
||||||
// Add system prompt if configured
|
// Build system prompt with skill information injected
|
||||||
let loop_runner = if let Some(ref prompt) = agent_config.system_prompt {
|
let system_prompt = self.build_system_prompt_with_skills(agent_config.system_prompt.as_ref()).await;
|
||||||
loop_runner.with_system_prompt(prompt)
|
let loop_runner = loop_runner.with_system_prompt(&system_prompt);
|
||||||
} else {
|
|
||||||
loop_runner
|
|
||||||
};
|
|
||||||
|
|
||||||
// Run the loop
|
// Run the loop
|
||||||
let result = loop_runner.run(session_id, message).await?;
|
let result = loop_runner.run(session_id, message).await?;
|
||||||
@@ -219,6 +377,16 @@ impl Kernel {
|
|||||||
&self,
|
&self,
|
||||||
agent_id: &AgentId,
|
agent_id: &AgentId,
|
||||||
message: String,
|
message: String,
|
||||||
|
) -> Result<mpsc::Receiver<zclaw_runtime::LoopEvent>> {
|
||||||
|
self.send_message_stream_with_prompt(agent_id, message, None).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a message with streaming and optional external system prompt
|
||||||
|
pub async fn send_message_stream_with_prompt(
|
||||||
|
&self,
|
||||||
|
agent_id: &AgentId,
|
||||||
|
message: String,
|
||||||
|
system_prompt_override: Option<String>,
|
||||||
) -> Result<mpsc::Receiver<zclaw_runtime::LoopEvent>> {
|
) -> Result<mpsc::Receiver<zclaw_runtime::LoopEvent>> {
|
||||||
let agent_config = self.registry.get(agent_id)
|
let agent_config = self.registry.get(agent_id)
|
||||||
.ok_or_else(|| zclaw_types::ZclawError::NotFound(format!("Agent not found: {}", agent_id)))?;
|
.ok_or_else(|| zclaw_types::ZclawError::NotFound(format!("Agent not found: {}", agent_id)))?;
|
||||||
@@ -241,14 +409,15 @@ impl Kernel {
|
|||||||
.with_model(&model)
|
.with_model(&model)
|
||||||
.with_skill_executor(self.skill_executor.clone())
|
.with_skill_executor(self.skill_executor.clone())
|
||||||
.with_max_tokens(agent_config.max_tokens.unwrap_or_else(|| self.config.max_tokens()))
|
.with_max_tokens(agent_config.max_tokens.unwrap_or_else(|| self.config.max_tokens()))
|
||||||
.with_temperature(agent_config.temperature.unwrap_or_else(|| self.config.temperature()));
|
.with_temperature(agent_config.temperature.unwrap_or_else(|| self.config.temperature()))
|
||||||
|
.with_compaction_threshold(15_000); // Compact when context exceeds ~15k tokens
|
||||||
|
|
||||||
// Add system prompt if configured
|
// Use external prompt if provided, otherwise build default
|
||||||
let loop_runner = if let Some(ref prompt) = agent_config.system_prompt {
|
let system_prompt = match system_prompt_override {
|
||||||
loop_runner.with_system_prompt(prompt)
|
Some(prompt) => prompt,
|
||||||
} else {
|
None => self.build_system_prompt_with_skills(agent_config.system_prompt.as_ref()).await,
|
||||||
loop_runner
|
|
||||||
};
|
};
|
||||||
|
let loop_runner = loop_runner.with_system_prompt(&system_prompt);
|
||||||
|
|
||||||
// Run with streaming
|
// Run with streaming
|
||||||
loop_runner.run_streaming(session_id, message).await
|
loop_runner.run_streaming(session_id, message).await
|
||||||
@@ -270,6 +439,11 @@ impl Kernel {
|
|||||||
&self.config
|
&self.config
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get the LLM driver
|
||||||
|
pub fn driver(&self) -> Arc<dyn LlmDriver> {
|
||||||
|
self.driver.clone()
|
||||||
|
}
|
||||||
|
|
||||||
/// Get the skills registry
|
/// Get the skills registry
|
||||||
pub fn skills(&self) -> &Arc<SkillRegistry> {
|
pub fn skills(&self) -> &Arc<SkillRegistry> {
|
||||||
&self.skills
|
&self.skills
|
||||||
@@ -297,7 +471,12 @@ impl Kernel {
|
|||||||
context: zclaw_skills::SkillContext,
|
context: zclaw_skills::SkillContext,
|
||||||
input: serde_json::Value,
|
input: serde_json::Value,
|
||||||
) -> Result<zclaw_skills::SkillResult> {
|
) -> Result<zclaw_skills::SkillResult> {
|
||||||
self.skills.execute(&zclaw_types::SkillId::new(id), &context, input).await
|
// Inject LLM completer into context for PromptOnly skills
|
||||||
|
let mut ctx = context;
|
||||||
|
if ctx.llm.is_none() {
|
||||||
|
ctx.llm = Some(self.llm_completer.clone());
|
||||||
|
}
|
||||||
|
self.skills.execute(&zclaw_types::SkillId::new(id), &ctx, input).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the hands registry
|
/// Get the hands registry
|
||||||
@@ -320,6 +499,140 @@ impl Kernel {
|
|||||||
let context = HandContext::default();
|
let context = HandContext::default();
|
||||||
self.hands.execute(hand_id, &context, input).await
|
self.hands.execute(hand_id, &context, input).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Trigger Management
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/// List all triggers
|
||||||
|
pub async fn list_triggers(&self) -> Vec<crate::trigger_manager::TriggerEntry> {
|
||||||
|
self.trigger_manager.list_triggers().await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a specific trigger
|
||||||
|
pub async fn get_trigger(&self, id: &str) -> Option<crate::trigger_manager::TriggerEntry> {
|
||||||
|
self.trigger_manager.get_trigger(id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new trigger
|
||||||
|
pub async fn create_trigger(
|
||||||
|
&self,
|
||||||
|
config: zclaw_hands::TriggerConfig,
|
||||||
|
) -> Result<crate::trigger_manager::TriggerEntry> {
|
||||||
|
self.trigger_manager.create_trigger(config).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update a trigger
|
||||||
|
pub async fn update_trigger(
|
||||||
|
&self,
|
||||||
|
id: &str,
|
||||||
|
updates: crate::trigger_manager::TriggerUpdateRequest,
|
||||||
|
) -> Result<crate::trigger_manager::TriggerEntry> {
|
||||||
|
self.trigger_manager.update_trigger(id, updates).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a trigger
|
||||||
|
pub async fn delete_trigger(&self, id: &str) -> Result<()> {
|
||||||
|
self.trigger_manager.delete_trigger(id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Execute a trigger
|
||||||
|
pub async fn execute_trigger(
|
||||||
|
&self,
|
||||||
|
id: &str,
|
||||||
|
input: serde_json::Value,
|
||||||
|
) -> Result<zclaw_hands::TriggerResult> {
|
||||||
|
self.trigger_manager.execute_trigger(id, input).await
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Approval Management
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/// List pending approvals
|
||||||
|
pub async fn list_approvals(&self) -> Vec<ApprovalEntry> {
|
||||||
|
let approvals = self.pending_approvals.lock().await;
|
||||||
|
approvals.iter().filter(|a| a.status == "pending").cloned().collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a pending approval (called when a needs_approval hand is triggered)
|
||||||
|
pub async fn create_approval(&self, hand_id: String, input: serde_json::Value) -> ApprovalEntry {
|
||||||
|
let entry = ApprovalEntry {
|
||||||
|
id: uuid::Uuid::new_v4().to_string(),
|
||||||
|
hand_id,
|
||||||
|
status: "pending".to_string(),
|
||||||
|
created_at: chrono::Utc::now(),
|
||||||
|
input,
|
||||||
|
};
|
||||||
|
let mut approvals = self.pending_approvals.lock().await;
|
||||||
|
approvals.push(entry.clone());
|
||||||
|
entry
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Respond to an approval
|
||||||
|
pub async fn respond_to_approval(
|
||||||
|
&self,
|
||||||
|
id: &str,
|
||||||
|
approved: bool,
|
||||||
|
_reason: Option<String>,
|
||||||
|
) -> Result<()> {
|
||||||
|
let mut approvals = self.pending_approvals.lock().await;
|
||||||
|
let entry = approvals.iter_mut().find(|a| a.id == id && a.status == "pending")
|
||||||
|
.ok_or_else(|| zclaw_types::ZclawError::NotFound(format!("Approval not found: {}", id)))?;
|
||||||
|
|
||||||
|
entry.status = if approved { "approved".to_string() } else { "rejected".to_string() };
|
||||||
|
|
||||||
|
if approved {
|
||||||
|
let hand_id = entry.hand_id.clone();
|
||||||
|
let input = entry.input.clone();
|
||||||
|
drop(approvals); // Release lock before async hand execution
|
||||||
|
|
||||||
|
// Execute the hand in background
|
||||||
|
let hands = self.hands.clone();
|
||||||
|
let approvals = self.pending_approvals.clone();
|
||||||
|
let id_owned = id.to_string();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let context = HandContext::default();
|
||||||
|
let result = hands.execute(&hand_id, &context, input).await;
|
||||||
|
|
||||||
|
// Update approval status based on execution result
|
||||||
|
let mut approvals = approvals.lock().await;
|
||||||
|
if let Some(entry) = approvals.iter_mut().find(|a| a.id == id_owned) {
|
||||||
|
match result {
|
||||||
|
Ok(_) => entry.status = "completed".to_string(),
|
||||||
|
Err(e) => {
|
||||||
|
entry.status = "failed".to_string();
|
||||||
|
// Store error in input metadata
|
||||||
|
if let Some(obj) = entry.input.as_object_mut() {
|
||||||
|
obj.insert("error".to_string(), Value::String(format!("{}", e)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cancel a pending approval
|
||||||
|
pub async fn cancel_approval(&self, id: &str) -> Result<()> {
|
||||||
|
let mut approvals = self.pending_approvals.lock().await;
|
||||||
|
let entry = approvals.iter_mut().find(|a| a.id == id && a.status == "pending")
|
||||||
|
.ok_or_else(|| zclaw_types::ZclawError::NotFound(format!("Approval not found: {}", id)))?;
|
||||||
|
entry.status = "cancelled".to_string();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Approval entry for pending approvals
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ApprovalEntry {
|
||||||
|
pub id: String,
|
||||||
|
pub hand_id: String,
|
||||||
|
pub status: String,
|
||||||
|
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
pub input: serde_json::Value,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Response from sending a message
|
/// Response from sending a message
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ mod kernel;
|
|||||||
mod registry;
|
mod registry;
|
||||||
mod capabilities;
|
mod capabilities;
|
||||||
mod events;
|
mod events;
|
||||||
|
pub mod trigger_manager;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
|
#[cfg(feature = "multi-agent")]
|
||||||
pub mod director;
|
pub mod director;
|
||||||
pub mod generation;
|
pub mod generation;
|
||||||
pub mod export;
|
pub mod export;
|
||||||
@@ -16,6 +18,11 @@ pub use registry::*;
|
|||||||
pub use capabilities::*;
|
pub use capabilities::*;
|
||||||
pub use events::*;
|
pub use events::*;
|
||||||
pub use config::*;
|
pub use config::*;
|
||||||
|
pub use trigger_manager::{TriggerManager, TriggerEntry, TriggerUpdateRequest, TriggerManagerConfig};
|
||||||
|
#[cfg(feature = "multi-agent")]
|
||||||
pub use director::*;
|
pub use director::*;
|
||||||
pub use generation::*;
|
pub use generation::*;
|
||||||
pub use export::{ExportFormat, ExportOptions, ExportResult, Exporter, export_classroom};
|
pub use export::{ExportFormat, ExportOptions, ExportResult, Exporter, export_classroom};
|
||||||
|
|
||||||
|
// Re-export hands types for convenience
|
||||||
|
pub use zclaw_hands::{HandRegistry, HandContext, HandResult, HandConfig, Hand, HandStatus};
|
||||||
|
|||||||
372
crates/zclaw-kernel/src/trigger_manager.rs
Normal file
372
crates/zclaw-kernel/src/trigger_manager.rs
Normal file
@@ -0,0 +1,372 @@
|
|||||||
|
//! Trigger Manager
|
||||||
|
//!
|
||||||
|
//! Manages triggers for automated task execution.
|
||||||
|
//!
|
||||||
|
//! # Lock Order Safety
|
||||||
|
//!
|
||||||
|
//! This module uses a single `RwLock<InternalState>` to avoid potential deadlocks.
|
||||||
|
//! Previously, multiple locks (`triggers` and `states`) could cause deadlocks when
|
||||||
|
//! acquired in different orders across methods.
|
||||||
|
//!
|
||||||
|
//! The unified state structure ensures atomic access to all trigger-related data.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use zclaw_types::Result;
|
||||||
|
use zclaw_hands::{TriggerConfig, TriggerType, TriggerState, TriggerResult, HandRegistry};
|
||||||
|
|
||||||
|
/// Internal state container for all trigger-related data.
|
||||||
|
///
|
||||||
|
/// Using a single structure behind one RwLock eliminates the possibility of
|
||||||
|
/// deadlocks caused by inconsistent lock acquisition orders.
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct InternalState {
|
||||||
|
/// Registered triggers
|
||||||
|
triggers: HashMap<String, TriggerEntry>,
|
||||||
|
/// Execution states
|
||||||
|
states: HashMap<String, TriggerState>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InternalState {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
triggers: HashMap::new(),
|
||||||
|
states: HashMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Trigger manager for coordinating automated triggers
|
||||||
|
pub struct TriggerManager {
|
||||||
|
/// Unified internal state behind a single RwLock.
|
||||||
|
///
|
||||||
|
/// This prevents deadlocks by ensuring all trigger data is accessed
|
||||||
|
/// through a single lock acquisition point.
|
||||||
|
state: RwLock<InternalState>,
|
||||||
|
/// Hand registry
|
||||||
|
hand_registry: Arc<HandRegistry>,
|
||||||
|
/// Configuration
|
||||||
|
config: TriggerManagerConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Trigger entry with additional metadata
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct TriggerEntry {
|
||||||
|
/// Core trigger configuration
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub config: TriggerConfig,
|
||||||
|
/// Creation timestamp
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
/// Last modification timestamp
|
||||||
|
pub modified_at: DateTime<Utc>,
|
||||||
|
/// Optional description
|
||||||
|
pub description: Option<String>,
|
||||||
|
/// Optional tags
|
||||||
|
#[serde(default)]
|
||||||
|
pub tags: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Default max executions per hour
|
||||||
|
fn default_max_executions_per_hour() -> u32 { 10 }
|
||||||
|
/// Default persist value
|
||||||
|
fn default_persist() -> bool { true }
|
||||||
|
|
||||||
|
/// Trigger manager configuration
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct TriggerManagerConfig {
|
||||||
|
/// Maximum executions per hour (default)
|
||||||
|
#[serde(default = "default_max_executions_per_hour")]
|
||||||
|
pub max_executions_per_hour: u32,
|
||||||
|
/// Enable persistent storage
|
||||||
|
#[serde(default = "default_persist")]
|
||||||
|
pub persist: bool,
|
||||||
|
/// Storage path for trigger data
|
||||||
|
pub storage_path: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for TriggerManagerConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
max_executions_per_hour: 10,
|
||||||
|
persist: true,
|
||||||
|
storage_path: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TriggerManager {
|
||||||
|
/// Create new trigger manager
|
||||||
|
pub fn new(hand_registry: Arc<HandRegistry>) -> Self {
|
||||||
|
Self {
|
||||||
|
state: RwLock::new(InternalState::new()),
|
||||||
|
hand_registry,
|
||||||
|
config: TriggerManagerConfig::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create with custom configuration
|
||||||
|
pub fn with_config(
|
||||||
|
hand_registry: Arc<HandRegistry>,
|
||||||
|
config: TriggerManagerConfig,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
state: RwLock::new(InternalState::new()),
|
||||||
|
hand_registry,
|
||||||
|
config,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List all triggers
|
||||||
|
pub async fn list_triggers(&self) -> Vec<TriggerEntry> {
|
||||||
|
let state = self.state.read().await;
|
||||||
|
state.triggers.values().cloned().collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a specific trigger
|
||||||
|
pub async fn get_trigger(&self, id: &str) -> Option<TriggerEntry> {
|
||||||
|
let state = self.state.read().await;
|
||||||
|
state.triggers.get(id).cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new trigger
|
||||||
|
pub async fn create_trigger(&self, config: TriggerConfig) -> Result<TriggerEntry> {
|
||||||
|
// Validate hand exists (outside of our lock to avoid holding two locks)
|
||||||
|
if self.hand_registry.get(&config.hand_id).await.is_none() {
|
||||||
|
return Err(zclaw_types::ZclawError::InvalidInput(
|
||||||
|
format!("Hand '{}' not found", config.hand_id)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let id = config.id.clone();
|
||||||
|
let now = Utc::now();
|
||||||
|
|
||||||
|
let entry = TriggerEntry {
|
||||||
|
config,
|
||||||
|
created_at: now,
|
||||||
|
modified_at: now,
|
||||||
|
description: None,
|
||||||
|
tags: Vec::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialize state and insert trigger atomically under single lock
|
||||||
|
let state = TriggerState::new(&id);
|
||||||
|
{
|
||||||
|
let mut internal = self.state.write().await;
|
||||||
|
internal.states.insert(id.clone(), state);
|
||||||
|
internal.triggers.insert(id.clone(), entry.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update an existing trigger
|
||||||
|
pub async fn update_trigger(
|
||||||
|
&self,
|
||||||
|
id: &str,
|
||||||
|
updates: TriggerUpdateRequest,
|
||||||
|
) -> Result<TriggerEntry> {
|
||||||
|
// Validate hand exists if being updated (outside of our lock)
|
||||||
|
if let Some(hand_id) = &updates.hand_id {
|
||||||
|
if self.hand_registry.get(hand_id).await.is_none() {
|
||||||
|
return Err(zclaw_types::ZclawError::InvalidInput(
|
||||||
|
format!("Hand '{}' not found", hand_id)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut internal = self.state.write().await;
|
||||||
|
|
||||||
|
let Some(entry) = internal.triggers.get_mut(id) else {
|
||||||
|
return Err(zclaw_types::ZclawError::NotFound(
|
||||||
|
format!("Trigger '{}' not found", id)
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Apply updates
|
||||||
|
if let Some(name) = &updates.name {
|
||||||
|
entry.config.name = name.clone();
|
||||||
|
}
|
||||||
|
if let Some(enabled) = updates.enabled {
|
||||||
|
entry.config.enabled = enabled;
|
||||||
|
}
|
||||||
|
if let Some(hand_id) = &updates.hand_id {
|
||||||
|
entry.config.hand_id = hand_id.clone();
|
||||||
|
}
|
||||||
|
if let Some(trigger_type) = &updates.trigger_type {
|
||||||
|
entry.config.trigger_type = trigger_type.clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.modified_at = Utc::now();
|
||||||
|
|
||||||
|
Ok(entry.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a trigger
|
||||||
|
pub async fn delete_trigger(&self, id: &str) -> Result<()> {
|
||||||
|
let mut internal = self.state.write().await;
|
||||||
|
if internal.triggers.remove(id).is_none() {
|
||||||
|
return Err(zclaw_types::ZclawError::NotFound(
|
||||||
|
format!("Trigger '{}' not found", id)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
// Also remove associated state atomically
|
||||||
|
internal.states.remove(id);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get trigger state
|
||||||
|
pub async fn get_state(&self, id: &str) -> Option<TriggerState> {
|
||||||
|
let state = self.state.read().await;
|
||||||
|
state.states.get(id).cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if trigger should fire based on type and input.
|
||||||
|
///
|
||||||
|
/// This method performs rate limiting and condition checks using a single
|
||||||
|
/// read lock to avoid deadlocks.
|
||||||
|
pub async fn should_fire(&self, id: &str, input: &serde_json::Value) -> bool {
|
||||||
|
let internal = self.state.read().await;
|
||||||
|
let Some(entry) = internal.triggers.get(id) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if enabled
|
||||||
|
if !entry.config.enabled {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check rate limiting using the same lock
|
||||||
|
if let Some(state) = internal.states.get(id) {
|
||||||
|
// Check execution count this hour
|
||||||
|
let one_hour_ago = Utc::now() - chrono::Duration::hours(1);
|
||||||
|
if let Some(last_exec) = state.last_execution {
|
||||||
|
if last_exec > one_hour_ago {
|
||||||
|
if state.execution_count >= self.config.max_executions_per_hour {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check trigger-specific conditions
|
||||||
|
match &entry.config.trigger_type {
|
||||||
|
TriggerType::Manual => false,
|
||||||
|
TriggerType::Schedule { cron: _ } => {
|
||||||
|
// For schedule triggers, use cron parser
|
||||||
|
// Simplified check - real implementation would use cron library
|
||||||
|
true
|
||||||
|
}
|
||||||
|
TriggerType::Event { pattern } => {
|
||||||
|
// Check if input matches pattern
|
||||||
|
input.to_string().contains(pattern)
|
||||||
|
}
|
||||||
|
TriggerType::Webhook { path: _, secret: _ } => {
|
||||||
|
// Webhook triggers are fired externally
|
||||||
|
false
|
||||||
|
}
|
||||||
|
TriggerType::MessagePattern { pattern } => {
|
||||||
|
// Check if message matches pattern
|
||||||
|
input.to_string().contains(pattern)
|
||||||
|
}
|
||||||
|
TriggerType::FileSystem { path: _, events: _ } => {
|
||||||
|
// File system triggers are fired by file watcher
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Execute a trigger.
|
||||||
|
///
|
||||||
|
/// This method carefully manages lock scope to avoid deadlocks:
|
||||||
|
/// 1. Acquires read lock to check trigger exists and get config
|
||||||
|
/// 2. Releases lock before calling external hand registry
|
||||||
|
/// 3. Acquires write lock to update state
|
||||||
|
pub async fn execute_trigger(&self, id: &str, input: serde_json::Value) -> Result<TriggerResult> {
|
||||||
|
// Check if should fire (uses its own lock scope)
|
||||||
|
if !self.should_fire(id, &input).await {
|
||||||
|
return Err(zclaw_types::ZclawError::InvalidInput(
|
||||||
|
format!("Trigger '{}' should not fire", id)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get hand_id (release lock before calling hand registry)
|
||||||
|
let hand_id = {
|
||||||
|
let internal = self.state.read().await;
|
||||||
|
let entry = internal.triggers.get(id)
|
||||||
|
.ok_or_else(|| zclaw_types::ZclawError::NotFound(
|
||||||
|
format!("Trigger '{}' not found", id)
|
||||||
|
))?;
|
||||||
|
entry.config.hand_id.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get hand (outside of our lock to avoid potential deadlock with hand_registry)
|
||||||
|
let hand = self.hand_registry.get(&hand_id).await
|
||||||
|
.ok_or_else(|| zclaw_types::ZclawError::InvalidInput(
|
||||||
|
format!("Hand '{}' not found", hand_id)
|
||||||
|
))?;
|
||||||
|
|
||||||
|
// Update state before execution
|
||||||
|
{
|
||||||
|
let mut internal = self.state.write().await;
|
||||||
|
let state = internal.states.entry(id.to_string()).or_insert_with(|| TriggerState::new(id));
|
||||||
|
state.execution_count += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute hand (outside of lock to avoid blocking other operations)
|
||||||
|
let context = zclaw_hands::HandContext {
|
||||||
|
agent_id: zclaw_types::AgentId::new(),
|
||||||
|
working_dir: None,
|
||||||
|
env: std::collections::HashMap::new(),
|
||||||
|
timeout_secs: 300,
|
||||||
|
callback_url: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let hand_result = hand.execute(&context, input.clone()).await;
|
||||||
|
|
||||||
|
// Build trigger result from hand result
|
||||||
|
let trigger_result = match &hand_result {
|
||||||
|
Ok(res) => TriggerResult {
|
||||||
|
timestamp: Utc::now(),
|
||||||
|
success: res.success,
|
||||||
|
output: Some(res.output.clone()),
|
||||||
|
error: res.error.clone(),
|
||||||
|
trigger_input: input.clone(),
|
||||||
|
},
|
||||||
|
Err(e) => TriggerResult {
|
||||||
|
timestamp: Utc::now(),
|
||||||
|
success: false,
|
||||||
|
output: None,
|
||||||
|
error: Some(e.to_string()),
|
||||||
|
trigger_input: input.clone(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update state after execution
|
||||||
|
{
|
||||||
|
let mut internal = self.state.write().await;
|
||||||
|
if let Some(state) = internal.states.get_mut(id) {
|
||||||
|
state.last_execution = Some(Utc::now());
|
||||||
|
state.last_result = Some(trigger_result.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the original hand result or convert to trigger result
|
||||||
|
hand_result.map(|_| trigger_result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request for updating a trigger
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct TriggerUpdateRequest {
|
||||||
|
/// New name
|
||||||
|
pub name: Option<String>,
|
||||||
|
/// Enable/disable
|
||||||
|
pub enabled: Option<bool>,
|
||||||
|
/// New hand ID
|
||||||
|
pub hand_id: Option<String>,
|
||||||
|
/// New trigger type
|
||||||
|
pub trigger_type: Option<TriggerType>,
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ tracing = { workspace = true }
|
|||||||
|
|
||||||
# SQLite
|
# SQLite
|
||||||
sqlx = { workspace = true }
|
sqlx = { workspace = true }
|
||||||
|
libsqlite3-sys = { workspace = true }
|
||||||
|
|
||||||
# Async utilities
|
# Async utilities
|
||||||
futures = { workspace = true }
|
futures = { workspace = true }
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user