Compare commits
30 Commits
ef3d4e3094
...
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 |
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
|
||||
*.pdb
|
||||
|
||||
#test
|
||||
# Test
|
||||
desktop/test-results/
|
||||
desktop/tests/e2e/test-results/
|
||||
desktop/coverage/
|
||||
.gstack/
|
||||
.trae/
|
||||
target/debug/
|
||||
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-skills/ # 技能系统 (SKILL.md解析, 执行器)
|
||||
│ ├── zclaw-hands/ # 自主能力 (Hand/Trigger 注册管理)
|
||||
│ ├── zclaw-channels/ # 通道适配器 (Telegram, Discord, Slack)
|
||||
│ ├── zclaw-channels/ # 通道适配器 (仅 ConsoleChannel 测试适配器)
|
||||
│ └── zclaw-protocols/ # 协议支持 (MCP, A2A)
|
||||
├── desktop/ # Tauri 桌面应用
|
||||
│ ├── src/
|
||||
@@ -175,24 +175,27 @@ Client → 负责网络通信和```
|
||||
| 分身管理 | ✅ 完成 | 创建、配置、切换 Agent |
|
||||
| 自动化面板 | ✅ 完成 | Hands 触发、参数配置 |
|
||||
| 技能市场 | 🚧 进行中 | 技能浏览和安装 |
|
||||
| 工作流编辑 | 📋 计划中 | 多步骤任务编排 |
|
||||
| 工作流编辑 | 🚧 进行中 | Pipeline 工作流编辑器 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 自主能力系统 (Hands)
|
||||
|
||||
ZCLAW 提供 8 个自主能力包:
|
||||
ZCLAW 提供 11 个自主能力包:
|
||||
|
||||
| Hand | 功能 | 状态 |
|
||||
|------|------|------|
|
||||
| Browser | 浏览器自动化 | ✅ 可用 |
|
||||
| Collector | 数据收集聚合 | ✅ 可用 |
|
||||
| Researcher | 深度研究 | ✅ 可用 |
|
||||
| Predictor | 预测分析 | ✅ 可用 |
|
||||
| Lead | 销售线索发现 | ✅ 可用 |
|
||||
| Trader | 交易分析 | ✅ 可用 |
|
||||
| Predictor | 预测分析 | ❌ 已禁用 (enabled=false),无 Rust 实现 |
|
||||
| Lead | 销售线索发现 | ❌ 已禁用 (enabled=false),无 Rust 实现 |
|
||||
| Clip | 视频处理 | ⚠️ 需 FFmpeg |
|
||||
| Twitter | Twitter 自动化 | ⚠️ 需 API Key |
|
||||
| Whiteboard | 白板演示 | ✅ 可用(导出功能开发中,标注 demo) |
|
||||
| Slideshow | 幻灯片生成 | ✅ 可用 |
|
||||
| Speech | 语音合成 | ✅ 可用 |
|
||||
| Quiz | 测验生成 | ✅ 可用 |
|
||||
|
||||
**触发 Hand 时:**
|
||||
1. 检查依赖是否满足
|
||||
|
||||
583
Cargo.lock
generated
583
Cargo.lock
generated
@@ -110,6 +110,18 @@ dependencies = [
|
||||
"derive_arbitrary",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "argon2"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"blake2",
|
||||
"cpufeatures",
|
||||
"password-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-broadcast"
|
||||
version = "0.7.2"
|
||||
@@ -315,6 +327,7 @@ checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum-core",
|
||||
"axum-macros",
|
||||
"bytes",
|
||||
"futures-util",
|
||||
"http 1.4.0",
|
||||
@@ -335,7 +348,7 @@ dependencies = [
|
||||
"serde_urlencoded",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tower",
|
||||
"tower 0.5.3",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
@@ -362,6 +375,47 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "axum-extra"
|
||||
version = "0.9.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c794b30c904f0a1c2fb7740f7df7f7972dfaa14ef6f57cb6178dc63e5dca2f04"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"axum-core",
|
||||
"bytes",
|
||||
"fastrand",
|
||||
"futures-util",
|
||||
"headers",
|
||||
"http 1.4.0",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"mime",
|
||||
"multer",
|
||||
"pin-project-lite",
|
||||
"serde",
|
||||
"tower 0.5.3",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "axum-macros"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "57d123550fa8d071b7255cb0cc04dc302baa6c8c4a79f55701552684d8399bce"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "base32"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "022dfe9eb35f19ebbcb51e0b40a5ab759f46ad60cadf7297e0bd085afb50e076"
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.21.7"
|
||||
@@ -410,6 +464,15 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "blake2"
|
||||
version = "0.10.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
|
||||
dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
@@ -654,6 +717,12 @@ version = "0.9.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
|
||||
|
||||
[[package]]
|
||||
name = "constant_time_eq"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6"
|
||||
|
||||
[[package]]
|
||||
name = "convert_case"
|
||||
version = "0.4.0"
|
||||
@@ -896,6 +965,12 @@ dependencies = [
|
||||
"parking_lot_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "data-encoding"
|
||||
version = "2.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea"
|
||||
|
||||
[[package]]
|
||||
name = "der"
|
||||
version = "0.7.10"
|
||||
@@ -975,6 +1050,7 @@ dependencies = [
|
||||
"fantoccini",
|
||||
"futures",
|
||||
"keyring",
|
||||
"libsqlite3-sys",
|
||||
"rand 0.8.5",
|
||||
"regex",
|
||||
"reqwest 0.12.28",
|
||||
@@ -1149,9 +1225,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "embed-resource"
|
||||
version = "3.0.7"
|
||||
version = "3.0.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47ec73ddcf6b7f23173d5c3c5a32b5507dc0a734de7730aa14abc5d5e296bb5f"
|
||||
checksum = "63a1d0de4f2249aa0ff5884d7080814f446bb241a559af6c170a41e878ed2d45"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"memchr",
|
||||
@@ -1167,6 +1243,15 @@ version = "1.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
|
||||
|
||||
[[package]]
|
||||
name = "encoding_rs"
|
||||
version = "0.8.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "endi"
|
||||
version = "1.1.1"
|
||||
@@ -1893,6 +1978,30 @@ dependencies = [
|
||||
"hashbrown 0.14.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "headers"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b3314d5adb5d94bcdf56771f2e50dbbc80bb4bdf88967526706205ac9eff24eb"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"headers-core",
|
||||
"http 1.4.0",
|
||||
"httpdate",
|
||||
"mime",
|
||||
"sha1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "headers-core"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4"
|
||||
dependencies = [
|
||||
"http 1.4.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.4.1"
|
||||
@@ -2300,9 +2409,9 @@ checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
|
||||
|
||||
[[package]]
|
||||
name = "iri-string"
|
||||
version = "0.7.10"
|
||||
version = "0.7.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a"
|
||||
checksum = "d8e7418f59cc01c88316161279a7f665217ae316b388e58a0d10e29f54f1e5eb"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"serde",
|
||||
@@ -2365,7 +2474,7 @@ dependencies = [
|
||||
"cesu8",
|
||||
"cfg-if",
|
||||
"combine",
|
||||
"jni-sys",
|
||||
"jni-sys 0.3.1",
|
||||
"log",
|
||||
"thiserror 1.0.69",
|
||||
"walkdir",
|
||||
@@ -2374,9 +2483,31 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "jni-sys"
|
||||
version = "0.3.0"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130"
|
||||
checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258"
|
||||
dependencies = [
|
||||
"jni-sys 0.4.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jni-sys"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2"
|
||||
dependencies = [
|
||||
"jni-sys-macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jni-sys-macros"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
@@ -2410,6 +2541,21 @@ dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jsonwebtoken"
|
||||
version = "9.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"js-sys",
|
||||
"pem",
|
||||
"ring",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"simple_asn1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "keyboard-types"
|
||||
version = "0.7.0"
|
||||
@@ -2506,9 +2652,9 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
|
||||
|
||||
[[package]]
|
||||
name = "libredox"
|
||||
version = "0.1.14"
|
||||
version = "0.1.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a"
|
||||
checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"libc",
|
||||
@@ -2602,6 +2748,15 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "matchers"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
|
||||
dependencies = [
|
||||
"regex-automata",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "matches"
|
||||
version = "0.1.10"
|
||||
@@ -2645,6 +2800,16 @@ version = "0.3.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||
|
||||
[[package]]
|
||||
name = "mime_guess"
|
||||
version = "2.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
|
||||
dependencies = [
|
||||
"mime",
|
||||
"unicase",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "minimal-lexical"
|
||||
version = "0.2.1"
|
||||
@@ -2693,6 +2858,23 @@ dependencies = [
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "multer"
|
||||
version = "3.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"encoding_rs",
|
||||
"futures-util",
|
||||
"http 1.4.0",
|
||||
"httparse",
|
||||
"memchr",
|
||||
"mime",
|
||||
"spin",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "native-tls"
|
||||
version = "0.2.18"
|
||||
@@ -2717,7 +2899,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"jni-sys",
|
||||
"jni-sys 0.3.1",
|
||||
"log",
|
||||
"ndk-sys",
|
||||
"num_enum",
|
||||
@@ -2737,7 +2919,7 @@ version = "0.6.0+11769913"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873"
|
||||
dependencies = [
|
||||
"jni-sys",
|
||||
"jni-sys 0.3.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2762,6 +2944,25 @@ dependencies = [
|
||||
"minimal-lexical",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nu-ansi-term"
|
||||
version = "0.50.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-bigint"
|
||||
version = "0.4.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9"
|
||||
dependencies = [
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-bigint-dig"
|
||||
version = "0.8.6"
|
||||
@@ -2780,9 +2981,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "num-conv"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050"
|
||||
checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967"
|
||||
|
||||
[[package]]
|
||||
name = "num-integer"
|
||||
@@ -3097,6 +3298,17 @@ dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "password-hash"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"rand_core 0.6.4",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "paste"
|
||||
version = "1.0.15"
|
||||
@@ -3109,6 +3321,16 @@ version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
|
||||
|
||||
[[package]]
|
||||
name = "pem"
|
||||
version = "3.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pem-rfc7468"
|
||||
version = "0.7.0"
|
||||
@@ -3311,6 +3533,26 @@ dependencies = [
|
||||
"siphasher 1.0.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pin-project"
|
||||
version = "1.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517"
|
||||
dependencies = [
|
||||
"pin-project-internal",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-internal"
|
||||
version = "1.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.17"
|
||||
@@ -3485,7 +3727,7 @@ version = "3.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
|
||||
dependencies = [
|
||||
"toml_edit 0.25.5+spec-1.1.0",
|
||||
"toml_edit 0.25.8+spec-1.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3837,7 +4079,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower 0.5.3",
|
||||
"tower-http 0.6.8",
|
||||
"tower-service",
|
||||
"url",
|
||||
@@ -3872,7 +4114,7 @@ dependencies = [
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower 0.5.3",
|
||||
"tower-http 0.6.8",
|
||||
"tower-service",
|
||||
"url",
|
||||
@@ -3916,6 +4158,41 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rust-embed"
|
||||
version = "8.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "04113cb9355a377d83f06ef1f0a45b8ab8cd7d8b1288160717d66df5c7988d27"
|
||||
dependencies = [
|
||||
"rust-embed-impl",
|
||||
"rust-embed-utils",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rust-embed-impl"
|
||||
version = "8.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "da0902e4c7c8e997159ab384e6d0fc91c221375f6894346ae107f47dd0f3ccaa"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"rust-embed-utils",
|
||||
"shellexpand",
|
||||
"syn 2.0.117",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rust-embed-utils"
|
||||
version = "8.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5bcdef0be6fe7f6fa333b1073c949729274b05f123a0ad7efcb8efd878e5c3b1"
|
||||
dependencies = [
|
||||
"sha2",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "2.1.1"
|
||||
@@ -4244,9 +4521,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "serde_spanned"
|
||||
version = "1.0.4"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776"
|
||||
checksum = "876ac351060d4f882bb1032b6369eb0aef79ad9df1ea8bc404874d8cc3d0cd98"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
@@ -4376,6 +4653,24 @@ dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sharded-slab"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shellexpand"
|
||||
version = "3.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32824fab5e16e6c4d86dc1ba84489390419a39f97699852b66480bb87d297ed8"
|
||||
dependencies = [
|
||||
"dirs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "1.3.0"
|
||||
@@ -4408,6 +4703,18 @@ version = "0.3.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
|
||||
|
||||
[[package]]
|
||||
name = "simple_asn1"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d"
|
||||
dependencies = [
|
||||
"num-bigint",
|
||||
"num-traits",
|
||||
"thiserror 2.0.18",
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "siphasher"
|
||||
version = "0.3.11"
|
||||
@@ -4542,6 +4849,7 @@ dependencies = [
|
||||
"atoi",
|
||||
"byteorder",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"crc",
|
||||
"crossbeam-queue",
|
||||
"either",
|
||||
@@ -4602,6 +4910,7 @@ dependencies = [
|
||||
"sha2",
|
||||
"sqlx-core",
|
||||
"sqlx-mysql",
|
||||
"sqlx-postgres",
|
||||
"sqlx-sqlite",
|
||||
"syn 1.0.109",
|
||||
"tempfile",
|
||||
@@ -4620,6 +4929,7 @@ dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"byteorder",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"crc",
|
||||
"digest",
|
||||
"dotenvy",
|
||||
@@ -4661,6 +4971,7 @@ dependencies = [
|
||||
"base64 0.21.7",
|
||||
"bitflags 2.11.0",
|
||||
"byteorder",
|
||||
"chrono",
|
||||
"crc",
|
||||
"dotenvy",
|
||||
"etcetera",
|
||||
@@ -4696,6 +5007,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b244ef0a8414da0bed4bb1910426e890b19e5e9bccc27ada6b797d05c55ae0aa"
|
||||
dependencies = [
|
||||
"atoi",
|
||||
"chrono",
|
||||
"flume",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
@@ -4858,9 +5170,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tao"
|
||||
version = "0.34.6"
|
||||
version = "0.34.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e06d52c379e63da659a483a958110bbde891695a0ecb53e48cc7786d5eda7bb"
|
||||
checksum = "9103edf55f2da3c82aea4c7fab7c4241032bfeea0e71fa557d98e00e7ce7cc20"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"block2",
|
||||
@@ -5238,6 +5550,15 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thread_local"
|
||||
version = "1.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "time"
|
||||
version = "0.3.47"
|
||||
@@ -5397,7 +5718,7 @@ checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863"
|
||||
dependencies = [
|
||||
"indexmap 2.13.0",
|
||||
"serde_core",
|
||||
"serde_spanned 1.0.4",
|
||||
"serde_spanned 1.1.0",
|
||||
"toml_datetime 0.7.5+spec-1.1.0",
|
||||
"toml_parser",
|
||||
"toml_writer",
|
||||
@@ -5424,9 +5745,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "toml_datetime"
|
||||
version = "1.0.1+spec-1.1.0"
|
||||
version = "1.1.0+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b320e741db58cac564e26c607d3cc1fdc4a88fd36c879568c07856ed83ff3e9"
|
||||
checksum = "97251a7c317e03ad83774a8752a7e81fb6067740609f75ea2b585b569a59198f"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
@@ -5457,30 +5778,58 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "toml_edit"
|
||||
version = "0.25.5+spec-1.1.0"
|
||||
version = "0.25.8+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ca1a40644a28bce036923f6a431df0b34236949d111cc07cb6dca830c9ef2e1"
|
||||
checksum = "16bff38f1d86c47f9ff0647e6838d7bb362522bdf44006c7068c2b1e606f1f3c"
|
||||
dependencies = [
|
||||
"indexmap 2.13.0",
|
||||
"toml_datetime 1.0.1+spec-1.1.0",
|
||||
"toml_datetime 1.1.0+spec-1.1.0",
|
||||
"toml_parser",
|
||||
"winnow 1.0.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_parser"
|
||||
version = "1.0.10+spec-1.1.0"
|
||||
version = "1.1.0+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7df25b4befd31c4816df190124375d5a20c6b6921e2cad937316de3fccd63420"
|
||||
checksum = "2334f11ee363607eb04df9b8fc8a13ca1715a72ba8662a26ac285c98aabb4011"
|
||||
dependencies = [
|
||||
"winnow 1.0.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_writer"
|
||||
version = "1.0.7+spec-1.1.0"
|
||||
version = "1.1.0+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f17aaa1c6e3dc22b1da4b6bba97d066e354c7945cac2f7852d4e4e7ca7a6b56d"
|
||||
checksum = "d282ade6016312faf3e41e57ebbba0c073e4056dab1232ab1cb624199648f8ed"
|
||||
|
||||
[[package]]
|
||||
name = "totp-rs"
|
||||
version = "5.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2b36a9dd327e9f401320a2cb4572cc76ff43742bcfc3291f871691050f140ba"
|
||||
dependencies = [
|
||||
"base32",
|
||||
"constant_time_eq",
|
||||
"hmac",
|
||||
"sha1",
|
||||
"sha2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tower"
|
||||
version = "0.4.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"pin-project",
|
||||
"pin-project-lite",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tower"
|
||||
@@ -5512,6 +5861,7 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5527,7 +5877,7 @@ dependencies = [
|
||||
"http-body",
|
||||
"iri-string",
|
||||
"pin-project-lite",
|
||||
"tower",
|
||||
"tower 0.5.3",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
]
|
||||
@@ -5574,6 +5924,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"valuable",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-log"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
|
||||
dependencies = [
|
||||
"log",
|
||||
"once_cell",
|
||||
"tracing-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-subscriber"
|
||||
version = "0.3.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
|
||||
dependencies = [
|
||||
"matchers",
|
||||
"nu-ansi-term",
|
||||
"once_cell",
|
||||
"regex-automata",
|
||||
"sharded-slab",
|
||||
"smallvec",
|
||||
"thread_local",
|
||||
"tracing",
|
||||
"tracing-core",
|
||||
"tracing-log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5668,6 +6048,12 @@ dependencies = [
|
||||
"unic-common",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicase"
|
||||
version = "2.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-bidi"
|
||||
version = "0.3.18"
|
||||
@@ -5697,9 +6083,9 @@ checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-segmentation"
|
||||
version = "1.12.0"
|
||||
version = "1.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
|
||||
checksum = "da36089a805484bcccfffe0739803392c8298778a2d2f09febf76fac5ad9025b"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-xid"
|
||||
@@ -5778,6 +6164,70 @@ version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
||||
|
||||
[[package]]
|
||||
name = "utoipa"
|
||||
version = "4.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c5afb1a60e207dca502682537fefcfd9921e71d0b83e9576060f09abc6efab23"
|
||||
dependencies = [
|
||||
"indexmap 2.13.0",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"utoipa-gen 4.3.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "utoipa"
|
||||
version = "5.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2fcc29c80c21c31608227e0912b2d7fddba57ad76b606890627ba8ee7964e993"
|
||||
dependencies = [
|
||||
"indexmap 2.13.0",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"utoipa-gen 5.4.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "utoipa-gen"
|
||||
version = "4.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "20c24e8ab68ff9ee746aad22d39b5535601e6416d1b0feeabf78be986a5c4392"
|
||||
dependencies = [
|
||||
"proc-macro-error",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "utoipa-gen"
|
||||
version = "5.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6d79d08d92ab8af4c5e8a6da20c47ae3f61a0f1dabc1997cdf2d082b757ca08b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"regex",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "utoipa-swagger-ui"
|
||||
version = "5.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f839caa8e09dddc3ff1c3112a91ef7da0601075ba5025d9f33ae99c4cb9b6e51"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"mime_guess",
|
||||
"regex",
|
||||
"rust-embed",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"utoipa 4.2.3",
|
||||
"zip 0.6.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.22.0"
|
||||
@@ -5791,6 +6241,12 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "valuable"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
|
||||
|
||||
[[package]]
|
||||
name = "vcpkg"
|
||||
version = "0.2.15"
|
||||
@@ -6957,6 +7413,7 @@ dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
"futures",
|
||||
"libsqlite3-sys",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sqlx",
|
||||
@@ -6981,6 +7438,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tracing",
|
||||
"uuid",
|
||||
"zclaw-runtime",
|
||||
"zclaw-types",
|
||||
]
|
||||
|
||||
@@ -7000,6 +7458,7 @@ dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"toml 0.8.2",
|
||||
"tracing",
|
||||
"uuid",
|
||||
"zclaw-hands",
|
||||
@@ -7008,7 +7467,7 @@ dependencies = [
|
||||
"zclaw-runtime",
|
||||
"zclaw-skills",
|
||||
"zclaw-types",
|
||||
"zip",
|
||||
"zip 2.4.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7017,6 +7476,7 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"futures",
|
||||
"libsqlite3-sys",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sqlx",
|
||||
@@ -7097,6 +7557,46 @@ dependencies = [
|
||||
"zclaw-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zclaw-saas"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"anyhow",
|
||||
"argon2",
|
||||
"async-stream",
|
||||
"axum",
|
||||
"axum-extra",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"dashmap",
|
||||
"data-encoding",
|
||||
"futures",
|
||||
"hex",
|
||||
"jsonwebtoken",
|
||||
"rand 0.8.5",
|
||||
"reqwest 0.12.28",
|
||||
"secrecy",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"sqlx",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"toml 0.8.2",
|
||||
"totp-rs",
|
||||
"tower 0.4.13",
|
||||
"tower-http 0.5.2",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"url",
|
||||
"urlencoding",
|
||||
"utoipa 5.4.0",
|
||||
"utoipa-swagger-ui",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zclaw-skills"
|
||||
version = "0.1.0"
|
||||
@@ -7105,6 +7605,7 @@ dependencies = [
|
||||
"regex",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"shlex",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
@@ -7203,6 +7704,18 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "0.6.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "760394e246e4c28189f19d488c058bf16f564016aefac5d32bb1f3b51d5e9261"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"crc32fast",
|
||||
"crossbeam-utils",
|
||||
"flate2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "2.4.2"
|
||||
|
||||
16
Cargo.toml
16
Cargo.toml
@@ -15,6 +15,8 @@ members = [
|
||||
"crates/zclaw-growth",
|
||||
# Desktop Application
|
||||
"desktop/src-tauri",
|
||||
# SaaS Backend
|
||||
"crates/zclaw-saas",
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
@@ -55,7 +57,8 @@ chrono = { version = "0.4", features = ["serde"] }
|
||||
uuid = { version = "1", features = ["v4", "v5", "serde"] }
|
||||
|
||||
# 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)
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls"] }
|
||||
@@ -94,6 +97,16 @@ 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
|
||||
zclaw-types = { path = "crates/zclaw-types" }
|
||||
zclaw-memory = { path = "crates/zclaw-memory" }
|
||||
@@ -105,6 +118,7 @@ zclaw-channels = { path = "crates/zclaw-channels" }
|
||||
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]
|
||||
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
|
||||
# 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
|
||||
@echo "ZCLAW - OpenFang Desktop Client"
|
||||
@echo "ZCLAW - AI Agent Desktop Client"
|
||||
@echo ""
|
||||
@echo "Usage: make [target]"
|
||||
@echo ""
|
||||
@@ -71,3 +73,32 @@ clean-deep: clean ## Deep clean (including pnpm cache)
|
||||
@rm -rf desktop/pnpm-lock.yaml
|
||||
@rm -rf pnpm-lock.yaml
|
||||
@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
|
||||
ZCLAW Tauri App (桌面 UI)
|
||||
+ 中文模型 Provider (GLM/Qwen/Kimi/MiniMax/DeepSeek)
|
||||
@@ -16,11 +16,11 @@ ZCLAW Tauri App (桌面 UI)
|
||||
+ 自定义 Skills
|
||||
```
|
||||
|
||||
## 为什么选择 OpenFang?
|
||||
## 为什么选择 ZCLAW?
|
||||
|
||||
相比 OpenClaw,OpenFang 提供了更强的性能和更丰富的功能:
|
||||
相比 ZCLAW,ZCLAW 提供了更强的性能和更丰富的功能:
|
||||
|
||||
| 特性 | OpenFang | OpenClaw |
|
||||
| 特性 | ZCLAW | ZCLAW |
|
||||
|------|----------|----------|
|
||||
| **开发语言** | Rust | TypeScript |
|
||||
| **冷启动** | < 200ms | ~6s |
|
||||
@@ -30,11 +30,11 @@ ZCLAW Tauri App (桌面 UI)
|
||||
| **渠道适配器** | 40 个 | 13 个 |
|
||||
| **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 - 预构建的"数字员工"
|
||||
- **中文模型**: 智谱 GLM-4、通义千问、Kimi、MiniMax、DeepSeek (OpenAI 兼容 API)
|
||||
- **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) |
|
||||
| **前端** | React 19 + TailwindCSS + Zustand + Lucide Icons |
|
||||
| **通信协议** | OpenFang API (REST/WS/SSE) + OpenAI 兼容 API |
|
||||
| **通信协议** | ZCLAW API (REST/WS/SSE) + OpenAI 兼容 API |
|
||||
| **安全** | WASM 沙箱 + Merkle 审计追踪 + Ed25519 签名 |
|
||||
|
||||
## 项目结构
|
||||
@@ -61,7 +61,7 @@ ZClaw/
|
||||
│ ├── src/
|
||||
│ │ ├── components/ # UI 组件
|
||||
│ │ ├── store/ # Zustand 状态管理
|
||||
│ │ └── lib/gateway-client.ts # OpenFang API 客户端
|
||||
│ │ └── lib/gateway-client.ts # ZCLAW API 客户端
|
||||
│ └── src-tauri/ # Rust 后端
|
||||
│
|
||||
├── skills/ # 自定义技能 (SKILL.md)
|
||||
@@ -71,14 +71,14 @@ ZClaw/
|
||||
├── hands/ # 自定义 Hands (HAND.toml)
|
||||
│ └── custom-automation/ # 自定义自动化任务
|
||||
│
|
||||
├── config/ # OpenFang 默认配置
|
||||
├── config/ # ZCLAW 默认配置
|
||||
│ ├── config.toml # 主配置文件
|
||||
│ ├── SOUL.md # Agent 人格
|
||||
│ └── AGENTS.md # Agent 指令
|
||||
│
|
||||
├── docs/
|
||||
│ ├── setup/ # 设置指南
|
||||
│ │ ├── OPENFANG-SETUP.md # OpenFang 配置指南
|
||||
│ │ ├── ZCLAW-SETUP.md # ZCLAW 配置指南
|
||||
│ │ └── chinese-models.md # 中文模型配置
|
||||
│ ├── architecture-v2.md # 架构设计
|
||||
│ └── deviation-analysis.md # 偏离分析报告
|
||||
@@ -88,20 +88,20 @@ ZClaw/
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 1. 安装 OpenFang
|
||||
### 1. 安装 ZCLAW
|
||||
|
||||
```bash
|
||||
# Windows (PowerShell)
|
||||
iwr -useb https://openfang.sh/install.ps1 | iex
|
||||
iwr -useb https://zclaw.sh/install.ps1 | iex
|
||||
|
||||
# macOS / Linux
|
||||
curl -fsSL https://openfang.sh/install.sh | bash
|
||||
curl -fsSL https://zclaw.sh/install.sh | bash
|
||||
```
|
||||
|
||||
### 2. 初始化配置
|
||||
|
||||
```bash
|
||||
openfang init
|
||||
zclaw init
|
||||
```
|
||||
|
||||
### 3. 配置 API Key
|
||||
@@ -121,8 +121,8 @@ export DEEPSEEK_API_KEY="your-deepseek-key" # DeepSeek
|
||||
### 4. 启动服务
|
||||
|
||||
```bash
|
||||
# 启动 OpenFang Kernel
|
||||
openfang start
|
||||
# 启动 ZCLAW Kernel
|
||||
zclaw start
|
||||
|
||||
# 在另一个终端启动 ZCLAW 桌面应用
|
||||
git clone https://github.com/xxx/ZClaw.git
|
||||
@@ -134,16 +134,16 @@ cd desktop && pnpm tauri dev
|
||||
### 5. 验证安装
|
||||
|
||||
```bash
|
||||
# 检查 OpenFang 状态
|
||||
openfang status
|
||||
# 检查 ZCLAW 状态
|
||||
zclaw status
|
||||
|
||||
# 运行健康检查
|
||||
openfang doctor
|
||||
zclaw doctor
|
||||
```
|
||||
|
||||
## OpenFang Hands (自主能力)
|
||||
## ZCLAW Hands (自主能力)
|
||||
|
||||
OpenFang 内置 7 个预构建的自主能力包,每个 Hand 都是一个具备完整工作流的"数字员工":
|
||||
ZCLAW 内置 7 个预构建的自主能力包,每个 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/architecture-v2.md) — 完整的 v2 架构方案
|
||||
- [偏离分析](docs/deviation-analysis.md) — 与 QClaw/AutoClaw/OpenClaw 对标分析
|
||||
- [偏离分析](docs/deviation-analysis.md) — 与 QClaw/AutoClaw/ZCLAW 对标分析
|
||||
|
||||
### 外部资源
|
||||
- [OpenFang 官方文档](https://openfang.sh/)
|
||||
- [OpenFang GitHub](https://github.com/RightNow-AI/openfang)
|
||||
- [OpenFang 架构概览](https://wurang.net/posts/openfang-intro/)
|
||||
- [ZCLAW 官方文档](https://zclaw.sh/)
|
||||
- [ZCLAW GitHub](https://github.com/RightNow-AI/zclaw)
|
||||
- [ZCLAW 架构概览](https://wurang.net/posts/zclaw-intro/)
|
||||
|
||||
## 对标参考
|
||||
|
||||
| 产品 | 基于 | IM 渠道 | 桌面框架 | 安全层数 |
|
||||
|------|------|---------|----------|----------|
|
||||
| **QClaw** (腾讯) | OpenClaw | 微信 + QQ | Electron | 3 |
|
||||
| **AutoClaw** (智谱) | OpenClaw | 飞书 | 自研 | 3 |
|
||||
| **ZCLAW** (本项目) | OpenFang | 飞书 + 钉钉 + 40+ | Tauri 2.0 | 16 |
|
||||
| **QClaw** (腾讯) | ZCLAW | 微信 + QQ | Electron | 3 |
|
||||
| **AutoClaw** (智谱) | ZCLAW | 飞书 | 自研 | 3 |
|
||||
| **ZCLAW** (本项目) | ZCLAW | 飞书 + 钉钉 + 40+ | Tauri 2.0 | 16 |
|
||||
|
||||
## 从 OpenClaw 迁移
|
||||
## 从 ZCLAW 迁移
|
||||
|
||||
如果你之前使用 OpenClaw,可以一键迁移:
|
||||
如果你之前使用 ZCLAW,可以一键迁移:
|
||||
|
||||
```bash
|
||||
# 迁移所有内容:代理、记忆、技能、配置
|
||||
openfang migrate --from openclaw
|
||||
zclaw migrate --from zclaw
|
||||
|
||||
# 先试运行查看变更
|
||||
openfang migrate --from openclaw --dry-run
|
||||
zclaw migrate --from zclaw --dry-run
|
||||
```
|
||||
|
||||
## 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
|
||||
# OpenFang TOML 格式的中文模型提供商配置
|
||||
# ZCLAW Chinese LLM Providers Configuration
|
||||
# ZCLAW TOML 格式的中文模型提供商配置
|
||||
#
|
||||
# 使用方法:
|
||||
# 1. 复制此文件到 ~/.openfang/config.d/ 目录
|
||||
# 2. 或者将内容追加到 ~/.openfang/config.toml
|
||||
# 1. 复制此文件到 ~/.zclaw/config.d/ 目录
|
||||
# 2. 或者将内容追加到 ~/.zclaw/config.toml
|
||||
# 3. 设置环境变量: ZHIPU_API_KEY, QWEN_API_KEY, KIMI_API_KEY, MINIMAX_API_KEY
|
||||
|
||||
# ============================================================
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# ============================================================
|
||||
# ZClaw OpenFang Main Configuration
|
||||
# OpenFang TOML format configuration file
|
||||
# ZCLAW Main Configuration
|
||||
# ZCLAW TOML format configuration file
|
||||
# ============================================================
|
||||
#
|
||||
# Usage:
|
||||
# 1. Copy this file to ~/.openfang/config.toml
|
||||
# 1. Copy this file to ~/.zclaw/config.toml
|
||||
# 2. Set environment variables for API keys
|
||||
# 3. Import chinese-providers.toml for Chinese LLM support
|
||||
#
|
||||
@@ -38,7 +38,7 @@ api_version = "v1"
|
||||
|
||||
[agent.defaults]
|
||||
# Default workspace for agent operations
|
||||
workspace = "~/.openfang/zclaw-workspace"
|
||||
workspace = "~/.zclaw/zclaw-workspace"
|
||||
|
||||
# Default model for new sessions
|
||||
default_model = "zhipu/glm-4-plus"
|
||||
@@ -57,7 +57,7 @@ max_sessions = 10
|
||||
|
||||
[agent.defaults.sandbox]
|
||||
# Sandbox root directory
|
||||
workspace_root = "~/.openfang/zclaw-workspace"
|
||||
workspace_root = "~/.zclaw/zclaw-workspace"
|
||||
|
||||
# Allowed shell commands (empty = all allowed)
|
||||
# allowed_commands = ["git", "npm", "pnpm", "cargo"]
|
||||
@@ -104,7 +104,7 @@ execution_timeout = "30m"
|
||||
|
||||
# Audit settings
|
||||
audit_enabled = true
|
||||
audit_log_path = "~/.openfang/logs/hands-audit.log"
|
||||
audit_log_path = "~/.zclaw/logs/hands-audit.log"
|
||||
|
||||
# ============================================================
|
||||
# LLM Provider Configuration
|
||||
@@ -166,7 +166,7 @@ burst_size = 20
|
||||
# Audit logging
|
||||
[security.audit]
|
||||
enabled = true
|
||||
log_path = "~/.openfang/logs/audit.log"
|
||||
log_path = "~/.zclaw/logs/audit.log"
|
||||
log_format = "json"
|
||||
|
||||
# ============================================================
|
||||
@@ -183,7 +183,7 @@ format = "pretty"
|
||||
# Log file settings
|
||||
[logging.file]
|
||||
enabled = true
|
||||
path = "~/.openfang/logs/openfang.log"
|
||||
path = "~/.zclaw/logs/zclaw.log"
|
||||
max_size = "10MB"
|
||||
max_files = 5
|
||||
compress = true
|
||||
@@ -228,7 +228,7 @@ max_results = 10
|
||||
|
||||
# File system tool
|
||||
[tools.fs]
|
||||
allowed_paths = ["~/.openfang/zclaw-workspace"]
|
||||
allowed_paths = ["~/.zclaw/zclaw-workspace"]
|
||||
max_file_size = "10MB"
|
||||
|
||||
# ============================================================
|
||||
@@ -237,7 +237,7 @@ max_file_size = "10MB"
|
||||
|
||||
[workflow]
|
||||
# Workflow storage
|
||||
storage_path = "~/.openfang/workflows"
|
||||
storage_path = "~/.zclaw/workflows"
|
||||
|
||||
# Execution settings
|
||||
max_steps = 100
|
||||
|
||||
@@ -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
|
||||
|
||||
mod telegram;
|
||||
mod discord;
|
||||
mod slack;
|
||||
mod console;
|
||||
|
||||
pub use telegram::TelegramChannel;
|
||||
pub use discord::DiscordChannel;
|
||||
pub use slack::SlackChannel;
|
||||
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,60 +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,
|
||||
#[allow(dead_code)] // TODO: Implement Telegram API client
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,7 @@ uuid = { workspace = true }
|
||||
|
||||
# Database
|
||||
sqlx = { workspace = true }
|
||||
libsqlite3-sys = { workspace = true }
|
||||
|
||||
# Internal crates
|
||||
zclaw-types = { workspace = true }
|
||||
|
||||
@@ -388,6 +388,8 @@ mod tests {
|
||||
access_count: 0,
|
||||
created_at: Utc::now(),
|
||||
last_accessed: Utc::now(),
|
||||
overview: None,
|
||||
abstract_summary: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -63,6 +63,7 @@ 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::{
|
||||
@@ -82,7 +83,8 @@ 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::{MemoryCache, QueryAnalyzer, SemanticScorer};
|
||||
pub use retrieval::{EmbeddingClient, MemoryCache, QueryAnalyzer, SemanticScorer};
|
||||
pub use summarizer::SummaryLlmDriver;
|
||||
|
||||
/// Growth system configuration
|
||||
#[derive(Debug, Clone)]
|
||||
|
||||
@@ -18,7 +18,8 @@ struct CacheEntry {
|
||||
access_count: u32,
|
||||
}
|
||||
|
||||
/// Cache key for efficient lookups
|
||||
/// Cache key for efficient lookups (reserved for future cache optimization)
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, Hash, Eq, PartialEq)]
|
||||
struct CacheKey {
|
||||
agent_id: String,
|
||||
|
||||
@@ -9,6 +9,6 @@ pub mod semantic;
|
||||
pub mod query;
|
||||
pub mod cache;
|
||||
|
||||
pub use semantic::SemanticScorer;
|
||||
pub use semantic::{EmbeddingClient, SemanticScorer};
|
||||
pub use query::QueryAnalyzer;
|
||||
pub use cache::MemoryCache;
|
||||
|
||||
@@ -3,11 +3,35 @@
|
||||
//! 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;
|
||||
|
||||
/// Semantic similarity scorer using TF-IDF
|
||||
/// 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>,
|
||||
@@ -15,8 +39,14 @@ pub struct SemanticScorer {
|
||||
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 {
|
||||
@@ -26,10 +56,41 @@ impl SemanticScorer {
|
||||
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> {
|
||||
[
|
||||
@@ -132,9 +193,34 @@ impl SemanticScorer {
|
||||
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
|
||||
@@ -167,6 +253,62 @@ impl SemanticScorer {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -246,6 +388,7 @@ impl SemanticScorer {
|
||||
self.document_frequencies.clear();
|
||||
self.total_documents = 0;
|
||||
self.entry_vectors.clear();
|
||||
self.entry_embeddings.clear();
|
||||
}
|
||||
|
||||
/// Get statistics about the index
|
||||
@@ -254,6 +397,8 @@ impl SemanticScorer {
|
||||
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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -270,6 +415,8 @@ 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)]
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! Persistent storage backend using SQLite for production use.
|
||||
//! Provides efficient querying and full-text search capabilities.
|
||||
|
||||
use crate::retrieval::semantic::SemanticScorer;
|
||||
use crate::retrieval::semantic::{EmbeddingClient, SemanticScorer};
|
||||
use crate::types::MemoryEntry;
|
||||
use crate::viking_adapter::{FindOptions, VikingStorage};
|
||||
use async_trait::async_trait;
|
||||
@@ -36,6 +36,8 @@ struct MemoryRow {
|
||||
access_count: i32,
|
||||
created_at: String,
|
||||
last_accessed: String,
|
||||
overview: Option<String>,
|
||||
abstract_summary: Option<String>,
|
||||
}
|
||||
|
||||
impl SqliteStorage {
|
||||
@@ -83,6 +85,26 @@ impl SqliteStorage {
|
||||
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
|
||||
@@ -131,6 +153,16 @@ impl SqliteStorage {
|
||||
.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#"
|
||||
@@ -151,7 +183,7 @@ impl SqliteStorage {
|
||||
/// 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 FROM memories"
|
||||
"SELECT uri, memory_type, content, keywords, importance, access_count, created_at, last_accessed, overview, abstract_summary FROM memories"
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
@@ -173,6 +205,32 @@ impl SqliteStorage {
|
||||
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);
|
||||
@@ -193,6 +251,8 @@ impl SqliteStorage {
|
||||
access_count: row.access_count as u32,
|
||||
created_at,
|
||||
last_accessed,
|
||||
overview: row.overview.clone(),
|
||||
abstract_summary: row.abstract_summary.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -223,6 +283,8 @@ impl sqlx::FromRow<'_, SqliteRow> for MemoryRow {
|
||||
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(),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -241,8 +303,8 @@ impl VikingStorage for SqliteStorage {
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT OR REPLACE INTO memories
|
||||
(uri, memory_type, content, keywords, importance, access_count, created_at, last_accessed)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
(uri, memory_type, content, keywords, importance, access_count, created_at, last_accessed, overview, abstract_summary)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
"#,
|
||||
)
|
||||
.bind(&entry.uri)
|
||||
@@ -253,6 +315,8 @@ impl VikingStorage for SqliteStorage {
|
||||
.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)))?;
|
||||
@@ -276,9 +340,13 @@ impl VikingStorage for SqliteStorage {
|
||||
.execute(&self.pool)
|
||||
.await;
|
||||
|
||||
// Update semantic scorer
|
||||
// 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(())
|
||||
@@ -286,7 +354,7 @@ impl VikingStorage for SqliteStorage {
|
||||
|
||||
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 FROM memories WHERE uri = ?"
|
||||
"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)
|
||||
@@ -309,7 +377,7 @@ impl VikingStorage for SqliteStorage {
|
||||
// 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 FROM memories WHERE uri LIKE ?"
|
||||
"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)
|
||||
@@ -317,7 +385,7 @@ impl VikingStorage for SqliteStorage {
|
||||
.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 FROM memories"
|
||||
"SELECT uri, memory_type, content, keywords, importance, access_count, created_at, last_accessed, overview, abstract_summary FROM memories"
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
@@ -325,14 +393,49 @@ impl VikingStorage for SqliteStorage {
|
||||
};
|
||||
|
||||
// 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 using TF-IDF
|
||||
let semantic_score = scorer.score_similarity(query, &entry);
|
||||
// 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 {
|
||||
@@ -362,7 +465,7 @@ impl VikingStorage for SqliteStorage {
|
||||
|
||||
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 FROM memories WHERE uri LIKE ?"
|
||||
"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)
|
||||
|
||||
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"));
|
||||
}
|
||||
}
|
||||
@@ -72,6 +72,10 @@ pub struct MemoryEntry {
|
||||
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 {
|
||||
@@ -92,6 +96,8 @@ impl MemoryEntry {
|
||||
access_count: 0,
|
||||
created_at: Utc::now(),
|
||||
last_accessed: Utc::now(),
|
||||
overview: None,
|
||||
abstract_summary: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,6 +113,18 @@ impl MemoryEntry {
|
||||
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;
|
||||
|
||||
@@ -9,6 +9,7 @@ description = "ZCLAW Hands - autonomous capabilities"
|
||||
|
||||
[dependencies]
|
||||
zclaw-types = { workspace = true }
|
||||
zclaw-runtime = { workspace = true }
|
||||
|
||||
tokio = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
mod whiteboard;
|
||||
mod slideshow;
|
||||
mod speech;
|
||||
mod quiz;
|
||||
pub mod quiz;
|
||||
mod browser;
|
||||
mod researcher;
|
||||
mod collector;
|
||||
|
||||
@@ -14,6 +14,7 @@ use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
use uuid::Uuid;
|
||||
use zclaw_types::Result;
|
||||
use zclaw_runtime::driver::{LlmDriver, CompletionRequest};
|
||||
|
||||
use crate::{Hand, HandConfig, HandContext, HandResult, HandStatus};
|
||||
|
||||
@@ -44,29 +45,242 @@ impl QuizGenerator for DefaultQuizGenerator {
|
||||
difficulty: &DifficultyLevel,
|
||||
_question_types: &[QuestionType],
|
||||
) -> 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)
|
||||
.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(),
|
||||
question_type: QuestionType::MultipleChoice,
|
||||
question: format!("Question {} about {}", i + 1, topic),
|
||||
options: Some(vec![
|
||||
"Option A".to_string(),
|
||||
"Option B".to_string(),
|
||||
"Option C".to_string(),
|
||||
"Option D".to_string(),
|
||||
]),
|
||||
correct_answer: Answer::Single("Option A".to_string()),
|
||||
explanation: Some(format!("Explanation for question {}", i + 1)),
|
||||
hints: Some(vec![format!("Hint 1 for question {}", i + 1)]),
|
||||
question: format!("关于{}的第{}题({}难度)", topic, i + 1, match difficulty {
|
||||
DifficultyLevel::Easy => "简单",
|
||||
DifficultyLevel::Medium => "中等",
|
||||
DifficultyLevel::Hard => "困难",
|
||||
DifficultyLevel::Adaptive => "自适应",
|
||||
}),
|
||||
options: Some(opts),
|
||||
correct_answer: Answer::Single(correct),
|
||||
explanation: Some(format!("第{}题的详细解释", i + 1)),
|
||||
hints: Some(vec![format!("提示:仔细阅读关于{}的内容", topic)]),
|
||||
points: 10.0,
|
||||
difficulty: difficulty.clone(),
|
||||
tags: vec![topic.to_string()],
|
||||
}
|
||||
})
|
||||
.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
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "action", rename_all = "snake_case")]
|
||||
|
||||
@@ -162,7 +162,7 @@ impl SpeechHand {
|
||||
"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,
|
||||
},
|
||||
state: Arc::new(RwLock::new(SpeechState {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
credentials: Arc::new(RwLock::new(None)),
|
||||
|
||||
@@ -7,6 +7,11 @@ repository.workspace = true
|
||||
rust-version.workspace = true
|
||||
description = "ZCLAW kernel - central coordinator for all subsystems"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
# Enable multi-agent orchestration (Director, A2A protocol)
|
||||
multi-agent = ["zclaw-protocols/a2a"]
|
||||
|
||||
[dependencies]
|
||||
zclaw-types = { workspace = true }
|
||||
zclaw-memory = { workspace = true }
|
||||
@@ -20,6 +25,7 @@ tokio-stream = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
toml = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Capability manager
|
||||
|
||||
use dashmap::DashMap;
|
||||
use zclaw_types::{AgentId, Capability, CapabilitySet, Result};
|
||||
use zclaw_types::{AgentId, Capability, CapabilitySet, Result, ZclawError};
|
||||
|
||||
/// Manages capabilities for all agents
|
||||
pub struct CapabilityManager {
|
||||
@@ -52,9 +52,31 @@ impl CapabilityManager {
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Validate capabilities don't exceed parent's
|
||||
pub fn validate(&self, _capabilities: &[Capability]) -> Result<()> {
|
||||
// TODO: Implement capability validation
|
||||
/// 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<()> {
|
||||
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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -168,7 +168,7 @@ fn default_skills_dir() -> Option<std::path::PathBuf> {
|
||||
// 1. Check environment variable override
|
||||
if let Ok(dir) = std::env::var("ZCLAW_SKILLS_DIR") {
|
||||
let path = std::path::PathBuf::from(&dir);
|
||||
eprintln!("[default_skills_dir] ZCLAW_SKILLS_DIR env: {} (exists: {})", path.display(), path.exists());
|
||||
tracing::debug!(target: "kernel_config", "ZCLAW_SKILLS_DIR env: {} (exists: {})", path.display(), path.exists());
|
||||
if path.exists() {
|
||||
return Some(path);
|
||||
}
|
||||
@@ -180,12 +180,12 @@ fn default_skills_dir() -> Option<std::path::PathBuf> {
|
||||
// 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"));
|
||||
eprintln!("[default_skills_dir] CARGO_MANIFEST_DIR: {}", manifest_dir.display());
|
||||
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");
|
||||
eprintln!("[default_skills_dir] Workspace skills: {} (exists: {})", workspace_skills.display(), workspace_skills.exists());
|
||||
tracing::debug!(target: "kernel_config", "Workspace skills: {} (exists: {})", workspace_skills.display(), workspace_skills.exists());
|
||||
if workspace_skills.exists() {
|
||||
return Some(workspace_skills);
|
||||
}
|
||||
@@ -194,7 +194,7 @@ fn default_skills_dir() -> Option<std::path::PathBuf> {
|
||||
// 3. Try current working directory first (for development)
|
||||
if let Ok(cwd) = std::env::current_dir() {
|
||||
let cwd_skills = cwd.join("skills");
|
||||
eprintln!("[default_skills_dir] Checking cwd: {} (exists: {})", cwd_skills.display(), cwd_skills.exists());
|
||||
tracing::debug!(target: "kernel_config", "Checking cwd: {} (exists: {})", cwd_skills.display(), cwd_skills.exists());
|
||||
if cwd_skills.exists() {
|
||||
return Some(cwd_skills);
|
||||
}
|
||||
@@ -204,7 +204,7 @@ fn default_skills_dir() -> Option<std::path::PathBuf> {
|
||||
for i in 0..6 {
|
||||
if let Some(parent) = current.parent() {
|
||||
let parent_skills = parent.join("skills");
|
||||
eprintln!("[default_skills_dir] CWD Level {}: {} (exists: {})", i, parent_skills.display(), parent_skills.exists());
|
||||
tracing::debug!(target: "kernel_config", "CWD Level {}: {} (exists: {})", i, parent_skills.display(), parent_skills.exists());
|
||||
if parent_skills.exists() {
|
||||
return Some(parent_skills);
|
||||
}
|
||||
@@ -217,11 +217,11 @@ fn default_skills_dir() -> Option<std::path::PathBuf> {
|
||||
|
||||
// 4. Try executable's directory and multiple levels up
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
eprintln!("[default_skills_dir] Current exe: {}", exe.display());
|
||||
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");
|
||||
eprintln!("[default_skills_dir] Checking exe dir: {} (exists: {})", exe_skills.display(), exe_skills.exists());
|
||||
tracing::debug!(target: "kernel_config", "Checking exe dir: {} (exists: {})", exe_skills.display(), exe_skills.exists());
|
||||
if exe_skills.exists() {
|
||||
return Some(exe_skills);
|
||||
}
|
||||
@@ -231,7 +231,7 @@ fn default_skills_dir() -> Option<std::path::PathBuf> {
|
||||
for i in 0..6 {
|
||||
if let Some(parent) = current.parent() {
|
||||
let parent_skills = parent.join("skills");
|
||||
eprintln!("[default_skills_dir] EXE Level {}: {} (exists: {})", i, parent_skills.display(), parent_skills.exists());
|
||||
tracing::debug!(target: "kernel_config", "EXE Level {}: {} (exists: {})", i, parent_skills.display(), parent_skills.exists());
|
||||
if parent_skills.exists() {
|
||||
return Some(parent_skills);
|
||||
}
|
||||
@@ -247,15 +247,83 @@ fn default_skills_dir() -> Option<std::path::PathBuf> {
|
||||
let fallback = std::env::current_dir()
|
||||
.ok()
|
||||
.map(|cwd| cwd.join("skills"));
|
||||
eprintln!("[default_skills_dir] Fallback to: {:?}", fallback);
|
||||
tracing::debug!(target: "kernel_config", "Fallback to: {:?}", fallback);
|
||||
fallback
|
||||
}
|
||||
|
||||
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> {
|
||||
// TODO: Load from ~/.zclaw/config.toml
|
||||
Ok(Self::default())
|
||||
let config_path = Self::find_config_path();
|
||||
|
||||
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
|
||||
@@ -439,3 +507,81 @@ impl LlmConfig {
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -703,48 +703,6 @@ Actions can be:
|
||||
self.parse_outline_from_text(&text, request)
|
||||
}
|
||||
|
||||
/// Generate scene using LLM
|
||||
#[allow(dead_code)] // Reserved for future LLM-based scene generation
|
||||
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
|
||||
fn extract_text_from_response(&self, response: &CompletionResponse) -> String {
|
||||
response.content.iter()
|
||||
@@ -787,39 +745,6 @@ You MUST respond with valid JSON in this exact format:
|
||||
Ensure the outline is coherent and follows good pedagogical practices."#.to_string()
|
||||
}
|
||||
|
||||
/// Get system prompt for scene generation
|
||||
#[allow(dead_code)] // Reserved for future use
|
||||
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
|
||||
fn parse_outline_from_text(&self, text: &str, request: &GenerationRequest) -> Result<Vec<OutlineItem>> {
|
||||
// Try to extract JSON from the response
|
||||
@@ -872,90 +797,6 @@ Actions can be:
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse scene from LLM response text
|
||||
#[allow(dead_code)] // Reserved for future use
|
||||
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
|
||||
#[allow(dead_code)] // Reserved for future use
|
||||
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
|
||||
#[allow(dead_code)] // Reserved for future use
|
||||
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)
|
||||
fn extract_json(&self, text: &str) -> String {
|
||||
// Try to extract from markdown code block
|
||||
@@ -1062,64 +903,6 @@ Generate {} outline items that flow logically and cover the topic comprehensivel
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Generate scene for outline item (would be replaced by LLM call)
|
||||
#[allow(dead_code)] // Reserved for future use
|
||||
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
|
||||
fn build_classroom(
|
||||
&self,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
//! Kernel - central coordinator
|
||||
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::{broadcast, mpsc};
|
||||
use tokio::sync::{broadcast, mpsc, Mutex};
|
||||
use zclaw_types::{AgentConfig, AgentId, AgentInfo, Event, Result};
|
||||
use async_trait::async_trait;
|
||||
use serde_json::Value;
|
||||
@@ -13,16 +14,53 @@ use crate::config::KernelConfig;
|
||||
use zclaw_memory::MemoryStore;
|
||||
use zclaw_runtime::{AgentLoop, LlmDriver, ToolRegistry, tool::SkillExecutor};
|
||||
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
|
||||
pub struct KernelSkillExecutor {
|
||||
skills: Arc<SkillRegistry>,
|
||||
llm: Arc<dyn LlmCompleter>,
|
||||
}
|
||||
|
||||
impl KernelSkillExecutor {
|
||||
pub fn new(skills: Arc<SkillRegistry>) -> Self {
|
||||
Self { skills }
|
||||
pub fn new(skills: Arc<SkillRegistry>, driver: Arc<dyn LlmDriver>) -> Self {
|
||||
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 {
|
||||
agent_id: agent_id.to_string(),
|
||||
session_id: session_id.to_string(),
|
||||
llm: Some(self.llm.clone()),
|
||||
..Default::default()
|
||||
};
|
||||
let result = self.skills.execute(&zclaw_types::SkillId::new(skill_id), &context, input).await?;
|
||||
@@ -53,10 +92,12 @@ pub struct Kernel {
|
||||
events: EventBus,
|
||||
memory: Arc<MemoryStore>,
|
||||
driver: Arc<dyn LlmDriver>,
|
||||
llm_completer: Arc<dyn zclaw_skills::LlmCompleter>,
|
||||
skills: Arc<SkillRegistry>,
|
||||
skill_executor: Arc<KernelSkillExecutor>,
|
||||
hands: Arc<HandRegistry>,
|
||||
trigger_manager: crate::trigger_manager::TriggerManager,
|
||||
pending_approvals: Arc<Mutex<Vec<ApprovalEntry>>>,
|
||||
}
|
||||
|
||||
impl Kernel {
|
||||
@@ -85,10 +126,12 @@ impl Kernel {
|
||||
|
||||
// Initialize hand registry with built-in hands
|
||||
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(SlideshowHand::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(ResearcherHand::new())).await;
|
||||
hands.register(Arc::new(CollectorHand::new())).await;
|
||||
@@ -96,7 +139,11 @@ impl Kernel {
|
||||
hands.register(Arc::new(TwitterHand::new())).await;
|
||||
|
||||
// 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());
|
||||
@@ -114,10 +161,12 @@ impl Kernel {
|
||||
events,
|
||||
memory,
|
||||
driver,
|
||||
llm_completer,
|
||||
skills,
|
||||
skill_executor,
|
||||
hands,
|
||||
trigger_manager,
|
||||
pending_approvals: Arc::new(Mutex::new(Vec::new())),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -129,9 +178,9 @@ impl Kernel {
|
||||
}
|
||||
|
||||
/// Build a system prompt with skill information injected
|
||||
fn build_system_prompt_with_skills(&self, base_prompt: Option<&String>) -> String {
|
||||
// Get skill list synchronously (we're in sync context)
|
||||
let skills = futures::executor::block_on(self.skills.list());
|
||||
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())
|
||||
@@ -306,10 +355,11 @@ impl Kernel {
|
||||
.with_model(&model)
|
||||
.with_skill_executor(self.skill_executor.clone())
|
||||
.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
|
||||
|
||||
// Build system prompt with skill information injected
|
||||
let system_prompt = self.build_system_prompt_with_skills(agent_config.system_prompt.as_ref());
|
||||
let system_prompt = self.build_system_prompt_with_skills(agent_config.system_prompt.as_ref()).await;
|
||||
let loop_runner = loop_runner.with_system_prompt(&system_prompt);
|
||||
|
||||
// Run the loop
|
||||
@@ -327,6 +377,16 @@ impl Kernel {
|
||||
&self,
|
||||
agent_id: &AgentId,
|
||||
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>> {
|
||||
let agent_config = self.registry.get(agent_id)
|
||||
.ok_or_else(|| zclaw_types::ZclawError::NotFound(format!("Agent not found: {}", agent_id)))?;
|
||||
@@ -349,10 +409,14 @@ impl Kernel {
|
||||
.with_model(&model)
|
||||
.with_skill_executor(self.skill_executor.clone())
|
||||
.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
|
||||
|
||||
// Build system prompt with skill information injected
|
||||
let system_prompt = self.build_system_prompt_with_skills(agent_config.system_prompt.as_ref());
|
||||
// Use external prompt if provided, otherwise build default
|
||||
let system_prompt = match system_prompt_override {
|
||||
Some(prompt) => prompt,
|
||||
None => self.build_system_prompt_with_skills(agent_config.system_prompt.as_ref()).await,
|
||||
};
|
||||
let loop_runner = loop_runner.with_system_prompt(&system_prompt);
|
||||
|
||||
// Run with streaming
|
||||
@@ -407,7 +471,12 @@ impl Kernel {
|
||||
context: zclaw_skills::SkillContext,
|
||||
input: serde_json::Value,
|
||||
) -> 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
|
||||
@@ -477,24 +546,82 @@ impl Kernel {
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Approval Management (Stub Implementation)
|
||||
// Approval Management
|
||||
// ============================================================
|
||||
|
||||
/// List pending approvals
|
||||
pub async fn list_approvals(&self) -> Vec<ApprovalEntry> {
|
||||
// Stub: Return empty list
|
||||
Vec::new()
|
||||
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,
|
||||
id: &str,
|
||||
approved: bool,
|
||||
_reason: Option<String>,
|
||||
) -> Result<()> {
|
||||
// Stub: Return error
|
||||
Err(zclaw_types::ZclawError::NotFound(format!("Approval not found")))
|
||||
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(())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ mod capabilities;
|
||||
mod events;
|
||||
pub mod trigger_manager;
|
||||
pub mod config;
|
||||
#[cfg(feature = "multi-agent")]
|
||||
pub mod director;
|
||||
pub mod generation;
|
||||
pub mod export;
|
||||
@@ -18,6 +19,7 @@ pub use capabilities::*;
|
||||
pub use events::*;
|
||||
pub use config::*;
|
||||
pub use trigger_manager::{TriggerManager, TriggerEntry, TriggerUpdateRequest, TriggerManagerConfig};
|
||||
#[cfg(feature = "multi-agent")]
|
||||
pub use director::*;
|
||||
pub use generation::*;
|
||||
pub use export::{ExportFormat, ExportOptions, ExportResult, Exporter, export_classroom};
|
||||
|
||||
@@ -20,6 +20,7 @@ tracing = { workspace = true }
|
||||
|
||||
# SQLite
|
||||
sqlx = { workspace = true }
|
||||
libsqlite3-sys = { workspace = true }
|
||||
|
||||
# Async utilities
|
||||
futures = { workspace = true }
|
||||
|
||||
@@ -26,7 +26,10 @@ impl MemoryStore {
|
||||
// Parse SQLite URL to extract file path
|
||||
// Format: sqlite:/path/to/db or sqlite://path/to/db
|
||||
if database_url.starts_with("sqlite:") {
|
||||
let path_part = database_url.strip_prefix("sqlite:").unwrap();
|
||||
let path_part = database_url.strip_prefix("sqlite:")
|
||||
.ok_or_else(|| ZclawError::StorageError(
|
||||
format!("Invalid database URL format: {}", database_url)
|
||||
))?;
|
||||
|
||||
// Skip in-memory databases
|
||||
if path_part == ":memory:" {
|
||||
@@ -34,7 +37,10 @@ impl MemoryStore {
|
||||
}
|
||||
|
||||
// Remove query parameters (e.g., ?mode=rwc)
|
||||
let path_without_query = path_part.split('?').next().unwrap();
|
||||
let path_without_query = path_part.split('?').next()
|
||||
.ok_or_else(|| ZclawError::StorageError(
|
||||
format!("Invalid database URL path: {}", path_part)
|
||||
))?;
|
||||
|
||||
// Handle both absolute and relative paths
|
||||
let path = std::path::Path::new(path_without_query);
|
||||
|
||||
@@ -46,11 +46,14 @@ pub async fn export_files(
|
||||
.map_err(|e| ActionError::Export(format!("Write error: {}", e)))?;
|
||||
}
|
||||
ExportFormat::Pptx => {
|
||||
// Will integrate with zclaw-kernel export
|
||||
return Err(ActionError::Export("PPTX export requires kernel integration".to_string()));
|
||||
return Err(ActionError::Export(
|
||||
"PPTX 导出暂不可用。桌面端可通过 Pipeline 结果面板使用 JSON 格式导出后转换。".to_string(),
|
||||
));
|
||||
}
|
||||
ExportFormat::Pdf => {
|
||||
return Err(ActionError::Export("PDF export not yet implemented".to_string()));
|
||||
return Err(ActionError::Export(
|
||||
"PDF 导出暂不可用。桌面端可通过 Pipeline 结果面板使用 HTML 格式导出后通过浏览器打印为 PDF。".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,6 +106,22 @@ fn render_markdown(data: &Value) -> String {
|
||||
md
|
||||
}
|
||||
|
||||
/// Escape HTML special characters to prevent XSS
|
||||
fn escape_html(s: &str) -> String {
|
||||
let mut escaped = String::with_capacity(s.len());
|
||||
for ch in s.chars() {
|
||||
match ch {
|
||||
'<' => escaped.push_str("<"),
|
||||
'>' => escaped.push_str(">"),
|
||||
'&' => escaped.push_str("&"),
|
||||
'"' => escaped.push_str("""),
|
||||
'\'' => escaped.push_str("'"),
|
||||
_ => escaped.push(ch),
|
||||
}
|
||||
}
|
||||
escaped
|
||||
}
|
||||
|
||||
/// Render data to HTML
|
||||
fn render_html(data: &Value) -> String {
|
||||
let mut html = String::from(r#"<!DOCTYPE html>
|
||||
@@ -123,11 +142,11 @@ fn render_html(data: &Value) -> String {
|
||||
"#);
|
||||
|
||||
if let Some(title) = data.get("title").and_then(|v| v.as_str()) {
|
||||
html.push_str(&format!("<h1>{}</h1>", title));
|
||||
html.push_str(&format!("<h1>{}</h1>", escape_html(title)));
|
||||
}
|
||||
|
||||
if let Some(description) = data.get("description").and_then(|v| v.as_str()) {
|
||||
html.push_str(&format!("<p>{}</p>", description));
|
||||
html.push_str(&format!("<p>{}</p>", escape_html(description)));
|
||||
}
|
||||
|
||||
if let Some(outline) = data.get("outline") {
|
||||
@@ -135,7 +154,7 @@ fn render_html(data: &Value) -> String {
|
||||
if let Some(items) = outline.get("items").and_then(|v| v.as_array()) {
|
||||
for item in items {
|
||||
if let Some(text) = item.get("title").and_then(|v| v.as_str()) {
|
||||
html.push_str(&format!("<li>{}</li>", text));
|
||||
html.push_str(&format!("<li>{}</li>", escape_html(text)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -147,10 +166,10 @@ fn render_html(data: &Value) -> String {
|
||||
for scene in scenes {
|
||||
html.push_str("<div class=\"scene\">");
|
||||
if let Some(title) = scene.get("title").and_then(|v| v.as_str()) {
|
||||
html.push_str(&format!("<h3>{}</h3>", title));
|
||||
html.push_str(&format!("<h3>{}</h3>", escape_html(title)));
|
||||
}
|
||||
if let Some(content) = scene.get("content").and_then(|v| v.as_str()) {
|
||||
html.push_str(&format!("<p>{}</p>", content));
|
||||
html.push_str(&format!("<p>{}</p>", escape_html(content)));
|
||||
}
|
||||
html.push_str("</div>");
|
||||
}
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
//! Hand execution action
|
||||
|
||||
use std::collections::HashMap;
|
||||
use serde_json::Value;
|
||||
|
||||
use super::ActionError;
|
||||
|
||||
/// Execute a hand action
|
||||
pub async fn execute_hand(
|
||||
hand_id: &str,
|
||||
action: &str,
|
||||
_params: HashMap<String, Value>,
|
||||
) -> Result<Value, ActionError> {
|
||||
// This will be implemented by injecting the hand registry
|
||||
// For now, return an error indicating it needs configuration
|
||||
|
||||
Err(ActionError::Hand(format!(
|
||||
"Hand '{}' action '{}' requires hand registry configuration",
|
||||
hand_id, action
|
||||
)))
|
||||
}
|
||||
@@ -7,8 +7,6 @@ mod parallel;
|
||||
mod render;
|
||||
mod export;
|
||||
mod http;
|
||||
mod skill;
|
||||
mod hand;
|
||||
mod orchestration;
|
||||
|
||||
pub use llm::*;
|
||||
@@ -16,8 +14,6 @@ pub use parallel::*;
|
||||
pub use render::*;
|
||||
pub use export::*;
|
||||
pub use http::*;
|
||||
pub use skill::*;
|
||||
pub use hand::*;
|
||||
pub use orchestration::*;
|
||||
|
||||
use std::collections::HashMap;
|
||||
@@ -134,10 +130,10 @@ impl ActionRegistry {
|
||||
max_tokens: Option<u32>,
|
||||
json_mode: bool,
|
||||
) -> Result<Value, ActionError> {
|
||||
println!("[DEBUG execute_llm] Called with template length: {}", template.len());
|
||||
println!("[DEBUG execute_llm] Input HashMap contents:");
|
||||
tracing::debug!(target: "pipeline_actions", "execute_llm: Called with template length: {}", template.len());
|
||||
tracing::debug!(target: "pipeline_actions", "execute_llm: Input HashMap contents:");
|
||||
for (k, v) in &input {
|
||||
println!(" {} => {:?}", k, v);
|
||||
tracing::debug!(target: "pipeline_actions", " {} => {:?}", k, v);
|
||||
}
|
||||
|
||||
if let Some(driver) = &self.llm_driver {
|
||||
@@ -148,13 +144,13 @@ impl ActionRegistry {
|
||||
template.to_string()
|
||||
};
|
||||
|
||||
println!("[DEBUG execute_llm] Calling driver.generate with prompt length: {}", prompt.len());
|
||||
tracing::debug!(target: "pipeline_actions", "execute_llm: Calling driver.generate with prompt length: {}", prompt.len());
|
||||
|
||||
driver.generate(prompt, input, model, temperature, max_tokens, json_mode)
|
||||
.await
|
||||
.map_err(ActionError::Llm)
|
||||
} else {
|
||||
Err(ActionError::Llm("LLM driver not configured".to_string()))
|
||||
Err(ActionError::Llm("LLM 驱动未配置,请在设置中配置模型与 API".to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,7 +165,7 @@ impl ActionRegistry {
|
||||
.await
|
||||
.map_err(ActionError::Skill)
|
||||
} else {
|
||||
Err(ActionError::Skill("Skill registry not configured".to_string()))
|
||||
Err(ActionError::Skill("技能注册表未初始化".to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,7 +181,7 @@ impl ActionRegistry {
|
||||
.await
|
||||
.map_err(ActionError::Hand)
|
||||
} else {
|
||||
Err(ActionError::Hand("Hand registry not configured".to_string()))
|
||||
Err(ActionError::Hand("Hand 注册表未初始化".to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -201,7 +197,7 @@ impl ActionRegistry {
|
||||
.await
|
||||
.map_err(ActionError::Orchestration)
|
||||
} else {
|
||||
Err(ActionError::Orchestration("Orchestration driver not configured".to_string()))
|
||||
Err(ActionError::Orchestration("编排驱动未初始化".to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -256,11 +252,14 @@ impl ActionRegistry {
|
||||
tokio::fs::write(&path, content).await?;
|
||||
}
|
||||
ExportFormat::Pptx => {
|
||||
// Will integrate with pptx exporter
|
||||
return Err(ActionError::Export("PPTX export not yet implemented".to_string()));
|
||||
return Err(ActionError::Export(
|
||||
"PPTX 导出暂不可用。桌面端可通过 Pipeline 结果面板使用 JSON 格式导出后转换。".to_string(),
|
||||
));
|
||||
}
|
||||
ExportFormat::Pdf => {
|
||||
return Err(ActionError::Export("PDF export not yet implemented".to_string()));
|
||||
return Err(ActionError::Export(
|
||||
"PDF 导出暂不可用。桌面端可通过 Pipeline 结果面板使用 HTML 格式导出后通过浏览器打印为 PDF。".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -346,14 +345,14 @@ impl ActionRegistry {
|
||||
let mut html = String::from("<!DOCTYPE html><html><head><meta charset=\"utf-8\"><title>Export</title></head><body>");
|
||||
|
||||
if let Some(title) = data.get("title").and_then(|v| v.as_str()) {
|
||||
html.push_str(&format!("<h1>{}</h1>", title));
|
||||
html.push_str(&format!("<h1>{}</h1>", escape_html(title)));
|
||||
}
|
||||
|
||||
if let Some(items) = data.get("items").and_then(|v| v.as_array()) {
|
||||
html.push_str("<ul>");
|
||||
for item in items {
|
||||
if let Some(text) = item.as_str() {
|
||||
html.push_str(&format!("<li>{}</li>", text));
|
||||
html.push_str(&format!("<li>{}</li>", escape_html(text)));
|
||||
}
|
||||
}
|
||||
html.push_str("</ul>");
|
||||
@@ -364,6 +363,22 @@ impl ActionRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
/// Escape HTML special characters to prevent XSS
|
||||
fn escape_html(s: &str) -> String {
|
||||
let mut escaped = String::with_capacity(s.len());
|
||||
for ch in s.chars() {
|
||||
match ch {
|
||||
'<' => escaped.push_str("<"),
|
||||
'>' => escaped.push_str(">"),
|
||||
'&' => escaped.push_str("&"),
|
||||
'"' => escaped.push_str("""),
|
||||
'\'' => escaped.push_str("'"),
|
||||
_ => escaped.push(ch),
|
||||
}
|
||||
}
|
||||
escaped
|
||||
}
|
||||
|
||||
impl ExportFormat {
|
||||
fn extension(&self) -> &'static str {
|
||||
match self {
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
//! Skill execution action
|
||||
|
||||
use std::collections::HashMap;
|
||||
use serde_json::Value;
|
||||
|
||||
use super::ActionError;
|
||||
|
||||
/// Execute a skill by ID
|
||||
pub async fn execute_skill(
|
||||
skill_id: &str,
|
||||
_input: HashMap<String, Value>,
|
||||
) -> Result<Value, ActionError> {
|
||||
// This will be implemented by injecting the skill registry
|
||||
// For now, return an error indicating it needs configuration
|
||||
|
||||
Err(ActionError::Skill(format!(
|
||||
"Skill '{}' execution requires skill registry configuration",
|
||||
skill_id
|
||||
)))
|
||||
}
|
||||
@@ -10,11 +10,10 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use async_trait::async_trait;
|
||||
use futures::future::join_all;
|
||||
use futures::stream::{self, StreamExt};
|
||||
use serde_json::{Value, json};
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use crate::types_v2::{Stage, ConditionalBranch, PresentationType};
|
||||
use crate::types_v2::{Stage, ConditionalBranch};
|
||||
use crate::engine::context::{ExecutionContextV2, ContextError};
|
||||
|
||||
/// Stage execution result
|
||||
@@ -242,14 +241,6 @@ impl StageEngine {
|
||||
Ok(result)
|
||||
}
|
||||
Err(e) => {
|
||||
let result = StageResult {
|
||||
stage_id: stage_id.clone(),
|
||||
output: Value::Null,
|
||||
status: StageStatus::Failed,
|
||||
error: Some(e.to_string()),
|
||||
duration_ms,
|
||||
};
|
||||
|
||||
self.emit_event(StageEvent::Error {
|
||||
stage_id,
|
||||
error: e.to_string(),
|
||||
@@ -279,7 +270,7 @@ impl StageEngine {
|
||||
|
||||
self.emit_event(StageEvent::Progress {
|
||||
stage_id: stage_id.to_string(),
|
||||
message: "Calling LLM...".to_string(),
|
||||
message: "正在调用 LLM...".to_string(),
|
||||
});
|
||||
|
||||
let prompt_str = resolved_prompt.as_str()
|
||||
@@ -323,29 +314,58 @@ impl StageEngine {
|
||||
return Ok(Value::Array(vec![]));
|
||||
}
|
||||
|
||||
let workers = max_workers.max(1).min(total);
|
||||
let stage_template = stage_template.clone();
|
||||
|
||||
// Clone Arc drivers for concurrent tasks
|
||||
let llm_driver = self.llm_driver.clone();
|
||||
let skill_driver = self.skill_driver.clone();
|
||||
let hand_driver = self.hand_driver.clone();
|
||||
let event_callback = self.event_callback.clone();
|
||||
|
||||
self.emit_event(StageEvent::Progress {
|
||||
stage_id: stage_id.to_string(),
|
||||
message: format!("Processing {} items", total),
|
||||
message: format!("并行处理 {} 项 (workers={})", total, workers),
|
||||
});
|
||||
|
||||
// Sequential execution with progress tracking
|
||||
// Note: True parallel execution would require Send-safe drivers
|
||||
let mut outputs = Vec::with_capacity(total);
|
||||
// Parallel execution using buffer_unordered
|
||||
let results: Vec<(usize, Result<StageResult, StageError>)> = stream::iter(
|
||||
items.into_iter().enumerate().map(|(index, item)| {
|
||||
let child_ctx = context.child_context(item, index, total);
|
||||
let stage = stage_template.clone();
|
||||
let llm = llm_driver.clone();
|
||||
let skill = skill_driver.clone();
|
||||
let hand = hand_driver.clone();
|
||||
let cb = event_callback.clone();
|
||||
|
||||
for (index, item) in items.into_iter().enumerate() {
|
||||
let mut child_context = context.child_context(item.clone(), index, total);
|
||||
|
||||
self.emit_event(StageEvent::ParallelProgress {
|
||||
stage_id: stage_id.to_string(),
|
||||
completed: index,
|
||||
total,
|
||||
});
|
||||
|
||||
match self.execute(stage_template, &mut child_context).await {
|
||||
Ok(result) => outputs.push(result.output),
|
||||
Err(e) => outputs.push(json!({ "error": e.to_string(), "index": index })),
|
||||
async move {
|
||||
let engine = StageEngine {
|
||||
llm_driver: llm,
|
||||
skill_driver: skill,
|
||||
hand_driver: hand,
|
||||
event_callback: cb,
|
||||
max_workers: workers,
|
||||
};
|
||||
let mut ctx = child_ctx;
|
||||
let result = engine.execute(&stage, &mut ctx).await;
|
||||
(index, result)
|
||||
}
|
||||
})
|
||||
)
|
||||
.buffer_unordered(workers)
|
||||
.collect()
|
||||
.await;
|
||||
|
||||
// Sort by original index to preserve order
|
||||
let mut ordered: Vec<_> = results.into_iter().collect();
|
||||
ordered.sort_by_key(|(idx, _)| *idx);
|
||||
|
||||
let outputs: Vec<Value> = ordered.into_iter().map(|(index, result)| {
|
||||
match result {
|
||||
Ok(sr) => sr.output,
|
||||
Err(e) => json!({ "error": e.to_string(), "index": index }),
|
||||
}
|
||||
}).collect();
|
||||
|
||||
Ok(Value::Array(outputs))
|
||||
}
|
||||
@@ -419,7 +439,7 @@ impl StageEngine {
|
||||
/// Execute compose stage
|
||||
async fn execute_compose(
|
||||
&self,
|
||||
stage_id: &str,
|
||||
_stage_id: &str,
|
||||
template: &str,
|
||||
context: &ExecutionContextV2,
|
||||
) -> Result<Value, StageError> {
|
||||
@@ -568,7 +588,8 @@ impl StageEngine {
|
||||
Ok(resolved_value)
|
||||
}
|
||||
|
||||
/// Clone with drivers
|
||||
/// Clone with drivers (reserved for future use)
|
||||
#[allow(dead_code)]
|
||||
fn clone_with_drivers(&self) -> Self {
|
||||
Self {
|
||||
llm_driver: self.llm_driver.clone(),
|
||||
|
||||
@@ -125,7 +125,7 @@ impl PipelineExecutor {
|
||||
return Ok(run.clone());
|
||||
}
|
||||
|
||||
Err(ExecuteError::Action("Run not found after execution".to_string()))
|
||||
Err(ExecuteError::Action("执行后未找到运行记录".to_string()))
|
||||
}
|
||||
|
||||
/// Execute pipeline steps
|
||||
@@ -185,22 +185,22 @@ impl PipelineExecutor {
|
||||
async move {
|
||||
match action {
|
||||
Action::LlmGenerate { template, input, model, temperature, max_tokens, json_mode } => {
|
||||
println!("[DEBUG executor] LlmGenerate action called");
|
||||
println!("[DEBUG executor] Raw input map:");
|
||||
tracing::debug!(target: "pipeline_executor", "LlmGenerate action called");
|
||||
tracing::debug!(target: "pipeline_executor", "Raw input map:");
|
||||
for (k, v) in input {
|
||||
println!(" {} => {}", k, v);
|
||||
tracing::debug!(target: "pipeline_executor", " {} => {}", k, v);
|
||||
}
|
||||
|
||||
// First resolve the template itself (handles ${inputs.xxx}, ${item.xxx}, etc.)
|
||||
let resolved_template = context.resolve(template)?;
|
||||
let resolved_template_str = resolved_template.as_str().unwrap_or(template).to_string();
|
||||
println!("[DEBUG executor] Resolved template (first 300 chars): {}",
|
||||
tracing::debug!(target: "pipeline_executor", "Resolved template (first 300 chars): {}",
|
||||
&resolved_template_str[..resolved_template_str.len().min(300)]);
|
||||
|
||||
let resolved_input = context.resolve_map(input)?;
|
||||
println!("[DEBUG executor] Resolved input map:");
|
||||
tracing::debug!(target: "pipeline_executor", "Resolved input map:");
|
||||
for (k, v) in &resolved_input {
|
||||
println!(" {} => {:?}", k, v);
|
||||
tracing::debug!(target: "pipeline_executor", " {} => {:?}", k, v);
|
||||
}
|
||||
self.action_registry.execute_llm(
|
||||
&resolved_template_str,
|
||||
@@ -215,7 +215,7 @@ impl PipelineExecutor {
|
||||
Action::Parallel { each, step, max_workers } => {
|
||||
let items = context.resolve(each)?;
|
||||
let items_array = items.as_array()
|
||||
.ok_or_else(|| ExecuteError::Action("Parallel 'each' must resolve to an array".to_string()))?;
|
||||
.ok_or_else(|| ExecuteError::Action("并行执行 'each' 必须解析为数组".to_string()))?;
|
||||
|
||||
let workers = max_workers.unwrap_or(4);
|
||||
let results = self.execute_parallel(step, items_array.clone(), workers, context).await?;
|
||||
|
||||
@@ -396,28 +396,31 @@ pub trait LlmIntentDriver: Send + Sync {
|
||||
}
|
||||
|
||||
/// Default LLM driver implementation using prompt-based matching
|
||||
#[allow(dead_code)]
|
||||
pub struct DefaultLlmIntentDriver {
|
||||
/// Model ID to use
|
||||
model_id: String,
|
||||
}
|
||||
|
||||
impl DefaultLlmIntentDriver {
|
||||
/// Create a new default LLM driver
|
||||
pub fn new(model_id: impl Into<String>) -> Self {
|
||||
Self {
|
||||
model_id: model_id.into(),
|
||||
}
|
||||
/// Runtime LLM driver that wraps zclaw-runtime's LlmDriver for actual LLM calls
|
||||
pub struct RuntimeLlmIntentDriver {
|
||||
driver: std::sync::Arc<dyn zclaw_runtime::driver::LlmDriver>,
|
||||
}
|
||||
|
||||
impl RuntimeLlmIntentDriver {
|
||||
/// Create a new runtime LLM intent driver wrapping an existing LLM driver
|
||||
pub fn new(driver: std::sync::Arc<dyn zclaw_runtime::driver::LlmDriver>) -> Self {
|
||||
Self { driver }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl LlmIntentDriver for DefaultLlmIntentDriver {
|
||||
impl LlmIntentDriver for RuntimeLlmIntentDriver {
|
||||
async fn semantic_match(
|
||||
&self,
|
||||
user_input: &str,
|
||||
triggers: &[CompiledTrigger],
|
||||
) -> Option<SemanticMatchResult> {
|
||||
// Build prompt for LLM
|
||||
let trigger_descriptions: Vec<String> = triggers
|
||||
.iter()
|
||||
.map(|t| {
|
||||
@@ -429,32 +432,43 @@ impl LlmIntentDriver for DefaultLlmIntentDriver {
|
||||
})
|
||||
.collect();
|
||||
|
||||
let prompt = format!(
|
||||
r#"分析用户输入,匹配合适的 Pipeline。
|
||||
let system_prompt = r#"分析用户输入,匹配合适的 Pipeline。只返回 JSON,不要其他内容。"#
|
||||
.to_string();
|
||||
|
||||
用户输入: {}
|
||||
|
||||
可选 Pipelines:
|
||||
{}
|
||||
|
||||
返回 JSON 格式:
|
||||
{{
|
||||
"pipeline_id": "匹配的 pipeline ID 或 null",
|
||||
"params": {{ "参数名": "值" }},
|
||||
"confidence": 0.0-1.0,
|
||||
"reason": "匹配原因"
|
||||
}}
|
||||
|
||||
只返回 JSON,不要其他内容。"#,
|
||||
let user_msg = format!(
|
||||
"用户输入: {}\n\n可选 Pipelines:\n{}",
|
||||
user_input,
|
||||
trigger_descriptions.join("\n")
|
||||
);
|
||||
|
||||
// In a real implementation, this would call the LLM
|
||||
// For now, we return None to indicate semantic matching is not available
|
||||
let _ = prompt; // Suppress unused warning
|
||||
let request = zclaw_runtime::driver::CompletionRequest {
|
||||
model: self.driver.provider().to_string(),
|
||||
system: Some(system_prompt),
|
||||
messages: vec![zclaw_types::Message::assistant(user_msg)],
|
||||
max_tokens: Some(512),
|
||||
temperature: Some(0.2),
|
||||
stream: false,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
match self.driver.complete(request).await {
|
||||
Ok(response) => {
|
||||
let text = response.content.iter()
|
||||
.filter_map(|block| match block {
|
||||
zclaw_runtime::driver::ContentBlock::Text { text } => Some(text.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("");
|
||||
|
||||
parse_semantic_match_response(&text)
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("[intent] LLM semantic match failed: {}", e);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn collect_params(
|
||||
&self,
|
||||
@@ -462,7 +476,10 @@ impl LlmIntentDriver for DefaultLlmIntentDriver {
|
||||
missing_params: &[MissingParam],
|
||||
_context: &HashMap<String, serde_json::Value>,
|
||||
) -> HashMap<String, serde_json::Value> {
|
||||
// Build prompt to extract parameters from user input
|
||||
if missing_params.is_empty() {
|
||||
return HashMap::new();
|
||||
}
|
||||
|
||||
let param_descriptions: Vec<String> = missing_params
|
||||
.iter()
|
||||
.map(|p| {
|
||||
@@ -475,28 +492,121 @@ impl LlmIntentDriver for DefaultLlmIntentDriver {
|
||||
})
|
||||
.collect();
|
||||
|
||||
let prompt = format!(
|
||||
r#"从用户输入中提取参数值。
|
||||
let system_prompt = r#"从用户输入中提取参数值。如果无法提取,该参数可以省略。只返回 JSON。"#
|
||||
.to_string();
|
||||
|
||||
用户输入: {}
|
||||
|
||||
需要提取的参数:
|
||||
{}
|
||||
|
||||
返回 JSON 格式:
|
||||
{{
|
||||
"参数名": "提取的值"
|
||||
}}
|
||||
|
||||
如果无法提取,该参数可以省略。只返回 JSON。"#,
|
||||
let user_msg = format!(
|
||||
"用户输入: {}\n\n需要提取的参数:\n{}",
|
||||
user_input,
|
||||
param_descriptions.join("\n")
|
||||
);
|
||||
|
||||
// In a real implementation, this would call the LLM
|
||||
let _ = prompt;
|
||||
let request = zclaw_runtime::driver::CompletionRequest {
|
||||
model: self.driver.provider().to_string(),
|
||||
system: Some(system_prompt),
|
||||
messages: vec![zclaw_types::Message::assistant(user_msg)],
|
||||
max_tokens: Some(512),
|
||||
temperature: Some(0.1),
|
||||
stream: false,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
match self.driver.complete(request).await {
|
||||
Ok(response) => {
|
||||
let text = response.content.iter()
|
||||
.filter_map(|block| match block {
|
||||
zclaw_runtime::driver::ContentBlock::Text { text } => Some(text.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("");
|
||||
|
||||
parse_params_response(&text)
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("[intent] LLM param extraction failed: {}", e);
|
||||
HashMap::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse semantic match JSON from LLM response
|
||||
fn parse_semantic_match_response(text: &str) -> Option<SemanticMatchResult> {
|
||||
let json_str = extract_json_from_text(text);
|
||||
let parsed: serde_json::Value = serde_json::from_str(&json_str).ok()?;
|
||||
|
||||
let pipeline_id = parsed.get("pipeline_id")?.as_str()?.to_string();
|
||||
let confidence = parsed.get("confidence")?.as_f64()? as f32;
|
||||
|
||||
// Reject low-confidence matches
|
||||
if confidence < 0.5 || pipeline_id.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let params = parsed.get("params")
|
||||
.and_then(|v| v.as_object())
|
||||
.map(|obj| {
|
||||
obj.iter()
|
||||
.filter_map(|(k, v)| {
|
||||
let val = match v {
|
||||
serde_json::Value::String(s) => serde_json::Value::String(s.clone()),
|
||||
serde_json::Value::Number(n) => serde_json::Value::Number(n.clone()),
|
||||
other => other.clone(),
|
||||
};
|
||||
Some((k.clone(), val))
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let reason = parsed.get("reason")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
Some(SemanticMatchResult {
|
||||
pipeline_id,
|
||||
params,
|
||||
confidence,
|
||||
reason,
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse params JSON from LLM response
|
||||
fn parse_params_response(text: &str) -> HashMap<String, serde_json::Value> {
|
||||
let json_str = extract_json_from_text(text);
|
||||
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&json_str) {
|
||||
if let Some(obj) = parsed.as_object() {
|
||||
return obj.iter()
|
||||
.filter_map(|(k, v)| Some((k.clone(), v.clone())))
|
||||
.collect();
|
||||
}
|
||||
}
|
||||
HashMap::new()
|
||||
}
|
||||
|
||||
/// Extract JSON from LLM response text (handles markdown code blocks)
|
||||
fn extract_json_from_text(text: &str) -> String {
|
||||
let trimmed = text.trim();
|
||||
|
||||
// Try markdown code block
|
||||
if let Some(start) = trimmed.find("```json") {
|
||||
if let Some(content_start) = trimmed[start..].find('\n') {
|
||||
if let Some(end) = trimmed[content_start..].find("```") {
|
||||
return trimmed[content_start + 1..content_start + end].trim().to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try bare JSON
|
||||
if let Some(start) = trimmed.find('{') {
|
||||
if let Some(end) = trimmed.rfind('}') {
|
||||
return trimmed[start..end + 1].to_string();
|
||||
}
|
||||
}
|
||||
|
||||
trimmed.to_string()
|
||||
}
|
||||
|
||||
/// Intent analysis result (for debugging/logging)
|
||||
|
||||
@@ -57,6 +57,7 @@ pub mod intent;
|
||||
pub mod engine;
|
||||
pub mod presentation;
|
||||
|
||||
// Glob re-exports with explicit disambiguation for conflicting names
|
||||
pub use types::*;
|
||||
pub use types_v2::*;
|
||||
pub use parser::*;
|
||||
@@ -67,6 +68,14 @@ pub use trigger::*;
|
||||
pub use intent::*;
|
||||
pub use engine::*;
|
||||
pub use presentation::*;
|
||||
|
||||
// Explicit re-exports: presentation::* wins for PresentationType/ExportFormat
|
||||
// types_v2::* wins for InputMode, engine::* wins for LoopContext
|
||||
pub use presentation::PresentationType;
|
||||
pub use presentation::ExportFormat;
|
||||
pub use types_v2::InputMode;
|
||||
pub use engine::context::LoopContext;
|
||||
|
||||
pub use actions::ActionRegistry;
|
||||
pub use actions::{LlmActionDriver, SkillActionDriver, HandActionDriver, OrchestrationActionDriver};
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@
|
||||
//! - Better recommendations for ambiguous cases
|
||||
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use super::types::*;
|
||||
|
||||
|
||||
@@ -254,13 +254,13 @@ pub fn compile_pattern(pattern: &str) -> Result<CompiledPattern, PatternError> {
|
||||
'{' => {
|
||||
// Named capture group
|
||||
let mut name = String::new();
|
||||
let mut has_type = false;
|
||||
let mut _has_type = false;
|
||||
|
||||
while let Some(c) = chars.next() {
|
||||
match c {
|
||||
'}' => break,
|
||||
':' => {
|
||||
has_type = true;
|
||||
_has_type = true;
|
||||
// Skip type part
|
||||
while let Some(nc) = chars.peek() {
|
||||
if *nc == '}' {
|
||||
|
||||
@@ -7,6 +7,11 @@ repository.workspace = true
|
||||
rust-version.workspace = true
|
||||
description = "ZCLAW protocol support (MCP, A2A)"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
# Enable A2A (Agent-to-Agent) protocol support
|
||||
a2a = []
|
||||
|
||||
[dependencies]
|
||||
zclaw-types = { workspace = true }
|
||||
|
||||
|
||||
@@ -256,7 +256,7 @@ pub struct A2aReceiver {
|
||||
}
|
||||
|
||||
impl A2aReceiver {
|
||||
#[allow(dead_code)] // Reserved for future A2A integration
|
||||
#[allow(dead_code)] // Will be used when A2A message channels are activated
|
||||
fn new(rx: mpsc::Receiver<A2aEnvelope>) -> Self {
|
||||
Self { receiver: Some(rx) }
|
||||
}
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
//! ZCLAW Protocols
|
||||
//!
|
||||
//! Protocol support for MCP (Model Context Protocol) and A2A (Agent-to-Agent).
|
||||
//!
|
||||
//! A2A is gated behind the `a2a` feature flag (reserved for future multi-agent scenarios).
|
||||
//! MCP is always available as a framework for tool integration.
|
||||
|
||||
mod mcp;
|
||||
mod mcp_types;
|
||||
mod mcp_transport;
|
||||
#[cfg(feature = "a2a")]
|
||||
mod a2a;
|
||||
|
||||
pub use mcp::*;
|
||||
pub use mcp_types::*;
|
||||
pub use mcp_transport::*;
|
||||
#[cfg(feature = "a2a")]
|
||||
pub use a2a::*;
|
||||
|
||||
641
crates/zclaw-runtime/src/compaction.rs
Normal file
641
crates/zclaw-runtime/src/compaction.rs
Normal file
@@ -0,0 +1,641 @@
|
||||
//! Context compaction for the agent loop.
|
||||
//!
|
||||
//! Provides rule-based token estimation and message compaction to prevent
|
||||
//! conversations from exceeding LLM context windows. When the estimated
|
||||
//! token count exceeds the configured threshold, older messages are
|
||||
//! summarized into a single system message and only recent messages are
|
||||
//! retained.
|
||||
//!
|
||||
//! Supports two compaction modes:
|
||||
//! - **Rule-based**: Heuristic topic extraction (default, no LLM needed)
|
||||
//! - **LLM-based**: Uses an LLM driver to generate higher-quality summaries
|
||||
//!
|
||||
//! Optionally flushes old messages to the growth/memory system before discarding.
|
||||
|
||||
use std::sync::Arc;
|
||||
use zclaw_types::{AgentId, Message, SessionId};
|
||||
|
||||
use crate::driver::{CompletionRequest, ContentBlock, LlmDriver};
|
||||
use crate::growth::GrowthIntegration;
|
||||
|
||||
/// Number of recent messages to preserve after compaction.
|
||||
const DEFAULT_KEEP_RECENT: usize = 6;
|
||||
|
||||
/// Heuristic token count estimation.
|
||||
///
|
||||
/// CJK characters ≈ 1.5 tokens each, English words ≈ 1.3 tokens each.
|
||||
/// Intentionally conservative (overestimates) to avoid hitting real limits.
|
||||
pub fn estimate_tokens(text: &str) -> usize {
|
||||
if text.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let mut tokens: f64 = 0.0;
|
||||
for char in text.chars() {
|
||||
let code = char as u32;
|
||||
if (0x4E00..=0x9FFF).contains(&code)
|
||||
|| (0x3400..=0x4DBF).contains(&code)
|
||||
|| (0x20000..=0x2A6DF).contains(&code)
|
||||
|| (0xF900..=0xFAFF).contains(&code)
|
||||
{
|
||||
// CJK ideographs — ~1.5 tokens
|
||||
tokens += 1.5;
|
||||
} else if (0x3000..=0x303F).contains(&code) || (0xFF00..=0xFFEF).contains(&code) {
|
||||
// CJK / fullwidth punctuation — ~1.0 token
|
||||
tokens += 1.0;
|
||||
} else if char == ' ' || char == '\n' || char == '\t' {
|
||||
// whitespace
|
||||
tokens += 0.25;
|
||||
} else {
|
||||
// ASCII / Latin characters — roughly 4 chars per token
|
||||
tokens += 0.3;
|
||||
}
|
||||
}
|
||||
|
||||
tokens.ceil() as usize
|
||||
}
|
||||
|
||||
/// Estimate total tokens for a list of messages (including framing overhead).
|
||||
pub fn estimate_messages_tokens(messages: &[Message]) -> usize {
|
||||
let mut total = 0;
|
||||
for msg in messages {
|
||||
match msg {
|
||||
Message::User { content } => {
|
||||
total += estimate_tokens(content);
|
||||
total += 4;
|
||||
}
|
||||
Message::Assistant { content, thinking } => {
|
||||
total += estimate_tokens(content);
|
||||
if let Some(th) = thinking {
|
||||
total += estimate_tokens(th);
|
||||
}
|
||||
total += 4;
|
||||
}
|
||||
Message::System { content } => {
|
||||
total += estimate_tokens(content);
|
||||
total += 4;
|
||||
}
|
||||
Message::ToolUse { input, .. } => {
|
||||
total += estimate_tokens(&input.to_string());
|
||||
total += 4;
|
||||
}
|
||||
Message::ToolResult { output, .. } => {
|
||||
total += estimate_tokens(&output.to_string());
|
||||
total += 4;
|
||||
}
|
||||
}
|
||||
}
|
||||
total
|
||||
}
|
||||
|
||||
/// Compact a message list by summarizing old messages and keeping recent ones.
|
||||
///
|
||||
/// When `messages.len() > keep_recent`, the oldest messages are summarized
|
||||
/// into a single system message. System messages at the beginning of the
|
||||
/// conversation are always preserved.
|
||||
///
|
||||
/// Returns the compacted message list and the number of original messages removed.
|
||||
pub fn compact_messages(messages: Vec<Message>, keep_recent: usize) -> (Vec<Message>, usize) {
|
||||
if messages.len() <= keep_recent {
|
||||
return (messages, 0);
|
||||
}
|
||||
|
||||
// Preserve leading system messages (they contain compaction summaries from prior runs)
|
||||
let leading_system_count = messages
|
||||
.iter()
|
||||
.take_while(|m| matches!(m, Message::System { .. }))
|
||||
.count();
|
||||
|
||||
// Calculate split point: keep leading system + recent messages
|
||||
let keep_from_end = keep_recent.min(messages.len().saturating_sub(leading_system_count));
|
||||
let split_index = messages.len().saturating_sub(keep_from_end);
|
||||
|
||||
// Ensure we keep at least the leading system messages
|
||||
let split_index = split_index.max(leading_system_count);
|
||||
|
||||
if split_index == 0 {
|
||||
return (messages, 0);
|
||||
}
|
||||
|
||||
let old_messages = &messages[..split_index];
|
||||
let recent_messages = &messages[split_index..];
|
||||
|
||||
let summary = generate_summary(old_messages);
|
||||
let removed_count = old_messages.len();
|
||||
|
||||
let mut compacted = Vec::with_capacity(1 + recent_messages.len());
|
||||
compacted.push(Message::system(summary));
|
||||
compacted.extend(recent_messages.iter().cloned());
|
||||
|
||||
(compacted, removed_count)
|
||||
}
|
||||
|
||||
/// Check if compaction should be triggered and perform it if needed.
|
||||
///
|
||||
/// Returns the (possibly compacted) message list.
|
||||
pub fn maybe_compact(messages: Vec<Message>, threshold: usize) -> Vec<Message> {
|
||||
let tokens = estimate_messages_tokens(&messages);
|
||||
if tokens < threshold {
|
||||
return messages;
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
"[Compaction] Triggered: {} tokens > {} threshold, {} messages",
|
||||
tokens,
|
||||
threshold,
|
||||
messages.len(),
|
||||
);
|
||||
|
||||
let (compacted, removed) = compact_messages(messages, DEFAULT_KEEP_RECENT);
|
||||
tracing::info!(
|
||||
"[Compaction] Removed {} messages, {} remain",
|
||||
removed,
|
||||
compacted.len(),
|
||||
);
|
||||
|
||||
compacted
|
||||
}
|
||||
|
||||
/// Configuration for compaction behavior.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CompactionConfig {
|
||||
/// Use LLM for generating summaries instead of rule-based extraction.
|
||||
pub use_llm: bool,
|
||||
/// Fall back to rule-based summary if LLM fails.
|
||||
pub llm_fallback_to_rules: bool,
|
||||
/// Flush memories from old messages before discarding them.
|
||||
pub memory_flush_enabled: bool,
|
||||
/// Maximum tokens for LLM-generated summary.
|
||||
pub summary_max_tokens: u32,
|
||||
}
|
||||
|
||||
impl Default for CompactionConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
use_llm: false,
|
||||
llm_fallback_to_rules: true,
|
||||
memory_flush_enabled: false,
|
||||
summary_max_tokens: 500,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Outcome of an async compaction operation.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CompactionOutcome {
|
||||
/// The (possibly compacted) message list.
|
||||
pub messages: Vec<Message>,
|
||||
/// Number of messages removed during compaction.
|
||||
pub removed_count: usize,
|
||||
/// Number of memories flushed to the growth system.
|
||||
pub flushed_memories: usize,
|
||||
/// Whether LLM was used for summary generation.
|
||||
pub used_llm: bool,
|
||||
}
|
||||
|
||||
/// Async compaction with optional LLM summary and memory flushing.
|
||||
///
|
||||
/// When `messages` exceed `threshold` tokens:
|
||||
/// 1. If `memory_flush_enabled`, extract memories from old messages via growth system
|
||||
/// 2. Generate summary (LLM or rule-based depending on config)
|
||||
/// 3. Replace old messages with summary + keep recent messages
|
||||
pub async fn maybe_compact_with_config(
|
||||
messages: Vec<Message>,
|
||||
threshold: usize,
|
||||
config: &CompactionConfig,
|
||||
agent_id: &AgentId,
|
||||
session_id: &SessionId,
|
||||
driver: Option<&Arc<dyn LlmDriver>>,
|
||||
growth: Option<&GrowthIntegration>,
|
||||
) -> CompactionOutcome {
|
||||
let tokens = estimate_messages_tokens(&messages);
|
||||
if tokens < threshold {
|
||||
return CompactionOutcome {
|
||||
messages,
|
||||
removed_count: 0,
|
||||
flushed_memories: 0,
|
||||
used_llm: false,
|
||||
};
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
"[Compaction] Triggered: {} tokens > {} threshold, {} messages",
|
||||
tokens,
|
||||
threshold,
|
||||
messages.len(),
|
||||
);
|
||||
|
||||
// Step 1: Flush memories from messages that are about to be compacted
|
||||
let flushed_memories = if config.memory_flush_enabled {
|
||||
if let Some(growth) = growth {
|
||||
match growth
|
||||
.process_conversation(agent_id, &messages, session_id.clone())
|
||||
.await
|
||||
{
|
||||
Ok(count) => {
|
||||
tracing::info!(
|
||||
"[Compaction] Flushed {} memories before compaction",
|
||||
count
|
||||
);
|
||||
count
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("[Compaction] Memory flush failed: {}", e);
|
||||
0
|
||||
}
|
||||
}
|
||||
} else {
|
||||
tracing::debug!("[Compaction] Memory flush requested but no growth integration available");
|
||||
0
|
||||
}
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
// Step 2: Determine split point (same logic as compact_messages)
|
||||
let leading_system_count = messages
|
||||
.iter()
|
||||
.take_while(|m| matches!(m, Message::System { .. }))
|
||||
.count();
|
||||
let keep_from_end = DEFAULT_KEEP_RECENT
|
||||
.min(messages.len().saturating_sub(leading_system_count));
|
||||
let split_index = messages.len().saturating_sub(keep_from_end);
|
||||
let split_index = split_index.max(leading_system_count);
|
||||
|
||||
if split_index == 0 {
|
||||
return CompactionOutcome {
|
||||
messages,
|
||||
removed_count: 0,
|
||||
flushed_memories,
|
||||
used_llm: false,
|
||||
};
|
||||
}
|
||||
|
||||
let old_messages = &messages[..split_index];
|
||||
let recent_messages = &messages[split_index..];
|
||||
let removed_count = old_messages.len();
|
||||
|
||||
// Step 3: Generate summary (LLM or rule-based)
|
||||
let summary = if config.use_llm {
|
||||
if let Some(driver) = driver {
|
||||
match generate_llm_summary(driver, old_messages, config.summary_max_tokens).await {
|
||||
Ok(llm_summary) => {
|
||||
tracing::info!(
|
||||
"[Compaction] Generated LLM summary ({} chars)",
|
||||
llm_summary.len()
|
||||
);
|
||||
llm_summary
|
||||
}
|
||||
Err(e) => {
|
||||
if config.llm_fallback_to_rules {
|
||||
tracing::warn!(
|
||||
"[Compaction] LLM summary failed: {}, falling back to rules",
|
||||
e
|
||||
);
|
||||
generate_summary(old_messages)
|
||||
} else {
|
||||
tracing::warn!(
|
||||
"[Compaction] LLM summary failed: {}, returning original messages",
|
||||
e
|
||||
);
|
||||
return CompactionOutcome {
|
||||
messages,
|
||||
removed_count: 0,
|
||||
flushed_memories,
|
||||
used_llm: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
tracing::warn!(
|
||||
"[Compaction] LLM compaction requested but no driver available, using rules"
|
||||
);
|
||||
generate_summary(old_messages)
|
||||
}
|
||||
} else {
|
||||
generate_summary(old_messages)
|
||||
};
|
||||
|
||||
let used_llm = config.use_llm && driver.is_some();
|
||||
|
||||
// Step 4: Build compacted message list
|
||||
let mut compacted = Vec::with_capacity(1 + recent_messages.len());
|
||||
compacted.push(Message::system(summary));
|
||||
compacted.extend(recent_messages.iter().cloned());
|
||||
|
||||
tracing::info!(
|
||||
"[Compaction] Removed {} messages, {} remain (llm={})",
|
||||
removed_count,
|
||||
compacted.len(),
|
||||
used_llm,
|
||||
);
|
||||
|
||||
CompactionOutcome {
|
||||
messages: compacted,
|
||||
removed_count,
|
||||
flushed_memories,
|
||||
used_llm,
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate a summary using an LLM driver.
|
||||
async fn generate_llm_summary(
|
||||
driver: &Arc<dyn LlmDriver>,
|
||||
messages: &[Message],
|
||||
max_tokens: u32,
|
||||
) -> Result<String, String> {
|
||||
let mut conversation_text = String::new();
|
||||
for msg in messages {
|
||||
match msg {
|
||||
Message::User { content } => {
|
||||
conversation_text.push_str(&format!("用户: {}\n", content))
|
||||
}
|
||||
Message::Assistant { content, .. } => {
|
||||
conversation_text.push_str(&format!("助手: {}\n", content))
|
||||
}
|
||||
Message::System { content } => {
|
||||
if !content.starts_with("[以下是之前对话的摘要]") {
|
||||
conversation_text.push_str(&format!("[系统]: {}\n", content))
|
||||
}
|
||||
}
|
||||
Message::ToolUse { tool, input, .. } => {
|
||||
conversation_text.push_str(&format!(
|
||||
"[工具调用 {}]: {}\n",
|
||||
tool.as_str(),
|
||||
input
|
||||
))
|
||||
}
|
||||
Message::ToolResult { output, .. } => {
|
||||
conversation_text.push_str(&format!("[工具结果]: {}\n", output))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Truncate conversation text if too long for the prompt itself
|
||||
let max_conversation_chars = 8000;
|
||||
if conversation_text.len() > max_conversation_chars {
|
||||
conversation_text.truncate(max_conversation_chars);
|
||||
conversation_text.push_str("\n...(对话已截断)");
|
||||
}
|
||||
|
||||
let prompt = format!(
|
||||
"请用简洁的中文总结以下对话的关键信息。保留重要的讨论主题、决策、结论和待办事项。\
|
||||
输出格式为段落式摘要,不超过200字。\n\n{}",
|
||||
conversation_text
|
||||
);
|
||||
|
||||
let request = CompletionRequest {
|
||||
model: String::new(),
|
||||
system: Some(
|
||||
"你是一个对话摘要助手。只输出摘要内容,不要添加额外解释。".to_string(),
|
||||
),
|
||||
messages: vec![Message::user(&prompt)],
|
||||
tools: Vec::new(),
|
||||
max_tokens: Some(max_tokens),
|
||||
temperature: Some(0.3),
|
||||
stop: Vec::new(),
|
||||
stream: false,
|
||||
};
|
||||
|
||||
let response = driver
|
||||
.complete(request)
|
||||
.await
|
||||
.map_err(|e| format!("{}", e))?;
|
||||
|
||||
// Extract text from content blocks
|
||||
let text_parts: Vec<String> = response
|
||||
.content
|
||||
.iter()
|
||||
.filter_map(|block| match block {
|
||||
ContentBlock::Text { text } => Some(text.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
|
||||
let summary = text_parts.join("");
|
||||
|
||||
if summary.is_empty() {
|
||||
return Err("LLM returned empty response".to_string());
|
||||
}
|
||||
|
||||
Ok(summary)
|
||||
}
|
||||
|
||||
/// Generate a rule-based summary of old messages.
|
||||
fn generate_summary(messages: &[Message]) -> String {
|
||||
if messages.is_empty() {
|
||||
return "[对话开始]".to_string();
|
||||
}
|
||||
|
||||
let mut sections: Vec<String> = vec!["[以下是之前对话的摘要]".to_string()];
|
||||
|
||||
let mut user_count = 0;
|
||||
let mut assistant_count = 0;
|
||||
let mut topics: Vec<String> = Vec::new();
|
||||
|
||||
for msg in messages {
|
||||
match msg {
|
||||
Message::User { content } => {
|
||||
user_count += 1;
|
||||
let topic = extract_topic(content);
|
||||
if let Some(t) = topic {
|
||||
topics.push(t);
|
||||
}
|
||||
}
|
||||
Message::Assistant { .. } => {
|
||||
assistant_count += 1;
|
||||
}
|
||||
Message::System { content } => {
|
||||
// Skip system messages that are previous compaction summaries
|
||||
if !content.starts_with("[以下是之前对话的摘要]") {
|
||||
sections.push(format!("系统提示: {}", truncate(content, 60)));
|
||||
}
|
||||
}
|
||||
Message::ToolUse { tool, .. } => {
|
||||
sections.push(format!("工具调用: {}", tool.as_str()));
|
||||
}
|
||||
Message::ToolResult { .. } => {
|
||||
// Skip tool results in summary
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !topics.is_empty() {
|
||||
let topic_list: Vec<String> = topics.iter().take(8).cloned().collect();
|
||||
sections.push(format!("讨论主题: {}", topic_list.join("; ")));
|
||||
}
|
||||
|
||||
sections.push(format!(
|
||||
"(已压缩 {} 条消息,其中用户 {} 条,助手 {} 条)",
|
||||
messages.len(),
|
||||
user_count,
|
||||
assistant_count,
|
||||
));
|
||||
|
||||
let summary = sections.join("\n");
|
||||
|
||||
// Enforce max length
|
||||
let max_chars = 800;
|
||||
if summary.len() > max_chars {
|
||||
format!("{}...\n(摘要已截断)", &summary[..max_chars])
|
||||
} else {
|
||||
summary
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract the main topic from a user message (first sentence or first 50 chars).
|
||||
fn extract_topic(content: &str) -> Option<String> {
|
||||
let trimmed = content.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Find sentence end markers
|
||||
for (i, char) in trimmed.char_indices() {
|
||||
if char == '。' || char == '!' || char == '?' || char == '\n' {
|
||||
let end = i + char.len_utf8();
|
||||
if end <= 80 {
|
||||
return Some(trimmed[..end].trim().to_string());
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if trimmed.chars().count() <= 50 {
|
||||
return Some(trimmed.to_string());
|
||||
}
|
||||
|
||||
Some(format!("{}...", trimmed.chars().take(50).collect::<String>()))
|
||||
}
|
||||
|
||||
/// Truncate text to max_chars at char boundary.
|
||||
fn truncate(text: &str, max_chars: usize) -> String {
|
||||
if text.chars().count() <= max_chars {
|
||||
return text.to_string();
|
||||
}
|
||||
let truncated: String = text.chars().take(max_chars).collect();
|
||||
format!("{}...", truncated)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_estimate_tokens_empty() {
|
||||
assert_eq!(estimate_tokens(""), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_estimate_tokens_english() {
|
||||
let tokens = estimate_tokens("Hello world");
|
||||
assert!(tokens > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_estimate_tokens_cjk() {
|
||||
let tokens = estimate_tokens("你好世界");
|
||||
assert!(tokens > 3); // CJK chars are ~1.5 tokens each
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_estimate_messages_tokens() {
|
||||
let messages = vec![
|
||||
Message::user("Hello"),
|
||||
Message::assistant("Hi there"),
|
||||
];
|
||||
let tokens = estimate_messages_tokens(&messages);
|
||||
assert!(tokens > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compact_messages_under_threshold() {
|
||||
let messages = vec![
|
||||
Message::user("Hello"),
|
||||
Message::assistant("Hi"),
|
||||
];
|
||||
let (result, removed) = compact_messages(messages, 6);
|
||||
assert_eq!(removed, 0);
|
||||
assert_eq!(result.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compact_messages_over_threshold() {
|
||||
let messages: Vec<Message> = (0..10)
|
||||
.flat_map(|i| {
|
||||
vec![
|
||||
Message::user(format!("Question {}", i)),
|
||||
Message::assistant(format!("Answer {}", i)),
|
||||
]
|
||||
})
|
||||
.collect();
|
||||
|
||||
let (result, removed) = compact_messages(messages, 4);
|
||||
assert!(removed > 0);
|
||||
// Should have: 1 summary + 4 recent messages
|
||||
assert_eq!(result.len(), 5);
|
||||
// First message should be a system summary
|
||||
assert!(matches!(&result[0], Message::System { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compact_preserves_leading_system() {
|
||||
let messages = vec![
|
||||
Message::system("You are helpful"),
|
||||
Message::user("Q1"),
|
||||
Message::assistant("A1"),
|
||||
Message::user("Q2"),
|
||||
Message::assistant("A2"),
|
||||
Message::user("Q3"),
|
||||
Message::assistant("A3"),
|
||||
];
|
||||
|
||||
let (result, removed) = compact_messages(messages, 4);
|
||||
assert!(removed > 0);
|
||||
// Should start with compaction summary, then recent messages
|
||||
assert!(matches!(&result[0], Message::System { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_maybe_compact_under_threshold() {
|
||||
let messages = vec![
|
||||
Message::user("Short message"),
|
||||
Message::assistant("Short reply"),
|
||||
];
|
||||
let result = maybe_compact(messages, 100_000);
|
||||
assert_eq!(result.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_topic_sentence() {
|
||||
let topic = extract_topic("什么是Rust的所有权系统?").unwrap();
|
||||
assert!(topic.contains("所有权"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_topic_short() {
|
||||
let topic = extract_topic("Hello").unwrap();
|
||||
assert_eq!(topic, "Hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_topic_long() {
|
||||
let long = "This is a very long message that exceeds fifty characters in total length";
|
||||
let topic = extract_topic(long).unwrap();
|
||||
assert!(topic.ends_with("..."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_summary() {
|
||||
let messages = vec![
|
||||
Message::user("What is Rust?"),
|
||||
Message::assistant("Rust is a systems programming language"),
|
||||
Message::user("How does ownership work?"),
|
||||
Message::assistant("Ownership is Rust's memory management system"),
|
||||
];
|
||||
let summary = generate_summary(&messages);
|
||||
assert!(summary.contains("摘要"));
|
||||
assert!(summary.contains("2"));
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,17 @@
|
||||
//! Google Gemini driver implementation
|
||||
//!
|
||||
//! Implements the Gemini REST API v1beta with full support for:
|
||||
//! - Text generation (complete and streaming)
|
||||
//! - Tool / function calling
|
||||
//! - System instructions
|
||||
//! - Token usage reporting
|
||||
|
||||
use async_trait::async_trait;
|
||||
use futures::Stream;
|
||||
use async_stream::stream;
|
||||
use futures::{Stream, StreamExt};
|
||||
use secrecy::{ExposeSecret, SecretString};
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::pin::Pin;
|
||||
use zclaw_types::{Result, ZclawError};
|
||||
|
||||
@@ -11,7 +19,6 @@ use super::{CompletionRequest, CompletionResponse, ContentBlock, LlmDriver, Stop
|
||||
use crate::stream::StreamChunk;
|
||||
|
||||
/// Google Gemini driver
|
||||
#[allow(dead_code)] // TODO: Implement full Gemini API support
|
||||
pub struct GeminiDriver {
|
||||
client: Client,
|
||||
api_key: SecretString,
|
||||
@@ -21,11 +28,31 @@ pub struct GeminiDriver {
|
||||
impl GeminiDriver {
|
||||
pub fn new(api_key: SecretString) -> Self {
|
||||
Self {
|
||||
client: Client::new(),
|
||||
client: Client::builder()
|
||||
.user_agent(crate::USER_AGENT)
|
||||
.http1_only()
|
||||
.timeout(std::time::Duration::from_secs(120))
|
||||
.connect_timeout(std::time::Duration::from_secs(30))
|
||||
.build()
|
||||
.unwrap_or_else(|_| Client::new()),
|
||||
api_key,
|
||||
base_url: "https://generativelanguage.googleapis.com/v1beta".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_base_url(api_key: SecretString, base_url: String) -> Self {
|
||||
Self {
|
||||
client: Client::builder()
|
||||
.user_agent(crate::USER_AGENT)
|
||||
.http1_only()
|
||||
.timeout(std::time::Duration::from_secs(120))
|
||||
.connect_timeout(std::time::Duration::from_secs(30))
|
||||
.build()
|
||||
.unwrap_or_else(|_| Client::new()),
|
||||
api_key,
|
||||
base_url,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -39,25 +66,594 @@ impl LlmDriver for GeminiDriver {
|
||||
}
|
||||
|
||||
async fn complete(&self, request: CompletionRequest) -> Result<CompletionResponse> {
|
||||
// TODO: Implement actual API call
|
||||
Ok(CompletionResponse {
|
||||
content: vec![ContentBlock::Text {
|
||||
text: "Gemini driver not yet implemented".to_string(),
|
||||
}],
|
||||
model: request.model,
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
stop_reason: StopReason::EndTurn,
|
||||
})
|
||||
let api_request = self.build_api_request(&request);
|
||||
let url = format!(
|
||||
"{}/models/{}:generateContent?key={}",
|
||||
self.base_url,
|
||||
request.model,
|
||||
self.api_key.expose_secret()
|
||||
);
|
||||
|
||||
tracing::debug!(target: "gemini_driver", "Sending request to: {}", url);
|
||||
|
||||
let response = self.client
|
||||
.post(&url)
|
||||
.header("content-type", "application/json")
|
||||
.json(&api_request)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ZclawError::LlmError(format!("HTTP request failed: {}", e)))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
tracing::warn!(target: "gemini_driver", "API error {}: {}", status, body);
|
||||
return Err(ZclawError::LlmError(format!("API error {}: {}", status, body)));
|
||||
}
|
||||
|
||||
let api_response: GeminiResponse = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| ZclawError::LlmError(format!("Failed to parse response: {}", e)))?;
|
||||
|
||||
Ok(self.convert_response(api_response, request.model))
|
||||
}
|
||||
|
||||
fn stream(
|
||||
&self,
|
||||
_request: CompletionRequest,
|
||||
request: CompletionRequest,
|
||||
) -> Pin<Box<dyn Stream<Item = Result<StreamChunk>> + Send + '_>> {
|
||||
// Placeholder - return error stream
|
||||
Box::pin(futures::stream::once(async {
|
||||
Err(ZclawError::LlmError("Gemini streaming not yet implemented".to_string()))
|
||||
}))
|
||||
let api_request = self.build_api_request(&request);
|
||||
let url = format!(
|
||||
"{}/models/{}:streamGenerateContent?alt=sse&key={}",
|
||||
self.base_url,
|
||||
request.model,
|
||||
self.api_key.expose_secret()
|
||||
);
|
||||
|
||||
tracing::debug!(target: "gemini_driver", "Starting stream request to: {}", url);
|
||||
|
||||
Box::pin(stream! {
|
||||
let response = match self.client
|
||||
.post(&url)
|
||||
.header("content-type", "application/json")
|
||||
.timeout(std::time::Duration::from_secs(120))
|
||||
.json(&api_request)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(r) => {
|
||||
tracing::debug!(target: "gemini_driver", "Stream response status: {}", r.status());
|
||||
r
|
||||
},
|
||||
Err(e) => {
|
||||
tracing::error!(target: "gemini_driver", "HTTP request failed: {:?}", e);
|
||||
yield Err(ZclawError::LlmError(format!("HTTP request failed: {}", e)));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
yield Err(ZclawError::LlmError(format!("API error {}: {}", status, body)));
|
||||
return;
|
||||
}
|
||||
|
||||
let mut byte_stream = response.bytes_stream();
|
||||
let mut accumulated_tool_calls: std::collections::HashMap<usize, (String, String)> = std::collections::HashMap::new();
|
||||
|
||||
while let Some(chunk_result) = byte_stream.next().await {
|
||||
let chunk = match chunk_result {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
yield Err(ZclawError::LlmError(format!("Stream error: {}", e)));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let text = String::from_utf8_lossy(&chunk);
|
||||
for line in text.lines() {
|
||||
if let Some(data) = line.strip_prefix("data: ") {
|
||||
match serde_json::from_str::<GeminiStreamResponse>(data) {
|
||||
Ok(resp) => {
|
||||
if let Some(candidate) = resp.candidates.first() {
|
||||
let content = match &candidate.content {
|
||||
Some(c) => c,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
let parts = &content.parts;
|
||||
|
||||
for (idx, part) in parts.iter().enumerate() {
|
||||
// Handle text content
|
||||
if let Some(text) = &part.text {
|
||||
if !text.is_empty() {
|
||||
yield Ok(StreamChunk::TextDelta { delta: text.clone() });
|
||||
}
|
||||
}
|
||||
|
||||
// Handle function call (tool use)
|
||||
if let Some(fc) = &part.function_call {
|
||||
let name = fc.name.clone().unwrap_or_default();
|
||||
let args = fc.args.clone().unwrap_or(serde_json::Value::Object(Default::default()));
|
||||
|
||||
// Emit ToolUseStart if this is a new tool call
|
||||
if !accumulated_tool_calls.contains_key(&idx) {
|
||||
accumulated_tool_calls.insert(idx, (name.clone(), String::new()));
|
||||
yield Ok(StreamChunk::ToolUseStart {
|
||||
id: format!("gemini_call_{}", idx),
|
||||
name,
|
||||
});
|
||||
}
|
||||
|
||||
// Emit the function arguments as delta
|
||||
let args_str = serde_json::to_string(&args).unwrap_or_default();
|
||||
let call_id = format!("gemini_call_{}", idx);
|
||||
yield Ok(StreamChunk::ToolUseDelta {
|
||||
id: call_id.clone(),
|
||||
delta: args_str.clone(),
|
||||
});
|
||||
|
||||
// Accumulate
|
||||
if let Some(entry) = accumulated_tool_calls.get_mut(&idx) {
|
||||
entry.1 = args_str;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// When the candidate is finished, emit ToolUseEnd for all pending
|
||||
if let Some(ref finish_reason) = candidate.finish_reason {
|
||||
let is_final = finish_reason == "STOP" || finish_reason == "MAX_TOKENS";
|
||||
|
||||
if is_final {
|
||||
// Emit ToolUseEnd for all accumulated tool calls
|
||||
for (idx, (_name, args_str)) in &accumulated_tool_calls {
|
||||
let input: serde_json::Value = if args_str.is_empty() {
|
||||
serde_json::json!({})
|
||||
} else {
|
||||
serde_json::from_str(args_str).unwrap_or_else(|e| {
|
||||
tracing::warn!(target: "gemini_driver", "Failed to parse tool args '{}': {}", args_str, e);
|
||||
serde_json::json!({})
|
||||
})
|
||||
};
|
||||
yield Ok(StreamChunk::ToolUseEnd {
|
||||
id: format!("gemini_call_{}", idx),
|
||||
input,
|
||||
});
|
||||
}
|
||||
|
||||
// Extract usage metadata from the response
|
||||
let usage = resp.usage_metadata.as_ref();
|
||||
let input_tokens = usage.map(|u| u.prompt_token_count.unwrap_or(0)).unwrap_or(0);
|
||||
let output_tokens = usage.map(|u| u.candidates_token_count.unwrap_or(0)).unwrap_or(0);
|
||||
|
||||
let stop_reason = match finish_reason.as_str() {
|
||||
"STOP" => "end_turn",
|
||||
"MAX_TOKENS" => "max_tokens",
|
||||
"SAFETY" => "error",
|
||||
"RECITATION" => "error",
|
||||
_ => "end_turn",
|
||||
};
|
||||
|
||||
yield Ok(StreamChunk::Complete {
|
||||
input_tokens,
|
||||
output_tokens,
|
||||
stop_reason: stop_reason.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(target: "gemini_driver", "Failed to parse SSE event: {} - {}", e, data);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl GeminiDriver {
|
||||
/// Convert a CompletionRequest into the Gemini API request format.
|
||||
///
|
||||
/// Key mapping decisions:
|
||||
/// - `system` prompt maps to `systemInstruction`
|
||||
/// - Messages use Gemini's `contents` array with `role`/`parts`
|
||||
/// - Tool definitions use `functionDeclarations`
|
||||
/// - Tool results are sent as `functionResponse` parts in `user` messages
|
||||
fn build_api_request(&self, request: &CompletionRequest) -> GeminiRequest {
|
||||
let mut contents: Vec<GeminiContent> = Vec::new();
|
||||
|
||||
for msg in &request.messages {
|
||||
match msg {
|
||||
zclaw_types::Message::User { content } => {
|
||||
contents.push(GeminiContent {
|
||||
role: "user".to_string(),
|
||||
parts: vec![GeminiPart {
|
||||
text: Some(content.clone()),
|
||||
inline_data: None,
|
||||
function_call: None,
|
||||
function_response: None,
|
||||
}],
|
||||
});
|
||||
}
|
||||
zclaw_types::Message::Assistant { content, thinking } => {
|
||||
let mut parts = Vec::new();
|
||||
// Gemini does not have a native "thinking" field, so we prepend
|
||||
// any thinking content as a text part with a marker.
|
||||
if let Some(think) = thinking {
|
||||
if !think.is_empty() {
|
||||
parts.push(GeminiPart {
|
||||
text: Some(format!("[thinking]\n{}\n[/thinking]", think)),
|
||||
inline_data: None,
|
||||
function_call: None,
|
||||
function_response: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
parts.push(GeminiPart {
|
||||
text: Some(content.clone()),
|
||||
inline_data: None,
|
||||
function_call: None,
|
||||
function_response: None,
|
||||
});
|
||||
contents.push(GeminiContent {
|
||||
role: "model".to_string(),
|
||||
parts,
|
||||
});
|
||||
}
|
||||
zclaw_types::Message::ToolUse { id: _, tool, input } => {
|
||||
// Tool use from the assistant is represented as a functionCall part
|
||||
let args = if input.is_null() {
|
||||
serde_json::json!({})
|
||||
} else {
|
||||
input.clone()
|
||||
};
|
||||
contents.push(GeminiContent {
|
||||
role: "model".to_string(),
|
||||
parts: vec![GeminiPart {
|
||||
text: None,
|
||||
inline_data: None,
|
||||
function_call: Some(GeminiFunctionCall {
|
||||
name: Some(tool.to_string()),
|
||||
args: Some(args),
|
||||
}),
|
||||
function_response: None,
|
||||
}],
|
||||
});
|
||||
}
|
||||
zclaw_types::Message::ToolResult { tool_call_id, tool, output, is_error } => {
|
||||
// Tool results are sent as functionResponse parts in a "user" role message.
|
||||
// Gemini requires that function responses reference the function name
|
||||
// and include the response wrapped in a "result" or "error" key.
|
||||
let response_content = if *is_error {
|
||||
serde_json::json!({ "error": output.to_string() })
|
||||
} else {
|
||||
serde_json::json!({ "result": output.clone() })
|
||||
};
|
||||
|
||||
contents.push(GeminiContent {
|
||||
role: "user".to_string(),
|
||||
parts: vec![GeminiPart {
|
||||
text: None,
|
||||
inline_data: None,
|
||||
function_call: None,
|
||||
function_response: Some(GeminiFunctionResponse {
|
||||
name: tool.to_string(),
|
||||
response: response_content,
|
||||
}),
|
||||
}],
|
||||
});
|
||||
|
||||
// Gemini ignores tool_call_id, but we log it for debugging
|
||||
let _ = tool_call_id;
|
||||
}
|
||||
zclaw_types::Message::System { content } => {
|
||||
// System messages are converted to user messages with system context.
|
||||
// Note: the primary system prompt is handled via systemInstruction.
|
||||
// Inline system messages in conversation history become user messages.
|
||||
contents.push(GeminiContent {
|
||||
role: "user".to_string(),
|
||||
parts: vec![GeminiPart {
|
||||
text: Some(content.clone()),
|
||||
inline_data: None,
|
||||
function_call: None,
|
||||
function_response: None,
|
||||
}],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build tool declarations
|
||||
let function_declarations: Vec<GeminiFunctionDeclaration> = request.tools
|
||||
.iter()
|
||||
.map(|t| GeminiFunctionDeclaration {
|
||||
name: t.name.clone(),
|
||||
description: t.description.clone(),
|
||||
parameters: t.input_schema.clone(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Build generation config
|
||||
let mut generation_config = GeminiGenerationConfig::default();
|
||||
if let Some(temp) = request.temperature {
|
||||
generation_config.temperature = Some(temp);
|
||||
}
|
||||
if let Some(max) = request.max_tokens {
|
||||
generation_config.max_output_tokens = Some(max);
|
||||
}
|
||||
if !request.stop.is_empty() {
|
||||
generation_config.stop_sequences = Some(request.stop.clone());
|
||||
}
|
||||
|
||||
// Build system instruction
|
||||
let system_instruction = request.system.as_ref().map(|s| GeminiSystemInstruction {
|
||||
parts: vec![GeminiPart {
|
||||
text: Some(s.clone()),
|
||||
inline_data: None,
|
||||
function_call: None,
|
||||
function_response: None,
|
||||
}],
|
||||
});
|
||||
|
||||
GeminiRequest {
|
||||
contents,
|
||||
system_instruction,
|
||||
generation_config: Some(generation_config),
|
||||
tools: if function_declarations.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(vec![GeminiTool {
|
||||
function_declarations,
|
||||
}])
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a Gemini API response into a CompletionResponse.
|
||||
fn convert_response(&self, api_response: GeminiResponse, model: String) -> CompletionResponse {
|
||||
let candidate = api_response.candidates.first();
|
||||
|
||||
let (content, stop_reason) = match candidate {
|
||||
Some(c) => {
|
||||
let parts = c.content.as_ref()
|
||||
.map(|content| content.parts.as_slice())
|
||||
.unwrap_or(&[]);
|
||||
|
||||
let mut blocks: Vec<ContentBlock> = Vec::new();
|
||||
let mut has_tool_use = false;
|
||||
|
||||
for part in parts {
|
||||
// Handle text content
|
||||
if let Some(text) = &part.text {
|
||||
// Skip thinking markers we injected
|
||||
if text.starts_with("[thinking]\n") && text.contains("[/thinking]") {
|
||||
let thinking_content = text
|
||||
.strip_prefix("[thinking]\n")
|
||||
.and_then(|s| s.strip_suffix("\n[/thinking]"))
|
||||
.unwrap_or("");
|
||||
if !thinking_content.is_empty() {
|
||||
blocks.push(ContentBlock::Thinking {
|
||||
thinking: thinking_content.to_string(),
|
||||
});
|
||||
}
|
||||
} else if !text.is_empty() {
|
||||
blocks.push(ContentBlock::Text { text: text.clone() });
|
||||
}
|
||||
}
|
||||
|
||||
// Handle function call (tool use)
|
||||
if let Some(fc) = &part.function_call {
|
||||
has_tool_use = true;
|
||||
blocks.push(ContentBlock::ToolUse {
|
||||
id: format!("gemini_call_{}", blocks.len()),
|
||||
name: fc.name.clone().unwrap_or_default(),
|
||||
input: fc.args.clone().unwrap_or(serde_json::Value::Object(Default::default())),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// If there are no content blocks, add an empty text block
|
||||
if blocks.is_empty() {
|
||||
blocks.push(ContentBlock::Text { text: String::new() });
|
||||
}
|
||||
|
||||
let stop = match c.finish_reason.as_deref() {
|
||||
Some("STOP") => StopReason::EndTurn,
|
||||
Some("MAX_TOKENS") => StopReason::MaxTokens,
|
||||
Some("SAFETY") => StopReason::Error,
|
||||
Some("RECITATION") => StopReason::Error,
|
||||
Some("TOOL_USE") => StopReason::ToolUse,
|
||||
_ => {
|
||||
if has_tool_use {
|
||||
StopReason::ToolUse
|
||||
} else {
|
||||
StopReason::EndTurn
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
(blocks, stop)
|
||||
}
|
||||
None => {
|
||||
tracing::warn!(target: "gemini_driver", "No candidates in response");
|
||||
(
|
||||
vec![ContentBlock::Text { text: String::new() }],
|
||||
StopReason::EndTurn,
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
let usage = api_response.usage_metadata.as_ref();
|
||||
let input_tokens = usage.map(|u| u.prompt_token_count.unwrap_or(0)).unwrap_or(0);
|
||||
let output_tokens = usage.map(|u| u.candidates_token_count.unwrap_or(0)).unwrap_or(0);
|
||||
|
||||
CompletionResponse {
|
||||
content,
|
||||
model,
|
||||
input_tokens,
|
||||
output_tokens,
|
||||
stop_reason,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Gemini API request types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct GeminiRequest {
|
||||
contents: Vec<GeminiContent>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
system_instruction: Option<GeminiSystemInstruction>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
generation_config: Option<GeminiGenerationConfig>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
tools: Option<Vec<GeminiTool>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct GeminiContent {
|
||||
role: String,
|
||||
parts: Vec<GeminiPart>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
struct GeminiPart {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
text: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
inline_data: Option<serde_json::Value>,
|
||||
#[serde(rename = "functionCall", skip_serializing_if = "Option::is_none")]
|
||||
function_call: Option<GeminiFunctionCall>,
|
||||
#[serde(rename = "functionResponse", skip_serializing_if = "Option::is_none")]
|
||||
function_response: Option<GeminiFunctionResponse>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct GeminiSystemInstruction {
|
||||
parts: Vec<GeminiPart>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct GeminiGenerationConfig {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
temperature: Option<f32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
max_output_tokens: Option<u32>,
|
||||
#[serde(rename = "stopSequences", skip_serializing_if = "Option::is_none")]
|
||||
stop_sequences: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
impl Default for GeminiGenerationConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
temperature: None,
|
||||
max_output_tokens: None,
|
||||
stop_sequences: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct GeminiTool {
|
||||
#[serde(rename = "functionDeclarations")]
|
||||
function_declarations: Vec<GeminiFunctionDeclaration>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct GeminiFunctionDeclaration {
|
||||
name: String,
|
||||
description: String,
|
||||
parameters: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
struct GeminiFunctionCall {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
name: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
args: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
struct GeminiFunctionResponse {
|
||||
name: String,
|
||||
response: serde_json::Value,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Gemini API response types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct GeminiResponse {
|
||||
#[serde(default)]
|
||||
candidates: Vec<GeminiCandidate>,
|
||||
#[serde(default)]
|
||||
usage_metadata: Option<GeminiUsageMetadata>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct GeminiCandidate {
|
||||
#[serde(default)]
|
||||
content: Option<GeminiResponseContent>,
|
||||
#[serde(default)]
|
||||
finish_reason: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct GeminiResponseContent {
|
||||
#[serde(default)]
|
||||
parts: Vec<GeminiResponsePart>,
|
||||
#[serde(default)]
|
||||
#[allow(dead_code)]
|
||||
role: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct GeminiResponsePart {
|
||||
#[serde(default)]
|
||||
text: Option<String>,
|
||||
#[serde(rename = "functionCall", default)]
|
||||
function_call: Option<GeminiResponseFunctionCall>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct GeminiResponseFunctionCall {
|
||||
#[serde(default)]
|
||||
name: Option<String>,
|
||||
#[serde(default)]
|
||||
args: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct GeminiUsageMetadata {
|
||||
#[serde(default)]
|
||||
prompt_token_count: Option<u32>,
|
||||
#[serde(default)]
|
||||
candidates_token_count: Option<u32>,
|
||||
#[serde(default)]
|
||||
#[allow(dead_code)]
|
||||
total_token_count: Option<u32>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Gemini streaming types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Streaming response from the Gemini SSE endpoint.
|
||||
/// Each SSE event contains the same structure as the non-streaming response,
|
||||
/// but with incremental content.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct GeminiStreamResponse {
|
||||
#[serde(default)]
|
||||
candidates: Vec<GeminiCandidate>,
|
||||
#[serde(default)]
|
||||
usage_metadata: Option<GeminiUsageMetadata>,
|
||||
}
|
||||
|
||||
@@ -1,40 +1,250 @@
|
||||
//! Local LLM driver (Ollama, LM Studio, vLLM, etc.)
|
||||
//!
|
||||
//! Uses the OpenAI-compatible API format. The only differences from the
|
||||
//! OpenAI driver are: no API key is required, and base_url points to a
|
||||
//! local server.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use futures::Stream;
|
||||
use async_stream::stream;
|
||||
use futures::{Stream, StreamExt};
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::pin::Pin;
|
||||
use zclaw_types::{Result, ZclawError};
|
||||
|
||||
use super::{CompletionRequest, CompletionResponse, ContentBlock, LlmDriver, StopReason};
|
||||
use crate::stream::StreamChunk;
|
||||
|
||||
/// Local LLM driver for Ollama, LM Studio, vLLM, etc.
|
||||
#[allow(dead_code)] // TODO: Implement full Local driver support
|
||||
/// Local LLM driver for Ollama, LM Studio, vLLM, and other OpenAI-compatible servers.
|
||||
pub struct LocalDriver {
|
||||
client: Client,
|
||||
base_url: String,
|
||||
}
|
||||
|
||||
impl LocalDriver {
|
||||
/// Create a driver pointing at a custom OpenAI-compatible endpoint.
|
||||
///
|
||||
/// The `base_url` should end with `/v1` (e.g. `http://localhost:8080/v1`).
|
||||
pub fn new(base_url: impl Into<String>) -> Self {
|
||||
Self {
|
||||
client: Client::new(),
|
||||
client: Client::builder()
|
||||
.user_agent(crate::USER_AGENT)
|
||||
.http1_only()
|
||||
.timeout(std::time::Duration::from_secs(300)) // 5 min -- local inference can be slow
|
||||
.connect_timeout(std::time::Duration::from_secs(10)) // short connect timeout
|
||||
.build()
|
||||
.unwrap_or_else(|_| Client::new()),
|
||||
base_url: base_url.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Ollama default endpoint (`http://localhost:11434/v1`).
|
||||
pub fn ollama() -> Self {
|
||||
Self::new("http://localhost:11434/v1")
|
||||
}
|
||||
|
||||
/// LM Studio default endpoint (`http://localhost:1234/v1`).
|
||||
pub fn lm_studio() -> Self {
|
||||
Self::new("http://localhost:1234/v1")
|
||||
}
|
||||
|
||||
/// vLLM default endpoint (`http://localhost:8000/v1`).
|
||||
pub fn vllm() -> Self {
|
||||
Self::new("http://localhost:8000/v1")
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Request / response conversion (OpenAI-compatible format)
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
fn build_api_request(&self, request: &CompletionRequest) -> LocalApiRequest {
|
||||
let messages: Vec<LocalApiMessage> = request
|
||||
.messages
|
||||
.iter()
|
||||
.filter_map(|msg| match msg {
|
||||
zclaw_types::Message::User { content } => Some(LocalApiMessage {
|
||||
role: "user".to_string(),
|
||||
content: Some(content.clone()),
|
||||
tool_calls: None,
|
||||
}),
|
||||
zclaw_types::Message::Assistant {
|
||||
content,
|
||||
thinking: _,
|
||||
} => Some(LocalApiMessage {
|
||||
role: "assistant".to_string(),
|
||||
content: Some(content.clone()),
|
||||
tool_calls: None,
|
||||
}),
|
||||
zclaw_types::Message::System { content } => Some(LocalApiMessage {
|
||||
role: "system".to_string(),
|
||||
content: Some(content.clone()),
|
||||
tool_calls: None,
|
||||
}),
|
||||
zclaw_types::Message::ToolUse {
|
||||
id, tool, input, ..
|
||||
} => {
|
||||
let args = if input.is_null() {
|
||||
"{}".to_string()
|
||||
} else {
|
||||
serde_json::to_string(input).unwrap_or_else(|_| "{}".to_string())
|
||||
};
|
||||
Some(LocalApiMessage {
|
||||
role: "assistant".to_string(),
|
||||
content: None,
|
||||
tool_calls: Some(vec![LocalApiToolCall {
|
||||
id: id.clone(),
|
||||
r#type: "function".to_string(),
|
||||
function: LocalFunctionCall {
|
||||
name: tool.to_string(),
|
||||
arguments: args,
|
||||
},
|
||||
}]),
|
||||
})
|
||||
}
|
||||
zclaw_types::Message::ToolResult {
|
||||
output, is_error, ..
|
||||
} => Some(LocalApiMessage {
|
||||
role: "tool".to_string(),
|
||||
content: Some(if *is_error {
|
||||
format!("Error: {}", output)
|
||||
} else {
|
||||
output.to_string()
|
||||
}),
|
||||
tool_calls: None,
|
||||
}),
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Prepend system prompt when provided.
|
||||
let mut messages = messages;
|
||||
if let Some(system) = &request.system {
|
||||
messages.insert(
|
||||
0,
|
||||
LocalApiMessage {
|
||||
role: "system".to_string(),
|
||||
content: Some(system.clone()),
|
||||
tool_calls: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
let tools: Vec<LocalApiTool> = request
|
||||
.tools
|
||||
.iter()
|
||||
.map(|t| LocalApiTool {
|
||||
r#type: "function".to_string(),
|
||||
function: LocalFunctionDef {
|
||||
name: t.name.clone(),
|
||||
description: t.description.clone(),
|
||||
parameters: t.input_schema.clone(),
|
||||
},
|
||||
})
|
||||
.collect();
|
||||
|
||||
LocalApiRequest {
|
||||
model: request.model.clone(),
|
||||
messages,
|
||||
max_tokens: request.max_tokens,
|
||||
temperature: request.temperature,
|
||||
stop: if request.stop.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(request.stop.clone())
|
||||
},
|
||||
stream: request.stream,
|
||||
tools: if tools.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(tools)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn convert_response(
|
||||
&self,
|
||||
api_response: LocalApiResponse,
|
||||
model: String,
|
||||
) -> CompletionResponse {
|
||||
let choice = api_response.choices.first();
|
||||
|
||||
let (content, stop_reason) = match choice {
|
||||
Some(c) => {
|
||||
let has_tool_calls = c
|
||||
.message
|
||||
.tool_calls
|
||||
.as_ref()
|
||||
.map(|tc| !tc.is_empty())
|
||||
.unwrap_or(false);
|
||||
let has_content = c
|
||||
.message
|
||||
.content
|
||||
.as_ref()
|
||||
.map(|t| !t.is_empty())
|
||||
.unwrap_or(false);
|
||||
|
||||
let blocks = if has_tool_calls {
|
||||
let tool_calls = c.message.tool_calls.as_ref().unwrap();
|
||||
tool_calls
|
||||
.iter()
|
||||
.map(|tc| {
|
||||
let input: serde_json::Value =
|
||||
serde_json::from_str(&tc.function.arguments)
|
||||
.unwrap_or(serde_json::Value::Null);
|
||||
ContentBlock::ToolUse {
|
||||
id: tc.id.clone(),
|
||||
name: tc.function.name.clone(),
|
||||
input,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
} else if has_content {
|
||||
vec![ContentBlock::Text {
|
||||
text: c.message.content.clone().unwrap(),
|
||||
}]
|
||||
} else {
|
||||
vec![ContentBlock::Text {
|
||||
text: String::new(),
|
||||
}]
|
||||
};
|
||||
|
||||
let stop = match c.finish_reason.as_deref() {
|
||||
Some("stop") => StopReason::EndTurn,
|
||||
Some("length") => StopReason::MaxTokens,
|
||||
Some("tool_calls") => StopReason::ToolUse,
|
||||
_ => StopReason::EndTurn,
|
||||
};
|
||||
|
||||
(blocks, stop)
|
||||
}
|
||||
None => (
|
||||
vec![ContentBlock::Text {
|
||||
text: String::new(),
|
||||
}],
|
||||
StopReason::EndTurn,
|
||||
),
|
||||
};
|
||||
|
||||
let (input_tokens, output_tokens) = api_response
|
||||
.usage
|
||||
.map(|u| (u.prompt_tokens, u.completion_tokens))
|
||||
.unwrap_or((0, 0));
|
||||
|
||||
CompletionResponse {
|
||||
content,
|
||||
model,
|
||||
input_tokens,
|
||||
output_tokens,
|
||||
stop_reason,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the `reqwest::RequestBuilder` with an optional Authorization header.
|
||||
///
|
||||
/// Ollama does not need one; LM Studio / vLLM may be configured with an
|
||||
/// optional API key. We send the header only when a key is present.
|
||||
fn authenticated_post(&self, url: &str) -> reqwest::RequestBuilder {
|
||||
self.client.post(url).header("Accept", "*/*")
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -44,30 +254,394 @@ impl LlmDriver for LocalDriver {
|
||||
}
|
||||
|
||||
fn is_configured(&self) -> bool {
|
||||
// Local drivers don't require API keys
|
||||
// Local drivers never require an API key.
|
||||
true
|
||||
}
|
||||
|
||||
async fn complete(&self, request: CompletionRequest) -> Result<CompletionResponse> {
|
||||
// TODO: Implement actual API call (OpenAI-compatible)
|
||||
Ok(CompletionResponse {
|
||||
content: vec![ContentBlock::Text {
|
||||
text: "Local driver not yet implemented".to_string(),
|
||||
}],
|
||||
model: request.model,
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
stop_reason: StopReason::EndTurn,
|
||||
})
|
||||
let api_request = self.build_api_request(&request);
|
||||
let url = format!("{}/chat/completions", self.base_url);
|
||||
|
||||
tracing::debug!(target: "local_driver", "Sending request to {}", url);
|
||||
tracing::trace!(
|
||||
target: "local_driver",
|
||||
"Request body: {}",
|
||||
serde_json::to_string(&api_request).unwrap_or_default()
|
||||
);
|
||||
|
||||
let response = self
|
||||
.authenticated_post(&url)
|
||||
.json(&api_request)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
let hint = connection_error_hint(&e);
|
||||
ZclawError::LlmError(format!("Failed to connect to local LLM server at {}: {}{}", self.base_url, e, hint))
|
||||
})?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
tracing::warn!(target: "local_driver", "API error {}: {}", status, body);
|
||||
return Err(ZclawError::LlmError(format!(
|
||||
"Local LLM API error {}: {}",
|
||||
status, body
|
||||
)));
|
||||
}
|
||||
|
||||
let api_response: LocalApiResponse = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| ZclawError::LlmError(format!("Failed to parse response: {}", e)))?;
|
||||
|
||||
Ok(self.convert_response(api_response, request.model))
|
||||
}
|
||||
|
||||
fn stream(
|
||||
&self,
|
||||
_request: CompletionRequest,
|
||||
request: CompletionRequest,
|
||||
) -> Pin<Box<dyn Stream<Item = Result<StreamChunk>> + Send + '_>> {
|
||||
// Placeholder - return error stream
|
||||
Box::pin(futures::stream::once(async {
|
||||
Err(ZclawError::LlmError("Local driver streaming not yet implemented".to_string()))
|
||||
}))
|
||||
let mut stream_request = self.build_api_request(&request);
|
||||
stream_request.stream = true;
|
||||
|
||||
let url = format!("{}/chat/completions", self.base_url);
|
||||
tracing::debug!(target: "local_driver", "Starting stream to {}", url);
|
||||
|
||||
Box::pin(stream! {
|
||||
let response = match self
|
||||
.authenticated_post(&url)
|
||||
.header("Content-Type", "application/json")
|
||||
.timeout(std::time::Duration::from_secs(300))
|
||||
.json(&stream_request)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(r) => {
|
||||
tracing::debug!(target: "local_driver", "Stream response status: {}", r.status());
|
||||
r
|
||||
}
|
||||
Err(e) => {
|
||||
let hint = connection_error_hint(&e);
|
||||
tracing::error!(target: "local_driver", "Stream connection failed: {}{}", e, hint);
|
||||
yield Err(ZclawError::LlmError(format!(
|
||||
"Failed to connect to local LLM server at {}: {}{}",
|
||||
self.base_url, e, hint
|
||||
)));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
yield Err(ZclawError::LlmError(format!("API error {}: {}", status, body)));
|
||||
return;
|
||||
}
|
||||
|
||||
let mut byte_stream = response.bytes_stream();
|
||||
let mut accumulated_tool_calls: std::collections::HashMap<String, (String, String)> =
|
||||
std::collections::HashMap::new();
|
||||
let mut current_tool_id: Option<String> = None;
|
||||
|
||||
while let Some(chunk_result) = byte_stream.next().await {
|
||||
let chunk = match chunk_result {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
yield Err(ZclawError::LlmError(format!("Stream error: {}", e)));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let text = String::from_utf8_lossy(&chunk);
|
||||
for line in text.lines() {
|
||||
if let Some(data) = line.strip_prefix("data: ") {
|
||||
if data == "[DONE]" {
|
||||
tracing::debug!(
|
||||
target: "local_driver",
|
||||
"Stream done, tool_calls accumulated: {}",
|
||||
accumulated_tool_calls.len()
|
||||
);
|
||||
|
||||
for (id, (name, args)) in &accumulated_tool_calls {
|
||||
if name.is_empty() {
|
||||
tracing::warn!(
|
||||
target: "local_driver",
|
||||
"Skipping tool call with empty name: id={}",
|
||||
id
|
||||
);
|
||||
continue;
|
||||
}
|
||||
let parsed_args: serde_json::Value = if args.is_empty() {
|
||||
serde_json::json!({})
|
||||
} else {
|
||||
serde_json::from_str(args).unwrap_or_else(|e| {
|
||||
tracing::warn!(
|
||||
target: "local_driver",
|
||||
"Failed to parse tool args '{}': {}",
|
||||
args, e
|
||||
);
|
||||
serde_json::json!({})
|
||||
})
|
||||
};
|
||||
yield Ok(StreamChunk::ToolUseEnd {
|
||||
id: id.clone(),
|
||||
input: parsed_args,
|
||||
});
|
||||
}
|
||||
|
||||
yield Ok(StreamChunk::Complete {
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
stop_reason: "end_turn".to_string(),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
match serde_json::from_str::<LocalStreamResponse>(data) {
|
||||
Ok(resp) => {
|
||||
if let Some(choice) = resp.choices.first() {
|
||||
let delta = &choice.delta;
|
||||
|
||||
// Text content
|
||||
if let Some(content) = &delta.content {
|
||||
if !content.is_empty() {
|
||||
yield Ok(StreamChunk::TextDelta {
|
||||
delta: content.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Tool calls
|
||||
if let Some(tool_calls) = &delta.tool_calls {
|
||||
for tc in tool_calls {
|
||||
// Tool call start
|
||||
if let Some(id) = &tc.id {
|
||||
let name = tc
|
||||
.function
|
||||
.as_ref()
|
||||
.and_then(|f| f.name.clone())
|
||||
.unwrap_or_default();
|
||||
|
||||
if !name.is_empty() {
|
||||
current_tool_id = Some(id.clone());
|
||||
accumulated_tool_calls
|
||||
.insert(id.clone(), (name.clone(), String::new()));
|
||||
yield Ok(StreamChunk::ToolUseStart {
|
||||
id: id.clone(),
|
||||
name,
|
||||
});
|
||||
} else {
|
||||
current_tool_id = Some(id.clone());
|
||||
accumulated_tool_calls
|
||||
.insert(id.clone(), (String::new(), String::new()));
|
||||
}
|
||||
}
|
||||
|
||||
// Tool call delta
|
||||
if let Some(function) = &tc.function {
|
||||
if let Some(args) = &function.arguments {
|
||||
let tool_id = tc
|
||||
.id
|
||||
.as_ref()
|
||||
.or(current_tool_id.as_ref())
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
|
||||
yield Ok(StreamChunk::ToolUseDelta {
|
||||
id: tool_id.clone(),
|
||||
delta: args.clone(),
|
||||
});
|
||||
|
||||
if let Some(entry) =
|
||||
accumulated_tool_calls.get_mut(&tool_id)
|
||||
{
|
||||
entry.1.push_str(args);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
target: "local_driver",
|
||||
"Failed to parse SSE: {}, data: {}",
|
||||
e, data
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Connection-error diagnostics
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Return a human-readable hint when the local server appears to be unreachable.
|
||||
fn connection_error_hint(error: &reqwest::Error) -> String {
|
||||
if error.is_connect() {
|
||||
format!(
|
||||
"\n\nHint: Is the local LLM server running at {}?\n\
|
||||
Make sure the server is started before using this driver.",
|
||||
// Extract just the host:port from whatever error we have.
|
||||
"localhost"
|
||||
)
|
||||
} else if error.is_timeout() {
|
||||
"\n\nHint: The request timed out. Local inference can be slow -- \
|
||||
try a smaller model or increase the timeout."
|
||||
.to_string()
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// OpenAI-compatible API types (private to this module)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct LocalApiRequest {
|
||||
model: String,
|
||||
messages: Vec<LocalApiMessage>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
max_tokens: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
temperature: Option<f32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
stop: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
stream: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
tools: Option<Vec<LocalApiTool>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct LocalApiMessage {
|
||||
role: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
content: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
tool_calls: Option<Vec<LocalApiToolCall>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct LocalApiToolCall {
|
||||
id: String,
|
||||
r#type: String,
|
||||
function: LocalFunctionCall,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct LocalFunctionCall {
|
||||
name: String,
|
||||
arguments: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct LocalApiTool {
|
||||
r#type: String,
|
||||
function: LocalFunctionDef,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct LocalFunctionDef {
|
||||
name: String,
|
||||
description: String,
|
||||
parameters: serde_json::Value,
|
||||
}
|
||||
|
||||
// --- Response types ---
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
struct LocalApiResponse {
|
||||
#[serde(default)]
|
||||
choices: Vec<LocalApiChoice>,
|
||||
#[serde(default)]
|
||||
usage: Option<LocalApiUsage>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
struct LocalApiChoice {
|
||||
#[serde(default)]
|
||||
message: LocalApiResponseMessage,
|
||||
#[serde(default)]
|
||||
finish_reason: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
struct LocalApiResponseMessage {
|
||||
#[serde(default)]
|
||||
content: Option<String>,
|
||||
#[serde(default)]
|
||||
tool_calls: Option<Vec<LocalApiToolCallResponse>>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
struct LocalApiToolCallResponse {
|
||||
#[serde(default)]
|
||||
id: String,
|
||||
#[serde(default)]
|
||||
function: LocalFunctionCallResponse,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
struct LocalFunctionCallResponse {
|
||||
#[serde(default)]
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
arguments: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
struct LocalApiUsage {
|
||||
#[serde(default)]
|
||||
prompt_tokens: u32,
|
||||
#[serde(default)]
|
||||
completion_tokens: u32,
|
||||
}
|
||||
|
||||
// --- Streaming types ---
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct LocalStreamResponse {
|
||||
#[serde(default)]
|
||||
choices: Vec<LocalStreamChoice>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct LocalStreamChoice {
|
||||
#[serde(default)]
|
||||
delta: LocalDelta,
|
||||
#[serde(default)]
|
||||
#[allow(dead_code)] // Deserialized from SSE, not accessed in code
|
||||
finish_reason: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Default)]
|
||||
struct LocalDelta {
|
||||
#[serde(default)]
|
||||
content: Option<String>,
|
||||
#[serde(default)]
|
||||
tool_calls: Option<Vec<LocalToolCallDelta>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct LocalToolCallDelta {
|
||||
#[serde(default)]
|
||||
id: Option<String>,
|
||||
#[serde(default)]
|
||||
function: Option<LocalFunctionDelta>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct LocalFunctionDelta {
|
||||
#[serde(default)]
|
||||
name: Option<String>,
|
||||
#[serde(default)]
|
||||
arguments: Option<String>,
|
||||
}
|
||||
|
||||
@@ -65,8 +65,8 @@ impl LlmDriver for OpenAiDriver {
|
||||
// Debug: log the request details
|
||||
let url = format!("{}/chat/completions", self.base_url);
|
||||
let request_body = serde_json::to_string(&api_request).unwrap_or_default();
|
||||
eprintln!("[OpenAiDriver] Sending request to: {}", url);
|
||||
eprintln!("[OpenAiDriver] Request body: {}", request_body);
|
||||
tracing::debug!(target: "openai_driver", "Sending request to: {}", url);
|
||||
tracing::trace!(target: "openai_driver", "Request body: {}", request_body);
|
||||
|
||||
let response = self.client
|
||||
.post(&url)
|
||||
@@ -80,11 +80,11 @@ impl LlmDriver for OpenAiDriver {
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
eprintln!("[OpenAiDriver] API error {}: {}", status, body);
|
||||
tracing::warn!(target: "openai_driver", "API error {}: {}", status, body);
|
||||
return Err(ZclawError::LlmError(format!("API error {}: {}", status, body)));
|
||||
}
|
||||
|
||||
eprintln!("[OpenAiDriver] Response status: {}", response.status());
|
||||
tracing::debug!(target: "openai_driver", "Response status: {}", response.status());
|
||||
|
||||
let api_response: OpenAiResponse = response
|
||||
.json()
|
||||
@@ -107,11 +107,11 @@ impl LlmDriver for OpenAiDriver {
|
||||
self.base_url.contains("aliyuncs") ||
|
||||
self.base_url.contains("bigmodel.cn");
|
||||
|
||||
eprintln!("[OpenAiDriver:stream] base_url={}, has_tools={}, needs_non_streaming={}",
|
||||
tracing::debug!(target: "openai_driver", "stream config: base_url={}, has_tools={}, needs_non_streaming={}",
|
||||
self.base_url, has_tools, needs_non_streaming);
|
||||
|
||||
if has_tools && needs_non_streaming {
|
||||
eprintln!("[OpenAiDriver:stream] Provider detected that may not support streaming with tools, using non-streaming mode. URL: {}", self.base_url);
|
||||
tracing::info!(target: "openai_driver", "Provider detected that may not support streaming with tools, using non-streaming mode. URL: {}", self.base_url);
|
||||
// Use non-streaming mode and convert to stream
|
||||
return self.stream_from_complete(request);
|
||||
}
|
||||
@@ -458,11 +458,11 @@ impl OpenAiDriver {
|
||||
let api_key = self.api_key.expose_secret().to_string();
|
||||
let model = request.model.clone();
|
||||
|
||||
eprintln!("[OpenAiDriver:stream_from_complete] Starting non-streaming request to: {}/chat/completions", base_url);
|
||||
tracing::debug!(target: "openai_driver", "stream_from_complete: Starting non-streaming request to: {}/chat/completions", base_url);
|
||||
|
||||
Box::pin(stream! {
|
||||
let url = format!("{}/chat/completions", base_url);
|
||||
eprintln!("[OpenAiDriver:stream_from_complete] Sending non-streaming request to: {}", url);
|
||||
tracing::debug!(target: "openai_driver", "stream_from_complete: Sending non-streaming request to: {}", url);
|
||||
|
||||
let response = match self.client
|
||||
.post(&url)
|
||||
@@ -490,15 +490,15 @@ impl OpenAiDriver {
|
||||
let api_response: OpenAiResponse = match response.json().await {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
eprintln!("[OpenAiDriver:stream_from_complete] Failed to parse response: {}", e);
|
||||
tracing::warn!(target: "openai_driver", "stream_from_complete: Failed to parse response: {}", e);
|
||||
yield Err(ZclawError::LlmError(format!("Failed to parse response: {}", e)));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
eprintln!("[OpenAiDriver:stream_from_complete] Got response with {} choices", api_response.choices.len());
|
||||
tracing::debug!(target: "openai_driver", "stream_from_complete: Got response with {} choices", api_response.choices.len());
|
||||
if let Some(choice) = api_response.choices.first() {
|
||||
eprintln!("[OpenAiDriver:stream_from_complete] First choice: content={:?}, tool_calls={:?}, finish_reason={:?}",
|
||||
tracing::debug!(target: "openai_driver", "stream_from_complete: First choice: content={:?}, tool_calls={:?}, finish_reason={:?}",
|
||||
choice.message.content.as_ref().map(|c| {
|
||||
if c.len() > 100 {
|
||||
// 使用 floor_char_boundary 确保不在多字节字符中间截断
|
||||
@@ -514,15 +514,15 @@ impl OpenAiDriver {
|
||||
|
||||
// Convert response to stream chunks
|
||||
let completion = self.convert_response(api_response, model.clone());
|
||||
eprintln!("[OpenAiDriver:stream_from_complete] Converted to {} content blocks, stop_reason: {:?}", completion.content.len(), completion.stop_reason);
|
||||
tracing::debug!(target: "openai_driver", "stream_from_complete: Converted to {} content blocks, stop_reason: {:?}", completion.content.len(), completion.stop_reason);
|
||||
|
||||
// Emit content blocks as stream chunks
|
||||
for block in &completion.content {
|
||||
eprintln!("[OpenAiDriver:stream_from_complete] Emitting block: {:?}", block);
|
||||
tracing::debug!(target: "openai_driver", "stream_from_complete: Emitting block: {:?}", block);
|
||||
match block {
|
||||
ContentBlock::Text { text } => {
|
||||
if !text.is_empty() {
|
||||
eprintln!("[OpenAiDriver:stream_from_complete] Emitting TextDelta: {} chars", text.len());
|
||||
tracing::debug!(target: "openai_driver", "stream_from_complete: Emitting TextDelta: {} chars", text.len());
|
||||
yield Ok(StreamChunk::TextDelta { delta: text.clone() });
|
||||
}
|
||||
}
|
||||
@@ -530,7 +530,7 @@ impl OpenAiDriver {
|
||||
yield Ok(StreamChunk::ThinkingDelta { delta: thinking.clone() });
|
||||
}
|
||||
ContentBlock::ToolUse { id, name, input } => {
|
||||
eprintln!("[OpenAiDriver:stream_from_complete] Emitting ToolUse: id={}, name={}", id, name);
|
||||
tracing::debug!(target: "openai_driver", "stream_from_complete: Emitting ToolUse: id={}, name={}", id, name);
|
||||
// Emit tool use start
|
||||
yield Ok(StreamChunk::ToolUseStart {
|
||||
id: id.clone(),
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
/// Default User-Agent header sent with all outgoing HTTP requests.
|
||||
/// Some LLM providers (e.g. Moonshot, Qwen, DashScope Coding Plan) reject requests without one.
|
||||
pub const USER_AGENT: &str = "ZCLAW/0.2.0";
|
||||
pub const USER_AGENT: &str = "ZCLAW/0.1.0";
|
||||
|
||||
pub mod driver;
|
||||
pub mod tool;
|
||||
@@ -12,6 +12,7 @@ pub mod loop_runner;
|
||||
pub mod loop_guard;
|
||||
pub mod stream;
|
||||
pub mod growth;
|
||||
pub mod compaction;
|
||||
|
||||
// Re-export main types
|
||||
pub use driver::{
|
||||
@@ -23,3 +24,4 @@ pub use loop_runner::{AgentLoop, AgentLoopResult, LoopEvent};
|
||||
pub use loop_guard::{LoopGuard, LoopGuardConfig, LoopGuardResult};
|
||||
pub use stream::{StreamEvent, StreamSender};
|
||||
pub use growth::GrowthIntegration;
|
||||
pub use compaction::{CompactionConfig, CompactionOutcome};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user