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
|
*.exe
|
||||||
*.pdb
|
*.pdb
|
||||||
|
|
||||||
#test
|
# Test
|
||||||
desktop/test-results/
|
desktop/test-results/
|
||||||
|
desktop/tests/e2e/test-results/
|
||||||
|
desktop/coverage/
|
||||||
.gstack/
|
.gstack/
|
||||||
.trae/
|
.trae/
|
||||||
target/debug/
|
target/debug/
|
||||||
target/release/
|
target/release/
|
||||||
|
|
||||||
|
# Coverage
|
||||||
|
coverage/
|
||||||
|
*.lcov
|
||||||
|
|
||||||
|
# Session plans
|
||||||
|
plans/
|
||||||
|
|
||||||
|
# Build artifacts
|
||||||
|
desktop/msi-smoke/
|
||||||
|
|||||||
67
CHANGELOG.md
Normal file
67
CHANGELOG.md
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to ZCLAW will be documented in this file.
|
||||||
|
|
||||||
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.1.0] - 2026-03-26
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
#### 核心功能
|
||||||
|
- 多模型 AI 对话,支持流式响应(Anthropic、OpenAI 兼容)
|
||||||
|
- Agent 分身管理(创建、配置、切换)
|
||||||
|
- Hands 自主能力(Browser、Collector、Researcher、Predictor、Lead、Clip、Twitter、Whiteboard、Slideshow、Speech、Quiz)
|
||||||
|
- 可视化工作流编辑器(React Flow)
|
||||||
|
- 技能系统(SKILL.md 定义)
|
||||||
|
- Agent Growth 记忆系统(语义提取、检索、注入)
|
||||||
|
- Pipeline 执行引擎(条件分支、并行执行)
|
||||||
|
- MCP 协议支持
|
||||||
|
- A2A 进程内通信
|
||||||
|
- OS Keyring 安全存储
|
||||||
|
- 加密聊天存储
|
||||||
|
- 离线消息队列
|
||||||
|
- 浏览器自动化
|
||||||
|
|
||||||
|
#### 安全
|
||||||
|
- Content Security Policy 启用
|
||||||
|
- Web fetch SSRF 防护
|
||||||
|
- 路径验证(default-deny 策略)
|
||||||
|
- Shell 命令白名单和危险命令黑名单
|
||||||
|
- API Key 通过 secrecy crate 保护
|
||||||
|
|
||||||
|
#### 基础设施
|
||||||
|
- GitHub Actions CI 流水线(lint、test、build)
|
||||||
|
- GitHub Actions Release 流水线(tag 触发、NSIS 安装包)
|
||||||
|
- Workspace 统一版本管理
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
- Valtio/XState 双轨状态管理层(未完成的迁移)
|
||||||
|
- Stub Channel 适配器(Telegram、Discord、Slack)
|
||||||
|
- 未使用的 Store(meshStore、personaStore)
|
||||||
|
- 不完整的 ActiveLearningPanel 和 skillMarketStore
|
||||||
|
- 未使用的 Feedback 组件目录
|
||||||
|
- Team(团队)和 Swarm(协作)功能(~8,100 行前端代码,零后端支持,Pipeline 系统已覆盖其全部能力)
|
||||||
|
- 调试日志清理(~310 处 console/println 语句)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 版本说明
|
||||||
|
|
||||||
|
### 版本号格式
|
||||||
|
|
||||||
|
- **主版本号**: 重大架构变更或不兼容的 API 修改
|
||||||
|
- **次版本号**: 向后兼容的功能新增
|
||||||
|
- **修订号**: 向后兼容的问题修复
|
||||||
|
|
||||||
|
### 变更类型
|
||||||
|
|
||||||
|
- `Added`: 新增功能
|
||||||
|
- `Changed`: 功能变更
|
||||||
|
- `Deprecated`: 即将废弃的功能
|
||||||
|
- `Removed`: 已移除的功能
|
||||||
|
- `Fixed`: 问题修复
|
||||||
|
- `Security`: 安全相关修复
|
||||||
15
CLAUDE.md
15
CLAUDE.md
@@ -36,7 +36,7 @@ ZCLAW/
|
|||||||
│ ├── zclaw-kernel/ # L4: 核心协调 (注册, 调度, 事件, 工作流)
|
│ ├── zclaw-kernel/ # L4: 核心协调 (注册, 调度, 事件, 工作流)
|
||||||
│ ├── zclaw-skills/ # 技能系统 (SKILL.md解析, 执行器)
|
│ ├── zclaw-skills/ # 技能系统 (SKILL.md解析, 执行器)
|
||||||
│ ├── zclaw-hands/ # 自主能力 (Hand/Trigger 注册管理)
|
│ ├── zclaw-hands/ # 自主能力 (Hand/Trigger 注册管理)
|
||||||
│ ├── zclaw-channels/ # 通道适配器 (Telegram, Discord, Slack)
|
│ ├── zclaw-channels/ # 通道适配器 (仅 ConsoleChannel 测试适配器)
|
||||||
│ └── zclaw-protocols/ # 协议支持 (MCP, A2A)
|
│ └── zclaw-protocols/ # 协议支持 (MCP, A2A)
|
||||||
├── desktop/ # Tauri 桌面应用
|
├── desktop/ # Tauri 桌面应用
|
||||||
│ ├── src/
|
│ ├── src/
|
||||||
@@ -175,24 +175,27 @@ Client → 负责网络通信和```
|
|||||||
| 分身管理 | ✅ 完成 | 创建、配置、切换 Agent |
|
| 分身管理 | ✅ 完成 | 创建、配置、切换 Agent |
|
||||||
| 自动化面板 | ✅ 完成 | Hands 触发、参数配置 |
|
| 自动化面板 | ✅ 完成 | Hands 触发、参数配置 |
|
||||||
| 技能市场 | 🚧 进行中 | 技能浏览和安装 |
|
| 技能市场 | 🚧 进行中 | 技能浏览和安装 |
|
||||||
| 工作流编辑 | 📋 计划中 | 多步骤任务编排 |
|
| 工作流编辑 | 🚧 进行中 | Pipeline 工作流编辑器 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. 自主能力系统 (Hands)
|
## 6. 自主能力系统 (Hands)
|
||||||
|
|
||||||
ZCLAW 提供 8 个自主能力包:
|
ZCLAW 提供 11 个自主能力包:
|
||||||
|
|
||||||
| Hand | 功能 | 状态 |
|
| Hand | 功能 | 状态 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| Browser | 浏览器自动化 | ✅ 可用 |
|
| Browser | 浏览器自动化 | ✅ 可用 |
|
||||||
| Collector | 数据收集聚合 | ✅ 可用 |
|
| Collector | 数据收集聚合 | ✅ 可用 |
|
||||||
| Researcher | 深度研究 | ✅ 可用 |
|
| Researcher | 深度研究 | ✅ 可用 |
|
||||||
| Predictor | 预测分析 | ✅ 可用 |
|
| Predictor | 预测分析 | ❌ 已禁用 (enabled=false),无 Rust 实现 |
|
||||||
| Lead | 销售线索发现 | ✅ 可用 |
|
| Lead | 销售线索发现 | ❌ 已禁用 (enabled=false),无 Rust 实现 |
|
||||||
| Trader | 交易分析 | ✅ 可用 |
|
|
||||||
| Clip | 视频处理 | ⚠️ 需 FFmpeg |
|
| Clip | 视频处理 | ⚠️ 需 FFmpeg |
|
||||||
| Twitter | Twitter 自动化 | ⚠️ 需 API Key |
|
| Twitter | Twitter 自动化 | ⚠️ 需 API Key |
|
||||||
|
| Whiteboard | 白板演示 | ✅ 可用(导出功能开发中,标注 demo) |
|
||||||
|
| Slideshow | 幻灯片生成 | ✅ 可用 |
|
||||||
|
| Speech | 语音合成 | ✅ 可用 |
|
||||||
|
| Quiz | 测验生成 | ✅ 可用 |
|
||||||
|
|
||||||
**触发 Hand 时:**
|
**触发 Hand 时:**
|
||||||
1. 检查依赖是否满足
|
1. 检查依赖是否满足
|
||||||
|
|||||||
583
Cargo.lock
generated
583
Cargo.lock
generated
@@ -110,6 +110,18 @@ dependencies = [
|
|||||||
"derive_arbitrary",
|
"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]]
|
[[package]]
|
||||||
name = "async-broadcast"
|
name = "async-broadcast"
|
||||||
version = "0.7.2"
|
version = "0.7.2"
|
||||||
@@ -315,6 +327,7 @@ checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"axum-core",
|
"axum-core",
|
||||||
|
"axum-macros",
|
||||||
"bytes",
|
"bytes",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"http 1.4.0",
|
"http 1.4.0",
|
||||||
@@ -335,7 +348,7 @@ dependencies = [
|
|||||||
"serde_urlencoded",
|
"serde_urlencoded",
|
||||||
"sync_wrapper",
|
"sync_wrapper",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tower",
|
"tower 0.5.3",
|
||||||
"tower-layer",
|
"tower-layer",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
"tracing",
|
"tracing",
|
||||||
@@ -362,6 +375,47 @@ dependencies = [
|
|||||||
"tracing",
|
"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]]
|
[[package]]
|
||||||
name = "base64"
|
name = "base64"
|
||||||
version = "0.21.7"
|
version = "0.21.7"
|
||||||
@@ -410,6 +464,15 @@ dependencies = [
|
|||||||
"serde_core",
|
"serde_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "blake2"
|
||||||
|
version = "0.10.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
|
||||||
|
dependencies = [
|
||||||
|
"digest",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "block-buffer"
|
name = "block-buffer"
|
||||||
version = "0.10.4"
|
version = "0.10.4"
|
||||||
@@ -654,6 +717,12 @@ version = "0.9.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
|
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "constant_time_eq"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "convert_case"
|
name = "convert_case"
|
||||||
version = "0.4.0"
|
version = "0.4.0"
|
||||||
@@ -896,6 +965,12 @@ dependencies = [
|
|||||||
"parking_lot_core",
|
"parking_lot_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "data-encoding"
|
||||||
|
version = "2.10.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "der"
|
name = "der"
|
||||||
version = "0.7.10"
|
version = "0.7.10"
|
||||||
@@ -975,6 +1050,7 @@ dependencies = [
|
|||||||
"fantoccini",
|
"fantoccini",
|
||||||
"futures",
|
"futures",
|
||||||
"keyring",
|
"keyring",
|
||||||
|
"libsqlite3-sys",
|
||||||
"rand 0.8.5",
|
"rand 0.8.5",
|
||||||
"regex",
|
"regex",
|
||||||
"reqwest 0.12.28",
|
"reqwest 0.12.28",
|
||||||
@@ -1149,9 +1225,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "embed-resource"
|
name = "embed-resource"
|
||||||
version = "3.0.7"
|
version = "3.0.8"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "47ec73ddcf6b7f23173d5c3c5a32b5507dc0a734de7730aa14abc5d5e296bb5f"
|
checksum = "63a1d0de4f2249aa0ff5884d7080814f446bb241a559af6c170a41e878ed2d45"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cc",
|
"cc",
|
||||||
"memchr",
|
"memchr",
|
||||||
@@ -1167,6 +1243,15 @@ version = "1.2.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
|
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]]
|
[[package]]
|
||||||
name = "endi"
|
name = "endi"
|
||||||
version = "1.1.1"
|
version = "1.1.1"
|
||||||
@@ -1893,6 +1978,30 @@ dependencies = [
|
|||||||
"hashbrown 0.14.5",
|
"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]]
|
[[package]]
|
||||||
name = "heck"
|
name = "heck"
|
||||||
version = "0.4.1"
|
version = "0.4.1"
|
||||||
@@ -2300,9 +2409,9 @@ checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "iri-string"
|
name = "iri-string"
|
||||||
version = "0.7.10"
|
version = "0.7.11"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a"
|
checksum = "d8e7418f59cc01c88316161279a7f665217ae316b388e58a0d10e29f54f1e5eb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"memchr",
|
"memchr",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -2365,7 +2474,7 @@ dependencies = [
|
|||||||
"cesu8",
|
"cesu8",
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"combine",
|
"combine",
|
||||||
"jni-sys",
|
"jni-sys 0.3.1",
|
||||||
"log",
|
"log",
|
||||||
"thiserror 1.0.69",
|
"thiserror 1.0.69",
|
||||||
"walkdir",
|
"walkdir",
|
||||||
@@ -2374,9 +2483,31 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "jni-sys"
|
name = "jni-sys"
|
||||||
version = "0.3.0"
|
version = "0.3.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
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]]
|
[[package]]
|
||||||
name = "js-sys"
|
name = "js-sys"
|
||||||
@@ -2410,6 +2541,21 @@ dependencies = [
|
|||||||
"serde_json",
|
"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]]
|
[[package]]
|
||||||
name = "keyboard-types"
|
name = "keyboard-types"
|
||||||
version = "0.7.0"
|
version = "0.7.0"
|
||||||
@@ -2506,9 +2652,9 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libredox"
|
name = "libredox"
|
||||||
version = "0.1.14"
|
version = "0.1.15"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a"
|
checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.0",
|
"bitflags 2.11.0",
|
||||||
"libc",
|
"libc",
|
||||||
@@ -2602,6 +2748,15 @@ dependencies = [
|
|||||||
"syn 2.0.117",
|
"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]]
|
[[package]]
|
||||||
name = "matches"
|
name = "matches"
|
||||||
version = "0.1.10"
|
version = "0.1.10"
|
||||||
@@ -2645,6 +2800,16 @@ version = "0.3.17"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
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]]
|
[[package]]
|
||||||
name = "minimal-lexical"
|
name = "minimal-lexical"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
@@ -2693,6 +2858,23 @@ dependencies = [
|
|||||||
"windows-sys 0.60.2",
|
"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]]
|
[[package]]
|
||||||
name = "native-tls"
|
name = "native-tls"
|
||||||
version = "0.2.18"
|
version = "0.2.18"
|
||||||
@@ -2717,7 +2899,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4"
|
checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.0",
|
"bitflags 2.11.0",
|
||||||
"jni-sys",
|
"jni-sys 0.3.1",
|
||||||
"log",
|
"log",
|
||||||
"ndk-sys",
|
"ndk-sys",
|
||||||
"num_enum",
|
"num_enum",
|
||||||
@@ -2737,7 +2919,7 @@ version = "0.6.0+11769913"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873"
|
checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"jni-sys",
|
"jni-sys 0.3.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2762,6 +2944,25 @@ dependencies = [
|
|||||||
"minimal-lexical",
|
"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]]
|
[[package]]
|
||||||
name = "num-bigint-dig"
|
name = "num-bigint-dig"
|
||||||
version = "0.8.6"
|
version = "0.8.6"
|
||||||
@@ -2780,9 +2981,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num-conv"
|
name = "num-conv"
|
||||||
version = "0.2.0"
|
version = "0.2.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050"
|
checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num-integer"
|
name = "num-integer"
|
||||||
@@ -3097,6 +3298,17 @@ dependencies = [
|
|||||||
"windows-link 0.2.1",
|
"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]]
|
[[package]]
|
||||||
name = "paste"
|
name = "paste"
|
||||||
version = "1.0.15"
|
version = "1.0.15"
|
||||||
@@ -3109,6 +3321,16 @@ version = "0.2.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
|
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]]
|
[[package]]
|
||||||
name = "pem-rfc7468"
|
name = "pem-rfc7468"
|
||||||
version = "0.7.0"
|
version = "0.7.0"
|
||||||
@@ -3311,6 +3533,26 @@ dependencies = [
|
|||||||
"siphasher 1.0.2",
|
"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]]
|
[[package]]
|
||||||
name = "pin-project-lite"
|
name = "pin-project-lite"
|
||||||
version = "0.2.17"
|
version = "0.2.17"
|
||||||
@@ -3485,7 +3727,7 @@ version = "3.5.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
|
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"toml_edit 0.25.5+spec-1.1.0",
|
"toml_edit 0.25.8+spec-1.1.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3837,7 +4079,7 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
"tokio-rustls",
|
"tokio-rustls",
|
||||||
"tokio-util",
|
"tokio-util",
|
||||||
"tower",
|
"tower 0.5.3",
|
||||||
"tower-http 0.6.8",
|
"tower-http 0.6.8",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
"url",
|
"url",
|
||||||
@@ -3872,7 +4114,7 @@ dependencies = [
|
|||||||
"sync_wrapper",
|
"sync_wrapper",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-util",
|
"tokio-util",
|
||||||
"tower",
|
"tower 0.5.3",
|
||||||
"tower-http 0.6.8",
|
"tower-http 0.6.8",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
"url",
|
"url",
|
||||||
@@ -3916,6 +4158,41 @@ dependencies = [
|
|||||||
"zeroize",
|
"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]]
|
[[package]]
|
||||||
name = "rustc-hash"
|
name = "rustc-hash"
|
||||||
version = "2.1.1"
|
version = "2.1.1"
|
||||||
@@ -4244,9 +4521,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_spanned"
|
name = "serde_spanned"
|
||||||
version = "1.0.4"
|
version = "1.1.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776"
|
checksum = "876ac351060d4f882bb1032b6369eb0aef79ad9df1ea8bc404874d8cc3d0cd98"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde_core",
|
"serde_core",
|
||||||
]
|
]
|
||||||
@@ -4376,6 +4653,24 @@ dependencies = [
|
|||||||
"digest",
|
"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]]
|
[[package]]
|
||||||
name = "shlex"
|
name = "shlex"
|
||||||
version = "1.3.0"
|
version = "1.3.0"
|
||||||
@@ -4408,6 +4703,18 @@ version = "0.3.8"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
|
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]]
|
[[package]]
|
||||||
name = "siphasher"
|
name = "siphasher"
|
||||||
version = "0.3.11"
|
version = "0.3.11"
|
||||||
@@ -4542,6 +4849,7 @@ dependencies = [
|
|||||||
"atoi",
|
"atoi",
|
||||||
"byteorder",
|
"byteorder",
|
||||||
"bytes",
|
"bytes",
|
||||||
|
"chrono",
|
||||||
"crc",
|
"crc",
|
||||||
"crossbeam-queue",
|
"crossbeam-queue",
|
||||||
"either",
|
"either",
|
||||||
@@ -4602,6 +4910,7 @@ dependencies = [
|
|||||||
"sha2",
|
"sha2",
|
||||||
"sqlx-core",
|
"sqlx-core",
|
||||||
"sqlx-mysql",
|
"sqlx-mysql",
|
||||||
|
"sqlx-postgres",
|
||||||
"sqlx-sqlite",
|
"sqlx-sqlite",
|
||||||
"syn 1.0.109",
|
"syn 1.0.109",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
@@ -4620,6 +4929,7 @@ dependencies = [
|
|||||||
"bitflags 2.11.0",
|
"bitflags 2.11.0",
|
||||||
"byteorder",
|
"byteorder",
|
||||||
"bytes",
|
"bytes",
|
||||||
|
"chrono",
|
||||||
"crc",
|
"crc",
|
||||||
"digest",
|
"digest",
|
||||||
"dotenvy",
|
"dotenvy",
|
||||||
@@ -4661,6 +4971,7 @@ dependencies = [
|
|||||||
"base64 0.21.7",
|
"base64 0.21.7",
|
||||||
"bitflags 2.11.0",
|
"bitflags 2.11.0",
|
||||||
"byteorder",
|
"byteorder",
|
||||||
|
"chrono",
|
||||||
"crc",
|
"crc",
|
||||||
"dotenvy",
|
"dotenvy",
|
||||||
"etcetera",
|
"etcetera",
|
||||||
@@ -4696,6 +5007,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "b244ef0a8414da0bed4bb1910426e890b19e5e9bccc27ada6b797d05c55ae0aa"
|
checksum = "b244ef0a8414da0bed4bb1910426e890b19e5e9bccc27ada6b797d05c55ae0aa"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"atoi",
|
"atoi",
|
||||||
|
"chrono",
|
||||||
"flume",
|
"flume",
|
||||||
"futures-channel",
|
"futures-channel",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
@@ -4858,9 +5170,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tao"
|
name = "tao"
|
||||||
version = "0.34.6"
|
version = "0.34.8"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6e06d52c379e63da659a483a958110bbde891695a0ecb53e48cc7786d5eda7bb"
|
checksum = "9103edf55f2da3c82aea4c7fab7c4241032bfeea0e71fa557d98e00e7ce7cc20"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.0",
|
"bitflags 2.11.0",
|
||||||
"block2",
|
"block2",
|
||||||
@@ -5238,6 +5550,15 @@ dependencies = [
|
|||||||
"syn 2.0.117",
|
"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]]
|
[[package]]
|
||||||
name = "time"
|
name = "time"
|
||||||
version = "0.3.47"
|
version = "0.3.47"
|
||||||
@@ -5397,7 +5718,7 @@ checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"indexmap 2.13.0",
|
"indexmap 2.13.0",
|
||||||
"serde_core",
|
"serde_core",
|
||||||
"serde_spanned 1.0.4",
|
"serde_spanned 1.1.0",
|
||||||
"toml_datetime 0.7.5+spec-1.1.0",
|
"toml_datetime 0.7.5+spec-1.1.0",
|
||||||
"toml_parser",
|
"toml_parser",
|
||||||
"toml_writer",
|
"toml_writer",
|
||||||
@@ -5424,9 +5745,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "toml_datetime"
|
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"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9b320e741db58cac564e26c607d3cc1fdc4a88fd36c879568c07856ed83ff3e9"
|
checksum = "97251a7c317e03ad83774a8752a7e81fb6067740609f75ea2b585b569a59198f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde_core",
|
"serde_core",
|
||||||
]
|
]
|
||||||
@@ -5457,30 +5778,58 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "toml_edit"
|
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"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8ca1a40644a28bce036923f6a431df0b34236949d111cc07cb6dca830c9ef2e1"
|
checksum = "16bff38f1d86c47f9ff0647e6838d7bb362522bdf44006c7068c2b1e606f1f3c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"indexmap 2.13.0",
|
"indexmap 2.13.0",
|
||||||
"toml_datetime 1.0.1+spec-1.1.0",
|
"toml_datetime 1.1.0+spec-1.1.0",
|
||||||
"toml_parser",
|
"toml_parser",
|
||||||
"winnow 1.0.0",
|
"winnow 1.0.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "toml_parser"
|
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"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7df25b4befd31c4816df190124375d5a20c6b6921e2cad937316de3fccd63420"
|
checksum = "2334f11ee363607eb04df9b8fc8a13ca1715a72ba8662a26ac285c98aabb4011"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"winnow 1.0.0",
|
"winnow 1.0.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "toml_writer"
|
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"
|
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]]
|
[[package]]
|
||||||
name = "tower"
|
name = "tower"
|
||||||
@@ -5512,6 +5861,7 @@ dependencies = [
|
|||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"tower-layer",
|
"tower-layer",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5527,7 +5877,7 @@ dependencies = [
|
|||||||
"http-body",
|
"http-body",
|
||||||
"iri-string",
|
"iri-string",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"tower",
|
"tower 0.5.3",
|
||||||
"tower-layer",
|
"tower-layer",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
]
|
]
|
||||||
@@ -5574,6 +5924,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
|
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"once_cell",
|
"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]]
|
[[package]]
|
||||||
@@ -5668,6 +6048,12 @@ dependencies = [
|
|||||||
"unic-common",
|
"unic-common",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicase"
|
||||||
|
version = "2.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-bidi"
|
name = "unicode-bidi"
|
||||||
version = "0.3.18"
|
version = "0.3.18"
|
||||||
@@ -5697,9 +6083,9 @@ checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-segmentation"
|
name = "unicode-segmentation"
|
||||||
version = "1.12.0"
|
version = "1.13.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
|
checksum = "da36089a805484bcccfffe0739803392c8298778a2d2f09febf76fac5ad9025b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-xid"
|
name = "unicode-xid"
|
||||||
@@ -5778,6 +6164,70 @@ version = "1.0.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
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]]
|
[[package]]
|
||||||
name = "uuid"
|
name = "uuid"
|
||||||
version = "1.22.0"
|
version = "1.22.0"
|
||||||
@@ -5791,6 +6241,12 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "valuable"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "vcpkg"
|
name = "vcpkg"
|
||||||
version = "0.2.15"
|
version = "0.2.15"
|
||||||
@@ -6957,6 +7413,7 @@ dependencies = [
|
|||||||
"async-trait",
|
"async-trait",
|
||||||
"chrono",
|
"chrono",
|
||||||
"futures",
|
"futures",
|
||||||
|
"libsqlite3-sys",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
@@ -6981,6 +7438,7 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
"uuid",
|
"uuid",
|
||||||
|
"zclaw-runtime",
|
||||||
"zclaw-types",
|
"zclaw-types",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -7000,6 +7458,7 @@ dependencies = [
|
|||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-stream",
|
"tokio-stream",
|
||||||
|
"toml 0.8.2",
|
||||||
"tracing",
|
"tracing",
|
||||||
"uuid",
|
"uuid",
|
||||||
"zclaw-hands",
|
"zclaw-hands",
|
||||||
@@ -7008,7 +7467,7 @@ dependencies = [
|
|||||||
"zclaw-runtime",
|
"zclaw-runtime",
|
||||||
"zclaw-skills",
|
"zclaw-skills",
|
||||||
"zclaw-types",
|
"zclaw-types",
|
||||||
"zip",
|
"zip 2.4.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -7017,6 +7476,7 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"futures",
|
"futures",
|
||||||
|
"libsqlite3-sys",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
@@ -7097,6 +7557,46 @@ dependencies = [
|
|||||||
"zclaw-types",
|
"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]]
|
[[package]]
|
||||||
name = "zclaw-skills"
|
name = "zclaw-skills"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -7105,6 +7605,7 @@ dependencies = [
|
|||||||
"regex",
|
"regex",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"shlex",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
@@ -7203,6 +7704,18 @@ dependencies = [
|
|||||||
"syn 2.0.117",
|
"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]]
|
[[package]]
|
||||||
name = "zip"
|
name = "zip"
|
||||||
version = "2.4.2"
|
version = "2.4.2"
|
||||||
|
|||||||
16
Cargo.toml
16
Cargo.toml
@@ -15,6 +15,8 @@ members = [
|
|||||||
"crates/zclaw-growth",
|
"crates/zclaw-growth",
|
||||||
# Desktop Application
|
# Desktop Application
|
||||||
"desktop/src-tauri",
|
"desktop/src-tauri",
|
||||||
|
# SaaS Backend
|
||||||
|
"crates/zclaw-saas",
|
||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
@@ -55,7 +57,8 @@ chrono = { version = "0.4", features = ["serde"] }
|
|||||||
uuid = { version = "1", features = ["v4", "v5", "serde"] }
|
uuid = { version = "1", features = ["v4", "v5", "serde"] }
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
sqlx = { version = "0.7", features = ["runtime-tokio", "sqlite"] }
|
sqlx = { version = "0.7", features = ["runtime-tokio", "postgres", "chrono"] }
|
||||||
|
libsqlite3-sys = { version = "0.27", features = ["bundled"] }
|
||||||
|
|
||||||
# HTTP client (for LLM drivers)
|
# HTTP client (for LLM drivers)
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls"] }
|
reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls"] }
|
||||||
@@ -94,6 +97,16 @@ shlex = "1"
|
|||||||
# Testing
|
# Testing
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
|
|
||||||
|
# SaaS dependencies
|
||||||
|
axum = { version = "0.7", features = ["macros"] }
|
||||||
|
axum-extra = { version = "0.9", features = ["typed-header"] }
|
||||||
|
tower = { version = "0.4", features = ["util"] }
|
||||||
|
tower-http = { version = "0.5", features = ["cors", "trace", "limit"] }
|
||||||
|
jsonwebtoken = "9"
|
||||||
|
argon2 = "0.5"
|
||||||
|
totp-rs = "5"
|
||||||
|
hex = "0.4"
|
||||||
|
|
||||||
# Internal crates
|
# Internal crates
|
||||||
zclaw-types = { path = "crates/zclaw-types" }
|
zclaw-types = { path = "crates/zclaw-types" }
|
||||||
zclaw-memory = { path = "crates/zclaw-memory" }
|
zclaw-memory = { path = "crates/zclaw-memory" }
|
||||||
@@ -105,6 +118,7 @@ zclaw-channels = { path = "crates/zclaw-channels" }
|
|||||||
zclaw-protocols = { path = "crates/zclaw-protocols" }
|
zclaw-protocols = { path = "crates/zclaw-protocols" }
|
||||||
zclaw-pipeline = { path = "crates/zclaw-pipeline" }
|
zclaw-pipeline = { path = "crates/zclaw-pipeline" }
|
||||||
zclaw-growth = { path = "crates/zclaw-growth" }
|
zclaw-growth = { path = "crates/zclaw-growth" }
|
||||||
|
zclaw-saas = { path = "crates/zclaw-saas" }
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
lto = true
|
lto = true
|
||||||
|
|||||||
83
Dockerfile
Normal file
83
Dockerfile
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
# ============================================================
|
||||||
|
# ZCLAW SaaS Backend - Multi-stage Docker Build
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
# ---- Stage 1: Builder ----
|
||||||
|
FROM rust:1.75-bookworm AS builder
|
||||||
|
|
||||||
|
# Install build dependencies for sqlx (postgres) and libsqlite3-sys (bundled)
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
pkg-config \
|
||||||
|
libssl-dev \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy workspace manifests first to leverage Docker layer caching
|
||||||
|
COPY Cargo.toml Cargo.lock ./
|
||||||
|
|
||||||
|
# Create stub source files so cargo can resolve and cache dependencies
|
||||||
|
# This avoids rebuilding dependencies when only application code changes
|
||||||
|
RUN mkdir -p crates/zclaw-saas/src \
|
||||||
|
&& echo 'fn main() {}' > crates/zclaw-saas/src/main.rs \
|
||||||
|
&& for crate in zclaw-types zclaw-memory zclaw-runtime zclaw-kernel \
|
||||||
|
zclaw-skills zclaw-hands zclaw-channels zclaw-protocols \
|
||||||
|
zclaw-pipeline zclaw-growth; do \
|
||||||
|
mkdir -p crates/$crate/src && echo '' > crates/$crate/src/lib.rs; \
|
||||||
|
done \
|
||||||
|
&& mkdir -p desktop/src-tauri/src && echo 'fn main() {}' > desktop/src-tauri/src/main.rs
|
||||||
|
|
||||||
|
# Pre-build dependencies (release profile with caching)
|
||||||
|
RUN cargo build --release --package zclaw-saas 2>/dev/null || true
|
||||||
|
|
||||||
|
# Copy actual source code (invalidates stubs, triggers recompile of app code only)
|
||||||
|
COPY crates/ crates/
|
||||||
|
COPY desktop/ desktop/
|
||||||
|
|
||||||
|
# Touch source files to invalidate the stub timestamps
|
||||||
|
RUN touch crates/zclaw-saas/src/main.rs \
|
||||||
|
&& for crate in zclaw-types zclaw-memory zclaw-runtime zclaw-kernel \
|
||||||
|
zclaw-skills zclaw-hands zclaw-channels zclaw-protocols \
|
||||||
|
zclaw-pipeline zclaw-growth; do \
|
||||||
|
touch crates/$crate/src/lib.rs 2>/dev/null || true; \
|
||||||
|
done \
|
||||||
|
&& touch desktop/src-tauri/src/main.rs 2>/dev/null || true
|
||||||
|
|
||||||
|
# Build the actual binary
|
||||||
|
RUN cargo build --release --package zclaw-saas
|
||||||
|
|
||||||
|
# ---- Stage 2: Runtime ----
|
||||||
|
FROM debian:bookworm-slim AS runtime
|
||||||
|
|
||||||
|
# Install runtime dependencies (ca-certificates for HTTPS, libgcc for Rust runtime)
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
ca-certificates \
|
||||||
|
libgcc-s \
|
||||||
|
&& rm -rf /var/lib/apt/lists/* \
|
||||||
|
&& update-ca-certificates
|
||||||
|
|
||||||
|
# Create non-root user for security
|
||||||
|
RUN groupadd --gid 1000 zclaw \
|
||||||
|
&& useradd --uid 1000 --gid zclaw --shell /bin/false zclaw
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy binary from builder
|
||||||
|
COPY --from=builder /app/target/release/zclaw-saas /app/zclaw-saas
|
||||||
|
|
||||||
|
# Copy configuration file
|
||||||
|
COPY saas-config.toml /app/saas-config.toml
|
||||||
|
|
||||||
|
# Ensure the non-root user owns the application files
|
||||||
|
RUN chown -R zclaw:zclaw /app
|
||||||
|
|
||||||
|
USER zclaw
|
||||||
|
|
||||||
|
# Expose the SaaS API port
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
# Health check endpoint (matches the saas-config.toml port)
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||||
|
CMD ["/app/zclaw-saas", "--healthcheck"] || exit 1
|
||||||
|
|
||||||
|
ENTRYPOINT ["/app/zclaw-saas"]
|
||||||
35
LICENSE
Normal file
35
LICENSE
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 ZCLAW Contributors
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Attribution Notice
|
||||||
|
==================
|
||||||
|
|
||||||
|
This software is based on and incorporates code from the OpenFang project
|
||||||
|
(https://github.com/nicepkg/openfang), which is licensed under the MIT License.
|
||||||
|
|
||||||
|
Original OpenFang Copyright:
|
||||||
|
Copyright (c) nicepkg
|
||||||
|
|
||||||
|
The OpenFang project provided the foundational architecture, security framework,
|
||||||
|
and agent runtime concepts that were adapted and extended to create ZCLAW.
|
||||||
35
Makefile
35
Makefile
@@ -1,10 +1,12 @@
|
|||||||
# ZCLAW Makefile
|
# ZCLAW Makefile
|
||||||
# Cross-platform task runner
|
# Cross-platform task runner
|
||||||
|
|
||||||
.PHONY: help start start-dev start-no-browser desktop desktop-build setup test clean
|
.PHONY: help start start-dev start-no-browser desktop desktop-build setup test clean \
|
||||||
|
saas-build saas-run saas-test saas-test-integration saas-clippy saas-migrate \
|
||||||
|
saas-docker-up saas-docker-down saas-docker-build
|
||||||
|
|
||||||
help: ## Show this help message
|
help: ## Show this help message
|
||||||
@echo "ZCLAW - OpenFang Desktop Client"
|
@echo "ZCLAW - AI Agent Desktop Client"
|
||||||
@echo ""
|
@echo ""
|
||||||
@echo "Usage: make [target]"
|
@echo "Usage: make [target]"
|
||||||
@echo ""
|
@echo ""
|
||||||
@@ -71,3 +73,32 @@ clean-deep: clean ## Deep clean (including pnpm cache)
|
|||||||
@rm -rf desktop/pnpm-lock.yaml
|
@rm -rf desktop/pnpm-lock.yaml
|
||||||
@rm -rf pnpm-lock.yaml
|
@rm -rf pnpm-lock.yaml
|
||||||
@echo "Deep clean complete. Run 'pnpm install' to reinstall."
|
@echo "Deep clean complete. Run 'pnpm install' to reinstall."
|
||||||
|
|
||||||
|
# === SaaS Backend ===
|
||||||
|
|
||||||
|
saas-build: ## Build zclaw-saas crate
|
||||||
|
@cargo build -p zclaw-saas
|
||||||
|
|
||||||
|
saas-run: ## Start SaaS backend (cargo run)
|
||||||
|
@cargo run -p zclaw-saas
|
||||||
|
|
||||||
|
saas-test: ## Run SaaS unit tests
|
||||||
|
@cargo test -p zclaw-saas -- --test-threads=1
|
||||||
|
|
||||||
|
saas-test-integration: ## Run SaaS integration tests (requires PostgreSQL)
|
||||||
|
@cargo test -p zclaw-saas -- --ignored --test-threads=1
|
||||||
|
|
||||||
|
saas-clippy: ## Run clippy on zclaw-saas
|
||||||
|
@cargo clippy -p zclaw-saas -- -D warnings
|
||||||
|
|
||||||
|
saas-migrate: ## Run database migrations
|
||||||
|
@cargo run -p zclaw-saas -- --migrate
|
||||||
|
|
||||||
|
saas-docker-up: ## Start SaaS services (PostgreSQL + backend)
|
||||||
|
@docker compose up -d
|
||||||
|
|
||||||
|
saas-docker-down: ## Stop SaaS services
|
||||||
|
@docker compose down
|
||||||
|
|
||||||
|
saas-docker-build: ## Build SaaS Docker images
|
||||||
|
@docker compose build
|
||||||
|
|||||||
72
README.md
72
README.md
@@ -1,11 +1,11 @@
|
|||||||
# ZCLAW 🦞 — OpenFang 定制版 (Tauri Desktop)
|
# ZCLAW 🦞 — ZCLAW 定制版 (Tauri Desktop)
|
||||||
|
|
||||||
基于 [OpenFang](https://openfang.sh/) —— 用 Rust 构建的 Agent 操作系统,打造中文优先的 Tauri 桌面 AI 助手。
|
基于 [ZCLAW](https://zclaw.sh/) —— 用 Rust 构建的 Agent 操作系统,打造中文优先的 Tauri 桌面 AI 助手。
|
||||||
|
|
||||||
## 核心定位
|
## 核心定位
|
||||||
|
|
||||||
```
|
```
|
||||||
OpenFang Kernel (Rust 执行引擎)
|
ZCLAW Kernel (Rust 执行引擎)
|
||||||
↕ WebSocket / HTTP API
|
↕ WebSocket / HTTP API
|
||||||
ZCLAW Tauri App (桌面 UI)
|
ZCLAW Tauri App (桌面 UI)
|
||||||
+ 中文模型 Provider (GLM/Qwen/Kimi/MiniMax/DeepSeek)
|
+ 中文模型 Provider (GLM/Qwen/Kimi/MiniMax/DeepSeek)
|
||||||
@@ -16,11 +16,11 @@ ZCLAW Tauri App (桌面 UI)
|
|||||||
+ 自定义 Skills
|
+ 自定义 Skills
|
||||||
```
|
```
|
||||||
|
|
||||||
## 为什么选择 OpenFang?
|
## 为什么选择 ZCLAW?
|
||||||
|
|
||||||
相比 OpenClaw,OpenFang 提供了更强的性能和更丰富的功能:
|
相比 ZCLAW,ZCLAW 提供了更强的性能和更丰富的功能:
|
||||||
|
|
||||||
| 特性 | OpenFang | OpenClaw |
|
| 特性 | ZCLAW | ZCLAW |
|
||||||
|------|----------|----------|
|
|------|----------|----------|
|
||||||
| **开发语言** | Rust | TypeScript |
|
| **开发语言** | Rust | TypeScript |
|
||||||
| **冷启动** | < 200ms | ~6s |
|
| **冷启动** | < 200ms | ~6s |
|
||||||
@@ -30,11 +30,11 @@ ZCLAW Tauri App (桌面 UI)
|
|||||||
| **渠道适配器** | 40 个 | 13 个 |
|
| **渠道适配器** | 40 个 | 13 个 |
|
||||||
| **LLM 提供商** | 27 个 | ~10 个 |
|
| **LLM 提供商** | 27 个 | ~10 个 |
|
||||||
|
|
||||||
**详细对比**:[OpenFang 架构概览](https://wurang.net/posts/openfang-intro/)
|
**详细对比**:[ZCLAW 架构概览](https://wurang.net/posts/zclaw-intro/)
|
||||||
|
|
||||||
## 功能特色
|
## 功能特色
|
||||||
|
|
||||||
- **基于 OpenFang**: 生产级 Agent 操作系统,16 层安全防护,WASM 沙箱
|
- **基于 ZCLAW**: 生产级 Agent 操作系统,16 层安全防护,WASM 沙箱
|
||||||
- **7 个自主 Hands**: Browser/Researcher/Collector/Predictor/Lead/Clip/Twitter - 预构建的"数字员工"
|
- **7 个自主 Hands**: Browser/Researcher/Collector/Predictor/Lead/Clip/Twitter - 预构建的"数字员工"
|
||||||
- **中文模型**: 智谱 GLM-4、通义千问、Kimi、MiniMax、DeepSeek (OpenAI 兼容 API)
|
- **中文模型**: 智谱 GLM-4、通义千问、Kimi、MiniMax、DeepSeek (OpenAI 兼容 API)
|
||||||
- **40+ 渠道**: 飞书、钉钉、Telegram、Discord、Slack、微信等
|
- **40+ 渠道**: 飞书、钉钉、Telegram、Discord、Slack、微信等
|
||||||
@@ -47,10 +47,10 @@ ZCLAW Tauri App (桌面 UI)
|
|||||||
|
|
||||||
| 层级 | 技术 |
|
| 层级 | 技术 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| **执行引擎** | OpenFang Kernel (Rust, http://127.0.0.1:50051) |
|
| **执行引擎** | ZCLAW Kernel (Rust, http://127.0.0.1:50051) |
|
||||||
| **桌面壳** | Tauri 2.0 (Rust + React 19) |
|
| **桌面壳** | Tauri 2.0 (Rust + React 19) |
|
||||||
| **前端** | React 19 + TailwindCSS + Zustand + Lucide Icons |
|
| **前端** | React 19 + TailwindCSS + Zustand + Lucide Icons |
|
||||||
| **通信协议** | OpenFang API (REST/WS/SSE) + OpenAI 兼容 API |
|
| **通信协议** | ZCLAW API (REST/WS/SSE) + OpenAI 兼容 API |
|
||||||
| **安全** | WASM 沙箱 + Merkle 审计追踪 + Ed25519 签名 |
|
| **安全** | WASM 沙箱 + Merkle 审计追踪 + Ed25519 签名 |
|
||||||
|
|
||||||
## 项目结构
|
## 项目结构
|
||||||
@@ -61,7 +61,7 @@ ZClaw/
|
|||||||
│ ├── src/
|
│ ├── src/
|
||||||
│ │ ├── components/ # UI 组件
|
│ │ ├── components/ # UI 组件
|
||||||
│ │ ├── store/ # Zustand 状态管理
|
│ │ ├── store/ # Zustand 状态管理
|
||||||
│ │ └── lib/gateway-client.ts # OpenFang API 客户端
|
│ │ └── lib/gateway-client.ts # ZCLAW API 客户端
|
||||||
│ └── src-tauri/ # Rust 后端
|
│ └── src-tauri/ # Rust 后端
|
||||||
│
|
│
|
||||||
├── skills/ # 自定义技能 (SKILL.md)
|
├── skills/ # 自定义技能 (SKILL.md)
|
||||||
@@ -71,14 +71,14 @@ ZClaw/
|
|||||||
├── hands/ # 自定义 Hands (HAND.toml)
|
├── hands/ # 自定义 Hands (HAND.toml)
|
||||||
│ └── custom-automation/ # 自定义自动化任务
|
│ └── custom-automation/ # 自定义自动化任务
|
||||||
│
|
│
|
||||||
├── config/ # OpenFang 默认配置
|
├── config/ # ZCLAW 默认配置
|
||||||
│ ├── config.toml # 主配置文件
|
│ ├── config.toml # 主配置文件
|
||||||
│ ├── SOUL.md # Agent 人格
|
│ ├── SOUL.md # Agent 人格
|
||||||
│ └── AGENTS.md # Agent 指令
|
│ └── AGENTS.md # Agent 指令
|
||||||
│
|
│
|
||||||
├── docs/
|
├── docs/
|
||||||
│ ├── setup/ # 设置指南
|
│ ├── setup/ # 设置指南
|
||||||
│ │ ├── OPENFANG-SETUP.md # OpenFang 配置指南
|
│ │ ├── ZCLAW-SETUP.md # ZCLAW 配置指南
|
||||||
│ │ └── chinese-models.md # 中文模型配置
|
│ │ └── chinese-models.md # 中文模型配置
|
||||||
│ ├── architecture-v2.md # 架构设计
|
│ ├── architecture-v2.md # 架构设计
|
||||||
│ └── deviation-analysis.md # 偏离分析报告
|
│ └── deviation-analysis.md # 偏离分析报告
|
||||||
@@ -88,20 +88,20 @@ ZClaw/
|
|||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
|
|
||||||
### 1. 安装 OpenFang
|
### 1. 安装 ZCLAW
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Windows (PowerShell)
|
# Windows (PowerShell)
|
||||||
iwr -useb https://openfang.sh/install.ps1 | iex
|
iwr -useb https://zclaw.sh/install.ps1 | iex
|
||||||
|
|
||||||
# macOS / Linux
|
# macOS / Linux
|
||||||
curl -fsSL https://openfang.sh/install.sh | bash
|
curl -fsSL https://zclaw.sh/install.sh | bash
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. 初始化配置
|
### 2. 初始化配置
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
openfang init
|
zclaw init
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3. 配置 API Key
|
### 3. 配置 API Key
|
||||||
@@ -121,8 +121,8 @@ export DEEPSEEK_API_KEY="your-deepseek-key" # DeepSeek
|
|||||||
### 4. 启动服务
|
### 4. 启动服务
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 启动 OpenFang Kernel
|
# 启动 ZCLAW Kernel
|
||||||
openfang start
|
zclaw start
|
||||||
|
|
||||||
# 在另一个终端启动 ZCLAW 桌面应用
|
# 在另一个终端启动 ZCLAW 桌面应用
|
||||||
git clone https://github.com/xxx/ZClaw.git
|
git clone https://github.com/xxx/ZClaw.git
|
||||||
@@ -134,16 +134,16 @@ cd desktop && pnpm tauri dev
|
|||||||
### 5. 验证安装
|
### 5. 验证安装
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 检查 OpenFang 状态
|
# 检查 ZCLAW 状态
|
||||||
openfang status
|
zclaw status
|
||||||
|
|
||||||
# 运行健康检查
|
# 运行健康检查
|
||||||
openfang doctor
|
zclaw doctor
|
||||||
```
|
```
|
||||||
|
|
||||||
## OpenFang Hands (自主能力)
|
## ZCLAW Hands (自主能力)
|
||||||
|
|
||||||
OpenFang 内置 7 个预构建的自主能力包,每个 Hand 都是一个具备完整工作流的"数字员工":
|
ZCLAW 内置 7 个预构建的自主能力包,每个 Hand 都是一个具备完整工作流的"数字员工":
|
||||||
|
|
||||||
| Hand | 功能 | 状态 |
|
| Hand | 功能 | 状态 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
@@ -170,36 +170,36 @@ OpenFang 内置 7 个预构建的自主能力包,每个 Hand 都是一个具
|
|||||||
## 文档
|
## 文档
|
||||||
|
|
||||||
### 设置指南
|
### 设置指南
|
||||||
- [OpenFang Kernel 配置指南](docs/setup/OPENFANG-SETUP.md) - 安装、配置、常见问题
|
- [ZCLAW Kernel 配置指南](docs/setup/ZCLAW-SETUP.md) - 安装、配置、常见问题
|
||||||
- [中文模型配置指南](docs/setup/chinese-models.md) - API Key 获取、模型选择、多模型配置
|
- [中文模型配置指南](docs/setup/chinese-models.md) - API Key 获取、模型选择、多模型配置
|
||||||
|
|
||||||
### 架构设计
|
### 架构设计
|
||||||
- [架构设计](docs/architecture-v2.md) — 完整的 v2 架构方案
|
- [架构设计](docs/architecture-v2.md) — 完整的 v2 架构方案
|
||||||
- [偏离分析](docs/deviation-analysis.md) — 与 QClaw/AutoClaw/OpenClaw 对标分析
|
- [偏离分析](docs/deviation-analysis.md) — 与 QClaw/AutoClaw/ZCLAW 对标分析
|
||||||
|
|
||||||
### 外部资源
|
### 外部资源
|
||||||
- [OpenFang 官方文档](https://openfang.sh/)
|
- [ZCLAW 官方文档](https://zclaw.sh/)
|
||||||
- [OpenFang GitHub](https://github.com/RightNow-AI/openfang)
|
- [ZCLAW GitHub](https://github.com/RightNow-AI/zclaw)
|
||||||
- [OpenFang 架构概览](https://wurang.net/posts/openfang-intro/)
|
- [ZCLAW 架构概览](https://wurang.net/posts/zclaw-intro/)
|
||||||
|
|
||||||
## 对标参考
|
## 对标参考
|
||||||
|
|
||||||
| 产品 | 基于 | IM 渠道 | 桌面框架 | 安全层数 |
|
| 产品 | 基于 | IM 渠道 | 桌面框架 | 安全层数 |
|
||||||
|------|------|---------|----------|----------|
|
|------|------|---------|----------|----------|
|
||||||
| **QClaw** (腾讯) | OpenClaw | 微信 + QQ | Electron | 3 |
|
| **QClaw** (腾讯) | ZCLAW | 微信 + QQ | Electron | 3 |
|
||||||
| **AutoClaw** (智谱) | OpenClaw | 飞书 | 自研 | 3 |
|
| **AutoClaw** (智谱) | ZCLAW | 飞书 | 自研 | 3 |
|
||||||
| **ZCLAW** (本项目) | OpenFang | 飞书 + 钉钉 + 40+ | Tauri 2.0 | 16 |
|
| **ZCLAW** (本项目) | ZCLAW | 飞书 + 钉钉 + 40+ | Tauri 2.0 | 16 |
|
||||||
|
|
||||||
## 从 OpenClaw 迁移
|
## 从 ZCLAW 迁移
|
||||||
|
|
||||||
如果你之前使用 OpenClaw,可以一键迁移:
|
如果你之前使用 ZCLAW,可以一键迁移:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 迁移所有内容:代理、记忆、技能、配置
|
# 迁移所有内容:代理、记忆、技能、配置
|
||||||
openfang migrate --from openclaw
|
zclaw migrate --from zclaw
|
||||||
|
|
||||||
# 先试运行查看变更
|
# 先试运行查看变更
|
||||||
openfang migrate --from openclaw --dry-run
|
zclaw migrate --from zclaw --dry-run
|
||||||
```
|
```
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|||||||
4
admin/.gitignore
vendored
Normal file
4
admin/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
.next/
|
||||||
|
node_modules/
|
||||||
|
.env.local
|
||||||
|
.env*.local
|
||||||
5
admin/next-env.d.ts
vendored
Normal file
5
admin/next-env.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
/// <reference types="next" />
|
||||||
|
/// <reference types="next/image-types/global" />
|
||||||
|
|
||||||
|
// NOTE: This file should not be edited
|
||||||
|
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
|
||||||
44
admin/next.config.js
Normal file
44
admin/next.config.js
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
async headers() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
source: '/(.*)',
|
||||||
|
headers: [
|
||||||
|
{
|
||||||
|
key: 'X-Frame-Options',
|
||||||
|
value: 'DENY',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'X-Content-Type-Options',
|
||||||
|
value: 'nosniff',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'Referrer-Policy',
|
||||||
|
value: 'strict-origin-when-cross-origin',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'Content-Security-Policy',
|
||||||
|
value: [
|
||||||
|
"default-src 'self'",
|
||||||
|
"script-src 'self' 'unsafe-eval' 'unsafe-inline'",
|
||||||
|
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
|
||||||
|
"font-src 'self' https://fonts.gstatic.com",
|
||||||
|
"img-src 'self' data: blob:",
|
||||||
|
"connect-src 'self'",
|
||||||
|
"frame-ancestors 'none'",
|
||||||
|
"base-uri 'self'",
|
||||||
|
"form-action 'self'",
|
||||||
|
].join('; '),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'Permissions-Policy',
|
||||||
|
value: 'camera=(), microphone=(), geolocation=()',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = nextConfig
|
||||||
38
admin/package.json
Normal file
38
admin/package.json
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"name": "zclaw-admin",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "next lint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-dialog": "^1.1.14",
|
||||||
|
"@radix-ui/react-select": "^2.2.5",
|
||||||
|
"@radix-ui/react-separator": "^1.1.7",
|
||||||
|
"@radix-ui/react-switch": "^1.2.5",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.12",
|
||||||
|
"@radix-ui/react-tooltip": "^1.2.7",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-react": "^0.484.0",
|
||||||
|
"next": "14.2.29",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"recharts": "^2.15.3",
|
||||||
|
"sonner": "^2.0.7",
|
||||||
|
"tailwind-merge": "^3.0.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^20.17.19",
|
||||||
|
"@types/react": "^18.3.18",
|
||||||
|
"@types/react-dom": "^18.3.5",
|
||||||
|
"autoprefixer": "^10.4.20",
|
||||||
|
"postcss": "^8.5.3",
|
||||||
|
"tailwindcss": "^3.4.17",
|
||||||
|
"typescript": "^5.7.3"
|
||||||
|
},
|
||||||
|
"packageManager": "pnpm@10.30.2"
|
||||||
|
}
|
||||||
2185
admin/pnpm-lock.yaml
generated
Normal file
2185
admin/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
6
admin/postcss.config.js
Normal file
6
admin/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
400
admin/src/app/(dashboard)/accounts/page.tsx
Normal file
400
admin/src/app/(dashboard)/accounts/page.tsx
Normal file
@@ -0,0 +1,400 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
|
import {
|
||||||
|
Search,
|
||||||
|
Plus,
|
||||||
|
Loader2,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Pencil,
|
||||||
|
Ban,
|
||||||
|
CheckCircle2,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
DialogDescription,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
import { api } from '@/lib/api-client'
|
||||||
|
import { ApiRequestError } from '@/lib/api-client'
|
||||||
|
import { formatDate } from '@/lib/utils'
|
||||||
|
import type { AccountPublic } from '@/lib/types'
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20
|
||||||
|
|
||||||
|
const roleLabels: Record<string, string> = {
|
||||||
|
super_admin: '超级管理员',
|
||||||
|
admin: '管理员',
|
||||||
|
user: '普通用户',
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusColors: Record<string, 'success' | 'destructive' | 'warning'> = {
|
||||||
|
active: 'success',
|
||||||
|
disabled: 'destructive',
|
||||||
|
suspended: 'warning',
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusLabels: Record<string, string> = {
|
||||||
|
active: '正常',
|
||||||
|
disabled: '已禁用',
|
||||||
|
suspended: '已暂停',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AccountsPage() {
|
||||||
|
const [accounts, setAccounts] = useState<AccountPublic[]>([])
|
||||||
|
const [total, setTotal] = useState(0)
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
|
||||||
|
// 搜索 debounce: 输入后 300ms 再触发请求
|
||||||
|
const [debouncedSearchState, setDebouncedSearchState] = useState('')
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => setDebouncedSearchState(search), 300)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}, [search])
|
||||||
|
const [roleFilter, setRoleFilter] = useState<string>('all')
|
||||||
|
const [statusFilter, setStatusFilter] = useState<string>('all')
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
// 编辑 Dialog
|
||||||
|
const [editTarget, setEditTarget] = useState<AccountPublic | null>(null)
|
||||||
|
const [editForm, setEditForm] = useState({ display_name: '', email: '', role: 'user' })
|
||||||
|
const [editSaving, setEditSaving] = useState(false)
|
||||||
|
|
||||||
|
// 确认 Dialog
|
||||||
|
const [confirmTarget, setConfirmTarget] = useState<{ id: string; action: string; status: string } | null>(null)
|
||||||
|
const [confirmSaving, setConfirmSaving] = useState(false)
|
||||||
|
|
||||||
|
const fetchAccounts = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const params: Record<string, unknown> = { page, page_size: PAGE_SIZE }
|
||||||
|
if (debouncedSearchState.trim()) params.search = debouncedSearchState.trim()
|
||||||
|
if (roleFilter !== 'all') params.role = roleFilter
|
||||||
|
if (statusFilter !== 'all') params.status = statusFilter
|
||||||
|
|
||||||
|
const res = await api.accounts.list(params)
|
||||||
|
setAccounts(res.items)
|
||||||
|
setTotal(res.total)
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiRequestError) {
|
||||||
|
setError(err.body.message)
|
||||||
|
} else {
|
||||||
|
setError('加载失败')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [page, debouncedSearchState, roleFilter, statusFilter])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchAccounts()
|
||||||
|
}, [fetchAccounts])
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
|
||||||
|
|
||||||
|
function openEditDialog(account: AccountPublic) {
|
||||||
|
setEditTarget(account)
|
||||||
|
setEditForm({
|
||||||
|
display_name: account.display_name,
|
||||||
|
email: account.email,
|
||||||
|
role: account.role,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleEditSave() {
|
||||||
|
if (!editTarget) return
|
||||||
|
setEditSaving(true)
|
||||||
|
try {
|
||||||
|
await api.accounts.update(editTarget.id, {
|
||||||
|
display_name: editForm.display_name,
|
||||||
|
email: editForm.email,
|
||||||
|
role: editForm.role as AccountPublic['role'],
|
||||||
|
})
|
||||||
|
setEditTarget(null)
|
||||||
|
fetchAccounts()
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiRequestError) {
|
||||||
|
setError(err.body.message)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setEditSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openConfirmDialog(account: AccountPublic) {
|
||||||
|
const newStatus = account.status === 'active' ? 'disabled' : 'active'
|
||||||
|
setConfirmTarget({
|
||||||
|
id: account.id,
|
||||||
|
action: newStatus === 'disabled' ? '禁用' : '启用',
|
||||||
|
status: newStatus,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleConfirmSave() {
|
||||||
|
if (!confirmTarget) return
|
||||||
|
setConfirmSaving(true)
|
||||||
|
try {
|
||||||
|
await api.accounts.updateStatus(confirmTarget.id, {
|
||||||
|
status: confirmTarget.status as AccountPublic['status'],
|
||||||
|
})
|
||||||
|
setConfirmTarget(null)
|
||||||
|
fetchAccounts()
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiRequestError) {
|
||||||
|
setError(err.body.message)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setConfirmSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 搜索和筛选 */}
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<div className="relative flex-1 min-w-[200px] max-w-sm">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="搜索用户名 / 邮箱 / 显示名..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => { setSearch(e.target.value); setPage(1) }}
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Select value={roleFilter} onValueChange={(v) => { setRoleFilter(v); setPage(1) }}>
|
||||||
|
<SelectTrigger className="w-[140px]">
|
||||||
|
<SelectValue placeholder="角色筛选" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">全部角色</SelectItem>
|
||||||
|
<SelectItem value="super_admin">超级管理员</SelectItem>
|
||||||
|
<SelectItem value="admin">管理员</SelectItem>
|
||||||
|
<SelectItem value="user">普通用户</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Select value={statusFilter} onValueChange={(v) => { setStatusFilter(v); setPage(1) }}>
|
||||||
|
<SelectTrigger className="w-[140px]">
|
||||||
|
<SelectValue placeholder="状态筛选" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">全部状态</SelectItem>
|
||||||
|
<SelectItem value="active">正常</SelectItem>
|
||||||
|
<SelectItem value="disabled">已禁用</SelectItem>
|
||||||
|
<SelectItem value="suspended">已暂停</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 错误提示 */}
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-md bg-destructive/10 border border-destructive/20 px-4 py-3 text-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
<button onClick={() => setError('')} className="ml-2 underline cursor-pointer">
|
||||||
|
关闭
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 表格 */}
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex h-64 items-center justify-center">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : accounts.length === 0 ? (
|
||||||
|
<div className="flex h-64 items-center justify-center text-muted-foreground text-sm">
|
||||||
|
暂无数据
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>用户名</TableHead>
|
||||||
|
<TableHead>邮箱</TableHead>
|
||||||
|
<TableHead>显示名</TableHead>
|
||||||
|
<TableHead>角色</TableHead>
|
||||||
|
<TableHead>状态</TableHead>
|
||||||
|
<TableHead>创建时间</TableHead>
|
||||||
|
<TableHead className="text-right">操作</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{accounts.map((account) => (
|
||||||
|
<TableRow key={account.id}>
|
||||||
|
<TableCell className="font-medium">{account.username}</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">{account.email}</TableCell>
|
||||||
|
<TableCell>{account.display_name || '-'}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={account.role === 'super_admin' ? 'default' : account.role === 'admin' ? 'info' : 'secondary'}>
|
||||||
|
{roleLabels[account.role] || account.role}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={statusColors[account.status] || 'secondary'}>
|
||||||
|
<span className="mr-1 inline-block h-1.5 w-1.5 rounded-full bg-current" />
|
||||||
|
{statusLabels[account.status] || account.status}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||||
|
{formatDate(account.created_at)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<div className="flex items-center justify-end gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => openEditDialog(account)}
|
||||||
|
title="编辑"
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => openConfirmDialog(account)}
|
||||||
|
title={account.status === 'active' ? '禁用' : '启用'}
|
||||||
|
>
|
||||||
|
{account.status === 'active' ? (
|
||||||
|
<Ban className="h-4 w-4 text-destructive" />
|
||||||
|
) : (
|
||||||
|
<CheckCircle2 className="h-4 w-4 text-green-400" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
|
||||||
|
{/* 分页 */}
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
第 {page} 页 / 共 {totalPages} 页 ({total} 条)
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={page <= 1}
|
||||||
|
onClick={() => setPage(page - 1)}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4 mr-1" />
|
||||||
|
上一页
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
onClick={() => setPage(page + 1)}
|
||||||
|
>
|
||||||
|
下一页
|
||||||
|
<ChevronRight className="h-4 w-4 ml-1" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 编辑 Dialog */}
|
||||||
|
<Dialog open={!!editTarget} onOpenChange={() => setEditTarget(null)}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>编辑账号</DialogTitle>
|
||||||
|
<DialogDescription>修改账号信息</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>显示名</Label>
|
||||||
|
<Input
|
||||||
|
value={editForm.display_name}
|
||||||
|
onChange={(e) => setEditForm({ ...editForm, display_name: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>邮箱</Label>
|
||||||
|
<Input
|
||||||
|
type="email"
|
||||||
|
value={editForm.email}
|
||||||
|
onChange={(e) => setEditForm({ ...editForm, email: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>角色</Label>
|
||||||
|
<Select value={editForm.role} onValueChange={(v) => setEditForm({ ...editForm, role: v })}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="user">普通用户</SelectItem>
|
||||||
|
<SelectItem value="admin">管理员</SelectItem>
|
||||||
|
<SelectItem value="super_admin">超级管理员</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setEditTarget(null)}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleEditSave} disabled={editSaving}>
|
||||||
|
{editSaving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* 确认 Dialog */}
|
||||||
|
<Dialog open={!!confirmTarget} onOpenChange={() => setConfirmTarget(null)}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>确认{confirmTarget?.action}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
确定要{confirmTarget?.action}该账号吗?此操作将立即生效。
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setConfirmTarget(null)}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={confirmTarget?.status === 'disabled' ? 'destructive' : 'default'}
|
||||||
|
onClick={handleConfirmSave}
|
||||||
|
disabled={confirmSaving}
|
||||||
|
>
|
||||||
|
{confirmSaving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
确认{confirmTarget?.action}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
351
admin/src/app/(dashboard)/api-keys/page.tsx
Normal file
351
admin/src/app/(dashboard)/api-keys/page.tsx
Normal file
@@ -0,0 +1,351 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
|
import {
|
||||||
|
Plus,
|
||||||
|
Loader2,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Trash2,
|
||||||
|
Copy,
|
||||||
|
Check,
|
||||||
|
AlertTriangle,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
DialogDescription,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import { api } from '@/lib/api-client'
|
||||||
|
import { ApiRequestError } from '@/lib/api-client'
|
||||||
|
import { formatDate } from '@/lib/utils'
|
||||||
|
import type { TokenInfo } from '@/lib/types'
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20
|
||||||
|
|
||||||
|
const allPermissions = [
|
||||||
|
{ key: 'chat', label: '对话' },
|
||||||
|
{ key: 'relay', label: '中转' },
|
||||||
|
{ key: 'admin', label: '管理' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function ApiKeysPage() {
|
||||||
|
const [tokens, setTokens] = useState<TokenInfo[]>([])
|
||||||
|
const [total, setTotal] = useState(0)
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
// 创建 Dialog
|
||||||
|
const [createOpen, setCreateOpen] = useState(false)
|
||||||
|
const [createForm, setCreateForm] = useState({ name: '', expires_days: '', permissions: ['chat'] as string[] })
|
||||||
|
const [creating, setCreating] = useState(false)
|
||||||
|
|
||||||
|
// 创建成功显示 token
|
||||||
|
const [createdToken, setCreatedToken] = useState<TokenInfo | null>(null)
|
||||||
|
const [copied, setCopied] = useState(false)
|
||||||
|
|
||||||
|
// 撤销确认
|
||||||
|
const [revokeTarget, setRevokeTarget] = useState<TokenInfo | null>(null)
|
||||||
|
const [revoking, setRevoking] = useState(false)
|
||||||
|
|
||||||
|
const fetchTokens = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const res = await api.tokens.list({ page, page_size: PAGE_SIZE })
|
||||||
|
setTokens(res.items)
|
||||||
|
setTotal(res.total)
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiRequestError) setError(err.body.message)
|
||||||
|
else setError('加载失败')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [page])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchTokens()
|
||||||
|
}, [fetchTokens])
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
|
||||||
|
|
||||||
|
function togglePermission(perm: string) {
|
||||||
|
setCreateForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
permissions: prev.permissions.includes(perm)
|
||||||
|
? prev.permissions.filter((p) => p !== perm)
|
||||||
|
: [...prev.permissions, perm],
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreate() {
|
||||||
|
if (!createForm.name.trim() || createForm.permissions.length === 0) return
|
||||||
|
setCreating(true)
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
name: createForm.name.trim(),
|
||||||
|
expires_days: createForm.expires_days ? parseInt(createForm.expires_days, 10) : undefined,
|
||||||
|
permissions: createForm.permissions,
|
||||||
|
}
|
||||||
|
const res = await api.tokens.create(payload)
|
||||||
|
setCreateOpen(false)
|
||||||
|
setCreatedToken(res)
|
||||||
|
setCreateForm({ name: '', expires_days: '', permissions: ['chat'] })
|
||||||
|
fetchTokens()
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiRequestError) setError(err.body.message)
|
||||||
|
} finally {
|
||||||
|
setCreating(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRevoke() {
|
||||||
|
if (!revokeTarget) return
|
||||||
|
setRevoking(true)
|
||||||
|
try {
|
||||||
|
await api.tokens.revoke(revokeTarget.id)
|
||||||
|
setRevokeTarget(null)
|
||||||
|
fetchTokens()
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiRequestError) setError(err.body.message)
|
||||||
|
} finally {
|
||||||
|
setRevoking(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyToken() {
|
||||||
|
if (!createdToken?.token) return
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(createdToken.token)
|
||||||
|
setCopied(true)
|
||||||
|
setTimeout(() => setCopied(false), 2000)
|
||||||
|
} catch {
|
||||||
|
// Fallback
|
||||||
|
const textarea = document.createElement('textarea')
|
||||||
|
textarea.value = createdToken.token
|
||||||
|
document.body.appendChild(textarea)
|
||||||
|
textarea.select()
|
||||||
|
document.execCommand('copy')
|
||||||
|
document.body.removeChild(textarea)
|
||||||
|
setCopied(true)
|
||||||
|
setTimeout(() => setCopied(false), 2000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div />
|
||||||
|
<Button onClick={() => setCreateOpen(true)}>
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
新建密钥
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-md bg-destructive/10 border border-destructive/20 px-4 py-3 text-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
<button onClick={() => setError('')} className="ml-2 underline cursor-pointer">关闭</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex h-64 items-center justify-center">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : tokens.length === 0 ? (
|
||||||
|
<div className="flex h-64 items-center justify-center text-muted-foreground text-sm">
|
||||||
|
暂无数据
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>名称</TableHead>
|
||||||
|
<TableHead>前缀</TableHead>
|
||||||
|
<TableHead>权限</TableHead>
|
||||||
|
<TableHead>最后使用</TableHead>
|
||||||
|
<TableHead>过期时间</TableHead>
|
||||||
|
<TableHead>创建时间</TableHead>
|
||||||
|
<TableHead className="text-right">操作</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{tokens.map((t) => (
|
||||||
|
<TableRow key={t.id}>
|
||||||
|
<TableCell className="font-medium">{t.name}</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||||
|
{t.token_prefix}...
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{t.permissions.map((p) => (
|
||||||
|
<Badge key={p} variant="outline" className="text-xs">
|
||||||
|
{p}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||||
|
{t.last_used_at ? formatDate(t.last_used_at) : '未使用'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||||
|
{t.expires_at ? formatDate(t.expires_at) : '永不过期'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||||
|
{formatDate(t.created_at)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => setRevokeTarget(t)} title="撤销">
|
||||||
|
<Trash2 className="h-4 w-4 text-destructive" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
第 {page} 页 / 共 {totalPages} 页 ({total} 条)
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => setPage(page - 1)}>
|
||||||
|
<ChevronLeft className="h-4 w-4 mr-1" />
|
||||||
|
上一页
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" disabled={page >= totalPages} onClick={() => setPage(page + 1)}>
|
||||||
|
下一页
|
||||||
|
<ChevronRight className="h-4 w-4 ml-1" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 创建 Dialog */}
|
||||||
|
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>新建 API 密钥</DialogTitle>
|
||||||
|
<DialogDescription>创建新的 API 密钥用于接口调用</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>名称 *</Label>
|
||||||
|
<Input
|
||||||
|
value={createForm.name}
|
||||||
|
onChange={(e) => setCreateForm({ ...createForm, name: e.target.value })}
|
||||||
|
placeholder="例如: 生产环境"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>过期天数 (留空则永不过期)</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={createForm.expires_days}
|
||||||
|
onChange={(e) => setCreateForm({ ...createForm, expires_days: e.target.value })}
|
||||||
|
placeholder="365"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>权限 *</Label>
|
||||||
|
<div className="flex flex-wrap gap-3 mt-1">
|
||||||
|
{allPermissions.map((perm) => (
|
||||||
|
<label
|
||||||
|
key={perm.key}
|
||||||
|
className="flex items-center gap-2 cursor-pointer"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={createForm.permissions.includes(perm.key)}
|
||||||
|
onChange={() => togglePermission(perm.key)}
|
||||||
|
className="h-4 w-4 rounded border-input bg-transparent accent-primary cursor-pointer"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-foreground">{perm.label}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setCreateOpen(false)}>取消</Button>
|
||||||
|
<Button onClick={handleCreate} disabled={creating || !createForm.name.trim() || createForm.permissions.length === 0}>
|
||||||
|
{creating && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
创建
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* 创建成功 Dialog */}
|
||||||
|
<Dialog open={!!createdToken} onOpenChange={() => setCreatedToken(null)}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<AlertTriangle className="h-5 w-5 text-yellow-400" />
|
||||||
|
密钥已创建
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
请立即复制并安全保存此密钥,关闭后将无法再次查看完整密钥。
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="rounded-md bg-muted p-4">
|
||||||
|
<p className="text-xs text-muted-foreground mb-2">完整密钥</p>
|
||||||
|
<p className="font-mono text-sm break-all text-foreground">
|
||||||
|
{createdToken?.token}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md bg-yellow-500/10 border border-yellow-500/20 p-3 text-sm text-yellow-400">
|
||||||
|
此密钥仅显示一次。请确保已保存到安全的位置。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button onClick={copyToken} variant="outline">
|
||||||
|
{copied ? <Check className="h-4 w-4 mr-2" /> : <Copy className="h-4 w-4 mr-2" />}
|
||||||
|
{copied ? '已复制' : '复制密钥'}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => setCreatedToken(null)}>我已保存</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* 撤销确认 */}
|
||||||
|
<Dialog open={!!revokeTarget} onOpenChange={() => setRevokeTarget(null)}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>确认撤销</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
确定要撤销密钥 "{revokeTarget?.name}" 吗?使用此密钥的应用将立即失去访问权限。
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setRevokeTarget(null)}>取消</Button>
|
||||||
|
<Button variant="destructive" onClick={handleRevoke} disabled={revoking}>
|
||||||
|
{revoking && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
撤销
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
283
admin/src/app/(dashboard)/config/page.tsx
Normal file
283
admin/src/app/(dashboard)/config/page.tsx
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
|
import {
|
||||||
|
Loader2,
|
||||||
|
Pencil,
|
||||||
|
RotateCcw,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
DialogDescription,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
|
import { api } from '@/lib/api-client'
|
||||||
|
import { ApiRequestError } from '@/lib/api-client'
|
||||||
|
import type { ConfigItem } from '@/lib/types'
|
||||||
|
|
||||||
|
const sourceLabels: Record<string, string> = {
|
||||||
|
default: '默认值',
|
||||||
|
env: '环境变量',
|
||||||
|
db: '数据库',
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceVariants: Record<string, 'secondary' | 'info' | 'default'> = {
|
||||||
|
default: 'secondary',
|
||||||
|
env: 'info',
|
||||||
|
db: 'default',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ConfigPage() {
|
||||||
|
const [configs, setConfigs] = useState<ConfigItem[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [activeTab, setActiveTab] = useState('all')
|
||||||
|
|
||||||
|
// 编辑 Dialog
|
||||||
|
const [editTarget, setEditTarget] = useState<ConfigItem | null>(null)
|
||||||
|
const [editValue, setEditValue] = useState('')
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
|
const fetchConfigs = useCallback(async (category?: string) => {
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const params: Record<string, unknown> = {}
|
||||||
|
if (category && category !== 'all') params.category = category
|
||||||
|
const res = await api.config.list(params)
|
||||||
|
setConfigs(res)
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiRequestError) setError(err.body.message)
|
||||||
|
else setError('加载失败')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchConfigs(activeTab)
|
||||||
|
}, [fetchConfigs, activeTab])
|
||||||
|
|
||||||
|
function openEditDialog(config: ConfigItem) {
|
||||||
|
setEditTarget(config)
|
||||||
|
setEditValue(config.current_value !== undefined ? String(config.current_value) : '')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
if (!editTarget) return
|
||||||
|
// 表单验证
|
||||||
|
if (editValue.trim() === '') {
|
||||||
|
setError('配置值不能为空')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (editTarget.value_type === 'number' && isNaN(Number(editValue))) {
|
||||||
|
setError('请输入有效的数字')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (editTarget.value_type === 'boolean' && editValue !== 'true' && editValue !== 'false') {
|
||||||
|
setError('布尔值只能为 true 或 false')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
let parsedValue: string | number | boolean = editValue
|
||||||
|
if (editTarget.value_type === 'number') {
|
||||||
|
parsedValue = parseFloat(editValue) || 0
|
||||||
|
} else if (editTarget.value_type === 'boolean') {
|
||||||
|
parsedValue = editValue === 'true'
|
||||||
|
}
|
||||||
|
await api.config.update(editTarget.id, { current_value: parsedValue })
|
||||||
|
setEditTarget(null)
|
||||||
|
fetchConfigs(activeTab)
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiRequestError) setError(err.body.message)
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatValue(value: unknown): string {
|
||||||
|
if (value === undefined || value === null) return '-'
|
||||||
|
if (typeof value === 'boolean') return value ? 'true' : 'false'
|
||||||
|
return String(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const categories = ['all', 'auth', 'relay', 'model', 'system']
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 分类 Tabs */}
|
||||||
|
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||||
|
<TabsList>
|
||||||
|
{categories.map((cat) => (
|
||||||
|
<TabsTrigger key={cat} value={cat}>
|
||||||
|
{cat === 'all' ? '全部' : cat}
|
||||||
|
</TabsTrigger>
|
||||||
|
))}
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-md bg-destructive/10 border border-destructive/20 px-4 py-3 text-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
<button onClick={() => setError('')} className="ml-2 underline cursor-pointer">关闭</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex h-64 items-center justify-center">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : configs.length === 0 ? (
|
||||||
|
<div className="flex h-64 items-center justify-center text-muted-foreground text-sm">
|
||||||
|
暂无配置项
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>分类</TableHead>
|
||||||
|
<TableHead>Key</TableHead>
|
||||||
|
<TableHead>当前值</TableHead>
|
||||||
|
<TableHead>默认值</TableHead>
|
||||||
|
<TableHead>来源</TableHead>
|
||||||
|
<TableHead>需重启</TableHead>
|
||||||
|
<TableHead>描述</TableHead>
|
||||||
|
<TableHead className="text-right">操作</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{configs.map((config) => (
|
||||||
|
<TableRow key={config.id}>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="outline">{config.category}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-sm">{config.key_path}</TableCell>
|
||||||
|
<TableCell className="font-mono text-sm max-w-[200px] truncate">
|
||||||
|
{formatValue(config.current_value)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs text-muted-foreground max-w-[200px] truncate">
|
||||||
|
{formatValue(config.default_value)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={sourceVariants[config.source] || 'secondary'}>
|
||||||
|
{sourceLabels[config.source] || config.source}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{config.requires_restart ? (
|
||||||
|
<Badge variant="warning">是</Badge>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">否</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground max-w-[250px] truncate">
|
||||||
|
{config.description || '-'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => openEditDialog(config)} title="编辑">
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 编辑 Dialog */}
|
||||||
|
<Dialog open={!!editTarget} onOpenChange={() => setEditTarget(null)}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>编辑配置</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
修改 {editTarget?.key_path} 的值
|
||||||
|
{editTarget?.requires_restart && (
|
||||||
|
<span className="block mt-1 text-yellow-400 text-xs">
|
||||||
|
注意: 修改此配置需要重启服务才能生效
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Key</Label>
|
||||||
|
<Input value={editTarget?.key_path || ''} disabled />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>类型</Label>
|
||||||
|
<Input value={editTarget?.value_type || ''} disabled />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>
|
||||||
|
新值 {editTarget?.default_value !== undefined && (
|
||||||
|
<span className="text-xs text-muted-foreground ml-2">
|
||||||
|
(默认: {formatValue(editTarget.default_value)})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Label>
|
||||||
|
{editTarget?.value_type === 'boolean' ? (
|
||||||
|
<Select value={editValue} onValueChange={setEditValue}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="true">true</SelectItem>
|
||||||
|
<SelectItem value="false">false</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
type={editTarget?.value_type === 'number' ? 'number' : 'text'}
|
||||||
|
value={editValue}
|
||||||
|
onChange={(e) => setEditValue(e.target.value)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
if (editTarget?.default_value !== undefined) {
|
||||||
|
setEditValue(String(editTarget.default_value))
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RotateCcw className="h-4 w-4 mr-2" />
|
||||||
|
恢复默认
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" onClick={() => setEditTarget(null)}>取消</Button>
|
||||||
|
<Button onClick={handleSave} disabled={saving}>
|
||||||
|
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
125
admin/src/app/(dashboard)/devices/page.tsx
Normal file
125
admin/src/app/(dashboard)/devices/page.tsx
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Monitor, Loader2, RefreshCw } from 'lucide-react'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import {
|
||||||
|
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
||||||
|
} from '@/components/ui/table'
|
||||||
|
import { api } from '@/lib/api-client'
|
||||||
|
import { ApiRequestError } from '@/lib/api-client'
|
||||||
|
import type { DeviceInfo } from '@/lib/types'
|
||||||
|
|
||||||
|
function formatRelativeTime(dateStr: string): string {
|
||||||
|
const now = Date.now()
|
||||||
|
const then = new Date(dateStr).getTime()
|
||||||
|
const diffMs = now - then
|
||||||
|
const diffMin = Math.floor(diffMs / 60000)
|
||||||
|
const diffHour = Math.floor(diffMs / 3600000)
|
||||||
|
const diffDay = Math.floor(diffMs / 86400000)
|
||||||
|
|
||||||
|
if (diffMin < 1) return '刚刚'
|
||||||
|
if (diffMin < 60) return `${diffMin} 分钟前`
|
||||||
|
if (diffHour < 24) return `${diffHour} 小时前`
|
||||||
|
return `${diffDay} 天前`
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOnline(lastSeen: string): boolean {
|
||||||
|
return Date.now() - new Date(lastSeen).getTime() < 5 * 60 * 1000
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DevicesPage() {
|
||||||
|
const [devices, setDevices] = useState<DeviceInfo[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
async function fetchDevices() {
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const res = await api.devices.list()
|
||||||
|
setDevices(res)
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiRequestError) setError(err.body.message)
|
||||||
|
else setError('加载失败')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => { fetchDevices() }, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-lg font-semibold text-foreground">设备管理</h2>
|
||||||
|
<button
|
||||||
|
onClick={fetchDevices}
|
||||||
|
disabled={loading}
|
||||||
|
className="flex items-center gap-2 rounded-md border border-border px-3 py-1.5 text-sm text-muted-foreground hover:bg-muted hover:text-foreground transition-colors cursor-pointer disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
|
||||||
|
刷新
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-md bg-destructive/10 border border-destructive/20 px-4 py-3 text-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading && !devices.length ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : devices.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
||||||
|
<Monitor className="h-10 w-10 mb-3" />
|
||||||
|
<p className="text-sm">暂无已注册设备</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-md border border-border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>设备名称</TableHead>
|
||||||
|
<TableHead>平台</TableHead>
|
||||||
|
<TableHead>版本</TableHead>
|
||||||
|
<TableHead>状态</TableHead>
|
||||||
|
<TableHead>最后活跃</TableHead>
|
||||||
|
<TableHead>注册时间</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{devices.map((d) => (
|
||||||
|
<TableRow key={d.id}>
|
||||||
|
<TableCell className="font-medium">
|
||||||
|
{d.device_name || d.device_id}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="secondary">{d.platform || 'unknown'}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">
|
||||||
|
{d.app_version || '-'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={isOnline(d.last_seen_at) ? 'success' : 'outline'}>
|
||||||
|
{isOnline(d.last_seen_at) ? '在线' : '离线'}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">
|
||||||
|
{formatRelativeTime(d.last_seen_at)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground text-xs">
|
||||||
|
{new Date(d.created_at).toLocaleString('zh-CN')}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
305
admin/src/app/(dashboard)/layout.tsx
Normal file
305
admin/src/app/(dashboard)/layout.tsx
Normal file
@@ -0,0 +1,305 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect, type ReactNode } from 'react'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { usePathname, useRouter } from 'next/navigation'
|
||||||
|
import {
|
||||||
|
LayoutDashboard,
|
||||||
|
Users,
|
||||||
|
Server,
|
||||||
|
Cpu,
|
||||||
|
Key,
|
||||||
|
BarChart3,
|
||||||
|
ArrowLeftRight,
|
||||||
|
Settings,
|
||||||
|
FileText,
|
||||||
|
LogOut,
|
||||||
|
ChevronLeft,
|
||||||
|
Menu,
|
||||||
|
Bell,
|
||||||
|
UserCog,
|
||||||
|
ShieldCheck,
|
||||||
|
Monitor,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { AuthGuard, useAuth } from '@/components/auth-guard'
|
||||||
|
import { logout } from '@/lib/auth'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ href: '/', label: '仪表盘', icon: LayoutDashboard, permission: null },
|
||||||
|
{ href: '/accounts', label: '账号管理', icon: Users, permission: 'account:admin' },
|
||||||
|
{ href: '/providers', label: '服务商', icon: Server, permission: 'model:admin' },
|
||||||
|
{ href: '/models', label: '模型管理', icon: Cpu, permission: 'model:admin' },
|
||||||
|
{ href: '/api-keys', label: 'API 密钥', icon: Key, permission: null },
|
||||||
|
{ href: '/usage', label: '用量统计', icon: BarChart3, permission: null },
|
||||||
|
{ href: '/relay', label: '中转任务', icon: ArrowLeftRight, permission: 'relay:admin' },
|
||||||
|
{ href: '/config', label: '系统配置', icon: Settings, permission: 'admin:full' },
|
||||||
|
{ href: '/logs', label: '操作日志', icon: FileText, permission: 'admin:full' },
|
||||||
|
{ href: '/profile', label: '个人设置', icon: UserCog, permission: null },
|
||||||
|
{ href: '/security', label: '安全设置', icon: ShieldCheck, permission: null },
|
||||||
|
{ href: '/devices', label: '设备管理', icon: Monitor, permission: null },
|
||||||
|
]
|
||||||
|
|
||||||
|
function Sidebar({
|
||||||
|
collapsed,
|
||||||
|
onToggle,
|
||||||
|
mobileOpen,
|
||||||
|
onMobileClose,
|
||||||
|
}: {
|
||||||
|
collapsed: boolean
|
||||||
|
onToggle: () => void
|
||||||
|
mobileOpen: boolean
|
||||||
|
onMobileClose: () => void
|
||||||
|
}) {
|
||||||
|
const pathname = usePathname()
|
||||||
|
const router = useRouter()
|
||||||
|
const { account } = useAuth()
|
||||||
|
|
||||||
|
// 路由变化时关闭移动端菜单
|
||||||
|
useEffect(() => {
|
||||||
|
onMobileClose()
|
||||||
|
}, [pathname, onMobileClose])
|
||||||
|
|
||||||
|
function handleLogout() {
|
||||||
|
logout()
|
||||||
|
router.replace('/login')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* 移动端 overlay */}
|
||||||
|
{mobileOpen && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-40 bg-black/50 lg:hidden"
|
||||||
|
onClick={onMobileClose}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<aside
|
||||||
|
className={cn(
|
||||||
|
'fixed left-0 top-0 z-50 flex h-screen flex-col border-r border-border bg-card transition-all duration-300',
|
||||||
|
collapsed ? 'w-16' : 'w-64',
|
||||||
|
'lg:z-40',
|
||||||
|
mobileOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Logo */}
|
||||||
|
<div className="flex h-14 items-center border-b border-border px-4">
|
||||||
|
<Link href="/" className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<div className="flex h-8 w-8 items-center justify-center rounded-md bg-primary text-primary-foreground font-bold text-sm">
|
||||||
|
Z
|
||||||
|
</div>
|
||||||
|
{!collapsed && (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-sm font-bold text-foreground">ZCLAW</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground">Admin</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 导航 */}
|
||||||
|
<nav className="flex-1 overflow-y-auto scrollbar-thin py-2 px-2">
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{navItems
|
||||||
|
.filter((item) => {
|
||||||
|
if (!item.permission) return true
|
||||||
|
if (!account) return false
|
||||||
|
// super_admin 拥有所有权限
|
||||||
|
if (account.role === 'super_admin') return true
|
||||||
|
return account.permissions?.includes(item.permission) ?? false
|
||||||
|
})
|
||||||
|
.map((item) => {
|
||||||
|
const isActive =
|
||||||
|
item.href === '/'
|
||||||
|
? pathname === '/'
|
||||||
|
: pathname.startsWith(item.href)
|
||||||
|
const Icon = item.icon
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li key={item.href}>
|
||||||
|
<Link
|
||||||
|
href={item.href}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors duration-200 cursor-pointer',
|
||||||
|
isActive
|
||||||
|
? 'bg-muted text-green-400'
|
||||||
|
: 'text-muted-foreground hover:bg-muted hover:text-foreground',
|
||||||
|
collapsed && 'justify-center px-2',
|
||||||
|
)}
|
||||||
|
title={collapsed ? item.label : undefined}
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4 shrink-0" />
|
||||||
|
{!collapsed && <span>{item.label}</span>}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* 底部折叠按钮 */}
|
||||||
|
<div className="border-t border-border p-2">
|
||||||
|
<button
|
||||||
|
onClick={onToggle}
|
||||||
|
className="flex w-full items-center justify-center rounded-md px-3 py-2 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors duration-200 cursor-pointer"
|
||||||
|
>
|
||||||
|
<ChevronLeft
|
||||||
|
className={cn(
|
||||||
|
'h-4 w-4 transition-transform duration-200',
|
||||||
|
collapsed && 'rotate-180',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 折叠时显示退出按钮 */}
|
||||||
|
{collapsed && (
|
||||||
|
<div className="border-t border-border p-2">
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="flex w-full items-center justify-center rounded-md px-3 py-2 text-muted-foreground hover:bg-muted hover:text-destructive transition-colors duration-200 cursor-pointer"
|
||||||
|
title="退出登录"
|
||||||
|
>
|
||||||
|
<LogOut className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 用户信息 */}
|
||||||
|
{!collapsed && (
|
||||||
|
<div className="border-t border-border p-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-medium text-foreground">
|
||||||
|
{account?.display_name?.[0] || account?.username?.[0] || 'A'}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="truncate text-sm font-medium text-foreground">
|
||||||
|
{account?.display_name || account?.username || 'Admin'}
|
||||||
|
</p>
|
||||||
|
<p className="truncate text-xs text-muted-foreground">
|
||||||
|
{account?.role || 'admin'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="rounded-md p-1.5 text-muted-foreground hover:bg-muted hover:text-destructive transition-colors duration-200 cursor-pointer"
|
||||||
|
title="退出登录"
|
||||||
|
>
|
||||||
|
<LogOut className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Header({ children }: { children?: ReactNode }) {
|
||||||
|
const pathname = usePathname()
|
||||||
|
const currentNav = navItems.find(
|
||||||
|
(item) =>
|
||||||
|
item.href === '/'
|
||||||
|
? pathname === '/'
|
||||||
|
: pathname.startsWith(item.href),
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="sticky top-0 z-30 flex h-14 items-center border-b border-border bg-background/80 backdrop-blur-sm px-6">
|
||||||
|
{/* 移动端菜单按钮 */}
|
||||||
|
{children}
|
||||||
|
|
||||||
|
{/* 页面标题 */}
|
||||||
|
<h1 className="text-lg font-semibold text-foreground">
|
||||||
|
{currentNav?.label || '仪表盘'}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div className="ml-auto flex items-center gap-2">
|
||||||
|
{/* 通知 */}
|
||||||
|
<button
|
||||||
|
className="relative rounded-md p-2 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors duration-200 cursor-pointer"
|
||||||
|
title="通知"
|
||||||
|
>
|
||||||
|
<Bell className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MobileMenuButton({ onClick }: { onClick: () => void }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
className="mr-3 rounded-md p-2 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors duration-200 lg:hidden cursor-pointer"
|
||||||
|
>
|
||||||
|
<Menu className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 路由级权限守卫:隐藏导航项但用户直接访问 URL 时拦截 */
|
||||||
|
function PageGuard({ children }: { children: ReactNode }) {
|
||||||
|
const pathname = usePathname()
|
||||||
|
const router = useRouter()
|
||||||
|
const { account } = useAuth()
|
||||||
|
|
||||||
|
const matchedNav = navItems.find((item) =>
|
||||||
|
item.href === '/' ? pathname === '/' : pathname.startsWith(item.href),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (matchedNav?.permission && account) {
|
||||||
|
if (account.role !== 'super_admin' && !(account.permissions?.includes(matchedNav.permission) ?? false)) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-1 items-center justify-center">
|
||||||
|
<div className="text-center space-y-3">
|
||||||
|
<p className="text-lg font-medium text-muted-foreground">权限不足</p>
|
||||||
|
<p className="text-sm text-muted-foreground">您没有访问「{matchedNav.label}」的权限</p>
|
||||||
|
<button
|
||||||
|
onClick={() => router.replace('/')}
|
||||||
|
className="text-sm text-primary hover:underline cursor-pointer"
|
||||||
|
>
|
||||||
|
返回仪表盘
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DashboardLayout({ children }: { children: ReactNode }) {
|
||||||
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
|
||||||
|
const [mobileOpen, setMobileOpen] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthGuard>
|
||||||
|
<PageGuard>
|
||||||
|
<div className="flex min-h-screen">
|
||||||
|
<Sidebar
|
||||||
|
collapsed={sidebarCollapsed}
|
||||||
|
onToggle={() => setSidebarCollapsed(!sidebarCollapsed)}
|
||||||
|
mobileOpen={mobileOpen}
|
||||||
|
onMobileClose={() => setMobileOpen(false)}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex flex-1 flex-col transition-all duration-300',
|
||||||
|
'ml-0 lg:transition-all',
|
||||||
|
sidebarCollapsed ? 'lg:ml-16' : 'lg:ml-64',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Header>
|
||||||
|
<MobileMenuButton onClick={() => setMobileOpen(true)} />
|
||||||
|
</Header>
|
||||||
|
<main className="flex-1 overflow-auto p-6 scrollbar-thin">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PageGuard>
|
||||||
|
</AuthGuard>
|
||||||
|
)
|
||||||
|
}
|
||||||
436
admin/src/app/(dashboard)/models/page.tsx
Normal file
436
admin/src/app/(dashboard)/models/page.tsx
Normal file
@@ -0,0 +1,436 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
|
import {
|
||||||
|
Plus,
|
||||||
|
Loader2,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Pencil,
|
||||||
|
Trash2,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Switch } from '@/components/ui/switch'
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
DialogDescription,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
import { api } from '@/lib/api-client'
|
||||||
|
import { ApiRequestError } from '@/lib/api-client'
|
||||||
|
import { formatNumber } from '@/lib/utils'
|
||||||
|
import type { Model, Provider } from '@/lib/types'
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20
|
||||||
|
|
||||||
|
interface ModelForm {
|
||||||
|
provider_id: string
|
||||||
|
model_id: string
|
||||||
|
alias: string
|
||||||
|
context_window: string
|
||||||
|
max_output_tokens: string
|
||||||
|
supports_streaming: boolean
|
||||||
|
supports_vision: boolean
|
||||||
|
enabled: boolean
|
||||||
|
pricing_input: string
|
||||||
|
pricing_output: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyForm: ModelForm = {
|
||||||
|
provider_id: '',
|
||||||
|
model_id: '',
|
||||||
|
alias: '',
|
||||||
|
context_window: '4096',
|
||||||
|
max_output_tokens: '4096',
|
||||||
|
supports_streaming: true,
|
||||||
|
supports_vision: false,
|
||||||
|
enabled: true,
|
||||||
|
pricing_input: '',
|
||||||
|
pricing_output: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ModelsPage() {
|
||||||
|
const [models, setModels] = useState<Model[]>([])
|
||||||
|
const [providers, setProviders] = useState<Provider[]>([])
|
||||||
|
const [total, setTotal] = useState(0)
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [providerFilter, setProviderFilter] = useState<string>('all')
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
// Dialog
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false)
|
||||||
|
const [editTarget, setEditTarget] = useState<Model | null>(null)
|
||||||
|
const [form, setForm] = useState<ModelForm>(emptyForm)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
|
// 删除
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<Model | null>(null)
|
||||||
|
const [deleting, setDeleting] = useState(false)
|
||||||
|
|
||||||
|
const fetchModels = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const params: Record<string, unknown> = { page, page_size: PAGE_SIZE }
|
||||||
|
if (providerFilter !== 'all') params.provider_id = providerFilter
|
||||||
|
const res = await api.models.list(params)
|
||||||
|
setModels(res.items)
|
||||||
|
setTotal(res.total)
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiRequestError) setError(err.body.message)
|
||||||
|
else setError('加载失败')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [page, providerFilter])
|
||||||
|
|
||||||
|
const fetchProviders = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await api.providers.list()
|
||||||
|
setProviders(res)
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchModels()
|
||||||
|
fetchProviders()
|
||||||
|
}, [fetchModels, fetchProviders])
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
|
||||||
|
|
||||||
|
const providerMap = new Map(providers.map((p) => [p.id, p.display_name || p.name]))
|
||||||
|
|
||||||
|
function openCreateDialog() {
|
||||||
|
setEditTarget(null)
|
||||||
|
setForm(emptyForm)
|
||||||
|
setDialogOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditDialog(model: Model) {
|
||||||
|
setEditTarget(model)
|
||||||
|
setForm({
|
||||||
|
provider_id: model.provider_id,
|
||||||
|
model_id: model.model_id,
|
||||||
|
alias: model.alias,
|
||||||
|
context_window: model.context_window.toString(),
|
||||||
|
max_output_tokens: model.max_output_tokens.toString(),
|
||||||
|
supports_streaming: model.supports_streaming,
|
||||||
|
supports_vision: model.supports_vision,
|
||||||
|
enabled: model.enabled,
|
||||||
|
pricing_input: model.pricing_input.toString(),
|
||||||
|
pricing_output: model.pricing_output.toString(),
|
||||||
|
})
|
||||||
|
setDialogOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
if (!form.model_id.trim() || !form.provider_id) return
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
provider_id: form.provider_id,
|
||||||
|
model_id: form.model_id.trim(),
|
||||||
|
alias: form.alias.trim(),
|
||||||
|
context_window: parseInt(form.context_window, 10) || 4096,
|
||||||
|
max_output_tokens: parseInt(form.max_output_tokens, 10) || 4096,
|
||||||
|
supports_streaming: form.supports_streaming,
|
||||||
|
supports_vision: form.supports_vision,
|
||||||
|
enabled: form.enabled,
|
||||||
|
pricing_input: parseFloat(form.pricing_input) || 0,
|
||||||
|
pricing_output: parseFloat(form.pricing_output) || 0,
|
||||||
|
}
|
||||||
|
if (editTarget) {
|
||||||
|
await api.models.update(editTarget.id, payload)
|
||||||
|
} else {
|
||||||
|
await api.models.create(payload)
|
||||||
|
}
|
||||||
|
setDialogOpen(false)
|
||||||
|
fetchModels()
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiRequestError) setError(err.body.message)
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete() {
|
||||||
|
if (!deleteTarget) return
|
||||||
|
setDeleting(true)
|
||||||
|
try {
|
||||||
|
await api.models.delete(deleteTarget.id)
|
||||||
|
setDeleteTarget(null)
|
||||||
|
fetchModels()
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiRequestError) setError(err.body.message)
|
||||||
|
} finally {
|
||||||
|
setDeleting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Select value={providerFilter} onValueChange={(v) => { setProviderFilter(v); setPage(1) }}>
|
||||||
|
<SelectTrigger className="w-[200px]">
|
||||||
|
<SelectValue placeholder="按服务商筛选" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">全部服务商</SelectItem>
|
||||||
|
{providers.map((p) => (
|
||||||
|
<SelectItem key={p.id} value={p.id}>
|
||||||
|
{p.display_name || p.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button onClick={openCreateDialog}>
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
新建模型
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-md bg-destructive/10 border border-destructive/20 px-4 py-3 text-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
<button onClick={() => setError('')} className="ml-2 underline cursor-pointer">关闭</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex h-64 items-center justify-center">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : models.length === 0 ? (
|
||||||
|
<div className="flex h-64 items-center justify-center text-muted-foreground text-sm">
|
||||||
|
暂无数据
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>模型 ID</TableHead>
|
||||||
|
<TableHead>别名</TableHead>
|
||||||
|
<TableHead>服务商</TableHead>
|
||||||
|
<TableHead>上下文窗口</TableHead>
|
||||||
|
<TableHead>最大输出</TableHead>
|
||||||
|
<TableHead>流式</TableHead>
|
||||||
|
<TableHead>视觉</TableHead>
|
||||||
|
<TableHead>启用</TableHead>
|
||||||
|
<TableHead className="text-right">操作</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{models.map((m) => (
|
||||||
|
<TableRow key={m.id}>
|
||||||
|
<TableCell className="font-mono text-sm">{m.model_id}</TableCell>
|
||||||
|
<TableCell>{m.alias || '-'}</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">
|
||||||
|
{providerMap.get(m.provider_id) || m.provider_id.slice(0, 8)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||||
|
{formatNumber(m.context_window)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||||
|
{formatNumber(m.max_output_tokens)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={m.supports_streaming ? 'success' : 'secondary'}>
|
||||||
|
{m.supports_streaming ? '是' : '否'}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={m.supports_vision ? 'success' : 'secondary'}>
|
||||||
|
{m.supports_vision ? '是' : '否'}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={m.enabled ? 'success' : 'destructive'}>
|
||||||
|
{m.enabled ? '启用' : '禁用'}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<div className="flex items-center justify-end gap-1">
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => openEditDialog(m)} title="编辑">
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => setDeleteTarget(m)} title="删除">
|
||||||
|
<Trash2 className="h-4 w-4 text-destructive" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
第 {page} 页 / 共 {totalPages} 页 ({total} 条)
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => setPage(page - 1)}>
|
||||||
|
<ChevronLeft className="h-4 w-4 mr-1" />
|
||||||
|
上一页
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" disabled={page >= totalPages} onClick={() => setPage(page + 1)}>
|
||||||
|
下一页
|
||||||
|
<ChevronRight className="h-4 w-4 ml-1" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 创建/编辑 Dialog */}
|
||||||
|
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||||
|
<DialogContent className="max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{editTarget ? '编辑模型' : '新建模型'}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{editTarget ? '修改模型配置' : '添加新的 AI 模型'}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 max-h-[60vh] overflow-y-auto scrollbar-thin pr-1">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>服务商 *</Label>
|
||||||
|
<Select value={form.provider_id} onValueChange={(v) => setForm({ ...form, provider_id: v })} disabled={!!editTarget}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="选择服务商" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{providers.map((p) => (
|
||||||
|
<SelectItem key={p.id} value={p.id}>
|
||||||
|
{p.display_name || p.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>模型 ID *</Label>
|
||||||
|
<Input
|
||||||
|
value={form.model_id}
|
||||||
|
onChange={(e) => setForm({ ...form, model_id: e.target.value })}
|
||||||
|
placeholder="gpt-4o"
|
||||||
|
disabled={!!editTarget}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>别名</Label>
|
||||||
|
<Input
|
||||||
|
value={form.alias}
|
||||||
|
onChange={(e) => setForm({ ...form, alias: e.target.value })}
|
||||||
|
placeholder="GPT-4o"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>上下文窗口</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={form.context_window}
|
||||||
|
onChange={(e) => setForm({ ...form, context_window: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>最大输出 Tokens</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={form.max_output_tokens}
|
||||||
|
onChange={(e) => setForm({ ...form, max_output_tokens: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Input 定价 ($/1M tokens)</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
value={form.pricing_input}
|
||||||
|
onChange={(e) => setForm({ ...form, pricing_input: e.target.value })}
|
||||||
|
placeholder="0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Output 定价 ($/1M tokens)</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
value={form.pricing_output}
|
||||||
|
onChange={(e) => setForm({ ...form, pricing_output: e.target.value })}
|
||||||
|
placeholder="0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-6">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Switch checked={form.supports_streaming} onCheckedChange={(v) => setForm({ ...form, supports_streaming: v })} />
|
||||||
|
<Label>流式</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Switch checked={form.supports_vision} onCheckedChange={(v) => setForm({ ...form, supports_vision: v })} />
|
||||||
|
<Label>视觉</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Switch checked={form.enabled} onCheckedChange={(v) => setForm({ ...form, enabled: v })} />
|
||||||
|
<Label>启用</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setDialogOpen(false)}>取消</Button>
|
||||||
|
<Button onClick={handleSave} disabled={saving || !form.model_id.trim() || !form.provider_id}>
|
||||||
|
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* 删除确认 */}
|
||||||
|
<Dialog open={!!deleteTarget} onOpenChange={() => setDeleteTarget(null)}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>确认删除</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
确定要删除模型 "{deleteTarget?.alias || deleteTarget?.model_id}" 吗?此操作不可撤销。
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setDeleteTarget(null)}>取消</Button>
|
||||||
|
<Button variant="destructive" onClick={handleDelete} disabled={deleting}>
|
||||||
|
{deleting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
338
admin/src/app/(dashboard)/page.tsx
Normal file
338
admin/src/app/(dashboard)/page.tsx
Normal file
@@ -0,0 +1,338 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import {
|
||||||
|
Users,
|
||||||
|
Server,
|
||||||
|
ArrowLeftRight,
|
||||||
|
Zap,
|
||||||
|
Loader2,
|
||||||
|
TrendingUp,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import {
|
||||||
|
AreaChart,
|
||||||
|
Area,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
CartesianGrid,
|
||||||
|
Tooltip,
|
||||||
|
ResponsiveContainer,
|
||||||
|
BarChart,
|
||||||
|
Bar,
|
||||||
|
Legend,
|
||||||
|
} from 'recharts'
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table'
|
||||||
|
import { api } from '@/lib/api-client'
|
||||||
|
import { formatNumber, formatDate } from '@/lib/utils'
|
||||||
|
import type {
|
||||||
|
DashboardStats,
|
||||||
|
UsageStats,
|
||||||
|
OperationLog,
|
||||||
|
} from '@/lib/types'
|
||||||
|
|
||||||
|
interface StatCardProps {
|
||||||
|
title: string
|
||||||
|
value: string | number
|
||||||
|
icon: React.ReactNode
|
||||||
|
color: string
|
||||||
|
subtitle?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatCard({ title, value, icon, color, subtitle }: StatCardProps) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">{title}</p>
|
||||||
|
<p className="mt-1 text-2xl font-bold text-foreground">{value}</p>
|
||||||
|
{subtitle && (
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">{subtitle}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`flex h-10 w-10 items-center justify-center rounded-lg ${color}`}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusBadge({ status }: { status: string }) {
|
||||||
|
const variantMap: Record<string, 'success' | 'destructive' | 'warning' | 'info' | 'secondary'> = {
|
||||||
|
active: 'success',
|
||||||
|
completed: 'success',
|
||||||
|
disabled: 'destructive',
|
||||||
|
failed: 'destructive',
|
||||||
|
processing: 'info',
|
||||||
|
queued: 'warning',
|
||||||
|
suspended: 'destructive',
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Badge variant={variantMap[status] || 'secondary'}>{status}</Badge>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DashboardPage() {
|
||||||
|
const [stats, setStats] = useState<DashboardStats | null>(null)
|
||||||
|
const [usageStats, setUsageStats] = useState<UsageStats | null>(null)
|
||||||
|
const [recentLogs, setRecentLogs] = useState<OperationLog[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchData() {
|
||||||
|
try {
|
||||||
|
const [statsRes, usageRes, logsRes] = await Promise.allSettled([
|
||||||
|
api.stats.dashboard(),
|
||||||
|
api.usage.get(),
|
||||||
|
api.logs.list({ page: 1, page_size: 5 }),
|
||||||
|
])
|
||||||
|
|
||||||
|
if (statsRes.status === 'fulfilled') setStats(statsRes.value)
|
||||||
|
if (usageRes.status === 'fulfilled') setUsageStats(usageRes.value)
|
||||||
|
if (logsRes.status === 'fulfilled') setRecentLogs(logsRes.value)
|
||||||
|
|
||||||
|
if (statsRes.status === 'rejected' && usageRes.status === 'rejected' && logsRes.status === 'rejected') {
|
||||||
|
setError('加载数据失败,请检查后端服务是否启动')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetchData()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-[60vh] items-center justify-center">
|
||||||
|
<div className="flex flex-col items-center gap-3">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||||
|
<p className="text-sm text-muted-foreground">加载中...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-[60vh] items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-destructive">{error}</p>
|
||||||
|
<button
|
||||||
|
onClick={() => window.location.reload()}
|
||||||
|
className="mt-4 text-sm text-primary hover:underline cursor-pointer"
|
||||||
|
>
|
||||||
|
重新加载
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const chartData = (usageStats?.by_day ?? []).map((r) => ({
|
||||||
|
day: r.date.slice(5), // MM-DD
|
||||||
|
请求量: r.request_count,
|
||||||
|
Input: r.input_tokens,
|
||||||
|
Output: r.output_tokens,
|
||||||
|
}))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* 统计卡片 */}
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<StatCard
|
||||||
|
title="总账号数"
|
||||||
|
value={stats?.total_accounts ?? '-'}
|
||||||
|
icon={<Users className="h-5 w-5 text-blue-400" />}
|
||||||
|
color="bg-blue-500/10"
|
||||||
|
subtitle={`活跃 ${stats?.active_accounts ?? 0}`}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title="活跃服务商"
|
||||||
|
value={stats?.active_providers ?? '-'}
|
||||||
|
icon={<Server className="h-5 w-5 text-green-400" />}
|
||||||
|
color="bg-green-500/10"
|
||||||
|
subtitle={`模型 ${stats?.active_models ?? 0}`}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title="今日请求"
|
||||||
|
value={stats?.tasks_today ?? '-'}
|
||||||
|
icon={<ArrowLeftRight className="h-5 w-5 text-purple-400" />}
|
||||||
|
color="bg-purple-500/10"
|
||||||
|
subtitle="中转任务"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title="今日 Token"
|
||||||
|
value={formatNumber((stats?.tokens_today_input ?? 0) + (stats?.tokens_today_output ?? 0))}
|
||||||
|
icon={<Zap className="h-5 w-5 text-orange-400" />}
|
||||||
|
color="bg-orange-500/10"
|
||||||
|
subtitle={`In: ${formatNumber(stats?.tokens_today_input ?? 0)} / Out: ${formatNumber(stats?.tokens_today_output ?? 0)}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 图表 */}
|
||||||
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||||
|
{/* 请求趋势 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<TrendingUp className="h-4 w-4 text-primary" />
|
||||||
|
请求趋势 (30 天)
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{chartData.length > 0 ? (
|
||||||
|
<ResponsiveContainer width="100%" height={280}>
|
||||||
|
<AreaChart data={chartData}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="colorRequests" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="5%" stopColor="#22C55E" stopOpacity={0.3} />
|
||||||
|
<stop offset="95%" stopColor="#22C55E" stopOpacity={0} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#1E293B" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="day"
|
||||||
|
tick={{ fontSize: 12, fill: '#94A3B8' }}
|
||||||
|
axisLine={{ stroke: '#1E293B' }}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tick={{ fontSize: 12, fill: '#94A3B8' }}
|
||||||
|
axisLine={{ stroke: '#1E293B' }}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: '#0F172A',
|
||||||
|
border: '1px solid #1E293B',
|
||||||
|
borderRadius: '8px',
|
||||||
|
color: '#F8FAFC',
|
||||||
|
fontSize: '12px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="请求量"
|
||||||
|
stroke="#22C55E"
|
||||||
|
fillOpacity={1}
|
||||||
|
fill="url(#colorRequests)"
|
||||||
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-[280px] items-center justify-center text-muted-foreground text-sm">
|
||||||
|
暂无数据
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Token 用量 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<Zap className="h-4 w-4 text-orange-400" />
|
||||||
|
Token 用量 (30 天)
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{chartData.length > 0 ? (
|
||||||
|
<ResponsiveContainer width="100%" height={280}>
|
||||||
|
<BarChart data={chartData}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#1E293B" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="day"
|
||||||
|
tick={{ fontSize: 12, fill: '#94A3B8' }}
|
||||||
|
axisLine={{ stroke: '#1E293B' }}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tick={{ fontSize: 12, fill: '#94A3B8' }}
|
||||||
|
axisLine={{ stroke: '#1E293B' }}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: '#0F172A',
|
||||||
|
border: '1px solid #1E293B',
|
||||||
|
borderRadius: '8px',
|
||||||
|
color: '#F8FAFC',
|
||||||
|
fontSize: '12px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Legend
|
||||||
|
wrapperStyle={{ fontSize: '12px', color: '#94A3B8' }}
|
||||||
|
/>
|
||||||
|
<Bar dataKey="Input" fill="#3B82F6" radius={[2, 2, 0, 0]} />
|
||||||
|
<Bar dataKey="Output" fill="#F97316" radius={[2, 2, 0, 0]} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-[280px] items-center justify-center text-muted-foreground text-sm">
|
||||||
|
暂无数据
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 最近操作日志 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">最近操作</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{recentLogs.length > 0 ? (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>时间</TableHead>
|
||||||
|
<TableHead>账号 ID</TableHead>
|
||||||
|
<TableHead>操作</TableHead>
|
||||||
|
<TableHead>目标类型</TableHead>
|
||||||
|
<TableHead>目标 ID</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{recentLogs.map((log) => (
|
||||||
|
<TableRow key={log.id}>
|
||||||
|
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||||
|
{formatDate(log.created_at)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs">
|
||||||
|
{log.account_id.slice(0, 8)}...
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="outline">{log.action}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">
|
||||||
|
{log.target_type}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||||
|
{log.target_id.slice(0, 8)}...
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-32 items-center justify-center text-muted-foreground text-sm">
|
||||||
|
暂无操作日志
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
154
admin/src/app/(dashboard)/profile/page.tsx
Normal file
154
admin/src/app/(dashboard)/profile/page.tsx
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { Lock, Loader2, Eye, EyeOff, Check } from 'lucide-react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
|
||||||
|
import { api } from '@/lib/api-client'
|
||||||
|
import { ApiRequestError } from '@/lib/api-client'
|
||||||
|
|
||||||
|
export default function ProfilePage() {
|
||||||
|
const [oldPassword, setOldPassword] = useState('')
|
||||||
|
const [newPassword, setNewPassword] = useState('')
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState('')
|
||||||
|
const [showOld, setShowOld] = useState(false)
|
||||||
|
const [showNew, setShowNew] = useState(false)
|
||||||
|
const [showConfirm, setShowConfirm] = useState(false)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [success, setSuccess] = useState('')
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setError('')
|
||||||
|
setSuccess('')
|
||||||
|
|
||||||
|
if (newPassword.length < 8) {
|
||||||
|
setError('新密码至少 8 个字符')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (newPassword !== confirmPassword) {
|
||||||
|
setError('两次输入的新密码不一致')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
await api.auth.changePassword({ old_password: oldPassword, new_password: newPassword })
|
||||||
|
setSuccess('密码修改成功')
|
||||||
|
setOldPassword('')
|
||||||
|
setNewPassword('')
|
||||||
|
setConfirmPassword('')
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiRequestError) {
|
||||||
|
setError(err.body.message || '修改失败')
|
||||||
|
} else {
|
||||||
|
setError('网络错误,请稍后重试')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-lg">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Lock className="h-5 w-5" />
|
||||||
|
修改密码
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>修改您的登录密码。修改后需要重新登录。</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="old-password">当前密码</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
id="old-password"
|
||||||
|
type={showOld ? 'text' : 'password'}
|
||||||
|
value={oldPassword}
|
||||||
|
onChange={(e) => setOldPassword(e.target.value)}
|
||||||
|
placeholder="请输入当前密码"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowOld(!showOld)}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground cursor-pointer"
|
||||||
|
>
|
||||||
|
{showOld ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="new-password">新密码</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
id="new-password"
|
||||||
|
type={showNew ? 'text' : 'password'}
|
||||||
|
value={newPassword}
|
||||||
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
|
placeholder="至少 8 个字符"
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowNew(!showNew)}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground cursor-pointer"
|
||||||
|
>
|
||||||
|
{showNew ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="confirm-password">确认新密码</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
id="confirm-password"
|
||||||
|
type={showConfirm ? 'text' : 'password'}
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
placeholder="再次输入新密码"
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowConfirm(!showConfirm)}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground cursor-pointer"
|
||||||
|
>
|
||||||
|
{showConfirm ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-md bg-destructive/10 border border-destructive/20 px-4 py-3 text-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{success && (
|
||||||
|
<div className="rounded-md bg-emerald-500/10 border border-emerald-500/20 px-4 py-3 text-sm text-emerald-500 flex items-center gap-2">
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
{success}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button type="submit" disabled={saving || !oldPassword || !newPassword || !confirmPassword}>
|
||||||
|
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
修改密码
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
369
admin/src/app/(dashboard)/providers/page.tsx
Normal file
369
admin/src/app/(dashboard)/providers/page.tsx
Normal file
@@ -0,0 +1,369 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
|
import {
|
||||||
|
Plus,
|
||||||
|
Loader2,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Pencil,
|
||||||
|
Trash2,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Switch } from '@/components/ui/switch'
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
DialogDescription,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
import { api } from '@/lib/api-client'
|
||||||
|
import { ApiRequestError } from '@/lib/api-client'
|
||||||
|
import { formatDate } from '@/lib/utils'
|
||||||
|
import type { Provider } from '@/lib/types'
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20
|
||||||
|
|
||||||
|
interface ProviderForm {
|
||||||
|
name: string
|
||||||
|
display_name: string
|
||||||
|
base_url: string
|
||||||
|
api_protocol: 'openai' | 'anthropic'
|
||||||
|
enabled: boolean
|
||||||
|
rate_limit_rpm: string
|
||||||
|
rate_limit_tpm: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyForm: ProviderForm = {
|
||||||
|
name: '',
|
||||||
|
display_name: '',
|
||||||
|
base_url: '',
|
||||||
|
api_protocol: 'openai',
|
||||||
|
enabled: true,
|
||||||
|
rate_limit_rpm: '',
|
||||||
|
rate_limit_tpm: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProvidersPage() {
|
||||||
|
const [providers, setProviders] = useState<Provider[]>([])
|
||||||
|
const [total, setTotal] = useState(0)
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
// 创建/编辑 Dialog
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false)
|
||||||
|
const [editTarget, setEditTarget] = useState<Provider | null>(null)
|
||||||
|
const [form, setForm] = useState<ProviderForm>(emptyForm)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
|
// 删除确认 Dialog
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<Provider | null>(null)
|
||||||
|
const [deleting, setDeleting] = useState(false)
|
||||||
|
|
||||||
|
const fetchProviders = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const res = await api.providers.list({ page, page_size: PAGE_SIZE })
|
||||||
|
setProviders(res.items)
|
||||||
|
setTotal(res.total)
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiRequestError) setError(err.body.message)
|
||||||
|
else setError('加载失败')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [page])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchProviders()
|
||||||
|
}, [fetchProviders])
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
|
||||||
|
|
||||||
|
function openCreateDialog() {
|
||||||
|
setEditTarget(null)
|
||||||
|
setForm(emptyForm)
|
||||||
|
setDialogOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditDialog(provider: Provider) {
|
||||||
|
setEditTarget(provider)
|
||||||
|
setForm({
|
||||||
|
name: provider.name,
|
||||||
|
display_name: provider.display_name,
|
||||||
|
base_url: provider.base_url,
|
||||||
|
api_protocol: provider.api_protocol,
|
||||||
|
enabled: provider.enabled,
|
||||||
|
rate_limit_rpm: provider.rate_limit_rpm?.toString() || '',
|
||||||
|
rate_limit_tpm: provider.rate_limit_tpm?.toString() || '',
|
||||||
|
})
|
||||||
|
setDialogOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
if (!form.name.trim() || !form.base_url.trim()) return
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
name: form.name.trim(),
|
||||||
|
display_name: form.display_name.trim(),
|
||||||
|
base_url: form.base_url.trim(),
|
||||||
|
api_protocol: form.api_protocol,
|
||||||
|
enabled: form.enabled,
|
||||||
|
rate_limit_rpm: form.rate_limit_rpm ? parseInt(form.rate_limit_rpm, 10) : undefined,
|
||||||
|
rate_limit_tpm: form.rate_limit_tpm ? parseInt(form.rate_limit_tpm, 10) : undefined,
|
||||||
|
}
|
||||||
|
if (editTarget) {
|
||||||
|
await api.providers.update(editTarget.id, payload)
|
||||||
|
} else {
|
||||||
|
await api.providers.create(payload)
|
||||||
|
}
|
||||||
|
setDialogOpen(false)
|
||||||
|
fetchProviders()
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiRequestError) setError(err.body.message)
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete() {
|
||||||
|
if (!deleteTarget) return
|
||||||
|
setDeleting(true)
|
||||||
|
try {
|
||||||
|
await api.providers.delete(deleteTarget.id)
|
||||||
|
setDeleteTarget(null)
|
||||||
|
fetchProviders()
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiRequestError) setError(err.body.message)
|
||||||
|
} finally {
|
||||||
|
setDeleting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 工具栏 */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div />
|
||||||
|
<Button onClick={openCreateDialog}>
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
新建服务商
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-md bg-destructive/10 border border-destructive/20 px-4 py-3 text-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
<button onClick={() => setError('')} className="ml-2 underline cursor-pointer">关闭</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex h-64 items-center justify-center">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : providers.length === 0 ? (
|
||||||
|
<div className="flex h-64 items-center justify-center text-muted-foreground text-sm">
|
||||||
|
暂无数据
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>名称</TableHead>
|
||||||
|
<TableHead>显示名</TableHead>
|
||||||
|
<TableHead>Base URL</TableHead>
|
||||||
|
<TableHead>协议</TableHead>
|
||||||
|
<TableHead>启用</TableHead>
|
||||||
|
<TableHead>RPM 限制</TableHead>
|
||||||
|
<TableHead>创建时间</TableHead>
|
||||||
|
<TableHead className="text-right">操作</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{providers.map((p) => (
|
||||||
|
<TableRow key={p.id}>
|
||||||
|
<TableCell className="font-medium">{p.name}</TableCell>
|
||||||
|
<TableCell>{p.display_name || '-'}</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs text-muted-foreground max-w-[200px] truncate">
|
||||||
|
{p.base_url}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={p.api_protocol === 'openai' ? 'default' : 'info'}>
|
||||||
|
{p.api_protocol}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={p.enabled ? 'success' : 'secondary'}>
|
||||||
|
{p.enabled ? '是' : '否'}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">
|
||||||
|
{p.rate_limit_rpm ?? '-'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||||
|
{formatDate(p.created_at)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<div className="flex items-center justify-end gap-1">
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => openEditDialog(p)} title="编辑">
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => setDeleteTarget(p)} title="删除">
|
||||||
|
<Trash2 className="h-4 w-4 text-destructive" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
第 {page} 页 / 共 {totalPages} 页 ({total} 条)
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => setPage(page - 1)}>
|
||||||
|
<ChevronLeft className="h-4 w-4 mr-1" />
|
||||||
|
上一页
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" disabled={page >= totalPages} onClick={() => setPage(page + 1)}>
|
||||||
|
下一页
|
||||||
|
<ChevronRight className="h-4 w-4 ml-1" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 创建/编辑 Dialog */}
|
||||||
|
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{editTarget ? '编辑服务商' : '新建服务商'}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{editTarget ? '修改服务商配置' : '添加新的 AI 服务商'}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 max-h-[60vh] overflow-y-auto scrollbar-thin pr-1">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>名称 *</Label>
|
||||||
|
<Input
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||||
|
placeholder="例如: openai"
|
||||||
|
disabled={!!editTarget}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>显示名</Label>
|
||||||
|
<Input
|
||||||
|
value={form.display_name}
|
||||||
|
onChange={(e) => setForm({ ...form, display_name: e.target.value })}
|
||||||
|
placeholder="例如: OpenAI"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Base URL *</Label>
|
||||||
|
<Input
|
||||||
|
value={form.base_url}
|
||||||
|
onChange={(e) => setForm({ ...form, base_url: e.target.value })}
|
||||||
|
placeholder="https://api.openai.com/v1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>API 协议</Label>
|
||||||
|
<Select value={form.api_protocol} onValueChange={(v) => setForm({ ...form, api_protocol: v as 'openai' | 'anthropic' })}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="openai">OpenAI</SelectItem>
|
||||||
|
<SelectItem value="anthropic">Anthropic</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Switch
|
||||||
|
checked={form.enabled}
|
||||||
|
onCheckedChange={(v) => setForm({ ...form, enabled: v })}
|
||||||
|
/>
|
||||||
|
<Label>启用</Label>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>RPM 限制</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={form.rate_limit_rpm}
|
||||||
|
onChange={(e) => setForm({ ...form, rate_limit_rpm: e.target.value })}
|
||||||
|
placeholder="不限"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>TPM 限制</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={form.rate_limit_tpm}
|
||||||
|
onChange={(e) => setForm({ ...form, rate_limit_tpm: e.target.value })}
|
||||||
|
placeholder="不限"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setDialogOpen(false)}>取消</Button>
|
||||||
|
<Button onClick={handleSave} disabled={saving || !form.name.trim() || !form.base_url.trim()}>
|
||||||
|
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* 删除确认 Dialog */}
|
||||||
|
<Dialog open={!!deleteTarget} onOpenChange={() => setDeleteTarget(null)}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>确认删除</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
确定要删除服务商 "{deleteTarget?.display_name || deleteTarget?.name}" 吗?此操作不可撤销。
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setDeleteTarget(null)}>取消</Button>
|
||||||
|
<Button variant="destructive" onClick={handleDelete} disabled={deleting}>
|
||||||
|
{deleting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
278
admin/src/app/(dashboard)/relay/page.tsx
Normal file
278
admin/src/app/(dashboard)/relay/page.tsx
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
|
import {
|
||||||
|
Loader2,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronUp,
|
||||||
|
RotateCcw,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table'
|
||||||
|
import { api } from '@/lib/api-client'
|
||||||
|
import { ApiRequestError } from '@/lib/api-client'
|
||||||
|
import { formatDate, formatNumber } from '@/lib/utils'
|
||||||
|
import type { RelayTask } from '@/lib/types'
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20
|
||||||
|
|
||||||
|
const statusVariants: Record<string, 'success' | 'info' | 'warning' | 'destructive' | 'secondary'> = {
|
||||||
|
queued: 'warning',
|
||||||
|
processing: 'info',
|
||||||
|
completed: 'success',
|
||||||
|
failed: 'destructive',
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusLabels: Record<string, string> = {
|
||||||
|
queued: '排队中',
|
||||||
|
processing: '处理中',
|
||||||
|
completed: '已完成',
|
||||||
|
failed: '失败',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RelayPage() {
|
||||||
|
const [tasks, setTasks] = useState<RelayTask[]>([])
|
||||||
|
const [total, setTotal] = useState(0)
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [statusFilter, setStatusFilter] = useState<string>('all')
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [expandedId, setExpandedId] = useState<string | null>(null)
|
||||||
|
const [retryingId, setRetryingId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const fetchTasks = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const params: Record<string, unknown> = { page, page_size: PAGE_SIZE }
|
||||||
|
if (statusFilter !== 'all') params.status = statusFilter
|
||||||
|
const res = await api.relay.list(params)
|
||||||
|
setTasks(res.items)
|
||||||
|
setTotal(res.total)
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiRequestError) setError(err.body.message)
|
||||||
|
else setError('加载失败')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [page, statusFilter])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchTasks()
|
||||||
|
}, [fetchTasks])
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
|
||||||
|
|
||||||
|
function toggleExpand(id: string) {
|
||||||
|
setExpandedId((prev) => (prev === id ? null : id))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRetry(taskId: string, e: React.MouseEvent) {
|
||||||
|
e.stopPropagation()
|
||||||
|
setRetryingId(taskId)
|
||||||
|
try {
|
||||||
|
await api.relay.retry(taskId)
|
||||||
|
fetchTasks()
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiRequestError) setError(err.body.message)
|
||||||
|
else setError('重试失败')
|
||||||
|
} finally {
|
||||||
|
setRetryingId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 筛选 */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Select value={statusFilter} onValueChange={(v) => { setStatusFilter(v); setPage(1) }}>
|
||||||
|
<SelectTrigger className="w-[140px]">
|
||||||
|
<SelectValue placeholder="状态筛选" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">全部状态</SelectItem>
|
||||||
|
<SelectItem value="queued">排队中</SelectItem>
|
||||||
|
<SelectItem value="processing">处理中</SelectItem>
|
||||||
|
<SelectItem value="completed">已完成</SelectItem>
|
||||||
|
<SelectItem value="failed">失败</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-md bg-destructive/10 border border-destructive/20 px-4 py-3 text-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
<button onClick={() => setError('')} className="ml-2 underline cursor-pointer">关闭</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex h-64 items-center justify-center">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : tasks.length === 0 ? (
|
||||||
|
<div className="flex h-64 items-center justify-center text-muted-foreground text-sm">
|
||||||
|
暂无数据
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-8" />
|
||||||
|
<TableHead>任务 ID</TableHead>
|
||||||
|
<TableHead>模型</TableHead>
|
||||||
|
<TableHead>状态</TableHead>
|
||||||
|
<TableHead>优先级</TableHead>
|
||||||
|
<TableHead>重试次数</TableHead>
|
||||||
|
<TableHead>Input Tokens</TableHead>
|
||||||
|
<TableHead>Output Tokens</TableHead>
|
||||||
|
<TableHead>错误信息</TableHead>
|
||||||
|
<TableHead>创建时间</TableHead>
|
||||||
|
<TableHead className="text-right">操作</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{tasks.map((task) => (
|
||||||
|
<>
|
||||||
|
<TableRow key={task.id} className="cursor-pointer" onClick={() => toggleExpand(task.id)}>
|
||||||
|
<TableCell>
|
||||||
|
{expandedId === task.id ? (
|
||||||
|
<ChevronUp className="h-4 w-4 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs">
|
||||||
|
{task.id.slice(0, 8)}...
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs">
|
||||||
|
{task.model_id}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={statusVariants[task.status] || 'secondary'}>
|
||||||
|
{statusLabels[task.status] || task.status}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">{task.priority}</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">{task.attempt_count}</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||||
|
{formatNumber(task.input_tokens)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||||
|
{formatNumber(task.output_tokens)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="max-w-[200px] truncate text-xs text-destructive">
|
||||||
|
{task.error_message || '-'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||||
|
{formatDate(task.created_at)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
{task.status === 'failed' && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={(e) => handleRetry(task.id, e)}
|
||||||
|
disabled={retryingId === task.id}
|
||||||
|
title="重试"
|
||||||
|
>
|
||||||
|
{retryingId === task.id ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<RotateCcw className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
{expandedId === task.id && (
|
||||||
|
<TableRow key={`${task.id}-detail`}>
|
||||||
|
<TableCell colSpan={11} className="bg-muted/20 px-8 py-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">任务 ID</p>
|
||||||
|
<p className="font-mono text-xs">{task.id}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">账号 ID</p>
|
||||||
|
<p className="font-mono text-xs">{task.account_id}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">服务商 ID</p>
|
||||||
|
<p className="font-mono text-xs">{task.provider_id}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">模型 ID</p>
|
||||||
|
<p className="font-mono text-xs">{task.model_id}</p>
|
||||||
|
</div>
|
||||||
|
{task.queued_at && (
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">排队时间</p>
|
||||||
|
<p className="font-mono text-xs">{formatDate(task.queued_at)}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{task.started_at && (
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">开始时间</p>
|
||||||
|
<p className="font-mono text-xs">{formatDate(task.started_at)}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{task.completed_at && (
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">完成时间</p>
|
||||||
|
<p className="font-mono text-xs">{formatDate(task.completed_at)}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{task.error_message && (
|
||||||
|
<div className="col-span-2">
|
||||||
|
<p className="text-muted-foreground">错误信息</p>
|
||||||
|
<p className="text-xs text-destructive mt-1">{task.error_message}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
第 {page} 页 / 共 {totalPages} 页 ({total} 条)
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => setPage(page - 1)}>
|
||||||
|
<ChevronLeft className="h-4 w-4 mr-1" />
|
||||||
|
上一页
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" disabled={page >= totalPages} onClick={() => setPage(page + 1)}>
|
||||||
|
下一页
|
||||||
|
<ChevronRight className="h-4 w-4 ml-1" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
203
admin/src/app/(dashboard)/security/page.tsx
Normal file
203
admin/src/app/(dashboard)/security/page.tsx
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { ShieldCheck, Loader2, Eye, EyeOff, QrCode, Key, AlertTriangle } from 'lucide-react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { api } from '@/lib/api-client'
|
||||||
|
import { useAuth } from '@/components/auth-guard'
|
||||||
|
import { ApiRequestError } from '@/lib/api-client'
|
||||||
|
|
||||||
|
export default function SecurityPage() {
|
||||||
|
const { account } = useAuth()
|
||||||
|
const totpEnabled = account?.totp_enabled ?? false
|
||||||
|
|
||||||
|
// Setup state
|
||||||
|
const [step, setStep] = useState<'idle' | 'verify' | 'done'>('idle')
|
||||||
|
const [otpauthUri, setOtpauthUri] = useState('')
|
||||||
|
const [secret, setSecret] = useState('')
|
||||||
|
const [verifyCode, setVerifyCode] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
// Disable state
|
||||||
|
const [disablePassword, setDisablePassword] = useState('')
|
||||||
|
const [showDisablePassword, setShowDisablePassword] = useState(false)
|
||||||
|
const [disabling, setDisabling] = useState(false)
|
||||||
|
|
||||||
|
async function handleSetup() {
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const res = await api.auth.totpSetup()
|
||||||
|
setOtpauthUri(res.otpauth_uri)
|
||||||
|
setSecret(res.secret)
|
||||||
|
setStep('verify')
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiRequestError) setError(err.body.message || '获取密钥失败')
|
||||||
|
else setError('网络错误')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleVerify() {
|
||||||
|
if (verifyCode.length !== 6) {
|
||||||
|
setError('请输入 6 位验证码')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
await api.auth.totpVerify({ code: verifyCode })
|
||||||
|
setStep('done')
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiRequestError) setError(err.body.message || '验证失败')
|
||||||
|
else setError('网络错误')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDisable() {
|
||||||
|
if (!disablePassword) {
|
||||||
|
setError('请输入密码以确认禁用')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setDisabling(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
await api.auth.totpDisable({ password: disablePassword })
|
||||||
|
setDisablePassword('')
|
||||||
|
window.location.reload()
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiRequestError) setError(err.body.message || '禁用失败')
|
||||||
|
else setError('网络错误')
|
||||||
|
} finally {
|
||||||
|
setDisabling(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-lg space-y-6">
|
||||||
|
{/* TOTP 状态 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<ShieldCheck className="h-5 w-5" />
|
||||||
|
双因素认证 (TOTP)
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
使用 Google Authenticator 等应用生成一次性验证码,增强账号安全。
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<span className="text-sm text-muted-foreground">当前状态:</span>
|
||||||
|
<Badge variant={totpEnabled ? 'success' : 'secondary'}>
|
||||||
|
{totpEnabled ? '已启用' : '未启用'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-md bg-destructive/10 border border-destructive/20 px-4 py-3 text-sm text-destructive mb-4">
|
||||||
|
{error}
|
||||||
|
<button onClick={() => setError('')} className="ml-2 underline cursor-pointer">关闭</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 未启用: 设置流程 */}
|
||||||
|
{!totpEnabled && step === 'idle' && (
|
||||||
|
<Button onClick={handleSetup} disabled={loading}>
|
||||||
|
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
<Key className="mr-2 h-4 w-4" />
|
||||||
|
启用双因素认证
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!totpEnabled && step === 'verify' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="rounded-md border border-border p-4 space-y-3">
|
||||||
|
<div className="flex items-center gap-2 text-sm font-medium">
|
||||||
|
<QrCode className="h-4 w-4" />
|
||||||
|
步骤 1: 扫描二维码或手动输入密钥
|
||||||
|
</div>
|
||||||
|
<div className="bg-muted rounded-md p-3 font-mono text-xs break-all">
|
||||||
|
{otpauthUri}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-xs text-muted-foreground">手动输入密钥:</p>
|
||||||
|
<p className="font-mono text-sm font-medium select-all">{secret}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>
|
||||||
|
步骤 2: 输入 6 位验证码
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
value={verifyCode}
|
||||||
|
onChange={(e) => setVerifyCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||||
|
placeholder="请输入应用中显示的 6 位数字"
|
||||||
|
maxLength={6}
|
||||||
|
className="font-mono tracking-widest text-center"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" onClick={() => { setStep('idle'); setVerifyCode('') }}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleVerify} disabled={loading || verifyCode.length !== 6}>
|
||||||
|
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
验证并启用
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!totpEnabled && step === 'done' && (
|
||||||
|
<div className="rounded-md bg-emerald-500/10 border border-emerald-500/20 p-4 text-sm text-emerald-500">
|
||||||
|
双因素认证已成功启用。下次登录时需要输入验证码。
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 已启用: 禁用流程 */}
|
||||||
|
{totpEnabled && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="rounded-md bg-amber-500/10 border border-amber-500/20 p-3 flex items-start gap-2 text-sm text-amber-600">
|
||||||
|
<AlertTriangle className="h-4 w-4 mt-0.5 shrink-0" />
|
||||||
|
<span>禁用双因素认证会降低账号安全性,建议仅在必要时操作。</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>输入当前密码以确认禁用</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
type={showDisablePassword ? 'text' : 'password'}
|
||||||
|
value={disablePassword}
|
||||||
|
onChange={(e) => setDisablePassword(e.target.value)}
|
||||||
|
placeholder="请输入当前密码"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowDisablePassword(!showDisablePassword)}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground cursor-pointer"
|
||||||
|
>
|
||||||
|
{showDisablePassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button variant="destructive" onClick={handleDisable} disabled={disabling || !disablePassword}>
|
||||||
|
{disabling && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
禁用双因素认证
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
234
admin/src/app/(dashboard)/usage/page.tsx
Normal file
234
admin/src/app/(dashboard)/usage/page.tsx
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
|
import { Loader2, Zap } from 'lucide-react'
|
||||||
|
import {
|
||||||
|
LineChart,
|
||||||
|
Line,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
CartesianGrid,
|
||||||
|
Tooltip,
|
||||||
|
ResponsiveContainer,
|
||||||
|
BarChart,
|
||||||
|
Bar,
|
||||||
|
Legend,
|
||||||
|
} from 'recharts'
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
import { api } from '@/lib/api-client'
|
||||||
|
import { ApiRequestError } from '@/lib/api-client'
|
||||||
|
import { formatNumber } from '@/lib/utils'
|
||||||
|
import type { UsageStats } from '@/lib/types'
|
||||||
|
|
||||||
|
export default function UsagePage() {
|
||||||
|
const [days, setDays] = useState(7)
|
||||||
|
const [usageStats, setUsageStats] = useState<UsageStats | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
const fetchData = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const from = new Date()
|
||||||
|
from.setDate(from.getDate() - days)
|
||||||
|
const fromStr = from.toISOString().slice(0, 10)
|
||||||
|
const res = await api.usage.get({ from: fromStr })
|
||||||
|
setUsageStats(res)
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiRequestError) setError(err.body.message)
|
||||||
|
else setError('加载数据失败')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [days])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData()
|
||||||
|
}, [fetchData])
|
||||||
|
|
||||||
|
const byDay = usageStats?.by_day ?? []
|
||||||
|
|
||||||
|
const lineChartData = byDay.map((r) => ({
|
||||||
|
day: r.date.slice(5),
|
||||||
|
Input: r.input_tokens,
|
||||||
|
Output: r.output_tokens,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const barChartData = (usageStats?.by_model ?? []).map((r) => ({
|
||||||
|
model: r.model_id,
|
||||||
|
请求量: r.request_count,
|
||||||
|
Input: r.input_tokens,
|
||||||
|
Output: r.output_tokens,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const totalInput = byDay.reduce((s, r) => s + r.input_tokens, 0)
|
||||||
|
const totalOutput = byDay.reduce((s, r) => s + r.output_tokens, 0)
|
||||||
|
const totalRequests = byDay.reduce((s, r) => s + r.request_count, 0)
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-[60vh] items-center justify-center">
|
||||||
|
<div className="flex flex-col items-center gap-3">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||||
|
<p className="text-sm text-muted-foreground">加载中...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-[60vh] items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-destructive">{error}</p>
|
||||||
|
<button onClick={() => fetchData()} className="mt-4 text-sm text-primary hover:underline cursor-pointer">
|
||||||
|
重新加载
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* 时间范围 */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-sm text-muted-foreground">时间范围:</span>
|
||||||
|
<Select value={String(days)} onValueChange={(v) => setDays(Number(v))}>
|
||||||
|
<SelectTrigger className="w-[140px]">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="7">最近 7 天</SelectItem>
|
||||||
|
<SelectItem value="30">最近 30 天</SelectItem>
|
||||||
|
<SelectItem value="90">最近 90 天</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 汇总统计 */}
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<p className="text-sm text-muted-foreground">总请求数</p>
|
||||||
|
<p className="mt-1 text-2xl font-bold text-foreground">
|
||||||
|
{formatNumber(totalRequests)}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<p className="text-sm text-muted-foreground">Input Tokens</p>
|
||||||
|
<p className="mt-1 text-2xl font-bold text-blue-400">
|
||||||
|
{formatNumber(totalInput)}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<p className="text-sm text-muted-foreground">Output Tokens</p>
|
||||||
|
<p className="mt-1 text-2xl font-bold text-orange-400">
|
||||||
|
{formatNumber(totalOutput)}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Token 用量趋势 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<Zap className="h-4 w-4 text-primary" />
|
||||||
|
Token 用量趋势
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{lineChartData.length > 0 ? (
|
||||||
|
<ResponsiveContainer width="100%" height={320}>
|
||||||
|
<LineChart data={lineChartData}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#1E293B" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="day"
|
||||||
|
tick={{ fontSize: 12, fill: '#94A3B8' }}
|
||||||
|
axisLine={{ stroke: '#1E293B' }}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tick={{ fontSize: 12, fill: '#94A3B8' }}
|
||||||
|
axisLine={{ stroke: '#1E293B' }}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: '#0F172A',
|
||||||
|
border: '1px solid #1E293B',
|
||||||
|
borderRadius: '8px',
|
||||||
|
color: '#F8FAFC',
|
||||||
|
fontSize: '12px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Legend wrapperStyle={{ fontSize: '12px', color: '#94A3B8' }} />
|
||||||
|
<Line type="monotone" dataKey="Input" stroke="#3B82F6" strokeWidth={2} dot={false} />
|
||||||
|
<Line type="monotone" dataKey="Output" stroke="#F97316" strokeWidth={2} dot={false} />
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-[320px] items-center justify-center text-muted-foreground text-sm">
|
||||||
|
暂无数据
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 按模型分布 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">按模型分布</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{barChartData.length > 0 ? (
|
||||||
|
<ResponsiveContainer width="100%" height={320}>
|
||||||
|
<BarChart data={barChartData} layout="vertical">
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#1E293B" />
|
||||||
|
<XAxis
|
||||||
|
type="number"
|
||||||
|
tick={{ fontSize: 12, fill: '#94A3B8' }}
|
||||||
|
axisLine={{ stroke: '#1E293B' }}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
type="category"
|
||||||
|
dataKey="model"
|
||||||
|
tick={{ fontSize: 12, fill: '#94A3B8' }}
|
||||||
|
axisLine={{ stroke: '#1E293B' }}
|
||||||
|
width={120}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: '#0F172A',
|
||||||
|
border: '1px solid #1E293B',
|
||||||
|
borderRadius: '8px',
|
||||||
|
color: '#F8FAFC',
|
||||||
|
fontSize: '12px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Legend wrapperStyle={{ fontSize: '12px', color: '#94A3B8' }} />
|
||||||
|
<Bar dataKey="Input" fill="#3B82F6" radius={[0, 2, 2, 0]} />
|
||||||
|
<Bar dataKey="Output" fill="#F97316" radius={[0, 2, 2, 0]} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-[320px] items-center justify-center text-muted-foreground text-sm">
|
||||||
|
暂无数据
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
66
admin/src/app/globals.css
Normal file
66
admin/src/app/globals.css
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
:root {
|
||||||
|
--background: 222 47% 5%;
|
||||||
|
--foreground: 210 40% 98%;
|
||||||
|
--card: 222 47% 8%;
|
||||||
|
--card-foreground: 210 40% 98%;
|
||||||
|
--primary: 142 71% 45%;
|
||||||
|
--primary-foreground: 222 47% 5%;
|
||||||
|
--muted: 217 33% 17%;
|
||||||
|
--muted-foreground: 215 20% 65%;
|
||||||
|
--accent: 215 28% 23%;
|
||||||
|
--accent-foreground: 210 40% 98%;
|
||||||
|
--destructive: 0 84% 60%;
|
||||||
|
--destructive-foreground: 210 40% 98%;
|
||||||
|
--border: 217 33% 17%;
|
||||||
|
--input: 217 33% 17%;
|
||||||
|
--ring: 142 71% 45%;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
border-color: hsl(var(--border));
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: hsl(var(--background));
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer utilities {
|
||||||
|
.scrollbar-thin {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: hsl(var(--muted)) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar-thin::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar-thin::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar-thin::-webkit-scrollbar-thumb {
|
||||||
|
background-color: hsl(var(--muted));
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
|
||||||
|
background-color: hsl(var(--accent));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
.glass-card {
|
||||||
|
@apply bg-card/80 backdrop-blur-sm border border-border rounded-lg;
|
||||||
|
}
|
||||||
|
}
|
||||||
29
admin/src/app/layout.tsx
Normal file
29
admin/src/app/layout.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import type { Metadata } from 'next'
|
||||||
|
import { Toaster } from 'sonner'
|
||||||
|
import './globals.css'
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'ZCLAW Admin',
|
||||||
|
description: 'ZCLAW AI Agent 管理平台',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<html lang="zh-CN" className="dark">
|
||||||
|
<head>
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
</head>
|
||||||
|
<body className="min-h-screen bg-background font-sans antialiased">
|
||||||
|
{children}
|
||||||
|
<Toaster richColors position="top-right" />
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
)
|
||||||
|
}
|
||||||
218
admin/src/app/login/page.tsx
Normal file
218
admin/src/app/login/page.tsx
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, type FormEvent } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import { Lock, User, Loader2, Eye, EyeOff, ShieldCheck } from 'lucide-react'
|
||||||
|
import { api } from '@/lib/api-client'
|
||||||
|
import { login } from '@/lib/auth'
|
||||||
|
import { ApiRequestError } from '@/lib/api-client'
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const router = useRouter()
|
||||||
|
const [username, setUsername] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [showPassword, setShowPassword] = useState(false)
|
||||||
|
const [totpCode, setTotpCode] = useState('')
|
||||||
|
const [showTotp, setShowTotp] = useState(false)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
async function handleSubmit(e: FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setError('')
|
||||||
|
|
||||||
|
if (!username.trim()) {
|
||||||
|
setError('请输入用户名')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!password.trim()) {
|
||||||
|
setError('请输入密码')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await api.auth.login({
|
||||||
|
username: username.trim(),
|
||||||
|
password,
|
||||||
|
totp_code: showTotp ? totpCode.trim() || undefined : undefined,
|
||||||
|
})
|
||||||
|
login(res.token, res.account)
|
||||||
|
router.replace('/')
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiRequestError) {
|
||||||
|
// 检测 TOTP 错误码,自动显示验证码输入框
|
||||||
|
if (err.body.error === 'totp_required' || err.body.message?.includes('双因素认证') || err.body.message?.includes('TOTP')) {
|
||||||
|
setShowTotp(true)
|
||||||
|
setError(err.body.message || '此账号已启用双因素认证,请输入验证码')
|
||||||
|
} else {
|
||||||
|
setError(err.body.message || '登录失败,请检查用户名和密码')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setError('网络错误,请稍后重试')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen">
|
||||||
|
{/* 左侧品牌区域 */}
|
||||||
|
<div className="hidden lg:flex lg:w-1/2 relative overflow-hidden bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900">
|
||||||
|
{/* 装饰性背景 */}
|
||||||
|
<div className="absolute inset-0">
|
||||||
|
<div className="absolute top-1/4 left-1/4 w-96 h-96 bg-green-500/5 rounded-full blur-3xl" />
|
||||||
|
<div className="absolute bottom-1/4 right-1/4 w-64 h-64 bg-green-500/8 rounded-full blur-3xl" />
|
||||||
|
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[600px] h-[600px] border border-green-500/10 rounded-full" />
|
||||||
|
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[400px] h-[400px] border border-green-500/10 rounded-full" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 品牌内容 */}
|
||||||
|
<div className="relative z-10 flex flex-col items-center justify-center w-full p-12">
|
||||||
|
<div className="text-center">
|
||||||
|
<h1 className="text-6xl font-bold tracking-tight text-foreground mb-4">
|
||||||
|
ZCLAW
|
||||||
|
</h1>
|
||||||
|
<p className="text-xl text-muted-foreground font-light">
|
||||||
|
AI Agent 管理平台
|
||||||
|
</p>
|
||||||
|
<div className="mt-8 flex items-center justify-center gap-2">
|
||||||
|
<div className="h-px w-12 bg-green-500/50" />
|
||||||
|
<div className="w-2 h-2 rounded-full bg-green-500" />
|
||||||
|
<div className="h-px w-12 bg-green-500/50" />
|
||||||
|
</div>
|
||||||
|
<p className="mt-6 text-sm text-muted-foreground/60 max-w-sm">
|
||||||
|
统一管理 AI 服务商、模型配置、API 密钥、用量监控与系统配置
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 右侧登录表单 */}
|
||||||
|
<div className="flex w-full lg:w-1/2 items-center justify-center p-8">
|
||||||
|
<div className="w-full max-w-sm space-y-8">
|
||||||
|
{/* 移动端 Logo */}
|
||||||
|
<div className="lg:hidden text-center">
|
||||||
|
<h1 className="text-4xl font-bold tracking-tight text-foreground mb-2">
|
||||||
|
ZCLAW
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">AI Agent 管理平台</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-semibold text-foreground">登录</h2>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
|
输入您的账号信息以继续
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
{/* 用户名 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label
|
||||||
|
htmlFor="username"
|
||||||
|
className="text-sm font-medium text-foreground"
|
||||||
|
>
|
||||||
|
用户名
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<User className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<input
|
||||||
|
id="username"
|
||||||
|
type="text"
|
||||||
|
placeholder="请输入用户名"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
className="flex h-10 w-full rounded-md border border-input bg-transparent pl-10 pr-3 py-2 text-sm shadow-sm transition-colors duration-200 placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||||
|
autoComplete="username"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 密码 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label
|
||||||
|
htmlFor="password"
|
||||||
|
className="text-sm font-medium text-foreground"
|
||||||
|
>
|
||||||
|
密码
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
placeholder="请输入密码"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
className="flex h-10 w-full rounded-md border border-input bg-transparent pl-10 pr-10 py-2 text-sm shadow-sm transition-colors duration-200 placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||||
|
autoComplete="current-password"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors duration-200 cursor-pointer"
|
||||||
|
>
|
||||||
|
{showPassword ? (
|
||||||
|
<EyeOff className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* TOTP 验证码 (仅账号启用 2FA 时显示) */}
|
||||||
|
{showTotp && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label
|
||||||
|
htmlFor="totp_code"
|
||||||
|
className="text-sm font-medium text-foreground"
|
||||||
|
>
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<ShieldCheck className="h-3.5 w-3.5" />
|
||||||
|
双因素验证码
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="totp_code"
|
||||||
|
type="text"
|
||||||
|
placeholder="请输入 6 位验证码"
|
||||||
|
value={totpCode}
|
||||||
|
onChange={(e) => setTotpCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||||
|
className="flex h-10 w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm tracking-widest text-center font-mono shadow-sm transition-colors duration-200 placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||||
|
maxLength={6}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 错误信息 */}
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-md bg-destructive/10 border border-destructive/20 px-4 py-3 text-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 登录按钮 */}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="flex h-10 w-full items-center justify-center rounded-md bg-primary text-primary-foreground font-medium text-sm shadow-sm transition-colors duration-200 hover:bg-primary-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 cursor-pointer"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
登录中...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'登录'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
85
admin/src/components/auth-guard.tsx
Normal file
85
admin/src/components/auth-guard.tsx
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { createContext, useContext, useEffect, useState, useCallback, type ReactNode } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import { isAuthenticated, getAccount, logout as clearCredentials, scheduleTokenRefresh, cancelTokenRefresh, setOnSessionExpired } from '@/lib/auth'
|
||||||
|
import { api } from '@/lib/api-client'
|
||||||
|
import type { AccountPublic } from '@/lib/types'
|
||||||
|
|
||||||
|
interface AuthContextValue {
|
||||||
|
account: AccountPublic | null
|
||||||
|
loading: boolean
|
||||||
|
refresh: () => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextValue>({
|
||||||
|
account: null,
|
||||||
|
loading: true,
|
||||||
|
refresh: async () => {},
|
||||||
|
})
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
return useContext(AuthContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthGuardProps {
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuthGuard({ children }: AuthGuardProps) {
|
||||||
|
const router = useRouter()
|
||||||
|
const [account, setAccount] = useState<AccountPublic | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const me = await api.auth.me()
|
||||||
|
setAccount(me)
|
||||||
|
} catch {
|
||||||
|
clearCredentials()
|
||||||
|
router.replace('/login')
|
||||||
|
}
|
||||||
|
}, [router])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAuthenticated()) {
|
||||||
|
router.replace('/login')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 验证 token 有效性并获取最新账号信息
|
||||||
|
refresh().finally(() => setLoading(false))
|
||||||
|
}, [router, refresh])
|
||||||
|
|
||||||
|
// Set up proactive token refresh with session-expired handler
|
||||||
|
useEffect(() => {
|
||||||
|
const handleSessionExpired = () => {
|
||||||
|
clearCredentials()
|
||||||
|
router.replace('/login')
|
||||||
|
}
|
||||||
|
setOnSessionExpired(handleSessionExpired)
|
||||||
|
scheduleTokenRefresh()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelTokenRefresh()
|
||||||
|
setOnSessionExpired(null)
|
||||||
|
}
|
||||||
|
}, [router])
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen w-screen items-center justify-center bg-background">
|
||||||
|
<div className="h-8 w-8 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!account) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={{ account, loading, refresh }}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
42
admin/src/components/ui/badge.tsx
Normal file
42
admin/src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
'border-transparent bg-primary/15 text-primary',
|
||||||
|
secondary:
|
||||||
|
'border-transparent bg-muted text-muted-foreground',
|
||||||
|
destructive:
|
||||||
|
'border-transparent bg-destructive/15 text-destructive',
|
||||||
|
outline:
|
||||||
|
'text-foreground border-border',
|
||||||
|
success:
|
||||||
|
'border-transparent bg-green-500/15 text-green-400',
|
||||||
|
warning:
|
||||||
|
'border-transparent bg-yellow-500/15 text-yellow-400',
|
||||||
|
info:
|
||||||
|
'border-transparent bg-blue-500/15 text-blue-400',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
export interface BadgeProps
|
||||||
|
extends React.HTMLAttributes<HTMLDivElement>,
|
||||||
|
VariantProps<typeof badgeVariants> {}
|
||||||
|
|
||||||
|
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||||
|
return (
|
||||||
|
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants }
|
||||||
56
admin/src/components/ui/button.tsx
Normal file
56
admin/src/components/ui/button.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import * as React from 'react'
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
'bg-primary text-primary-foreground hover:bg-primary-hover shadow-sm',
|
||||||
|
secondary:
|
||||||
|
'bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground',
|
||||||
|
destructive:
|
||||||
|
'bg-destructive text-destructive-foreground hover:bg-red-600 shadow-sm',
|
||||||
|
outline:
|
||||||
|
'border border-border bg-transparent hover:bg-accent hover:text-accent-foreground',
|
||||||
|
ghost:
|
||||||
|
'hover:bg-accent hover:text-accent-foreground',
|
||||||
|
link:
|
||||||
|
'text-primary underline-offset-4 hover:underline',
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: 'h-9 px-4 py-2',
|
||||||
|
sm: 'h-8 rounded-md px-3 text-xs',
|
||||||
|
lg: 'h-10 rounded-md px-8',
|
||||||
|
icon: 'h-9 w-9',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
size: 'default',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
export interface ButtonProps
|
||||||
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
|
VariantProps<typeof buttonVariants> {}
|
||||||
|
|
||||||
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ className, variant, size, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
Button.displayName = 'Button'
|
||||||
|
|
||||||
|
export { Button, buttonVariants }
|
||||||
75
admin/src/components/ui/card.tsx
Normal file
75
admin/src/components/ui/card.tsx
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const Card = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'rounded-lg border border-border bg-card text-card-foreground shadow-sm',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Card.displayName = 'Card'
|
||||||
|
|
||||||
|
const CardHeader = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn('flex flex-col space-y-1.5 p-6', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardHeader.displayName = 'CardHeader'
|
||||||
|
|
||||||
|
const CardTitle = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLHeadingElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<h3
|
||||||
|
ref={ref}
|
||||||
|
className={cn('font-semibold leading-none tracking-tight', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardTitle.displayName = 'CardTitle'
|
||||||
|
|
||||||
|
const CardDescription = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<p
|
||||||
|
ref={ref}
|
||||||
|
className={cn('text-sm text-muted-foreground', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardDescription.displayName = 'CardDescription'
|
||||||
|
|
||||||
|
const CardContent = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
|
||||||
|
))
|
||||||
|
CardContent.displayName = 'CardContent'
|
||||||
|
|
||||||
|
const CardFooter = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn('flex items-center p-6 pt-0', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardFooter.displayName = 'CardFooter'
|
||||||
|
|
||||||
|
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||||
118
admin/src/components/ui/dialog.tsx
Normal file
118
admin/src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import * as React from 'react'
|
||||||
|
import * as DialogPrimitive from '@radix-ui/react-dialog'
|
||||||
|
import { X } from 'lucide-react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const Dialog = DialogPrimitive.Root
|
||||||
|
const DialogTrigger = DialogPrimitive.Trigger
|
||||||
|
const DialogPortal = DialogPrimitive.Portal
|
||||||
|
const DialogClose = DialogPrimitive.Close
|
||||||
|
|
||||||
|
const DialogOverlay = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'fixed inset-0 z-50 bg-black/60 backdrop-blur-sm',
|
||||||
|
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
||||||
|
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||||
|
|
||||||
|
const DialogContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<DialogPortal>
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%]',
|
||||||
|
'gap-4 border border-border bg-card p-6 shadow-lg duration-200',
|
||||||
|
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
||||||
|
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||||
|
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
||||||
|
'data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%]',
|
||||||
|
'data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]',
|
||||||
|
'rounded-lg',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPortal>
|
||||||
|
))
|
||||||
|
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const DialogHeader = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn('flex flex-col space-y-1.5 text-center sm:text-left', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
DialogHeader.displayName = 'DialogHeader'
|
||||||
|
|
||||||
|
const DialogFooter = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
DialogFooter.displayName = 'DialogFooter'
|
||||||
|
|
||||||
|
const DialogTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
ref={ref}
|
||||||
|
className={cn('text-lg font-semibold leading-none tracking-tight', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||||
|
|
||||||
|
const DialogDescription = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
ref={ref}
|
||||||
|
className={cn('text-sm text-muted-foreground', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogPortal,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogClose,
|
||||||
|
DialogTrigger,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogFooter,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
}
|
||||||
28
admin/src/components/ui/input.tsx
Normal file
28
admin/src/components/ui/input.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export interface InputProps
|
||||||
|
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||||
|
|
||||||
|
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||||
|
({ className, type, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
className={cn(
|
||||||
|
'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors duration-200',
|
||||||
|
'file:border-0 file:bg-transparent file:text-sm file:font-medium',
|
||||||
|
'placeholder:text-muted-foreground',
|
||||||
|
'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring',
|
||||||
|
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
Input.displayName = 'Input'
|
||||||
|
|
||||||
|
export { Input }
|
||||||
23
admin/src/components/ui/label.tsx
Normal file
23
admin/src/components/ui/label.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export interface LabelProps
|
||||||
|
extends React.LabelHTMLAttributes<HTMLLabelElement> {}
|
||||||
|
|
||||||
|
const Label = React.forwardRef<HTMLLabelElement, LabelProps>(
|
||||||
|
({ className, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
Label.displayName = 'Label'
|
||||||
|
|
||||||
|
export { Label }
|
||||||
100
admin/src/components/ui/select.tsx
Normal file
100
admin/src/components/ui/select.tsx
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import * as React from 'react'
|
||||||
|
import * as SelectPrimitive from '@radix-ui/react-select'
|
||||||
|
import { Check, ChevronDown } from 'lucide-react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const Select = SelectPrimitive.Root
|
||||||
|
const SelectGroup = SelectPrimitive.Group
|
||||||
|
const SelectValue = SelectPrimitive.Value
|
||||||
|
|
||||||
|
const SelectTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background',
|
||||||
|
'placeholder:text-muted-foreground',
|
||||||
|
'focus:outline-none focus:ring-1 focus:ring-ring',
|
||||||
|
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
'[&>span]:line-clamp-1',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SelectPrimitive.Icon asChild>
|
||||||
|
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||||
|
</SelectPrimitive.Icon>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
))
|
||||||
|
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
||||||
|
|
||||||
|
const SelectContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||||
|
>(({ className, children, position = 'popper', ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border border-border bg-card text-foreground shadow-md',
|
||||||
|
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
||||||
|
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||||
|
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
||||||
|
position === 'popper' &&
|
||||||
|
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
position={position}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectPrimitive.Viewport
|
||||||
|
className={cn(
|
||||||
|
'p-1',
|
||||||
|
position === 'popper' &&
|
||||||
|
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SelectPrimitive.Viewport>
|
||||||
|
</SelectPrimitive.Content>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
))
|
||||||
|
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const SelectItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'relative flex w-full cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none',
|
||||||
|
'focus:bg-accent focus:text-accent-foreground',
|
||||||
|
'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<SelectPrimitive.ItemIndicator>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
))
|
||||||
|
SelectItem.displayName = SelectPrimitive.Item.displayName
|
||||||
|
|
||||||
|
export {
|
||||||
|
Select,
|
||||||
|
SelectGroup,
|
||||||
|
SelectValue,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
}
|
||||||
30
admin/src/components/ui/separator.tsx
Normal file
30
admin/src/components/ui/separator.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import * as React from 'react'
|
||||||
|
import * as SeparatorPrimitive from '@radix-ui/react-separator'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const Separator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||||
|
>(
|
||||||
|
(
|
||||||
|
{ className, orientation = 'horizontal', decorative = true, ...props },
|
||||||
|
ref,
|
||||||
|
) => (
|
||||||
|
<SeparatorPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
decorative={decorative}
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
'shrink-0 bg-border',
|
||||||
|
orientation === 'horizontal' ? 'h-[1px] w-full' : 'h-full w-[1px]',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
)
|
||||||
|
Separator.displayName = SeparatorPrimitive.Root.displayName
|
||||||
|
|
||||||
|
export { Separator }
|
||||||
32
admin/src/components/ui/switch.tsx
Normal file
32
admin/src/components/ui/switch.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import * as React from 'react'
|
||||||
|
import * as SwitchPrimitive from '@radix-ui/react-switch'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const Switch = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SwitchPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SwitchPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SwitchPrimitive.Root
|
||||||
|
className={cn(
|
||||||
|
'peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors duration-200',
|
||||||
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background',
|
||||||
|
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
'data-[state=checked]:bg-primary data-[state=unchecked]:bg-input',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
>
|
||||||
|
<SwitchPrimitive.Thumb
|
||||||
|
className={cn(
|
||||||
|
'pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform duration-200',
|
||||||
|
'data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SwitchPrimitive.Root>
|
||||||
|
))
|
||||||
|
Switch.displayName = SwitchPrimitive.Root.displayName
|
||||||
|
|
||||||
|
export { Switch }
|
||||||
119
admin/src/components/ui/table.tsx
Normal file
119
admin/src/components/ui/table.tsx
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const Table = React.forwardRef<
|
||||||
|
HTMLTableElement,
|
||||||
|
React.HTMLAttributes<HTMLTableElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div className="relative w-full overflow-auto scrollbar-thin">
|
||||||
|
<table
|
||||||
|
ref={ref}
|
||||||
|
className={cn('w-full caption-bottom text-sm', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
Table.displayName = 'Table'
|
||||||
|
|
||||||
|
const TableHeader = React.forwardRef<
|
||||||
|
HTMLTableSectionElement,
|
||||||
|
React.HTMLAttributes<HTMLTableSectionElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<thead ref={ref} className={cn('[&_tr]:border-b', className)} {...props} />
|
||||||
|
))
|
||||||
|
TableHeader.displayName = 'TableHeader'
|
||||||
|
|
||||||
|
const TableBody = React.forwardRef<
|
||||||
|
HTMLTableSectionElement,
|
||||||
|
React.HTMLAttributes<HTMLTableSectionElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<tbody
|
||||||
|
ref={ref}
|
||||||
|
className={cn('[&_tr:last-child]:border-0', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TableBody.displayName = 'TableBody'
|
||||||
|
|
||||||
|
const TableFooter = React.forwardRef<
|
||||||
|
HTMLTableSectionElement,
|
||||||
|
React.HTMLAttributes<HTMLTableSectionElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<tfoot
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'border-t bg-muted/50 font-medium [&>tr]:last:border-b-0',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TableFooter.displayName = 'TableFooter'
|
||||||
|
|
||||||
|
const TableRow = React.forwardRef<
|
||||||
|
HTMLTableRowElement,
|
||||||
|
React.HTMLAttributes<HTMLTableRowElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<tr
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'border-b border-border transition-colors duration-200 hover:bg-muted/50',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TableRow.displayName = 'TableRow'
|
||||||
|
|
||||||
|
const TableHead = React.forwardRef<
|
||||||
|
HTMLTableCellElement,
|
||||||
|
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<th
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'h-10 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TableHead.displayName = 'TableHead'
|
||||||
|
|
||||||
|
const TableCell = React.forwardRef<
|
||||||
|
HTMLTableCellElement,
|
||||||
|
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<td
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'p-4 align-middle [&:has([role=checkbox])]:pr-0',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TableCell.displayName = 'TableCell'
|
||||||
|
|
||||||
|
const TableCaption = React.forwardRef<
|
||||||
|
HTMLTableCaptionElement,
|
||||||
|
React.HTMLAttributes<HTMLTableCaptionElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<caption
|
||||||
|
ref={ref}
|
||||||
|
className={cn('mt-4 text-sm text-muted-foreground', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TableCaption.displayName = 'TableCaption'
|
||||||
|
|
||||||
|
export {
|
||||||
|
Table,
|
||||||
|
TableHeader,
|
||||||
|
TableBody,
|
||||||
|
TableFooter,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
TableCell,
|
||||||
|
TableCaption,
|
||||||
|
}
|
||||||
57
admin/src/components/ui/tabs.tsx
Normal file
57
admin/src/components/ui/tabs.tsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import * as React from 'react'
|
||||||
|
import * as TabsPrimitive from '@radix-ui/react-tabs'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const Tabs = TabsPrimitive.Root
|
||||||
|
|
||||||
|
const TabsList = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.List>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.List
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TabsList.displayName = TabsPrimitive.List.displayName
|
||||||
|
|
||||||
|
const TabsTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all duration-200',
|
||||||
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||||
|
'disabled:pointer-events-none disabled:opacity-50',
|
||||||
|
'data-[state=active]:bg-card data-[state=active]:text-foreground data-[state=active]:shadow',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
||||||
|
|
||||||
|
const TabsContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TabsContent.displayName = TabsPrimitive.Content.displayName
|
||||||
|
|
||||||
|
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||||
31
admin/src/components/ui/tooltip.tsx
Normal file
31
admin/src/components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import * as React from 'react'
|
||||||
|
import * as TooltipPrimitive from '@radix-ui/react-tooltip'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const TooltipProvider = TooltipPrimitive.Provider
|
||||||
|
const Tooltip = TooltipPrimitive.Root
|
||||||
|
const TooltipTrigger = TooltipPrimitive.Trigger
|
||||||
|
|
||||||
|
const TooltipContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||||
|
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||||
|
<TooltipPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
'z-50 overflow-hidden rounded-md bg-card border border-border px-3 py-1.5 text-sm text-foreground shadow-md',
|
||||||
|
'animate-in fade-in-0 zoom-in-95',
|
||||||
|
'data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95',
|
||||||
|
'data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2',
|
||||||
|
'data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TooltipContent.displayName = TooltipPrimitive.Content.displayName
|
||||||
|
|
||||||
|
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||||
347
admin/src/lib/api-client.ts
Normal file
347
admin/src/lib/api-client.ts
Normal file
@@ -0,0 +1,347 @@
|
|||||||
|
// ============================================================
|
||||||
|
// ZCLAW SaaS Admin — 类型化 HTTP 客户端
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { getToken, logout, refreshToken } from './auth'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import type {
|
||||||
|
AccountPublic,
|
||||||
|
ApiError,
|
||||||
|
ConfigItem,
|
||||||
|
CreateTokenRequest,
|
||||||
|
DashboardStats,
|
||||||
|
DeviceInfo,
|
||||||
|
LoginRequest,
|
||||||
|
LoginResponse,
|
||||||
|
Model,
|
||||||
|
OperationLog,
|
||||||
|
PaginatedResponse,
|
||||||
|
Provider,
|
||||||
|
RelayTask,
|
||||||
|
TokenInfo,
|
||||||
|
UsageByModel,
|
||||||
|
UsageStats,
|
||||||
|
} from './types'
|
||||||
|
|
||||||
|
// ── 错误类 ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export class ApiRequestError extends Error {
|
||||||
|
constructor(
|
||||||
|
public status: number,
|
||||||
|
public body: ApiError,
|
||||||
|
) {
|
||||||
|
super(body.message || `Request failed with status ${status}`)
|
||||||
|
this.name = 'ApiRequestError'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 基础请求 ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
const BASE_URL = process.env.NEXT_PUBLIC_SAAS_API_URL || 'http://localhost:8080'
|
||||||
|
const API_PREFIX = '/api/v1'
|
||||||
|
|
||||||
|
async function request<T>(
|
||||||
|
method: string,
|
||||||
|
path: string,
|
||||||
|
body?: unknown,
|
||||||
|
): Promise<T> {
|
||||||
|
const token = getToken()
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
}
|
||||||
|
if (token) {
|
||||||
|
headers['Authorization'] = `Bearer ${token}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(`${BASE_URL}${API_PREFIX}${path}`, {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (res.status === 401) {
|
||||||
|
// 尝试刷新 token 后重试
|
||||||
|
try {
|
||||||
|
const newToken = await refreshToken()
|
||||||
|
headers['Authorization'] = `Bearer ${newToken}`
|
||||||
|
const retryRes = await fetch(`${BASE_URL}${API_PREFIX}${path}`, {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
})
|
||||||
|
if (retryRes.ok || retryRes.status === 204) {
|
||||||
|
return retryRes.status === 204 ? (undefined as T) : retryRes.json()
|
||||||
|
}
|
||||||
|
// 刷新成功但重试仍失败,走正常错误处理
|
||||||
|
if (!retryRes.ok) {
|
||||||
|
let errorBody: ApiError
|
||||||
|
try { errorBody = await retryRes.json() } catch { errorBody = { error: 'unknown', message: `请求失败 (${retryRes.status})` } }
|
||||||
|
throw new ApiRequestError(retryRes.status, errorBody)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 刷新失败,执行登出
|
||||||
|
}
|
||||||
|
logout()
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.location.href = '/login'
|
||||||
|
}
|
||||||
|
throw new ApiRequestError(401, { error: 'unauthorized', message: '登录已过期,请重新登录' })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
let errorBody: ApiError
|
||||||
|
try {
|
||||||
|
errorBody = await res.json()
|
||||||
|
} catch {
|
||||||
|
errorBody = { error: 'unknown', message: `请求失败 (${res.status})` }
|
||||||
|
}
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
toast.error(errorBody.message || `请求失败 (${res.status})`)
|
||||||
|
}
|
||||||
|
throw new ApiRequestError(res.status, errorBody)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 204 No Content
|
||||||
|
if (res.status === 204) {
|
||||||
|
return undefined as T
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json() as Promise<T>
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── API 客户端 ────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
// ── 认证 ──────────────────────────────────────────────
|
||||||
|
auth: {
|
||||||
|
async login(data: LoginRequest): Promise<LoginResponse> {
|
||||||
|
return request<LoginResponse>('POST', '/auth/login', data)
|
||||||
|
},
|
||||||
|
|
||||||
|
async register(data: {
|
||||||
|
username: string
|
||||||
|
password: string
|
||||||
|
email: string
|
||||||
|
display_name?: string
|
||||||
|
}): Promise<LoginResponse> {
|
||||||
|
return request<LoginResponse>('POST', '/auth/register', data)
|
||||||
|
},
|
||||||
|
|
||||||
|
async me(): Promise<AccountPublic> {
|
||||||
|
return request<AccountPublic>('GET', '/auth/me')
|
||||||
|
},
|
||||||
|
|
||||||
|
async changePassword(data: { old_password: string; new_password: string }): Promise<void> {
|
||||||
|
return request<void>('PUT', '/auth/password', data)
|
||||||
|
},
|
||||||
|
|
||||||
|
async totpSetup(): Promise<{ otpauth_uri: string; secret: string; issuer: string }> {
|
||||||
|
return request<{ otpauth_uri: string; secret: string; issuer: string }>('POST', '/auth/totp/setup')
|
||||||
|
},
|
||||||
|
|
||||||
|
async totpVerify(data: { code: string }): Promise<void> {
|
||||||
|
return request<void>('POST', '/auth/totp/verify', data)
|
||||||
|
},
|
||||||
|
|
||||||
|
async totpDisable(data: { password: string }): Promise<void> {
|
||||||
|
return request<void>('POST', '/auth/totp/disable', data)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── 账号管理 ──────────────────────────────────────────
|
||||||
|
accounts: {
|
||||||
|
async list(params?: {
|
||||||
|
page?: number
|
||||||
|
page_size?: number
|
||||||
|
search?: string
|
||||||
|
role?: string
|
||||||
|
status?: string
|
||||||
|
}): Promise<PaginatedResponse<AccountPublic>> {
|
||||||
|
const qs = buildQueryString(params)
|
||||||
|
return request<PaginatedResponse<AccountPublic>>('GET', `/accounts${qs}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
async get(id: string): Promise<AccountPublic> {
|
||||||
|
return request<AccountPublic>('GET', `/accounts/${id}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
async update(
|
||||||
|
id: string,
|
||||||
|
data: Partial<Pick<AccountPublic, 'display_name' | 'email' | 'role'>>,
|
||||||
|
): Promise<AccountPublic> {
|
||||||
|
return request<AccountPublic>('PUT', `/accounts/${id}`, data)
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateStatus(
|
||||||
|
id: string,
|
||||||
|
data: { status: AccountPublic['status'] },
|
||||||
|
): Promise<void> {
|
||||||
|
return request<void>('PATCH', `/accounts/${id}/status`, data)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── 服务商管理 ────────────────────────────────────────
|
||||||
|
providers: {
|
||||||
|
async list(params?: {
|
||||||
|
page?: number
|
||||||
|
page_size?: number
|
||||||
|
}): Promise<PaginatedResponse<Provider>> {
|
||||||
|
const qs = buildQueryString(params)
|
||||||
|
return request<PaginatedResponse<Provider>>('GET', `/providers${qs}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
async get(id: string): Promise<Provider> {
|
||||||
|
return request<Provider>('GET', `/providers/${id}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
async create(data: Partial<Omit<Provider, 'id' | 'created_at' | 'updated_at'>>): Promise<Provider> {
|
||||||
|
return request<Provider>('POST', '/providers', data)
|
||||||
|
},
|
||||||
|
|
||||||
|
async update(
|
||||||
|
id: string,
|
||||||
|
data: Partial<Omit<Provider, 'id' | 'created_at' | 'updated_at'>>,
|
||||||
|
): Promise<Provider> {
|
||||||
|
return request<Provider>('PUT', `/providers/${id}`, data)
|
||||||
|
},
|
||||||
|
|
||||||
|
async delete(id: string): Promise<void> {
|
||||||
|
return request<void>('DELETE', `/providers/${id}`)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── 模型管理 ──────────────────────────────────────────
|
||||||
|
models: {
|
||||||
|
async list(params?: {
|
||||||
|
page?: number
|
||||||
|
page_size?: number
|
||||||
|
provider_id?: string
|
||||||
|
}): Promise<PaginatedResponse<Model>> {
|
||||||
|
const qs = buildQueryString(params)
|
||||||
|
return request<PaginatedResponse<Model>>('GET', `/models${qs}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
async get(id: string): Promise<Model> {
|
||||||
|
return request<Model>('GET', `/models/${id}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
async create(data: Partial<Omit<Model, 'id'>>): Promise<Model> {
|
||||||
|
return request<Model>('POST', '/models', data)
|
||||||
|
},
|
||||||
|
|
||||||
|
async update(id: string, data: Partial<Omit<Model, 'id'>>): Promise<Model> {
|
||||||
|
return request<Model>('PUT', `/models/${id}`, data)
|
||||||
|
},
|
||||||
|
|
||||||
|
async delete(id: string): Promise<void> {
|
||||||
|
return request<void>('DELETE', `/models/${id}`)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── API 密钥 ──────────────────────────────────────────
|
||||||
|
tokens: {
|
||||||
|
async list(params?: {
|
||||||
|
page?: number
|
||||||
|
page_size?: number
|
||||||
|
}): Promise<PaginatedResponse<TokenInfo>> {
|
||||||
|
const qs = buildQueryString(params)
|
||||||
|
return request<PaginatedResponse<TokenInfo>>('GET', `/tokens${qs}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
async create(data: CreateTokenRequest): Promise<TokenInfo> {
|
||||||
|
return request<TokenInfo>('POST', '/tokens', data)
|
||||||
|
},
|
||||||
|
|
||||||
|
async revoke(id: string): Promise<void> {
|
||||||
|
return request<void>('DELETE', `/tokens/${id}`)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── 用量统计 ──────────────────────────────────────────
|
||||||
|
usage: {
|
||||||
|
async get(params?: { from?: string; to?: string; provider_id?: string; model_id?: string }): Promise<UsageStats> {
|
||||||
|
const qs = buildQueryString(params)
|
||||||
|
return request<UsageStats>('GET', `/usage${qs}`)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── 中转任务 ──────────────────────────────────────────
|
||||||
|
relay: {
|
||||||
|
async list(params?: {
|
||||||
|
page?: number
|
||||||
|
page_size?: number
|
||||||
|
status?: string
|
||||||
|
}): Promise<PaginatedResponse<RelayTask>> {
|
||||||
|
const qs = buildQueryString(params)
|
||||||
|
return request<PaginatedResponse<RelayTask>>('GET', `/relay/tasks${qs}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
async get(id: string): Promise<RelayTask> {
|
||||||
|
return request<RelayTask>('GET', `/relay/tasks/${id}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
async retry(id: string): Promise<void> {
|
||||||
|
return request<void>('POST', `/relay/tasks/${id}/retry`)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── 系统配置 ──────────────────────────────────────────
|
||||||
|
config: {
|
||||||
|
async list(params?: {
|
||||||
|
category?: string
|
||||||
|
}): Promise<ConfigItem[]> {
|
||||||
|
const qs = buildQueryString(params)
|
||||||
|
return request<ConfigItem[]>('GET', `/config/items${qs}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
async update(id: string, data: { current_value: string | number | boolean }): Promise<ConfigItem> {
|
||||||
|
return request<ConfigItem>('PUT', `/config/items/${id}`, data)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── 操作日志 ──────────────────────────────────────────
|
||||||
|
logs: {
|
||||||
|
async list(params?: {
|
||||||
|
page?: number
|
||||||
|
page_size?: number
|
||||||
|
action?: string
|
||||||
|
}): Promise<OperationLog[]> {
|
||||||
|
const qs = buildQueryString(params)
|
||||||
|
return request<OperationLog[]>('GET', `/logs/operations${qs}`)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── 仪表盘 ────────────────────────────────────────────
|
||||||
|
stats: {
|
||||||
|
async dashboard(): Promise<DashboardStats> {
|
||||||
|
return request<DashboardStats>('GET', '/stats/dashboard')
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── 设备管理 ──────────────────────────────────────────
|
||||||
|
devices: {
|
||||||
|
async list(): Promise<DeviceInfo[]> {
|
||||||
|
return request<DeviceInfo[]>('GET', '/devices')
|
||||||
|
},
|
||||||
|
async register(data: { device_id: string; device_name?: string; platform?: string; app_version?: string }) {
|
||||||
|
return request<{ ok: boolean; device_id: string }>('POST', '/devices/register', data)
|
||||||
|
},
|
||||||
|
async heartbeat(data: { device_id: string }) {
|
||||||
|
return request<{ ok: boolean }>('POST', '/devices/heartbeat', data)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 工具函数 ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
function buildQueryString(params?: Record<string, unknown>): string {
|
||||||
|
if (!params) return ''
|
||||||
|
const entries = Object.entries(params).filter(
|
||||||
|
([, v]) => v !== undefined && v !== null && v !== '',
|
||||||
|
)
|
||||||
|
if (entries.length === 0) return ''
|
||||||
|
const qs = entries
|
||||||
|
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`)
|
||||||
|
.join('&')
|
||||||
|
return `?${qs}`
|
||||||
|
}
|
||||||
216
admin/src/lib/auth.ts
Normal file
216
admin/src/lib/auth.ts
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
// ============================================================
|
||||||
|
// ZCLAW SaaS Admin — JWT Token 管理
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import type { AccountPublic, LoginResponse } from './types'
|
||||||
|
|
||||||
|
const TOKEN_KEY = 'zclaw_admin_token'
|
||||||
|
const ACCOUNT_KEY = 'zclaw_admin_account'
|
||||||
|
|
||||||
|
// ── JWT 辅助函数 ────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface JwtPayload {
|
||||||
|
exp?: number
|
||||||
|
iat?: number
|
||||||
|
sub?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode a JWT payload without verifying the signature.
|
||||||
|
* Returns the parsed JSON payload, or null if the token is malformed.
|
||||||
|
*/
|
||||||
|
function decodeJwtPayload<T = Record<string, unknown>>(token: string): T | null {
|
||||||
|
try {
|
||||||
|
const parts = token.split('.')
|
||||||
|
if (parts.length !== 3) return null
|
||||||
|
const base64 = parts[1].replace(/-/g, '+').replace(/_/g, '/')
|
||||||
|
const json = decodeURIComponent(
|
||||||
|
atob(base64)
|
||||||
|
.split('')
|
||||||
|
.map((c) => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))
|
||||||
|
.join(''),
|
||||||
|
)
|
||||||
|
return JSON.parse(json) as T
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate the delay (ms) until 80% of the token's remaining lifetime
|
||||||
|
* has elapsed. Returns null if the token is already past that point.
|
||||||
|
*/
|
||||||
|
function getRefreshDelay(exp: number): number | null {
|
||||||
|
const now = Math.floor(Date.now() / 1000)
|
||||||
|
const totalLifetime = exp - now
|
||||||
|
if (totalLifetime <= 0) return null
|
||||||
|
|
||||||
|
const refreshAt = now + Math.floor(totalLifetime * 0.8)
|
||||||
|
const delayMs = (refreshAt - now) * 1000
|
||||||
|
return delayMs > 5000 ? delayMs : 5000
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 定时刷新状态 ────────────────────────────────────────────
|
||||||
|
|
||||||
|
let refreshTimerId: ReturnType<typeof setTimeout> | null = null
|
||||||
|
let visibilityHandler: (() => void) | null = null
|
||||||
|
let sessionExpiredCallback: (() => void) | null = null
|
||||||
|
|
||||||
|
// ── 凭证操作 ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** 保存登录凭证并启动自动刷新 */
|
||||||
|
export function login(token: string, account: AccountPublic): void {
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
localStorage.setItem(TOKEN_KEY, token)
|
||||||
|
localStorage.setItem(ACCOUNT_KEY, JSON.stringify(account))
|
||||||
|
scheduleTokenRefresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 清除登录凭证并停止自动刷新 */
|
||||||
|
export function logout(): void {
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
cancelTokenRefresh()
|
||||||
|
localStorage.removeItem(TOKEN_KEY)
|
||||||
|
localStorage.removeItem(ACCOUNT_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取 JWT token */
|
||||||
|
export function getToken(): string | null {
|
||||||
|
if (typeof window === 'undefined') return null
|
||||||
|
return localStorage.getItem(TOKEN_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取当前登录用户信息 */
|
||||||
|
export function getAccount(): AccountPublic | null {
|
||||||
|
if (typeof window === 'undefined') return null
|
||||||
|
const raw = localStorage.getItem(ACCOUNT_KEY)
|
||||||
|
if (!raw) return null
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw) as AccountPublic
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 是否已认证 */
|
||||||
|
export function isAuthenticated(): boolean {
|
||||||
|
return !!getToken()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 尝试刷新 token,成功则更新 localStorage 并返回新 token */
|
||||||
|
export async function refreshToken(): Promise<string> {
|
||||||
|
const res = await fetch(
|
||||||
|
`${process.env.NEXT_PUBLIC_SAAS_API_URL || 'http://localhost:8080'}/api/v1/auth/refresh`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${getToken()}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error('Token 刷新失败')
|
||||||
|
}
|
||||||
|
const data: LoginResponse = await res.json()
|
||||||
|
login(data.token, data.account)
|
||||||
|
return data.token
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 自动刷新调度 ────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a callback invoked when the proactive token refresh fails.
|
||||||
|
* The caller should use this to trigger a logout/redirect flow.
|
||||||
|
*/
|
||||||
|
export function setOnSessionExpired(handler: (() => void) | null): void {
|
||||||
|
sessionExpiredCallback = handler
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedule a proactive token refresh at 80% of the token's remaining lifetime.
|
||||||
|
* Also registers a visibilitychange listener to re-check when the tab regains focus.
|
||||||
|
*/
|
||||||
|
export function scheduleTokenRefresh(): void {
|
||||||
|
cancelTokenRefresh()
|
||||||
|
|
||||||
|
const token = getToken()
|
||||||
|
if (!token) return
|
||||||
|
|
||||||
|
const payload = decodeJwtPayload<JwtPayload>(token)
|
||||||
|
if (!payload?.exp) return
|
||||||
|
|
||||||
|
const delay = getRefreshDelay(payload.exp)
|
||||||
|
if (delay === null) {
|
||||||
|
attemptTokenRefresh()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshTimerId = setTimeout(() => {
|
||||||
|
attemptTokenRefresh()
|
||||||
|
}, delay)
|
||||||
|
|
||||||
|
if (typeof document !== 'undefined' && !visibilityHandler) {
|
||||||
|
visibilityHandler = () => {
|
||||||
|
if (document.visibilityState === 'visible') {
|
||||||
|
checkAndRefreshToken()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('visibilitychange', visibilityHandler)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancel any pending token refresh timer and remove the visibility listener.
|
||||||
|
*/
|
||||||
|
export function cancelTokenRefresh(): void {
|
||||||
|
if (refreshTimerId !== null) {
|
||||||
|
clearTimeout(refreshTimerId)
|
||||||
|
refreshTimerId = null
|
||||||
|
}
|
||||||
|
if (visibilityHandler !== null && typeof document !== 'undefined') {
|
||||||
|
document.removeEventListener('visibilitychange', visibilityHandler)
|
||||||
|
visibilityHandler = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the current token is close to expiry and refresh if needed.
|
||||||
|
* Called on visibility change to handle clock skew / long background tabs.
|
||||||
|
*/
|
||||||
|
function checkAndRefreshToken(): void {
|
||||||
|
const token = getToken()
|
||||||
|
if (!token) return
|
||||||
|
|
||||||
|
const payload = decodeJwtPayload<JwtPayload>(token)
|
||||||
|
if (!payload?.exp) return
|
||||||
|
|
||||||
|
const now = Math.floor(Date.now() / 1000)
|
||||||
|
const remaining = payload.exp - now
|
||||||
|
|
||||||
|
if (remaining <= 0) {
|
||||||
|
attemptTokenRefresh()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const delay = getRefreshDelay(payload.exp)
|
||||||
|
if (delay !== null && delay < 60_000) {
|
||||||
|
attemptTokenRefresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempt to refresh the token. On success, the new token is persisted via
|
||||||
|
* login() which also reschedules the next refresh. On failure, invoke the
|
||||||
|
* session-expired callback.
|
||||||
|
*/
|
||||||
|
async function attemptTokenRefresh(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await refreshToken()
|
||||||
|
} catch {
|
||||||
|
cancelTokenRefresh()
|
||||||
|
if (sessionExpiredCallback) {
|
||||||
|
sessionExpiredCallback()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
193
admin/src/lib/types.ts
Normal file
193
admin/src/lib/types.ts
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
// ============================================================
|
||||||
|
// ZCLAW SaaS Admin — 全局类型定义
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/** 公共账号信息 */
|
||||||
|
export interface AccountPublic {
|
||||||
|
id: string
|
||||||
|
username: string
|
||||||
|
email: string
|
||||||
|
display_name: string
|
||||||
|
role: 'super_admin' | 'admin' | 'user'
|
||||||
|
permissions: string[]
|
||||||
|
status: 'active' | 'disabled' | 'suspended'
|
||||||
|
totp_enabled: boolean
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 登录请求 */
|
||||||
|
export interface LoginRequest {
|
||||||
|
username: string
|
||||||
|
password: string
|
||||||
|
totp_code?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 登录响应 */
|
||||||
|
export interface LoginResponse {
|
||||||
|
token: string
|
||||||
|
account: AccountPublic
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 注册请求 */
|
||||||
|
export interface RegisterRequest {
|
||||||
|
username: string
|
||||||
|
password: string
|
||||||
|
email: string
|
||||||
|
display_name?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 分页响应 */
|
||||||
|
export interface PaginatedResponse<T> {
|
||||||
|
items: T[]
|
||||||
|
total: number
|
||||||
|
page: number
|
||||||
|
page_size: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 服务商 (Provider) */
|
||||||
|
export interface Provider {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
display_name: string
|
||||||
|
base_url: string
|
||||||
|
api_protocol: 'openai' | 'anthropic'
|
||||||
|
enabled: boolean
|
||||||
|
rate_limit_rpm?: number
|
||||||
|
rate_limit_tpm?: number
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 模型 */
|
||||||
|
export interface Model {
|
||||||
|
id: string
|
||||||
|
provider_id: string
|
||||||
|
model_id: string
|
||||||
|
alias: string
|
||||||
|
context_window: number
|
||||||
|
max_output_tokens: number
|
||||||
|
supports_streaming: boolean
|
||||||
|
supports_vision: boolean
|
||||||
|
enabled: boolean
|
||||||
|
pricing_input: number
|
||||||
|
pricing_output: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** API 密钥信息 */
|
||||||
|
export interface TokenInfo {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
token_prefix: string
|
||||||
|
permissions: string[]
|
||||||
|
last_used_at?: string
|
||||||
|
expires_at?: string
|
||||||
|
created_at: string
|
||||||
|
token?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 创建 Token 请求 */
|
||||||
|
export interface CreateTokenRequest {
|
||||||
|
name: string
|
||||||
|
expires_days?: number
|
||||||
|
permissions: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 中转任务 */
|
||||||
|
export interface RelayTask {
|
||||||
|
id: string
|
||||||
|
account_id: string
|
||||||
|
provider_id: string
|
||||||
|
model_id: string
|
||||||
|
status: 'queued' | 'processing' | 'completed' | 'failed'
|
||||||
|
priority: number
|
||||||
|
attempt_count: number
|
||||||
|
input_tokens: number
|
||||||
|
output_tokens: number
|
||||||
|
error_message?: string
|
||||||
|
queued_at?: string
|
||||||
|
started_at?: string
|
||||||
|
completed_at?: string
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 用量统计 — 后端返回的完整结构 */
|
||||||
|
export interface UsageStats {
|
||||||
|
total_requests: number
|
||||||
|
total_input_tokens: number
|
||||||
|
total_output_tokens: number
|
||||||
|
by_model: UsageByModel[]
|
||||||
|
by_day: DailyUsage[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 每日用量 */
|
||||||
|
export interface DailyUsage {
|
||||||
|
date: string
|
||||||
|
request_count: number
|
||||||
|
input_tokens: number
|
||||||
|
output_tokens: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按模型用量 */
|
||||||
|
export interface UsageByModel {
|
||||||
|
provider_id: string
|
||||||
|
model_id: string
|
||||||
|
request_count: number
|
||||||
|
input_tokens: number
|
||||||
|
output_tokens: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 系统配置项 */
|
||||||
|
export interface ConfigItem {
|
||||||
|
id: string
|
||||||
|
category: string
|
||||||
|
key_path: string
|
||||||
|
value_type: 'string' | 'number' | 'boolean'
|
||||||
|
current_value?: string
|
||||||
|
default_value?: string
|
||||||
|
source: 'default' | 'env' | 'db'
|
||||||
|
description?: string
|
||||||
|
requires_restart: boolean
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 操作日志 */
|
||||||
|
export interface OperationLog {
|
||||||
|
id: number
|
||||||
|
account_id: string
|
||||||
|
action: string
|
||||||
|
target_type: string
|
||||||
|
target_id: string
|
||||||
|
details?: Record<string, unknown>
|
||||||
|
ip_address?: string
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 仪表盘统计 */
|
||||||
|
export interface DashboardStats {
|
||||||
|
total_accounts: number
|
||||||
|
active_accounts: number
|
||||||
|
tasks_today: number
|
||||||
|
active_providers: number
|
||||||
|
active_models: number
|
||||||
|
tokens_today_input: number
|
||||||
|
tokens_today_output: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 设备信息 */
|
||||||
|
export interface DeviceInfo {
|
||||||
|
id: string
|
||||||
|
device_id: string
|
||||||
|
device_name?: string
|
||||||
|
platform?: string
|
||||||
|
app_version?: string
|
||||||
|
last_seen_at: string
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** API 错误响应 */
|
||||||
|
export interface ApiError {
|
||||||
|
error: string
|
||||||
|
message: string
|
||||||
|
status?: number
|
||||||
|
}
|
||||||
34
admin/src/lib/utils.ts
Normal file
34
admin/src/lib/utils.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { type ClassValue, clsx } from 'clsx'
|
||||||
|
import { twMerge } from 'tailwind-merge'
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDate(date: string | Date): string {
|
||||||
|
const d = new Date(date)
|
||||||
|
return d.toLocaleString('zh-CN', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatNumber(n: number): string {
|
||||||
|
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
|
||||||
|
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`
|
||||||
|
return n.toLocaleString()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function maskApiKey(key?: string): string {
|
||||||
|
if (!key) return '-'
|
||||||
|
if (key.length <= 8) return '****'
|
||||||
|
return `${key.slice(0, 4)}${'*'.repeat(key.length - 8)}${key.slice(-4)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise(resolve => setTimeout(resolve, ms))
|
||||||
|
}
|
||||||
62
admin/tailwind.config.ts
Normal file
62
admin/tailwind.config.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import type { Config } from 'tailwindcss'
|
||||||
|
|
||||||
|
const config: Config = {
|
||||||
|
darkMode: 'class',
|
||||||
|
content: [
|
||||||
|
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
|
||||||
|
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
|
||||||
|
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
background: '#020617',
|
||||||
|
foreground: '#F8FAFC',
|
||||||
|
card: {
|
||||||
|
DEFAULT: '#0F172A',
|
||||||
|
foreground: '#F8FAFC',
|
||||||
|
},
|
||||||
|
primary: {
|
||||||
|
DEFAULT: '#22C55E',
|
||||||
|
foreground: '#020617',
|
||||||
|
hover: '#16A34A',
|
||||||
|
},
|
||||||
|
muted: {
|
||||||
|
DEFAULT: '#1E293B',
|
||||||
|
foreground: '#94A3B8',
|
||||||
|
},
|
||||||
|
accent: {
|
||||||
|
DEFAULT: '#334155',
|
||||||
|
foreground: '#F8FAFC',
|
||||||
|
},
|
||||||
|
destructive: {
|
||||||
|
DEFAULT: '#EF4444',
|
||||||
|
foreground: '#F8FAFC',
|
||||||
|
},
|
||||||
|
border: '#1E293B',
|
||||||
|
input: '#1E293B',
|
||||||
|
ring: '#22C55E',
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
sans: ['Inter', 'system-ui', '-apple-system', 'sans-serif'],
|
||||||
|
mono: ['JetBrains Mono', 'Fira Code', 'monospace'],
|
||||||
|
},
|
||||||
|
keyframes: {
|
||||||
|
'fade-in': {
|
||||||
|
'0%': { opacity: '0', transform: 'translateY(4px)' },
|
||||||
|
'100%': { opacity: '1', transform: 'translateY(0)' },
|
||||||
|
},
|
||||||
|
'slide-in': {
|
||||||
|
'0%': { opacity: '0', transform: 'translateX(-8px)' },
|
||||||
|
'100%': { opacity: '1', transform: 'translateX(0)' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
'fade-in': 'fade-in 0.2s ease-out',
|
||||||
|
'slide-in': 'slide-in 0.2s ease-out',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
|
export default config
|
||||||
21
admin/tsconfig.json
Normal file
21
admin/tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "es2017",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [{ "name": "next" }],
|
||||||
|
"paths": { "@/*": ["./src/*"] }
|
||||||
|
},
|
||||||
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
# ZClaw Chinese LLM Providers Configuration
|
# ZCLAW Chinese LLM Providers Configuration
|
||||||
# OpenFang TOML 格式的中文模型提供商配置
|
# ZCLAW TOML 格式的中文模型提供商配置
|
||||||
#
|
#
|
||||||
# 使用方法:
|
# 使用方法:
|
||||||
# 1. 复制此文件到 ~/.openfang/config.d/ 目录
|
# 1. 复制此文件到 ~/.zclaw/config.d/ 目录
|
||||||
# 2. 或者将内容追加到 ~/.openfang/config.toml
|
# 2. 或者将内容追加到 ~/.zclaw/config.toml
|
||||||
# 3. 设置环境变量: ZHIPU_API_KEY, QWEN_API_KEY, KIMI_API_KEY, MINIMAX_API_KEY
|
# 3. 设置环境变量: ZHIPU_API_KEY, QWEN_API_KEY, KIMI_API_KEY, MINIMAX_API_KEY
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
# ============================================================
|
# ============================================================
|
||||||
# ZClaw OpenFang Main Configuration
|
# ZCLAW Main Configuration
|
||||||
# OpenFang TOML format configuration file
|
# ZCLAW TOML format configuration file
|
||||||
# ============================================================
|
# ============================================================
|
||||||
#
|
#
|
||||||
# Usage:
|
# Usage:
|
||||||
# 1. Copy this file to ~/.openfang/config.toml
|
# 1. Copy this file to ~/.zclaw/config.toml
|
||||||
# 2. Set environment variables for API keys
|
# 2. Set environment variables for API keys
|
||||||
# 3. Import chinese-providers.toml for Chinese LLM support
|
# 3. Import chinese-providers.toml for Chinese LLM support
|
||||||
#
|
#
|
||||||
@@ -38,7 +38,7 @@ api_version = "v1"
|
|||||||
|
|
||||||
[agent.defaults]
|
[agent.defaults]
|
||||||
# Default workspace for agent operations
|
# Default workspace for agent operations
|
||||||
workspace = "~/.openfang/zclaw-workspace"
|
workspace = "~/.zclaw/zclaw-workspace"
|
||||||
|
|
||||||
# Default model for new sessions
|
# Default model for new sessions
|
||||||
default_model = "zhipu/glm-4-plus"
|
default_model = "zhipu/glm-4-plus"
|
||||||
@@ -57,7 +57,7 @@ max_sessions = 10
|
|||||||
|
|
||||||
[agent.defaults.sandbox]
|
[agent.defaults.sandbox]
|
||||||
# Sandbox root directory
|
# Sandbox root directory
|
||||||
workspace_root = "~/.openfang/zclaw-workspace"
|
workspace_root = "~/.zclaw/zclaw-workspace"
|
||||||
|
|
||||||
# Allowed shell commands (empty = all allowed)
|
# Allowed shell commands (empty = all allowed)
|
||||||
# allowed_commands = ["git", "npm", "pnpm", "cargo"]
|
# allowed_commands = ["git", "npm", "pnpm", "cargo"]
|
||||||
@@ -104,7 +104,7 @@ execution_timeout = "30m"
|
|||||||
|
|
||||||
# Audit settings
|
# Audit settings
|
||||||
audit_enabled = true
|
audit_enabled = true
|
||||||
audit_log_path = "~/.openfang/logs/hands-audit.log"
|
audit_log_path = "~/.zclaw/logs/hands-audit.log"
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# LLM Provider Configuration
|
# LLM Provider Configuration
|
||||||
@@ -166,7 +166,7 @@ burst_size = 20
|
|||||||
# Audit logging
|
# Audit logging
|
||||||
[security.audit]
|
[security.audit]
|
||||||
enabled = true
|
enabled = true
|
||||||
log_path = "~/.openfang/logs/audit.log"
|
log_path = "~/.zclaw/logs/audit.log"
|
||||||
log_format = "json"
|
log_format = "json"
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
@@ -183,7 +183,7 @@ format = "pretty"
|
|||||||
# Log file settings
|
# Log file settings
|
||||||
[logging.file]
|
[logging.file]
|
||||||
enabled = true
|
enabled = true
|
||||||
path = "~/.openfang/logs/openfang.log"
|
path = "~/.zclaw/logs/zclaw.log"
|
||||||
max_size = "10MB"
|
max_size = "10MB"
|
||||||
max_files = 5
|
max_files = 5
|
||||||
compress = true
|
compress = true
|
||||||
@@ -228,7 +228,7 @@ max_results = 10
|
|||||||
|
|
||||||
# File system tool
|
# File system tool
|
||||||
[tools.fs]
|
[tools.fs]
|
||||||
allowed_paths = ["~/.openfang/zclaw-workspace"]
|
allowed_paths = ["~/.zclaw/zclaw-workspace"]
|
||||||
max_file_size = "10MB"
|
max_file_size = "10MB"
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
@@ -237,7 +237,7 @@ max_file_size = "10MB"
|
|||||||
|
|
||||||
[workflow]
|
[workflow]
|
||||||
# Workflow storage
|
# Workflow storage
|
||||||
storage_path = "~/.openfang/workflows"
|
storage_path = "~/.zclaw/workflows"
|
||||||
|
|
||||||
# Execution settings
|
# Execution settings
|
||||||
max_steps = 100
|
max_steps = 100
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
//! Discord channel adapter
|
|
||||||
|
|
||||||
use async_trait::async_trait;
|
|
||||||
use std::sync::Arc;
|
|
||||||
use tokio::sync::mpsc;
|
|
||||||
use zclaw_types::Result;
|
|
||||||
|
|
||||||
use crate::{Channel, ChannelConfig, ChannelStatus, IncomingMessage, OutgoingMessage};
|
|
||||||
|
|
||||||
/// Discord channel adapter
|
|
||||||
pub struct DiscordChannel {
|
|
||||||
config: ChannelConfig,
|
|
||||||
status: Arc<tokio::sync::RwLock<ChannelStatus>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl DiscordChannel {
|
|
||||||
pub fn new(config: ChannelConfig) -> Self {
|
|
||||||
Self {
|
|
||||||
config,
|
|
||||||
status: Arc::new(tokio::sync::RwLock::new(ChannelStatus::Disconnected)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl Channel for DiscordChannel {
|
|
||||||
fn config(&self) -> &ChannelConfig {
|
|
||||||
&self.config
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn connect(&self) -> Result<()> {
|
|
||||||
let mut status = self.status.write().await;
|
|
||||||
*status = ChannelStatus::Connected;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn disconnect(&self) -> Result<()> {
|
|
||||||
let mut status = self.status.write().await;
|
|
||||||
*status = ChannelStatus::Disconnected;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn status(&self) -> ChannelStatus {
|
|
||||||
self.status.read().await.clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn send(&self, _message: OutgoingMessage) -> Result<String> {
|
|
||||||
// TODO: Implement Discord API send
|
|
||||||
Ok("discord_msg_id".to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn receive(&self) -> Result<mpsc::Receiver<IncomingMessage>> {
|
|
||||||
let (_tx, rx) = mpsc::channel(100);
|
|
||||||
// TODO: Implement Discord gateway
|
|
||||||
Ok(rx)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,11 +1,5 @@
|
|||||||
//! Channel adapters
|
//! Channel adapters
|
||||||
|
|
||||||
mod telegram;
|
|
||||||
mod discord;
|
|
||||||
mod slack;
|
|
||||||
mod console;
|
mod console;
|
||||||
|
|
||||||
pub use telegram::TelegramChannel;
|
|
||||||
pub use discord::DiscordChannel;
|
|
||||||
pub use slack::SlackChannel;
|
|
||||||
pub use console::ConsoleChannel;
|
pub use console::ConsoleChannel;
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
//! Slack channel adapter
|
|
||||||
|
|
||||||
use async_trait::async_trait;
|
|
||||||
use std::sync::Arc;
|
|
||||||
use tokio::sync::mpsc;
|
|
||||||
use zclaw_types::Result;
|
|
||||||
|
|
||||||
use crate::{Channel, ChannelConfig, ChannelStatus, IncomingMessage, OutgoingMessage};
|
|
||||||
|
|
||||||
/// Slack channel adapter
|
|
||||||
pub struct SlackChannel {
|
|
||||||
config: ChannelConfig,
|
|
||||||
status: Arc<tokio::sync::RwLock<ChannelStatus>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SlackChannel {
|
|
||||||
pub fn new(config: ChannelConfig) -> Self {
|
|
||||||
Self {
|
|
||||||
config,
|
|
||||||
status: Arc::new(tokio::sync::RwLock::new(ChannelStatus::Disconnected)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl Channel for SlackChannel {
|
|
||||||
fn config(&self) -> &ChannelConfig {
|
|
||||||
&self.config
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn connect(&self) -> Result<()> {
|
|
||||||
let mut status = self.status.write().await;
|
|
||||||
*status = ChannelStatus::Connected;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn disconnect(&self) -> Result<()> {
|
|
||||||
let mut status = self.status.write().await;
|
|
||||||
*status = ChannelStatus::Disconnected;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn status(&self) -> ChannelStatus {
|
|
||||||
self.status.read().await.clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn send(&self, _message: OutgoingMessage) -> Result<String> {
|
|
||||||
// TODO: Implement Slack API send
|
|
||||||
Ok("slack_msg_ts".to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn receive(&self) -> Result<mpsc::Receiver<IncomingMessage>> {
|
|
||||||
let (_tx, rx) = mpsc::channel(100);
|
|
||||||
// TODO: Implement Slack RTM/events API
|
|
||||||
Ok(rx)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,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
|
# Database
|
||||||
sqlx = { workspace = true }
|
sqlx = { workspace = true }
|
||||||
|
libsqlite3-sys = { workspace = true }
|
||||||
|
|
||||||
# Internal crates
|
# Internal crates
|
||||||
zclaw-types = { workspace = true }
|
zclaw-types = { workspace = true }
|
||||||
|
|||||||
@@ -388,6 +388,8 @@ mod tests {
|
|||||||
access_count: 0,
|
access_count: 0,
|
||||||
created_at: Utc::now(),
|
created_at: Utc::now(),
|
||||||
last_accessed: Utc::now(),
|
last_accessed: Utc::now(),
|
||||||
|
overview: None,
|
||||||
|
abstract_summary: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ pub mod tracker;
|
|||||||
pub mod viking_adapter;
|
pub mod viking_adapter;
|
||||||
pub mod storage;
|
pub mod storage;
|
||||||
pub mod retrieval;
|
pub mod retrieval;
|
||||||
|
pub mod summarizer;
|
||||||
|
|
||||||
// Re-export main types for convenience
|
// Re-export main types for convenience
|
||||||
pub use types::{
|
pub use types::{
|
||||||
@@ -82,7 +83,8 @@ pub use injector::{InjectionFormat, PromptInjector};
|
|||||||
pub use tracker::{AgentMetadata, GrowthTracker, LearningEvent};
|
pub use tracker::{AgentMetadata, GrowthTracker, LearningEvent};
|
||||||
pub use viking_adapter::{FindOptions, VikingAdapter, VikingLevel, VikingStorage};
|
pub use viking_adapter::{FindOptions, VikingAdapter, VikingLevel, VikingStorage};
|
||||||
pub use storage::SqliteStorage;
|
pub use storage::SqliteStorage;
|
||||||
pub use retrieval::{MemoryCache, QueryAnalyzer, SemanticScorer};
|
pub use retrieval::{EmbeddingClient, MemoryCache, QueryAnalyzer, SemanticScorer};
|
||||||
|
pub use summarizer::SummaryLlmDriver;
|
||||||
|
|
||||||
/// Growth system configuration
|
/// Growth system configuration
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ struct CacheEntry {
|
|||||||
access_count: u32,
|
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)]
|
#[derive(Debug, Clone, Hash, Eq, PartialEq)]
|
||||||
struct CacheKey {
|
struct CacheKey {
|
||||||
agent_id: String,
|
agent_id: String,
|
||||||
|
|||||||
@@ -9,6 +9,6 @@ pub mod semantic;
|
|||||||
pub mod query;
|
pub mod query;
|
||||||
pub mod cache;
|
pub mod cache;
|
||||||
|
|
||||||
pub use semantic::SemanticScorer;
|
pub use semantic::{EmbeddingClient, SemanticScorer};
|
||||||
pub use query::QueryAnalyzer;
|
pub use query::QueryAnalyzer;
|
||||||
pub use cache::MemoryCache;
|
pub use cache::MemoryCache;
|
||||||
|
|||||||
@@ -3,11 +3,35 @@
|
|||||||
//! Provides TF-IDF based semantic similarity computation for memory retrieval.
|
//! Provides TF-IDF based semantic similarity computation for memory retrieval.
|
||||||
//! This is a lightweight, dependency-free implementation suitable for
|
//! This is a lightweight, dependency-free implementation suitable for
|
||||||
//! medium-scale memory systems.
|
//! medium-scale memory systems.
|
||||||
|
//!
|
||||||
|
//! Supports optional embedding API integration for improved semantic search.
|
||||||
|
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
|
use std::sync::Arc;
|
||||||
use crate::types::MemoryEntry;
|
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 {
|
pub struct SemanticScorer {
|
||||||
/// Document frequency for IDF computation
|
/// Document frequency for IDF computation
|
||||||
document_frequencies: HashMap<String, usize>,
|
document_frequencies: HashMap<String, usize>,
|
||||||
@@ -15,8 +39,14 @@ pub struct SemanticScorer {
|
|||||||
total_documents: usize,
|
total_documents: usize,
|
||||||
/// Precomputed TF-IDF vectors for entries
|
/// Precomputed TF-IDF vectors for entries
|
||||||
entry_vectors: HashMap<String, HashMap<String, f32>>,
|
entry_vectors: HashMap<String, HashMap<String, f32>>,
|
||||||
|
/// Precomputed embedding vectors for entries
|
||||||
|
entry_embeddings: HashMap<String, Vec<f32>>,
|
||||||
/// Stop words to ignore
|
/// Stop words to ignore
|
||||||
stop_words: HashSet<String>,
|
stop_words: HashSet<String>,
|
||||||
|
/// Optional embedding client
|
||||||
|
embedding_client: Arc<dyn EmbeddingClient>,
|
||||||
|
/// Whether to use embedding for similarity
|
||||||
|
use_embedding: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SemanticScorer {
|
impl SemanticScorer {
|
||||||
@@ -26,10 +56,41 @@ impl SemanticScorer {
|
|||||||
document_frequencies: HashMap::new(),
|
document_frequencies: HashMap::new(),
|
||||||
total_documents: 0,
|
total_documents: 0,
|
||||||
entry_vectors: HashMap::new(),
|
entry_vectors: HashMap::new(),
|
||||||
|
entry_embeddings: HashMap::new(),
|
||||||
stop_words: Self::default_stop_words(),
|
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
|
/// Get default stop words
|
||||||
fn default_stop_words() -> HashSet<String> {
|
fn default_stop_words() -> HashSet<String> {
|
||||||
[
|
[
|
||||||
@@ -132,9 +193,34 @@ impl SemanticScorer {
|
|||||||
self.entry_vectors.insert(entry.uri.clone(), tfidf);
|
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
|
/// Remove an entry from the index
|
||||||
pub fn remove_entry(&mut self, uri: &str) {
|
pub fn remove_entry(&mut self, uri: &str) {
|
||||||
self.entry_vectors.remove(uri);
|
self.entry_vectors.remove(uri);
|
||||||
|
self.entry_embeddings.remove(uri);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Compute cosine similarity between two vectors
|
/// 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
|
/// Score similarity between query and entry
|
||||||
pub fn score_similarity(&self, query: &str, entry: &MemoryEntry) -> f32 {
|
pub fn score_similarity(&self, query: &str, entry: &MemoryEntry) -> f32 {
|
||||||
// Tokenize query
|
// Tokenize query
|
||||||
@@ -246,6 +388,7 @@ impl SemanticScorer {
|
|||||||
self.document_frequencies.clear();
|
self.document_frequencies.clear();
|
||||||
self.total_documents = 0;
|
self.total_documents = 0;
|
||||||
self.entry_vectors.clear();
|
self.entry_vectors.clear();
|
||||||
|
self.entry_embeddings.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get statistics about the index
|
/// Get statistics about the index
|
||||||
@@ -254,6 +397,8 @@ impl SemanticScorer {
|
|||||||
total_documents: self.total_documents,
|
total_documents: self.total_documents,
|
||||||
unique_terms: self.document_frequencies.len(),
|
unique_terms: self.document_frequencies.len(),
|
||||||
indexed_entries: self.entry_vectors.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 total_documents: usize,
|
||||||
pub unique_terms: usize,
|
pub unique_terms: usize,
|
||||||
pub indexed_entries: usize,
|
pub indexed_entries: usize,
|
||||||
|
pub embedding_entries: usize,
|
||||||
|
pub use_embedding: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
//! Persistent storage backend using SQLite for production use.
|
//! Persistent storage backend using SQLite for production use.
|
||||||
//! Provides efficient querying and full-text search capabilities.
|
//! 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::types::MemoryEntry;
|
||||||
use crate::viking_adapter::{FindOptions, VikingStorage};
|
use crate::viking_adapter::{FindOptions, VikingStorage};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
@@ -36,6 +36,8 @@ struct MemoryRow {
|
|||||||
access_count: i32,
|
access_count: i32,
|
||||||
created_at: String,
|
created_at: String,
|
||||||
last_accessed: String,
|
last_accessed: String,
|
||||||
|
overview: Option<String>,
|
||||||
|
abstract_summary: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SqliteStorage {
|
impl SqliteStorage {
|
||||||
@@ -83,6 +85,26 @@ impl SqliteStorage {
|
|||||||
Self::new(":memory:").await.expect("Failed to create in-memory database")
|
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
|
/// Initialize database schema with FTS5
|
||||||
async fn initialize_schema(&self) -> Result<()> {
|
async fn initialize_schema(&self) -> Result<()> {
|
||||||
// Create main memories table
|
// Create main memories table
|
||||||
@@ -131,6 +153,16 @@ impl SqliteStorage {
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| ZclawError::StorageError(format!("Failed to create importance index: {}", e)))?;
|
.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
|
// Create metadata table
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
@@ -151,7 +183,7 @@ impl SqliteStorage {
|
|||||||
/// Warmup semantic scorer with existing entries
|
/// Warmup semantic scorer with existing entries
|
||||||
async fn warmup_scorer(&self) -> Result<()> {
|
async fn warmup_scorer(&self) -> Result<()> {
|
||||||
let rows = sqlx::query_as::<_, MemoryRow>(
|
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)
|
.fetch_all(&self.pool)
|
||||||
.await
|
.await
|
||||||
@@ -173,6 +205,32 @@ impl SqliteStorage {
|
|||||||
Ok(())
|
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
|
/// Convert database row to MemoryEntry
|
||||||
fn row_to_entry(&self, row: &MemoryRow) -> MemoryEntry {
|
fn row_to_entry(&self, row: &MemoryRow) -> MemoryEntry {
|
||||||
let memory_type = crate::types::MemoryType::parse(&row.memory_type);
|
let memory_type = crate::types::MemoryType::parse(&row.memory_type);
|
||||||
@@ -193,6 +251,8 @@ impl SqliteStorage {
|
|||||||
access_count: row.access_count as u32,
|
access_count: row.access_count as u32,
|
||||||
created_at,
|
created_at,
|
||||||
last_accessed,
|
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")?,
|
access_count: row.try_get("access_count")?,
|
||||||
created_at: row.try_get("created_at")?,
|
created_at: row.try_get("created_at")?,
|
||||||
last_accessed: row.try_get("last_accessed")?,
|
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(
|
sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
INSERT OR REPLACE INTO memories
|
INSERT OR REPLACE INTO memories
|
||||||
(uri, memory_type, content, keywords, importance, access_count, created_at, last_accessed)
|
(uri, memory_type, content, keywords, importance, access_count, created_at, last_accessed, overview, abstract_summary)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(&entry.uri)
|
.bind(&entry.uri)
|
||||||
@@ -253,6 +315,8 @@ impl VikingStorage for SqliteStorage {
|
|||||||
.bind(entry.access_count as i32)
|
.bind(entry.access_count as i32)
|
||||||
.bind(&created_at)
|
.bind(&created_at)
|
||||||
.bind(&last_accessed)
|
.bind(&last_accessed)
|
||||||
|
.bind(&entry.overview)
|
||||||
|
.bind(&entry.abstract_summary)
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ZclawError::StorageError(format!("Failed to store memory: {}", e)))?;
|
.map_err(|e| ZclawError::StorageError(format!("Failed to store memory: {}", e)))?;
|
||||||
@@ -276,9 +340,13 @@ impl VikingStorage for SqliteStorage {
|
|||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
// Update semantic scorer
|
// Update semantic scorer (use embedding when available)
|
||||||
let mut scorer = self.scorer.write().await;
|
let mut scorer = self.scorer.write().await;
|
||||||
scorer.index_entry(entry);
|
if scorer.is_embedding_available() {
|
||||||
|
scorer.index_entry_with_embedding(entry).await;
|
||||||
|
} else {
|
||||||
|
scorer.index_entry(entry);
|
||||||
|
}
|
||||||
|
|
||||||
tracing::debug!("[SqliteStorage] Stored memory: {}", entry.uri);
|
tracing::debug!("[SqliteStorage] Stored memory: {}", entry.uri);
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -286,7 +354,7 @@ impl VikingStorage for SqliteStorage {
|
|||||||
|
|
||||||
async fn get(&self, uri: &str) -> Result<Option<MemoryEntry>> {
|
async fn get(&self, uri: &str) -> Result<Option<MemoryEntry>> {
|
||||||
let row = sqlx::query_as::<_, MemoryRow>(
|
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)
|
.bind(uri)
|
||||||
.fetch_optional(&self.pool)
|
.fetch_optional(&self.pool)
|
||||||
@@ -309,7 +377,7 @@ impl VikingStorage for SqliteStorage {
|
|||||||
// Get all matching entries
|
// Get all matching entries
|
||||||
let rows = if let Some(ref scope) = options.scope {
|
let rows = if let Some(ref scope) = options.scope {
|
||||||
sqlx::query_as::<_, MemoryRow>(
|
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))
|
.bind(format!("{}%", scope))
|
||||||
.fetch_all(&self.pool)
|
.fetch_all(&self.pool)
|
||||||
@@ -317,7 +385,7 @@ impl VikingStorage for SqliteStorage {
|
|||||||
.map_err(|e| ZclawError::StorageError(format!("Failed to find memories: {}", e)))?
|
.map_err(|e| ZclawError::StorageError(format!("Failed to find memories: {}", e)))?
|
||||||
} else {
|
} else {
|
||||||
sqlx::query_as::<_, MemoryRow>(
|
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)
|
.fetch_all(&self.pool)
|
||||||
.await
|
.await
|
||||||
@@ -325,14 +393,49 @@ impl VikingStorage for SqliteStorage {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Convert to entries and compute semantic scores
|
// Convert to entries and compute semantic scores
|
||||||
let scorer = self.scorer.read().await;
|
let use_embedding = {
|
||||||
|
let scorer = self.scorer.read().await;
|
||||||
|
scorer.is_embedding_available()
|
||||||
|
};
|
||||||
|
|
||||||
let mut scored_entries: Vec<(f32, MemoryEntry)> = Vec::new();
|
let mut scored_entries: Vec<(f32, MemoryEntry)> = Vec::new();
|
||||||
|
|
||||||
for row in rows {
|
for row in rows {
|
||||||
let entry = self.row_to_entry(&row);
|
let entry = self.row_to_entry(&row);
|
||||||
|
|
||||||
// Compute semantic score using TF-IDF
|
// Compute semantic score: use embedding when available, fallback to TF-IDF
|
||||||
let semantic_score = scorer.score_similarity(query, &entry);
|
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
|
// Apply similarity threshold
|
||||||
if let Some(min_similarity) = options.min_similarity {
|
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>> {
|
async fn find_by_prefix(&self, prefix: &str) -> Result<Vec<MemoryEntry>> {
|
||||||
let rows = sqlx::query_as::<_, MemoryRow>(
|
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))
|
.bind(format!("{}%", prefix))
|
||||||
.fetch_all(&self.pool)
|
.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>,
|
pub created_at: DateTime<Utc>,
|
||||||
/// Last access timestamp
|
/// Last access timestamp
|
||||||
pub last_accessed: DateTime<Utc>,
|
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 {
|
impl MemoryEntry {
|
||||||
@@ -92,6 +96,8 @@ impl MemoryEntry {
|
|||||||
access_count: 0,
|
access_count: 0,
|
||||||
created_at: Utc::now(),
|
created_at: Utc::now(),
|
||||||
last_accessed: Utc::now(),
|
last_accessed: Utc::now(),
|
||||||
|
overview: None,
|
||||||
|
abstract_summary: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,6 +113,18 @@ impl MemoryEntry {
|
|||||||
self
|
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
|
/// Mark as accessed
|
||||||
pub fn touch(&mut self) {
|
pub fn touch(&mut self) {
|
||||||
self.access_count += 1;
|
self.access_count += 1;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ description = "ZCLAW Hands - autonomous capabilities"
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
zclaw-types = { workspace = true }
|
zclaw-types = { workspace = true }
|
||||||
|
zclaw-runtime = { workspace = true }
|
||||||
|
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
mod whiteboard;
|
mod whiteboard;
|
||||||
mod slideshow;
|
mod slideshow;
|
||||||
mod speech;
|
mod speech;
|
||||||
mod quiz;
|
pub mod quiz;
|
||||||
mod browser;
|
mod browser;
|
||||||
mod researcher;
|
mod researcher;
|
||||||
mod collector;
|
mod collector;
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ use std::sync::Arc;
|
|||||||
use tokio::sync::RwLock;
|
use tokio::sync::RwLock;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
use zclaw_types::Result;
|
use zclaw_types::Result;
|
||||||
|
use zclaw_runtime::driver::{LlmDriver, CompletionRequest};
|
||||||
|
|
||||||
use crate::{Hand, HandConfig, HandContext, HandResult, HandStatus};
|
use crate::{Hand, HandConfig, HandContext, HandResult, HandStatus};
|
||||||
|
|
||||||
@@ -44,29 +45,242 @@ impl QuizGenerator for DefaultQuizGenerator {
|
|||||||
difficulty: &DifficultyLevel,
|
difficulty: &DifficultyLevel,
|
||||||
_question_types: &[QuestionType],
|
_question_types: &[QuestionType],
|
||||||
) -> Result<Vec<QuizQuestion>> {
|
) -> Result<Vec<QuizQuestion>> {
|
||||||
// Generate placeholder questions
|
// Generate placeholder questions with randomized correct answers
|
||||||
|
let options_pool: Vec<Vec<String>> = vec![
|
||||||
|
vec!["光合作用".into(), "呼吸作用".into(), "蒸腾作用".into(), "运输作用".into()],
|
||||||
|
vec!["牛顿".into(), "爱因斯坦".into(), "伽利略".into(), "开普勒".into()],
|
||||||
|
vec!["太平洋".into(), "大西洋".into(), "印度洋".into(), "北冰洋".into()],
|
||||||
|
vec!["DNA".into(), "RNA".into(), "蛋白质".into(), "碳水化合物".into()],
|
||||||
|
vec!["引力".into(), "电磁力".into(), "强力".into(), "弱力".into()],
|
||||||
|
];
|
||||||
|
|
||||||
Ok((0..count)
|
Ok((0..count)
|
||||||
.map(|i| QuizQuestion {
|
.map(|i| {
|
||||||
id: uuid_v4(),
|
let pool_idx = i % options_pool.len();
|
||||||
question_type: QuestionType::MultipleChoice,
|
let mut opts = options_pool[pool_idx].clone();
|
||||||
question: format!("Question {} about {}", i + 1, topic),
|
// Shuffle options to randomize correct answer position
|
||||||
options: Some(vec![
|
let correct_idx = (i * 3 + 1) % opts.len();
|
||||||
"Option A".to_string(),
|
opts.swap(0, correct_idx);
|
||||||
"Option B".to_string(),
|
let correct = opts[0].clone();
|
||||||
"Option C".to_string(),
|
|
||||||
"Option D".to_string(),
|
QuizQuestion {
|
||||||
]),
|
id: uuid_v4(),
|
||||||
correct_answer: Answer::Single("Option A".to_string()),
|
question_type: QuestionType::MultipleChoice,
|
||||||
explanation: Some(format!("Explanation for question {}", i + 1)),
|
question: format!("关于{}的第{}题({}难度)", topic, i + 1, match difficulty {
|
||||||
hints: Some(vec![format!("Hint 1 for question {}", i + 1)]),
|
DifficultyLevel::Easy => "简单",
|
||||||
points: 10.0,
|
DifficultyLevel::Medium => "中等",
|
||||||
difficulty: difficulty.clone(),
|
DifficultyLevel::Hard => "困难",
|
||||||
tags: vec![topic.to_string()],
|
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())
|
.collect())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// LLM-powered quiz generator that produces real questions via an LLM driver.
|
||||||
|
pub struct LlmQuizGenerator {
|
||||||
|
driver: Arc<dyn LlmDriver>,
|
||||||
|
model: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LlmQuizGenerator {
|
||||||
|
pub fn new(driver: Arc<dyn LlmDriver>, model: String) -> Self {
|
||||||
|
Self { driver, model }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl QuizGenerator for LlmQuizGenerator {
|
||||||
|
async fn generate_questions(
|
||||||
|
&self,
|
||||||
|
topic: &str,
|
||||||
|
content: Option<&str>,
|
||||||
|
count: usize,
|
||||||
|
difficulty: &DifficultyLevel,
|
||||||
|
question_types: &[QuestionType],
|
||||||
|
) -> Result<Vec<QuizQuestion>> {
|
||||||
|
let difficulty_str = match difficulty {
|
||||||
|
DifficultyLevel::Easy => "简单",
|
||||||
|
DifficultyLevel::Medium => "中等",
|
||||||
|
DifficultyLevel::Hard => "困难",
|
||||||
|
DifficultyLevel::Adaptive => "中等",
|
||||||
|
};
|
||||||
|
|
||||||
|
let type_str = if question_types.is_empty() {
|
||||||
|
String::from("选择题(multiple_choice)")
|
||||||
|
} else {
|
||||||
|
question_types
|
||||||
|
.iter()
|
||||||
|
.map(|t| match t {
|
||||||
|
QuestionType::MultipleChoice => "选择题",
|
||||||
|
QuestionType::TrueFalse => "判断题",
|
||||||
|
QuestionType::FillBlank => "填空题",
|
||||||
|
QuestionType::ShortAnswer => "简答题",
|
||||||
|
QuestionType::Essay => "论述题",
|
||||||
|
_ => "选择题",
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(",")
|
||||||
|
};
|
||||||
|
|
||||||
|
let content_section = match content {
|
||||||
|
Some(c) if !c.is_empty() => format!("\n\n参考内容:\n{}", &c[..c.len().min(3000)]),
|
||||||
|
_ => String::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let content_note = if content.is_some() && content.map_or(false, |c| !c.is_empty()) {
|
||||||
|
"(基于提供的参考内容出题)"
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
};
|
||||||
|
|
||||||
|
let prompt = format!(
|
||||||
|
r#"你是一个专业的出题专家。请根据以下要求生成测验题目:
|
||||||
|
|
||||||
|
主题: {}
|
||||||
|
难度: {}
|
||||||
|
题目类型: {}
|
||||||
|
数量: {}{}
|
||||||
|
{}
|
||||||
|
|
||||||
|
请严格按照以下 JSON 格式输出,不要添加任何其他文字:
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{{
|
||||||
|
"question": "题目内容",
|
||||||
|
"options": ["选项A", "选项B", "选项C", "选项D"],
|
||||||
|
"correct_answer": "正确答案(与options中某项完全一致)",
|
||||||
|
"explanation": "答案解释",
|
||||||
|
"hint": "提示信息"
|
||||||
|
}}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
要求:
|
||||||
|
1. 题目要有实际内容,不要使用占位符
|
||||||
|
2. 正确答案必须随机分布(不要总在第一个选项)
|
||||||
|
3. 每道题的选项要有区分度,干扰项要合理
|
||||||
|
4. 解释要清晰准确
|
||||||
|
5. 直接输出 JSON,不要有 markdown 包裹"#,
|
||||||
|
topic, difficulty_str, type_str, count, content_section, content_note,
|
||||||
|
);
|
||||||
|
|
||||||
|
let request = CompletionRequest {
|
||||||
|
model: self.model.clone(),
|
||||||
|
system: Some("你是一个专业的出题专家,只输出纯JSON格式。".to_string()),
|
||||||
|
messages: vec![zclaw_types::Message::user(&prompt)],
|
||||||
|
tools: Vec::new(),
|
||||||
|
max_tokens: Some(4096),
|
||||||
|
temperature: Some(0.7),
|
||||||
|
stop: Vec::new(),
|
||||||
|
stream: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
let response = self.driver.complete(request).await.map_err(|e| {
|
||||||
|
zclaw_types::ZclawError::Internal(format!("LLM quiz generation failed: {}", e))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Extract text from response
|
||||||
|
let text: String = response
|
||||||
|
.content
|
||||||
|
.iter()
|
||||||
|
.filter_map(|block| match block {
|
||||||
|
zclaw_runtime::driver::ContentBlock::Text { text } => Some(text.clone()),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
// Parse JSON from response (handle markdown code fences)
|
||||||
|
let json_str = extract_json(&text);
|
||||||
|
|
||||||
|
let raw_questions: Vec<serde_json::Value> =
|
||||||
|
serde_json::from_str(json_str).map_err(|e| {
|
||||||
|
zclaw_types::ZclawError::Internal(format!(
|
||||||
|
"Failed to parse quiz JSON: {}. Raw: {}",
|
||||||
|
e,
|
||||||
|
&text[..text.len().min(200)]
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let questions: Vec<QuizQuestion> = raw_questions
|
||||||
|
.into_iter()
|
||||||
|
.take(count)
|
||||||
|
.map(|q| {
|
||||||
|
let options: Vec<String> = q["options"]
|
||||||
|
.as_array()
|
||||||
|
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let correct = q["correct_answer"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
QuizQuestion {
|
||||||
|
id: uuid_v4(),
|
||||||
|
question_type: QuestionType::MultipleChoice,
|
||||||
|
question: q["question"].as_str().unwrap_or("未知题目").to_string(),
|
||||||
|
options: if options.is_empty() { None } else { Some(options) },
|
||||||
|
correct_answer: Answer::Single(correct),
|
||||||
|
explanation: q["explanation"].as_str().map(String::from),
|
||||||
|
hints: q["hint"].as_str().map(|h| vec![h.to_string()]),
|
||||||
|
points: 10.0,
|
||||||
|
difficulty: difficulty.clone(),
|
||||||
|
tags: vec![topic.to_string()],
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if questions.is_empty() {
|
||||||
|
// Fallback to default if LLM returns nothing parseable
|
||||||
|
return DefaultQuizGenerator
|
||||||
|
.generate_questions(topic, content, count, difficulty, question_types)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(questions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract JSON from a string that may be wrapped in markdown code fences.
|
||||||
|
fn extract_json(text: &str) -> &str {
|
||||||
|
let trimmed = text.trim();
|
||||||
|
|
||||||
|
// Try to find ```json ... ``` block
|
||||||
|
if let Some(start) = trimmed.find("```json") {
|
||||||
|
let after_start = &trimmed[start + 7..];
|
||||||
|
if let Some(end) = after_start.find("```") {
|
||||||
|
return after_start[..end].trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to find ``` ... ``` block
|
||||||
|
if let Some(start) = trimmed.find("```") {
|
||||||
|
let after_start = &trimmed[start + 3..];
|
||||||
|
if let Some(end) = after_start.find("```") {
|
||||||
|
return after_start[..end].trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to find raw JSON array
|
||||||
|
if let Some(start) = trimmed.find('[') {
|
||||||
|
if let Some(end) = trimmed.rfind(']') {
|
||||||
|
return &trimmed[start..=end];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
trimmed
|
||||||
|
}
|
||||||
|
|
||||||
/// Quiz action types
|
/// Quiz action types
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(tag = "action", rename_all = "snake_case")]
|
#[serde(tag = "action", rename_all = "snake_case")]
|
||||||
|
|||||||
@@ -162,7 +162,7 @@ impl SpeechHand {
|
|||||||
"rate": { "type": "number" },
|
"rate": { "type": "number" },
|
||||||
}
|
}
|
||||||
})),
|
})),
|
||||||
tags: vec!["audio".to_string(), "tts".to_string(), "education".to_string()],
|
tags: vec!["audio".to_string(), "tts".to_string(), "education".to_string(), "demo".to_string()],
|
||||||
enabled: true,
|
enabled: true,
|
||||||
},
|
},
|
||||||
state: Arc::new(RwLock::new(SpeechState {
|
state: Arc::new(RwLock::new(SpeechState {
|
||||||
|
|||||||
@@ -270,7 +270,7 @@ impl TwitterHand {
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
})),
|
})),
|
||||||
tags: vec!["twitter".to_string(), "social".to_string(), "automation".to_string()],
|
tags: vec!["twitter".to_string(), "social".to_string(), "automation".to_string(), "demo".to_string()],
|
||||||
enabled: true,
|
enabled: true,
|
||||||
},
|
},
|
||||||
credentials: Arc::new(RwLock::new(None)),
|
credentials: Arc::new(RwLock::new(None)),
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ repository.workspace = true
|
|||||||
rust-version.workspace = true
|
rust-version.workspace = true
|
||||||
description = "ZCLAW kernel - central coordinator for all subsystems"
|
description = "ZCLAW kernel - central coordinator for all subsystems"
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = []
|
||||||
|
# Enable multi-agent orchestration (Director, A2A protocol)
|
||||||
|
multi-agent = ["zclaw-protocols/a2a"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
zclaw-types = { workspace = true }
|
zclaw-types = { workspace = true }
|
||||||
zclaw-memory = { workspace = true }
|
zclaw-memory = { workspace = true }
|
||||||
@@ -20,6 +25,7 @@ tokio-stream = { workspace = true }
|
|||||||
futures = { workspace = true }
|
futures = { workspace = true }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
|
toml = { workspace = true }
|
||||||
thiserror = { workspace = true }
|
thiserror = { workspace = true }
|
||||||
uuid = { workspace = true }
|
uuid = { workspace = true }
|
||||||
chrono = { workspace = true }
|
chrono = { workspace = true }
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
//! Capability manager
|
//! Capability manager
|
||||||
|
|
||||||
use dashmap::DashMap;
|
use dashmap::DashMap;
|
||||||
use zclaw_types::{AgentId, Capability, CapabilitySet, Result};
|
use zclaw_types::{AgentId, Capability, CapabilitySet, Result, ZclawError};
|
||||||
|
|
||||||
/// Manages capabilities for all agents
|
/// Manages capabilities for all agents
|
||||||
pub struct CapabilityManager {
|
pub struct CapabilityManager {
|
||||||
@@ -52,9 +52,31 @@ impl CapabilityManager {
|
|||||||
.unwrap_or(false)
|
.unwrap_or(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Validate capabilities don't exceed parent's
|
/// Validate capabilities for dangerous combinations
|
||||||
pub fn validate(&self, _capabilities: &[Capability]) -> Result<()> {
|
///
|
||||||
// TODO: Implement capability validation
|
/// 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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -168,7 +168,7 @@ fn default_skills_dir() -> Option<std::path::PathBuf> {
|
|||||||
// 1. Check environment variable override
|
// 1. Check environment variable override
|
||||||
if let Ok(dir) = std::env::var("ZCLAW_SKILLS_DIR") {
|
if let Ok(dir) = std::env::var("ZCLAW_SKILLS_DIR") {
|
||||||
let path = std::path::PathBuf::from(&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() {
|
if path.exists() {
|
||||||
return Some(path);
|
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)
|
// CARGO_MANIFEST_DIR is the crate directory (crates/zclaw-kernel)
|
||||||
// We need to go up to find the workspace root
|
// We need to go up to find the workspace root
|
||||||
let manifest_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
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
|
// Go up from crates/zclaw-kernel to workspace root
|
||||||
if let Some(workspace_root) = manifest_dir.parent().and_then(|p| p.parent()) {
|
if let Some(workspace_root) = manifest_dir.parent().and_then(|p| p.parent()) {
|
||||||
let workspace_skills = workspace_root.join("skills");
|
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() {
|
if workspace_skills.exists() {
|
||||||
return Some(workspace_skills);
|
return Some(workspace_skills);
|
||||||
}
|
}
|
||||||
@@ -194,7 +194,7 @@ fn default_skills_dir() -> Option<std::path::PathBuf> {
|
|||||||
// 3. Try current working directory first (for development)
|
// 3. Try current working directory first (for development)
|
||||||
if let Ok(cwd) = std::env::current_dir() {
|
if let Ok(cwd) = std::env::current_dir() {
|
||||||
let cwd_skills = cwd.join("skills");
|
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() {
|
if cwd_skills.exists() {
|
||||||
return Some(cwd_skills);
|
return Some(cwd_skills);
|
||||||
}
|
}
|
||||||
@@ -204,7 +204,7 @@ fn default_skills_dir() -> Option<std::path::PathBuf> {
|
|||||||
for i in 0..6 {
|
for i in 0..6 {
|
||||||
if let Some(parent) = current.parent() {
|
if let Some(parent) = current.parent() {
|
||||||
let parent_skills = parent.join("skills");
|
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() {
|
if parent_skills.exists() {
|
||||||
return Some(parent_skills);
|
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
|
// 4. Try executable's directory and multiple levels up
|
||||||
if let Ok(exe) = std::env::current_exe() {
|
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()) {
|
if let Some(exe_dir) = exe.parent().map(|p| p.to_path_buf()) {
|
||||||
// Same directory as exe
|
// Same directory as exe
|
||||||
let exe_skills = exe_dir.join("skills");
|
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() {
|
if exe_skills.exists() {
|
||||||
return Some(exe_skills);
|
return Some(exe_skills);
|
||||||
}
|
}
|
||||||
@@ -231,7 +231,7 @@ fn default_skills_dir() -> Option<std::path::PathBuf> {
|
|||||||
for i in 0..6 {
|
for i in 0..6 {
|
||||||
if let Some(parent) = current.parent() {
|
if let Some(parent) = current.parent() {
|
||||||
let parent_skills = parent.join("skills");
|
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() {
|
if parent_skills.exists() {
|
||||||
return Some(parent_skills);
|
return Some(parent_skills);
|
||||||
}
|
}
|
||||||
@@ -247,15 +247,83 @@ fn default_skills_dir() -> Option<std::path::PathBuf> {
|
|||||||
let fallback = std::env::current_dir()
|
let fallback = std::env::current_dir()
|
||||||
.ok()
|
.ok()
|
||||||
.map(|cwd| cwd.join("skills"));
|
.map(|cwd| cwd.join("skills"));
|
||||||
eprintln!("[default_skills_dir] Fallback to: {:?}", fallback);
|
tracing::debug!(target: "kernel_config", "Fallback to: {:?}", fallback);
|
||||||
fallback
|
fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
impl KernelConfig {
|
impl KernelConfig {
|
||||||
/// Load configuration from file
|
/// Load configuration from file.
|
||||||
|
///
|
||||||
|
/// Search order:
|
||||||
|
/// 1. Path from `ZCLAW_CONFIG` environment variable
|
||||||
|
/// 2. `~/.zclaw/config.toml`
|
||||||
|
/// 3. Fallback to `Self::default()`
|
||||||
|
///
|
||||||
|
/// Supports `${VAR_NAME}` environment variable interpolation in string values.
|
||||||
pub async fn load() -> Result<Self> {
|
pub async fn load() -> Result<Self> {
|
||||||
// TODO: Load from ~/.zclaw/config.toml
|
let config_path = Self::find_config_path();
|
||||||
Ok(Self::default())
|
|
||||||
|
match config_path {
|
||||||
|
Some(path) => {
|
||||||
|
if !path.exists() {
|
||||||
|
tracing::debug!(target: "kernel_config", "Config file not found: {:?}, using defaults", path);
|
||||||
|
return Ok(Self::default());
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!(target: "kernel_config", "Loading config from: {:?}", path);
|
||||||
|
let content = std::fs::read_to_string(&path).map_err(|e| {
|
||||||
|
zclaw_types::ZclawError::Internal(format!("Failed to read config {}: {}", path.display(), e))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let interpolated = interpolate_env_vars(&content);
|
||||||
|
let mut config: KernelConfig = toml::from_str(&interpolated).map_err(|e| {
|
||||||
|
zclaw_types::ZclawError::Internal(format!("Failed to parse config {}: {}", path.display(), e))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Resolve skills_dir if not explicitly set
|
||||||
|
if config.skills_dir.is_none() {
|
||||||
|
config.skills_dir = default_skills_dir();
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
target: "kernel_config",
|
||||||
|
model = %config.llm.model,
|
||||||
|
base_url = %config.llm.base_url,
|
||||||
|
has_api_key = !config.llm.api_key.is_empty(),
|
||||||
|
"Config loaded successfully"
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(config)
|
||||||
|
}
|
||||||
|
None => Ok(Self::default()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find the config file path.
|
||||||
|
fn find_config_path() -> Option<PathBuf> {
|
||||||
|
// 1. Environment variable override
|
||||||
|
if let Ok(path) = std::env::var("ZCLAW_CONFIG") {
|
||||||
|
return Some(PathBuf::from(path));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. ~/.zclaw/config.toml
|
||||||
|
if let Some(home) = dirs::home_dir() {
|
||||||
|
let path = home.join(".zclaw").join("config.toml");
|
||||||
|
if path.exists() {
|
||||||
|
return Some(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Project root config/config.toml (for development)
|
||||||
|
let project_config = std::env::current_dir()
|
||||||
|
.ok()
|
||||||
|
.map(|cwd| cwd.join("config").join("config.toml"))?;
|
||||||
|
|
||||||
|
if project_config.exists() {
|
||||||
|
return Some(project_config);
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create the LLM driver
|
/// Create the LLM driver
|
||||||
@@ -439,3 +507,81 @@ impl LlmConfig {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// === Environment variable interpolation ===
|
||||||
|
|
||||||
|
/// Replace `${VAR_NAME}` patterns in a string with environment variable values.
|
||||||
|
/// If the variable is not set, the pattern is left as-is.
|
||||||
|
fn interpolate_env_vars(content: &str) -> String {
|
||||||
|
let mut result = String::with_capacity(content.len());
|
||||||
|
let mut chars = content.char_indices().peekable();
|
||||||
|
|
||||||
|
while let Some((_, ch)) = chars.next() {
|
||||||
|
if ch == '$' && chars.peek().map(|(_, c)| *c == '{').unwrap_or(false) {
|
||||||
|
chars.next(); // consume '{'
|
||||||
|
|
||||||
|
let mut var_name = String::new();
|
||||||
|
|
||||||
|
while let Some((_, c)) = chars.peek() {
|
||||||
|
match c {
|
||||||
|
'}' => {
|
||||||
|
chars.next(); // consume '}'
|
||||||
|
if let Ok(value) = std::env::var(&var_name) {
|
||||||
|
result.push_str(&value);
|
||||||
|
} else {
|
||||||
|
result.push_str("${");
|
||||||
|
result.push_str(&var_name);
|
||||||
|
result.push('}');
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
var_name.push(*c);
|
||||||
|
chars.next();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle unclosed ${... at end of string
|
||||||
|
if !content[result.len()..].contains('}') && var_name.is_empty() {
|
||||||
|
// Already consumed, nothing to do
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
result.push(ch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_interpolate_env_vars_basic() {
|
||||||
|
std::env::set_var("ZCLAW_TEST_VAR", "hello");
|
||||||
|
let result = interpolate_env_vars("prefix ${ZCLAW_TEST_VAR} suffix");
|
||||||
|
assert_eq!(result, "prefix hello suffix");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_interpolate_env_vars_missing() {
|
||||||
|
let result = interpolate_env_vars("${ZCLAW_NONEXISTENT_VAR_12345}");
|
||||||
|
assert_eq!(result, "${ZCLAW_NONEXISTENT_VAR_12345}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_interpolate_env_vars_no_vars() {
|
||||||
|
let result = interpolate_env_vars("no variables here");
|
||||||
|
assert_eq!(result, "no variables here");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_interpolate_env_vars_multiple() {
|
||||||
|
std::env::set_var("ZCLAW_TEST_A", "alpha");
|
||||||
|
std::env::set_var("ZCLAW_TEST_B", "beta");
|
||||||
|
let result = interpolate_env_vars("${ZCLAW_TEST_A}-${ZCLAW_TEST_B}");
|
||||||
|
assert_eq!(result, "alpha-beta");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -703,48 +703,6 @@ Actions can be:
|
|||||||
self.parse_outline_from_text(&text, request)
|
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
|
/// Extract text from LLM response
|
||||||
fn extract_text_from_response(&self, response: &CompletionResponse) -> String {
|
fn extract_text_from_response(&self, response: &CompletionResponse) -> String {
|
||||||
response.content.iter()
|
response.content.iter()
|
||||||
@@ -787,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()
|
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
|
/// Parse outline from LLM response text
|
||||||
fn parse_outline_from_text(&self, text: &str, request: &GenerationRequest) -> Result<Vec<OutlineItem>> {
|
fn parse_outline_from_text(&self, text: &str, request: &GenerationRequest) -> Result<Vec<OutlineItem>> {
|
||||||
// Try to extract JSON from the response
|
// Try to extract JSON from the response
|
||||||
@@ -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)
|
/// Extract JSON from text (handles markdown code blocks)
|
||||||
fn extract_json(&self, text: &str) -> String {
|
fn extract_json(&self, text: &str) -> String {
|
||||||
// Try to extract from markdown code block
|
// Try to extract from markdown code block
|
||||||
@@ -1062,64 +903,6 @@ Generate {} outline items that flow logically and cover the topic comprehensivel
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generate scene for outline item (would be replaced by LLM call)
|
|
||||||
#[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
|
/// Build classroom from components
|
||||||
fn build_classroom(
|
fn build_classroom(
|
||||||
&self,
|
&self,
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
//! Kernel - central coordinator
|
//! Kernel - central coordinator
|
||||||
|
|
||||||
|
use std::pin::Pin;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::sync::{broadcast, mpsc};
|
use tokio::sync::{broadcast, mpsc, Mutex};
|
||||||
use zclaw_types::{AgentConfig, AgentId, AgentInfo, Event, Result};
|
use zclaw_types::{AgentConfig, AgentId, AgentInfo, Event, Result};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
@@ -13,16 +14,53 @@ use crate::config::KernelConfig;
|
|||||||
use zclaw_memory::MemoryStore;
|
use zclaw_memory::MemoryStore;
|
||||||
use zclaw_runtime::{AgentLoop, LlmDriver, ToolRegistry, tool::SkillExecutor};
|
use zclaw_runtime::{AgentLoop, LlmDriver, ToolRegistry, tool::SkillExecutor};
|
||||||
use zclaw_skills::SkillRegistry;
|
use zclaw_skills::SkillRegistry;
|
||||||
use zclaw_hands::{HandRegistry, HandContext, HandResult, hands::{BrowserHand, SlideshowHand, SpeechHand, QuizHand, WhiteboardHand, ResearcherHand, CollectorHand, ClipHand, TwitterHand}};
|
use zclaw_skills::LlmCompleter;
|
||||||
|
use zclaw_hands::{HandRegistry, HandContext, HandResult, hands::{BrowserHand, SlideshowHand, SpeechHand, QuizHand, WhiteboardHand, ResearcherHand, CollectorHand, ClipHand, TwitterHand, quiz::LlmQuizGenerator}};
|
||||||
|
|
||||||
|
/// Adapter that bridges `zclaw_runtime::LlmDriver` → `zclaw_skills::LlmCompleter`
|
||||||
|
struct LlmDriverAdapter {
|
||||||
|
driver: Arc<dyn LlmDriver>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl zclaw_skills::LlmCompleter for LlmDriverAdapter {
|
||||||
|
fn complete(
|
||||||
|
&self,
|
||||||
|
prompt: &str,
|
||||||
|
) -> Pin<Box<dyn std::future::Future<Output = std::result::Result<String, String>> + Send + '_>> {
|
||||||
|
let driver = self.driver.clone();
|
||||||
|
let prompt = prompt.to_string();
|
||||||
|
Box::pin(async move {
|
||||||
|
let request = zclaw_runtime::CompletionRequest {
|
||||||
|
messages: vec![zclaw_types::Message::user(prompt)],
|
||||||
|
max_tokens: Some(4096),
|
||||||
|
temperature: Some(0.7),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let response = driver.complete(request).await
|
||||||
|
.map_err(|e| format!("LLM completion error: {}", e))?;
|
||||||
|
// Extract text from content blocks
|
||||||
|
let text: String = response.content.iter()
|
||||||
|
.filter_map(|block| match block {
|
||||||
|
zclaw_runtime::ContentBlock::Text { text } => Some(text.as_str()),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("");
|
||||||
|
Ok(text)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Skill executor implementation for Kernel
|
/// Skill executor implementation for Kernel
|
||||||
pub struct KernelSkillExecutor {
|
pub struct KernelSkillExecutor {
|
||||||
skills: Arc<SkillRegistry>,
|
skills: Arc<SkillRegistry>,
|
||||||
|
llm: Arc<dyn LlmCompleter>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl KernelSkillExecutor {
|
impl KernelSkillExecutor {
|
||||||
pub fn new(skills: Arc<SkillRegistry>) -> Self {
|
pub fn new(skills: Arc<SkillRegistry>, driver: Arc<dyn LlmDriver>) -> Self {
|
||||||
Self { skills }
|
let llm: Arc<dyn zclaw_skills::LlmCompleter> = Arc::new(LlmDriverAdapter { driver });
|
||||||
|
Self { skills, llm }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,6 +76,7 @@ impl SkillExecutor for KernelSkillExecutor {
|
|||||||
let context = zclaw_skills::SkillContext {
|
let context = zclaw_skills::SkillContext {
|
||||||
agent_id: agent_id.to_string(),
|
agent_id: agent_id.to_string(),
|
||||||
session_id: session_id.to_string(),
|
session_id: session_id.to_string(),
|
||||||
|
llm: Some(self.llm.clone()),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
let result = self.skills.execute(&zclaw_types::SkillId::new(skill_id), &context, input).await?;
|
let result = self.skills.execute(&zclaw_types::SkillId::new(skill_id), &context, input).await?;
|
||||||
@@ -53,10 +92,12 @@ pub struct Kernel {
|
|||||||
events: EventBus,
|
events: EventBus,
|
||||||
memory: Arc<MemoryStore>,
|
memory: Arc<MemoryStore>,
|
||||||
driver: Arc<dyn LlmDriver>,
|
driver: Arc<dyn LlmDriver>,
|
||||||
|
llm_completer: Arc<dyn zclaw_skills::LlmCompleter>,
|
||||||
skills: Arc<SkillRegistry>,
|
skills: Arc<SkillRegistry>,
|
||||||
skill_executor: Arc<KernelSkillExecutor>,
|
skill_executor: Arc<KernelSkillExecutor>,
|
||||||
hands: Arc<HandRegistry>,
|
hands: Arc<HandRegistry>,
|
||||||
trigger_manager: crate::trigger_manager::TriggerManager,
|
trigger_manager: crate::trigger_manager::TriggerManager,
|
||||||
|
pending_approvals: Arc<Mutex<Vec<ApprovalEntry>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Kernel {
|
impl Kernel {
|
||||||
@@ -85,10 +126,12 @@ impl Kernel {
|
|||||||
|
|
||||||
// Initialize hand registry with built-in hands
|
// Initialize hand registry with built-in hands
|
||||||
let hands = Arc::new(HandRegistry::new());
|
let hands = Arc::new(HandRegistry::new());
|
||||||
|
let quiz_model = config.model().to_string();
|
||||||
|
let quiz_generator = Arc::new(LlmQuizGenerator::new(driver.clone(), quiz_model));
|
||||||
hands.register(Arc::new(BrowserHand::new())).await;
|
hands.register(Arc::new(BrowserHand::new())).await;
|
||||||
hands.register(Arc::new(SlideshowHand::new())).await;
|
hands.register(Arc::new(SlideshowHand::new())).await;
|
||||||
hands.register(Arc::new(SpeechHand::new())).await;
|
hands.register(Arc::new(SpeechHand::new())).await;
|
||||||
hands.register(Arc::new(QuizHand::new())).await;
|
hands.register(Arc::new(QuizHand::with_generator(quiz_generator))).await;
|
||||||
hands.register(Arc::new(WhiteboardHand::new())).await;
|
hands.register(Arc::new(WhiteboardHand::new())).await;
|
||||||
hands.register(Arc::new(ResearcherHand::new())).await;
|
hands.register(Arc::new(ResearcherHand::new())).await;
|
||||||
hands.register(Arc::new(CollectorHand::new())).await;
|
hands.register(Arc::new(CollectorHand::new())).await;
|
||||||
@@ -96,7 +139,11 @@ impl Kernel {
|
|||||||
hands.register(Arc::new(TwitterHand::new())).await;
|
hands.register(Arc::new(TwitterHand::new())).await;
|
||||||
|
|
||||||
// Create skill executor
|
// Create skill executor
|
||||||
let skill_executor = Arc::new(KernelSkillExecutor::new(skills.clone()));
|
let skill_executor = Arc::new(KernelSkillExecutor::new(skills.clone(), driver.clone()));
|
||||||
|
|
||||||
|
// Create LLM completer for skill system (shared with skill_executor)
|
||||||
|
let llm_completer: Arc<dyn zclaw_skills::LlmCompleter> =
|
||||||
|
Arc::new(LlmDriverAdapter { driver: driver.clone() });
|
||||||
|
|
||||||
// Initialize trigger manager
|
// Initialize trigger manager
|
||||||
let trigger_manager = crate::trigger_manager::TriggerManager::new(hands.clone());
|
let trigger_manager = crate::trigger_manager::TriggerManager::new(hands.clone());
|
||||||
@@ -114,10 +161,12 @@ impl Kernel {
|
|||||||
events,
|
events,
|
||||||
memory,
|
memory,
|
||||||
driver,
|
driver,
|
||||||
|
llm_completer,
|
||||||
skills,
|
skills,
|
||||||
skill_executor,
|
skill_executor,
|
||||||
hands,
|
hands,
|
||||||
trigger_manager,
|
trigger_manager,
|
||||||
|
pending_approvals: Arc::new(Mutex::new(Vec::new())),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,9 +178,9 @@ impl Kernel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Build a system prompt with skill information injected
|
/// Build a system prompt with skill information injected
|
||||||
fn build_system_prompt_with_skills(&self, base_prompt: Option<&String>) -> String {
|
async fn build_system_prompt_with_skills(&self, base_prompt: Option<&String>) -> String {
|
||||||
// Get skill list synchronously (we're in sync context)
|
// Get skill list asynchronously
|
||||||
let skills = futures::executor::block_on(self.skills.list());
|
let skills = self.skills.list().await;
|
||||||
|
|
||||||
let mut prompt = base_prompt
|
let mut prompt = base_prompt
|
||||||
.map(|p| p.clone())
|
.map(|p| p.clone())
|
||||||
@@ -306,10 +355,11 @@ impl Kernel {
|
|||||||
.with_model(&model)
|
.with_model(&model)
|
||||||
.with_skill_executor(self.skill_executor.clone())
|
.with_skill_executor(self.skill_executor.clone())
|
||||||
.with_max_tokens(agent_config.max_tokens.unwrap_or_else(|| self.config.max_tokens()))
|
.with_max_tokens(agent_config.max_tokens.unwrap_or_else(|| self.config.max_tokens()))
|
||||||
.with_temperature(agent_config.temperature.unwrap_or_else(|| self.config.temperature()));
|
.with_temperature(agent_config.temperature.unwrap_or_else(|| self.config.temperature()))
|
||||||
|
.with_compaction_threshold(15_000); // Compact when context exceeds ~15k tokens
|
||||||
|
|
||||||
// Build system prompt with skill information injected
|
// 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);
|
let loop_runner = loop_runner.with_system_prompt(&system_prompt);
|
||||||
|
|
||||||
// Run the loop
|
// Run the loop
|
||||||
@@ -327,6 +377,16 @@ impl Kernel {
|
|||||||
&self,
|
&self,
|
||||||
agent_id: &AgentId,
|
agent_id: &AgentId,
|
||||||
message: String,
|
message: String,
|
||||||
|
) -> Result<mpsc::Receiver<zclaw_runtime::LoopEvent>> {
|
||||||
|
self.send_message_stream_with_prompt(agent_id, message, None).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a message with streaming and optional external system prompt
|
||||||
|
pub async fn send_message_stream_with_prompt(
|
||||||
|
&self,
|
||||||
|
agent_id: &AgentId,
|
||||||
|
message: String,
|
||||||
|
system_prompt_override: Option<String>,
|
||||||
) -> Result<mpsc::Receiver<zclaw_runtime::LoopEvent>> {
|
) -> Result<mpsc::Receiver<zclaw_runtime::LoopEvent>> {
|
||||||
let agent_config = self.registry.get(agent_id)
|
let agent_config = self.registry.get(agent_id)
|
||||||
.ok_or_else(|| zclaw_types::ZclawError::NotFound(format!("Agent not found: {}", agent_id)))?;
|
.ok_or_else(|| zclaw_types::ZclawError::NotFound(format!("Agent not found: {}", agent_id)))?;
|
||||||
@@ -349,10 +409,14 @@ impl Kernel {
|
|||||||
.with_model(&model)
|
.with_model(&model)
|
||||||
.with_skill_executor(self.skill_executor.clone())
|
.with_skill_executor(self.skill_executor.clone())
|
||||||
.with_max_tokens(agent_config.max_tokens.unwrap_or_else(|| self.config.max_tokens()))
|
.with_max_tokens(agent_config.max_tokens.unwrap_or_else(|| self.config.max_tokens()))
|
||||||
.with_temperature(agent_config.temperature.unwrap_or_else(|| self.config.temperature()));
|
.with_temperature(agent_config.temperature.unwrap_or_else(|| self.config.temperature()))
|
||||||
|
.with_compaction_threshold(15_000); // Compact when context exceeds ~15k tokens
|
||||||
|
|
||||||
// Build system prompt with skill information injected
|
// Use external prompt if provided, otherwise build default
|
||||||
let system_prompt = self.build_system_prompt_with_skills(agent_config.system_prompt.as_ref());
|
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);
|
let loop_runner = loop_runner.with_system_prompt(&system_prompt);
|
||||||
|
|
||||||
// Run with streaming
|
// Run with streaming
|
||||||
@@ -407,7 +471,12 @@ impl Kernel {
|
|||||||
context: zclaw_skills::SkillContext,
|
context: zclaw_skills::SkillContext,
|
||||||
input: serde_json::Value,
|
input: serde_json::Value,
|
||||||
) -> Result<zclaw_skills::SkillResult> {
|
) -> Result<zclaw_skills::SkillResult> {
|
||||||
self.skills.execute(&zclaw_types::SkillId::new(id), &context, input).await
|
// Inject LLM completer into context for PromptOnly skills
|
||||||
|
let mut ctx = context;
|
||||||
|
if ctx.llm.is_none() {
|
||||||
|
ctx.llm = Some(self.llm_completer.clone());
|
||||||
|
}
|
||||||
|
self.skills.execute(&zclaw_types::SkillId::new(id), &ctx, input).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the hands registry
|
/// Get the hands registry
|
||||||
@@ -477,24 +546,82 @@ impl Kernel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Approval Management (Stub Implementation)
|
// Approval Management
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
/// List pending approvals
|
/// List pending approvals
|
||||||
pub async fn list_approvals(&self) -> Vec<ApprovalEntry> {
|
pub async fn list_approvals(&self) -> Vec<ApprovalEntry> {
|
||||||
// Stub: Return empty list
|
let approvals = self.pending_approvals.lock().await;
|
||||||
Vec::new()
|
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
|
/// Respond to an approval
|
||||||
pub async fn respond_to_approval(
|
pub async fn respond_to_approval(
|
||||||
&self,
|
&self,
|
||||||
_id: &str,
|
id: &str,
|
||||||
_approved: bool,
|
approved: bool,
|
||||||
_reason: Option<String>,
|
_reason: Option<String>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
// Stub: Return error
|
let mut approvals = self.pending_approvals.lock().await;
|
||||||
Err(zclaw_types::ZclawError::NotFound(format!("Approval not found")))
|
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;
|
mod events;
|
||||||
pub mod trigger_manager;
|
pub mod trigger_manager;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
|
#[cfg(feature = "multi-agent")]
|
||||||
pub mod director;
|
pub mod director;
|
||||||
pub mod generation;
|
pub mod generation;
|
||||||
pub mod export;
|
pub mod export;
|
||||||
@@ -18,6 +19,7 @@ pub use capabilities::*;
|
|||||||
pub use events::*;
|
pub use events::*;
|
||||||
pub use config::*;
|
pub use config::*;
|
||||||
pub use trigger_manager::{TriggerManager, TriggerEntry, TriggerUpdateRequest, TriggerManagerConfig};
|
pub use trigger_manager::{TriggerManager, TriggerEntry, TriggerUpdateRequest, TriggerManagerConfig};
|
||||||
|
#[cfg(feature = "multi-agent")]
|
||||||
pub use director::*;
|
pub use director::*;
|
||||||
pub use generation::*;
|
pub use generation::*;
|
||||||
pub use export::{ExportFormat, ExportOptions, ExportResult, Exporter, export_classroom};
|
pub use export::{ExportFormat, ExportOptions, ExportResult, Exporter, export_classroom};
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ tracing = { workspace = true }
|
|||||||
|
|
||||||
# SQLite
|
# SQLite
|
||||||
sqlx = { workspace = true }
|
sqlx = { workspace = true }
|
||||||
|
libsqlite3-sys = { workspace = true }
|
||||||
|
|
||||||
# Async utilities
|
# Async utilities
|
||||||
futures = { workspace = true }
|
futures = { workspace = true }
|
||||||
|
|||||||
@@ -26,7 +26,10 @@ impl MemoryStore {
|
|||||||
// Parse SQLite URL to extract file path
|
// Parse SQLite URL to extract file path
|
||||||
// Format: sqlite:/path/to/db or sqlite://path/to/db
|
// Format: sqlite:/path/to/db or sqlite://path/to/db
|
||||||
if database_url.starts_with("sqlite:") {
|
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
|
// Skip in-memory databases
|
||||||
if path_part == ":memory:" {
|
if path_part == ":memory:" {
|
||||||
@@ -34,7 +37,10 @@ impl MemoryStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Remove query parameters (e.g., ?mode=rwc)
|
// 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
|
// Handle both absolute and relative paths
|
||||||
let path = std::path::Path::new(path_without_query);
|
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)))?;
|
.map_err(|e| ActionError::Export(format!("Write error: {}", e)))?;
|
||||||
}
|
}
|
||||||
ExportFormat::Pptx => {
|
ExportFormat::Pptx => {
|
||||||
// Will integrate with zclaw-kernel export
|
return Err(ActionError::Export(
|
||||||
return Err(ActionError::Export("PPTX export requires kernel integration".to_string()));
|
"PPTX 导出暂不可用。桌面端可通过 Pipeline 结果面板使用 JSON 格式导出后转换。".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
ExportFormat::Pdf => {
|
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
|
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
|
/// Render data to HTML
|
||||||
fn render_html(data: &Value) -> String {
|
fn render_html(data: &Value) -> String {
|
||||||
let mut html = String::from(r#"<!DOCTYPE html>
|
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()) {
|
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()) {
|
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") {
|
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()) {
|
if let Some(items) = outline.get("items").and_then(|v| v.as_array()) {
|
||||||
for item in items {
|
for item in items {
|
||||||
if let Some(text) = item.get("title").and_then(|v| v.as_str()) {
|
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 {
|
for scene in scenes {
|
||||||
html.push_str("<div class=\"scene\">");
|
html.push_str("<div class=\"scene\">");
|
||||||
if let Some(title) = scene.get("title").and_then(|v| v.as_str()) {
|
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()) {
|
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>");
|
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 render;
|
||||||
mod export;
|
mod export;
|
||||||
mod http;
|
mod http;
|
||||||
mod skill;
|
|
||||||
mod hand;
|
|
||||||
mod orchestration;
|
mod orchestration;
|
||||||
|
|
||||||
pub use llm::*;
|
pub use llm::*;
|
||||||
@@ -16,8 +14,6 @@ pub use parallel::*;
|
|||||||
pub use render::*;
|
pub use render::*;
|
||||||
pub use export::*;
|
pub use export::*;
|
||||||
pub use http::*;
|
pub use http::*;
|
||||||
pub use skill::*;
|
|
||||||
pub use hand::*;
|
|
||||||
pub use orchestration::*;
|
pub use orchestration::*;
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
@@ -134,10 +130,10 @@ impl ActionRegistry {
|
|||||||
max_tokens: Option<u32>,
|
max_tokens: Option<u32>,
|
||||||
json_mode: bool,
|
json_mode: bool,
|
||||||
) -> Result<Value, ActionError> {
|
) -> Result<Value, ActionError> {
|
||||||
println!("[DEBUG execute_llm] Called with template length: {}", template.len());
|
tracing::debug!(target: "pipeline_actions", "execute_llm: Called with template length: {}", template.len());
|
||||||
println!("[DEBUG execute_llm] Input HashMap contents:");
|
tracing::debug!(target: "pipeline_actions", "execute_llm: Input HashMap contents:");
|
||||||
for (k, v) in &input {
|
for (k, v) in &input {
|
||||||
println!(" {} => {:?}", k, v);
|
tracing::debug!(target: "pipeline_actions", " {} => {:?}", k, v);
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(driver) = &self.llm_driver {
|
if let Some(driver) = &self.llm_driver {
|
||||||
@@ -148,13 +144,13 @@ impl ActionRegistry {
|
|||||||
template.to_string()
|
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)
|
driver.generate(prompt, input, model, temperature, max_tokens, json_mode)
|
||||||
.await
|
.await
|
||||||
.map_err(ActionError::Llm)
|
.map_err(ActionError::Llm)
|
||||||
} else {
|
} else {
|
||||||
Err(ActionError::Llm("LLM driver not configured".to_string()))
|
Err(ActionError::Llm("LLM 驱动未配置,请在设置中配置模型与 API".to_string()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,7 +165,7 @@ impl ActionRegistry {
|
|||||||
.await
|
.await
|
||||||
.map_err(ActionError::Skill)
|
.map_err(ActionError::Skill)
|
||||||
} else {
|
} else {
|
||||||
Err(ActionError::Skill("Skill registry not configured".to_string()))
|
Err(ActionError::Skill("技能注册表未初始化".to_string()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,7 +181,7 @@ impl ActionRegistry {
|
|||||||
.await
|
.await
|
||||||
.map_err(ActionError::Hand)
|
.map_err(ActionError::Hand)
|
||||||
} else {
|
} else {
|
||||||
Err(ActionError::Hand("Hand registry not configured".to_string()))
|
Err(ActionError::Hand("Hand 注册表未初始化".to_string()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,7 +197,7 @@ impl ActionRegistry {
|
|||||||
.await
|
.await
|
||||||
.map_err(ActionError::Orchestration)
|
.map_err(ActionError::Orchestration)
|
||||||
} else {
|
} 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?;
|
tokio::fs::write(&path, content).await?;
|
||||||
}
|
}
|
||||||
ExportFormat::Pptx => {
|
ExportFormat::Pptx => {
|
||||||
// Will integrate with pptx exporter
|
return Err(ActionError::Export(
|
||||||
return Err(ActionError::Export("PPTX export not yet implemented".to_string()));
|
"PPTX 导出暂不可用。桌面端可通过 Pipeline 结果面板使用 JSON 格式导出后转换。".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
ExportFormat::Pdf => {
|
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>");
|
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()) {
|
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()) {
|
if let Some(items) = data.get("items").and_then(|v| v.as_array()) {
|
||||||
html.push_str("<ul>");
|
html.push_str("<ul>");
|
||||||
for item in items {
|
for item in items {
|
||||||
if let Some(text) = item.as_str() {
|
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>");
|
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 {
|
impl ExportFormat {
|
||||||
fn extension(&self) -> &'static str {
|
fn extension(&self) -> &'static str {
|
||||||
match self {
|
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::collections::HashMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use futures::future::join_all;
|
use futures::stream::{self, StreamExt};
|
||||||
use serde_json::{Value, json};
|
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};
|
use crate::engine::context::{ExecutionContextV2, ContextError};
|
||||||
|
|
||||||
/// Stage execution result
|
/// Stage execution result
|
||||||
@@ -242,14 +241,6 @@ impl StageEngine {
|
|||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
Err(e) => {
|
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 {
|
self.emit_event(StageEvent::Error {
|
||||||
stage_id,
|
stage_id,
|
||||||
error: e.to_string(),
|
error: e.to_string(),
|
||||||
@@ -279,7 +270,7 @@ impl StageEngine {
|
|||||||
|
|
||||||
self.emit_event(StageEvent::Progress {
|
self.emit_event(StageEvent::Progress {
|
||||||
stage_id: stage_id.to_string(),
|
stage_id: stage_id.to_string(),
|
||||||
message: "Calling LLM...".to_string(),
|
message: "正在调用 LLM...".to_string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
let prompt_str = resolved_prompt.as_str()
|
let prompt_str = resolved_prompt.as_str()
|
||||||
@@ -323,29 +314,58 @@ impl StageEngine {
|
|||||||
return Ok(Value::Array(vec![]));
|
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 {
|
self.emit_event(StageEvent::Progress {
|
||||||
stage_id: stage_id.to_string(),
|
stage_id: stage_id.to_string(),
|
||||||
message: format!("Processing {} items", total),
|
message: format!("并行处理 {} 项 (workers={})", total, workers),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Sequential execution with progress tracking
|
// Parallel execution using buffer_unordered
|
||||||
// Note: True parallel execution would require Send-safe drivers
|
let results: Vec<(usize, Result<StageResult, StageError>)> = stream::iter(
|
||||||
let mut outputs = Vec::with_capacity(total);
|
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() {
|
async move {
|
||||||
let mut child_context = context.child_context(item.clone(), index, total);
|
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;
|
||||||
|
|
||||||
self.emit_event(StageEvent::ParallelProgress {
|
// Sort by original index to preserve order
|
||||||
stage_id: stage_id.to_string(),
|
let mut ordered: Vec<_> = results.into_iter().collect();
|
||||||
completed: index,
|
ordered.sort_by_key(|(idx, _)| *idx);
|
||||||
total,
|
|
||||||
});
|
|
||||||
|
|
||||||
match self.execute(stage_template, &mut child_context).await {
|
let outputs: Vec<Value> = ordered.into_iter().map(|(index, result)| {
|
||||||
Ok(result) => outputs.push(result.output),
|
match result {
|
||||||
Err(e) => outputs.push(json!({ "error": e.to_string(), "index": index })),
|
Ok(sr) => sr.output,
|
||||||
|
Err(e) => json!({ "error": e.to_string(), "index": index }),
|
||||||
}
|
}
|
||||||
}
|
}).collect();
|
||||||
|
|
||||||
Ok(Value::Array(outputs))
|
Ok(Value::Array(outputs))
|
||||||
}
|
}
|
||||||
@@ -419,7 +439,7 @@ impl StageEngine {
|
|||||||
/// Execute compose stage
|
/// Execute compose stage
|
||||||
async fn execute_compose(
|
async fn execute_compose(
|
||||||
&self,
|
&self,
|
||||||
stage_id: &str,
|
_stage_id: &str,
|
||||||
template: &str,
|
template: &str,
|
||||||
context: &ExecutionContextV2,
|
context: &ExecutionContextV2,
|
||||||
) -> Result<Value, StageError> {
|
) -> Result<Value, StageError> {
|
||||||
@@ -568,7 +588,8 @@ impl StageEngine {
|
|||||||
Ok(resolved_value)
|
Ok(resolved_value)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clone with drivers
|
/// Clone with drivers (reserved for future use)
|
||||||
|
#[allow(dead_code)]
|
||||||
fn clone_with_drivers(&self) -> Self {
|
fn clone_with_drivers(&self) -> Self {
|
||||||
Self {
|
Self {
|
||||||
llm_driver: self.llm_driver.clone(),
|
llm_driver: self.llm_driver.clone(),
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ impl PipelineExecutor {
|
|||||||
return Ok(run.clone());
|
return Ok(run.clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
Err(ExecuteError::Action("Run not found after execution".to_string()))
|
Err(ExecuteError::Action("执行后未找到运行记录".to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Execute pipeline steps
|
/// Execute pipeline steps
|
||||||
@@ -185,22 +185,22 @@ impl PipelineExecutor {
|
|||||||
async move {
|
async move {
|
||||||
match action {
|
match action {
|
||||||
Action::LlmGenerate { template, input, model, temperature, max_tokens, json_mode } => {
|
Action::LlmGenerate { template, input, model, temperature, max_tokens, json_mode } => {
|
||||||
println!("[DEBUG executor] LlmGenerate action called");
|
tracing::debug!(target: "pipeline_executor", "LlmGenerate action called");
|
||||||
println!("[DEBUG executor] Raw input map:");
|
tracing::debug!(target: "pipeline_executor", "Raw input map:");
|
||||||
for (k, v) in input {
|
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.)
|
// First resolve the template itself (handles ${inputs.xxx}, ${item.xxx}, etc.)
|
||||||
let resolved_template = context.resolve(template)?;
|
let resolved_template = context.resolve(template)?;
|
||||||
let resolved_template_str = resolved_template.as_str().unwrap_or(template).to_string();
|
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)]);
|
&resolved_template_str[..resolved_template_str.len().min(300)]);
|
||||||
|
|
||||||
let resolved_input = context.resolve_map(input)?;
|
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 {
|
for (k, v) in &resolved_input {
|
||||||
println!(" {} => {:?}", k, v);
|
tracing::debug!(target: "pipeline_executor", " {} => {:?}", k, v);
|
||||||
}
|
}
|
||||||
self.action_registry.execute_llm(
|
self.action_registry.execute_llm(
|
||||||
&resolved_template_str,
|
&resolved_template_str,
|
||||||
@@ -215,7 +215,7 @@ impl PipelineExecutor {
|
|||||||
Action::Parallel { each, step, max_workers } => {
|
Action::Parallel { each, step, max_workers } => {
|
||||||
let items = context.resolve(each)?;
|
let items = context.resolve(each)?;
|
||||||
let items_array = items.as_array()
|
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 workers = max_workers.unwrap_or(4);
|
||||||
let results = self.execute_parallel(step, items_array.clone(), workers, context).await?;
|
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
|
/// Default LLM driver implementation using prompt-based matching
|
||||||
|
#[allow(dead_code)]
|
||||||
pub struct DefaultLlmIntentDriver {
|
pub struct DefaultLlmIntentDriver {
|
||||||
/// Model ID to use
|
/// Model ID to use
|
||||||
model_id: String,
|
model_id: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DefaultLlmIntentDriver {
|
/// Runtime LLM driver that wraps zclaw-runtime's LlmDriver for actual LLM calls
|
||||||
/// Create a new default LLM driver
|
pub struct RuntimeLlmIntentDriver {
|
||||||
pub fn new(model_id: impl Into<String>) -> Self {
|
driver: std::sync::Arc<dyn zclaw_runtime::driver::LlmDriver>,
|
||||||
Self {
|
}
|
||||||
model_id: model_id.into(),
|
|
||||||
}
|
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]
|
#[async_trait]
|
||||||
impl LlmIntentDriver for DefaultLlmIntentDriver {
|
impl LlmIntentDriver for RuntimeLlmIntentDriver {
|
||||||
async fn semantic_match(
|
async fn semantic_match(
|
||||||
&self,
|
&self,
|
||||||
user_input: &str,
|
user_input: &str,
|
||||||
triggers: &[CompiledTrigger],
|
triggers: &[CompiledTrigger],
|
||||||
) -> Option<SemanticMatchResult> {
|
) -> Option<SemanticMatchResult> {
|
||||||
// Build prompt for LLM
|
|
||||||
let trigger_descriptions: Vec<String> = triggers
|
let trigger_descriptions: Vec<String> = triggers
|
||||||
.iter()
|
.iter()
|
||||||
.map(|t| {
|
.map(|t| {
|
||||||
@@ -429,31 +432,42 @@ impl LlmIntentDriver for DefaultLlmIntentDriver {
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let prompt = format!(
|
let system_prompt = r#"分析用户输入,匹配合适的 Pipeline。只返回 JSON,不要其他内容。"#
|
||||||
r#"分析用户输入,匹配合适的 Pipeline。
|
.to_string();
|
||||||
|
|
||||||
用户输入: {}
|
let user_msg = format!(
|
||||||
|
"用户输入: {}\n\n可选 Pipelines:\n{}",
|
||||||
可选 Pipelines:
|
|
||||||
{}
|
|
||||||
|
|
||||||
返回 JSON 格式:
|
|
||||||
{{
|
|
||||||
"pipeline_id": "匹配的 pipeline ID 或 null",
|
|
||||||
"params": {{ "参数名": "值" }},
|
|
||||||
"confidence": 0.0-1.0,
|
|
||||||
"reason": "匹配原因"
|
|
||||||
}}
|
|
||||||
|
|
||||||
只返回 JSON,不要其他内容。"#,
|
|
||||||
user_input,
|
user_input,
|
||||||
trigger_descriptions.join("\n")
|
trigger_descriptions.join("\n")
|
||||||
);
|
);
|
||||||
|
|
||||||
// In a real implementation, this would call the LLM
|
let request = zclaw_runtime::driver::CompletionRequest {
|
||||||
// For now, we return None to indicate semantic matching is not available
|
model: self.driver.provider().to_string(),
|
||||||
let _ = prompt; // Suppress unused warning
|
system: Some(system_prompt),
|
||||||
None
|
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(
|
async fn collect_params(
|
||||||
@@ -462,7 +476,10 @@ impl LlmIntentDriver for DefaultLlmIntentDriver {
|
|||||||
missing_params: &[MissingParam],
|
missing_params: &[MissingParam],
|
||||||
_context: &HashMap<String, serde_json::Value>,
|
_context: &HashMap<String, serde_json::Value>,
|
||||||
) -> 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
|
let param_descriptions: Vec<String> = missing_params
|
||||||
.iter()
|
.iter()
|
||||||
.map(|p| {
|
.map(|p| {
|
||||||
@@ -475,30 +492,123 @@ impl LlmIntentDriver for DefaultLlmIntentDriver {
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let prompt = format!(
|
let system_prompt = r#"从用户输入中提取参数值。如果无法提取,该参数可以省略。只返回 JSON。"#
|
||||||
r#"从用户输入中提取参数值。
|
.to_string();
|
||||||
|
|
||||||
用户输入: {}
|
let user_msg = format!(
|
||||||
|
"用户输入: {}\n\n需要提取的参数:\n{}",
|
||||||
需要提取的参数:
|
|
||||||
{}
|
|
||||||
|
|
||||||
返回 JSON 格式:
|
|
||||||
{{
|
|
||||||
"参数名": "提取的值"
|
|
||||||
}}
|
|
||||||
|
|
||||||
如果无法提取,该参数可以省略。只返回 JSON。"#,
|
|
||||||
user_input,
|
user_input,
|
||||||
param_descriptions.join("\n")
|
param_descriptions.join("\n")
|
||||||
);
|
);
|
||||||
|
|
||||||
// In a real implementation, this would call the LLM
|
let request = zclaw_runtime::driver::CompletionRequest {
|
||||||
let _ = prompt;
|
model: self.driver.provider().to_string(),
|
||||||
HashMap::new()
|
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)
|
/// Intent analysis result (for debugging/logging)
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ pub mod intent;
|
|||||||
pub mod engine;
|
pub mod engine;
|
||||||
pub mod presentation;
|
pub mod presentation;
|
||||||
|
|
||||||
|
// Glob re-exports with explicit disambiguation for conflicting names
|
||||||
pub use types::*;
|
pub use types::*;
|
||||||
pub use types_v2::*;
|
pub use types_v2::*;
|
||||||
pub use parser::*;
|
pub use parser::*;
|
||||||
@@ -67,6 +68,14 @@ pub use trigger::*;
|
|||||||
pub use intent::*;
|
pub use intent::*;
|
||||||
pub use engine::*;
|
pub use engine::*;
|
||||||
pub use presentation::*;
|
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::ActionRegistry;
|
||||||
pub use actions::{LlmActionDriver, SkillActionDriver, HandActionDriver, OrchestrationActionDriver};
|
pub use actions::{LlmActionDriver, SkillActionDriver, HandActionDriver, OrchestrationActionDriver};
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,6 @@
|
|||||||
//! - Better recommendations for ambiguous cases
|
//! - Better recommendations for ambiguous cases
|
||||||
|
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use std::collections::HashMap;
|
|
||||||
|
|
||||||
use super::types::*;
|
use super::types::*;
|
||||||
|
|
||||||
|
|||||||
@@ -254,13 +254,13 @@ pub fn compile_pattern(pattern: &str) -> Result<CompiledPattern, PatternError> {
|
|||||||
'{' => {
|
'{' => {
|
||||||
// Named capture group
|
// Named capture group
|
||||||
let mut name = String::new();
|
let mut name = String::new();
|
||||||
let mut has_type = false;
|
let mut _has_type = false;
|
||||||
|
|
||||||
while let Some(c) = chars.next() {
|
while let Some(c) = chars.next() {
|
||||||
match c {
|
match c {
|
||||||
'}' => break,
|
'}' => break,
|
||||||
':' => {
|
':' => {
|
||||||
has_type = true;
|
_has_type = true;
|
||||||
// Skip type part
|
// Skip type part
|
||||||
while let Some(nc) = chars.peek() {
|
while let Some(nc) = chars.peek() {
|
||||||
if *nc == '}' {
|
if *nc == '}' {
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ repository.workspace = true
|
|||||||
rust-version.workspace = true
|
rust-version.workspace = true
|
||||||
description = "ZCLAW protocol support (MCP, A2A)"
|
description = "ZCLAW protocol support (MCP, A2A)"
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = []
|
||||||
|
# Enable A2A (Agent-to-Agent) protocol support
|
||||||
|
a2a = []
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
zclaw-types = { workspace = true }
|
zclaw-types = { workspace = true }
|
||||||
|
|
||||||
|
|||||||
@@ -256,7 +256,7 @@ pub struct A2aReceiver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl 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 {
|
fn new(rx: mpsc::Receiver<A2aEnvelope>) -> Self {
|
||||||
Self { receiver: Some(rx) }
|
Self { receiver: Some(rx) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,18 @@
|
|||||||
//! ZCLAW Protocols
|
//! ZCLAW Protocols
|
||||||
//!
|
//!
|
||||||
//! Protocol support for MCP (Model Context Protocol) and A2A (Agent-to-Agent).
|
//! 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;
|
||||||
mod mcp_types;
|
mod mcp_types;
|
||||||
mod mcp_transport;
|
mod mcp_transport;
|
||||||
|
#[cfg(feature = "a2a")]
|
||||||
mod a2a;
|
mod a2a;
|
||||||
|
|
||||||
pub use mcp::*;
|
pub use mcp::*;
|
||||||
pub use mcp_types::*;
|
pub use mcp_types::*;
|
||||||
pub use mcp_transport::*;
|
pub use mcp_transport::*;
|
||||||
|
#[cfg(feature = "a2a")]
|
||||||
pub use 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
|
//! 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 async_trait::async_trait;
|
||||||
use futures::Stream;
|
use async_stream::stream;
|
||||||
|
use futures::{Stream, StreamExt};
|
||||||
use secrecy::{ExposeSecret, SecretString};
|
use secrecy::{ExposeSecret, SecretString};
|
||||||
use reqwest::Client;
|
use reqwest::Client;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
use std::pin::Pin;
|
use std::pin::Pin;
|
||||||
use zclaw_types::{Result, ZclawError};
|
use zclaw_types::{Result, ZclawError};
|
||||||
|
|
||||||
@@ -11,7 +19,6 @@ use super::{CompletionRequest, CompletionResponse, ContentBlock, LlmDriver, Stop
|
|||||||
use crate::stream::StreamChunk;
|
use crate::stream::StreamChunk;
|
||||||
|
|
||||||
/// Google Gemini driver
|
/// Google Gemini driver
|
||||||
#[allow(dead_code)] // TODO: Implement full Gemini API support
|
|
||||||
pub struct GeminiDriver {
|
pub struct GeminiDriver {
|
||||||
client: Client,
|
client: Client,
|
||||||
api_key: SecretString,
|
api_key: SecretString,
|
||||||
@@ -21,11 +28,31 @@ pub struct GeminiDriver {
|
|||||||
impl GeminiDriver {
|
impl GeminiDriver {
|
||||||
pub fn new(api_key: SecretString) -> Self {
|
pub fn new(api_key: SecretString) -> Self {
|
||||||
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,
|
api_key,
|
||||||
base_url: "https://generativelanguage.googleapis.com/v1beta".to_string(),
|
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]
|
#[async_trait]
|
||||||
@@ -39,25 +66,594 @@ impl LlmDriver for GeminiDriver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn complete(&self, request: CompletionRequest) -> Result<CompletionResponse> {
|
async fn complete(&self, request: CompletionRequest) -> Result<CompletionResponse> {
|
||||||
// TODO: Implement actual API call
|
let api_request = self.build_api_request(&request);
|
||||||
Ok(CompletionResponse {
|
let url = format!(
|
||||||
content: vec![ContentBlock::Text {
|
"{}/models/{}:generateContent?key={}",
|
||||||
text: "Gemini driver not yet implemented".to_string(),
|
self.base_url,
|
||||||
}],
|
request.model,
|
||||||
model: request.model,
|
self.api_key.expose_secret()
|
||||||
input_tokens: 0,
|
);
|
||||||
output_tokens: 0,
|
|
||||||
stop_reason: StopReason::EndTurn,
|
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(
|
fn stream(
|
||||||
&self,
|
&self,
|
||||||
_request: CompletionRequest,
|
request: CompletionRequest,
|
||||||
) -> Pin<Box<dyn Stream<Item = Result<StreamChunk>> + Send + '_>> {
|
) -> Pin<Box<dyn Stream<Item = Result<StreamChunk>> + Send + '_>> {
|
||||||
// Placeholder - return error stream
|
let api_request = self.build_api_request(&request);
|
||||||
Box::pin(futures::stream::once(async {
|
let url = format!(
|
||||||
Err(ZclawError::LlmError("Gemini streaming not yet implemented".to_string()))
|
"{}/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.)
|
//! 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 async_trait::async_trait;
|
||||||
use futures::Stream;
|
use async_stream::stream;
|
||||||
|
use futures::{Stream, StreamExt};
|
||||||
use reqwest::Client;
|
use reqwest::Client;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
use std::pin::Pin;
|
use std::pin::Pin;
|
||||||
use zclaw_types::{Result, ZclawError};
|
use zclaw_types::{Result, ZclawError};
|
||||||
|
|
||||||
use super::{CompletionRequest, CompletionResponse, ContentBlock, LlmDriver, StopReason};
|
use super::{CompletionRequest, CompletionResponse, ContentBlock, LlmDriver, StopReason};
|
||||||
use crate::stream::StreamChunk;
|
use crate::stream::StreamChunk;
|
||||||
|
|
||||||
/// Local LLM driver for Ollama, LM Studio, vLLM, etc.
|
/// Local LLM driver for Ollama, LM Studio, vLLM, and other OpenAI-compatible servers.
|
||||||
#[allow(dead_code)] // TODO: Implement full Local driver support
|
|
||||||
pub struct LocalDriver {
|
pub struct LocalDriver {
|
||||||
client: Client,
|
client: Client,
|
||||||
base_url: String,
|
base_url: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl LocalDriver {
|
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 {
|
pub fn new(base_url: impl Into<String>) -> Self {
|
||||||
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(),
|
base_url: base_url.into(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Ollama default endpoint (`http://localhost:11434/v1`).
|
||||||
pub fn ollama() -> Self {
|
pub fn ollama() -> Self {
|
||||||
Self::new("http://localhost:11434/v1")
|
Self::new("http://localhost:11434/v1")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// LM Studio default endpoint (`http://localhost:1234/v1`).
|
||||||
pub fn lm_studio() -> Self {
|
pub fn lm_studio() -> Self {
|
||||||
Self::new("http://localhost:1234/v1")
|
Self::new("http://localhost:1234/v1")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// vLLM default endpoint (`http://localhost:8000/v1`).
|
||||||
pub fn vllm() -> Self {
|
pub fn vllm() -> Self {
|
||||||
Self::new("http://localhost:8000/v1")
|
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]
|
#[async_trait]
|
||||||
@@ -44,30 +254,394 @@ impl LlmDriver for LocalDriver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn is_configured(&self) -> bool {
|
fn is_configured(&self) -> bool {
|
||||||
// Local drivers don't require API keys
|
// Local drivers never require an API key.
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn complete(&self, request: CompletionRequest) -> Result<CompletionResponse> {
|
async fn complete(&self, request: CompletionRequest) -> Result<CompletionResponse> {
|
||||||
// TODO: Implement actual API call (OpenAI-compatible)
|
let api_request = self.build_api_request(&request);
|
||||||
Ok(CompletionResponse {
|
let url = format!("{}/chat/completions", self.base_url);
|
||||||
content: vec![ContentBlock::Text {
|
|
||||||
text: "Local driver not yet implemented".to_string(),
|
tracing::debug!(target: "local_driver", "Sending request to {}", url);
|
||||||
}],
|
tracing::trace!(
|
||||||
model: request.model,
|
target: "local_driver",
|
||||||
input_tokens: 0,
|
"Request body: {}",
|
||||||
output_tokens: 0,
|
serde_json::to_string(&api_request).unwrap_or_default()
|
||||||
stop_reason: StopReason::EndTurn,
|
);
|
||||||
})
|
|
||||||
|
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(
|
fn stream(
|
||||||
&self,
|
&self,
|
||||||
_request: CompletionRequest,
|
request: CompletionRequest,
|
||||||
) -> Pin<Box<dyn Stream<Item = Result<StreamChunk>> + Send + '_>> {
|
) -> Pin<Box<dyn Stream<Item = Result<StreamChunk>> + Send + '_>> {
|
||||||
// Placeholder - return error stream
|
let mut stream_request = self.build_api_request(&request);
|
||||||
Box::pin(futures::stream::once(async {
|
stream_request.stream = true;
|
||||||
Err(ZclawError::LlmError("Local driver streaming not yet implemented".to_string()))
|
|
||||||
}))
|
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
|
// Debug: log the request details
|
||||||
let url = format!("{}/chat/completions", self.base_url);
|
let url = format!("{}/chat/completions", self.base_url);
|
||||||
let request_body = serde_json::to_string(&api_request).unwrap_or_default();
|
let request_body = serde_json::to_string(&api_request).unwrap_or_default();
|
||||||
eprintln!("[OpenAiDriver] Sending request to: {}", url);
|
tracing::debug!(target: "openai_driver", "Sending request to: {}", url);
|
||||||
eprintln!("[OpenAiDriver] Request body: {}", request_body);
|
tracing::trace!(target: "openai_driver", "Request body: {}", request_body);
|
||||||
|
|
||||||
let response = self.client
|
let response = self.client
|
||||||
.post(&url)
|
.post(&url)
|
||||||
@@ -80,11 +80,11 @@ impl LlmDriver for OpenAiDriver {
|
|||||||
if !response.status().is_success() {
|
if !response.status().is_success() {
|
||||||
let status = response.status();
|
let status = response.status();
|
||||||
let body = response.text().await.unwrap_or_default();
|
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)));
|
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
|
let api_response: OpenAiResponse = response
|
||||||
.json()
|
.json()
|
||||||
@@ -107,11 +107,11 @@ impl LlmDriver for OpenAiDriver {
|
|||||||
self.base_url.contains("aliyuncs") ||
|
self.base_url.contains("aliyuncs") ||
|
||||||
self.base_url.contains("bigmodel.cn");
|
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);
|
self.base_url, has_tools, needs_non_streaming);
|
||||||
|
|
||||||
if 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
|
// Use non-streaming mode and convert to stream
|
||||||
return self.stream_from_complete(request);
|
return self.stream_from_complete(request);
|
||||||
}
|
}
|
||||||
@@ -458,11 +458,11 @@ impl OpenAiDriver {
|
|||||||
let api_key = self.api_key.expose_secret().to_string();
|
let api_key = self.api_key.expose_secret().to_string();
|
||||||
let model = request.model.clone();
|
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! {
|
Box::pin(stream! {
|
||||||
let url = format!("{}/chat/completions", base_url);
|
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
|
let response = match self.client
|
||||||
.post(&url)
|
.post(&url)
|
||||||
@@ -490,15 +490,15 @@ impl OpenAiDriver {
|
|||||||
let api_response: OpenAiResponse = match response.json().await {
|
let api_response: OpenAiResponse = match response.json().await {
|
||||||
Ok(r) => r,
|
Ok(r) => r,
|
||||||
Err(e) => {
|
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)));
|
yield Err(ZclawError::LlmError(format!("Failed to parse response: {}", e)));
|
||||||
return;
|
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() {
|
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| {
|
choice.message.content.as_ref().map(|c| {
|
||||||
if c.len() > 100 {
|
if c.len() > 100 {
|
||||||
// 使用 floor_char_boundary 确保不在多字节字符中间截断
|
// 使用 floor_char_boundary 确保不在多字节字符中间截断
|
||||||
@@ -514,15 +514,15 @@ impl OpenAiDriver {
|
|||||||
|
|
||||||
// Convert response to stream chunks
|
// Convert response to stream chunks
|
||||||
let completion = self.convert_response(api_response, model.clone());
|
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
|
// Emit content blocks as stream chunks
|
||||||
for block in &completion.content {
|
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 {
|
match block {
|
||||||
ContentBlock::Text { text } => {
|
ContentBlock::Text { text } => {
|
||||||
if !text.is_empty() {
|
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() });
|
yield Ok(StreamChunk::TextDelta { delta: text.clone() });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -530,7 +530,7 @@ impl OpenAiDriver {
|
|||||||
yield Ok(StreamChunk::ThinkingDelta { delta: thinking.clone() });
|
yield Ok(StreamChunk::ThinkingDelta { delta: thinking.clone() });
|
||||||
}
|
}
|
||||||
ContentBlock::ToolUse { id, name, input } => {
|
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
|
// Emit tool use start
|
||||||
yield Ok(StreamChunk::ToolUseStart {
|
yield Ok(StreamChunk::ToolUseStart {
|
||||||
id: id.clone(),
|
id: id.clone(),
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
/// Default User-Agent header sent with all outgoing HTTP requests.
|
/// Default User-Agent header sent with all outgoing HTTP requests.
|
||||||
/// Some LLM providers (e.g. Moonshot, Qwen, DashScope Coding Plan) reject requests without one.
|
/// 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 driver;
|
||||||
pub mod tool;
|
pub mod tool;
|
||||||
@@ -12,6 +12,7 @@ pub mod loop_runner;
|
|||||||
pub mod loop_guard;
|
pub mod loop_guard;
|
||||||
pub mod stream;
|
pub mod stream;
|
||||||
pub mod growth;
|
pub mod growth;
|
||||||
|
pub mod compaction;
|
||||||
|
|
||||||
// Re-export main types
|
// Re-export main types
|
||||||
pub use driver::{
|
pub use driver::{
|
||||||
@@ -23,3 +24,4 @@ pub use loop_runner::{AgentLoop, AgentLoopResult, LoopEvent};
|
|||||||
pub use loop_guard::{LoopGuard, LoopGuardConfig, LoopGuardResult};
|
pub use loop_guard::{LoopGuard, LoopGuardConfig, LoopGuardResult};
|
||||||
pub use stream::{StreamEvent, StreamSender};
|
pub use stream::{StreamEvent, StreamSender};
|
||||||
pub use growth::GrowthIntegration;
|
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