Compare commits

...

20 Commits

Author SHA1 Message Date
iven
834aa12076 fix: P0 panic风险修复 + P1编译warnings清零 + P2代码/文档清理
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
P0 安全性:
- account/handlers.rs: .unwrap() → .expect() 语义化错误信息
- relay/handlers.rs: SSE Response .unwrap() → .expect()

P1 编译质量 (6 warnings → 0):
- kernel.rs: 移除未使用的 Capability import 和 config_clone 变量
- pipeline_commands.rs: 未使用变量 id → _id
- db.rs: 移除多余括号
- relay/service.rs: 移除未使用的 StreamExt import
- telemetry/service.rs: 抑制 param_idx 未读赋值警告
- main.rs: TcpKeepalive::with_retries() Linux-only 条件编译

P2 代码清理:
- 移除 handStore/HandsPanel/HandTaskPanel/gateway-api/SchedulerPanel 调试 console.log
- SchedulerPanel: 修复 updateWorkflow 未解构导致 TS 编译错误
- 文档清理 zclaw-channels 已移除 crate 的引用
2026-03-30 11:33:47 +08:00
iven
813b49a986 feat: P0 KernelClient功能修复 + P1/P2/P3质量改进
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
P0 KernelClient 功能断裂修复:
- Skill CUD: registry.rs create/update/delete + serialize_skill_md + kernel proxy
- Workflow CUD: pipeline_commands.rs create/update/delete + serde_yaml依赖
- Agent更新: registry update方法 + AgentConfigUpdated事件 + agent_update命令
- Hand流式事件: HandStart/HandEnd变体替换ToolStart/ToolEnd
- 后端验证: hand_get/hand_run_status/hand_run_list确认实现完整
- Approval闭环: respond_to_approval后台spawn+5分钟超时轮询

P2/P3 质量改进:
- Browser WebDriver: TCP探测ChromeDriver/GeckoDriver/Edge端口替换硬编码true
- api-fallbacks: 移除假技能和16个捏造安全层,替换为真实能力映射
- dead_code清理: 移除5个模块级#![allow(dead_code)],删除3个真正死方法,
  删除未注册的compactor_compact_llm命令,warnings从8降到3
- 所有变更通过cargo check + tsc --noEmit验证
2026-03-30 10:55:08 +08:00
iven
d345e60a6a fix(scripts): start-all.ps1 适配 admin-v2 (Vite port 5173)
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
Admin 从 Next.js (port 3000) 迁移到 Vite (port 5173),
更新启动/停止/清理逻辑,保留旧端口 3000-3002 的 legacy 清理。
2026-03-30 09:44:49 +08:00
iven
a7d33d0207 feat(admin): Admin V2 — Ant Design Pro 纯 SPA 重写
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
Next.js SSR/hydration 与 SWR fetch-on-mount 存在根本冲突:
hydration 卸载组件时 abort 的请求仍占用后端 DB 连接,
retry 循环耗尽 PostgreSQL 连接池导致后端完全卡死。

admin-v2 使用 Vite + React + antd 纯 SPA 彻底消除此问题:
- 12 页面全部完成(Login, Dashboard, Accounts, Providers, Models,
  API Keys, Usage, Relay, Config, Prompts, Logs, Agent Templates)
- ProTable + ProForm + ProLayout 统一 UI 模式
- TanStack Query + Axios + Zustand 数据层
- JWT 自动刷新 + 401 重试机制
- 全部 18 网络请求 200 OK,零 ERR_ABORTED

同时更新 troubleshooting 第 13 节和 SaaS 平台文档。
2026-03-30 09:35:59 +08:00
iven
13c0b18bbc feat: Batch 5-9 — GrowthIntegration桥接、验证补全、死代码清理、Pipeline模板、Speech/Twitter真实实现
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
Batch 5 (P0): GrowthIntegration 接入 Tauri
- Kernel 新增 set_viking()/set_extraction_driver() 桥接 SqliteStorage
- 中间件链共享存储,MemoryExtractor 接入 LLM 驱动

Batch 6 (P1): 输入验证 + Heartbeat
- Relay 验证补全(stream 兼容检查、API key 格式校验)
- UUID 类型校验、SessionId 错误返回
- Heartbeat 默认开启 + 首次聊天自动初始化

Batch 7 (P2): 死代码清理
- zclaw-channels 整体移除(317 行)
- multi-agent 特性门控、admin 方法标注

Batch 8 (P2): Pipeline 模板
- PipelineMetadata 新增 annotations 字段
- pipeline_templates 命令 + 2 个示例模板
- fallback driver base_url 修复(doubao/qwen/deepseek 端点)

Batch 9 (P1): SpeechHand/TwitterHand 真实实现
- SpeechHand: tts_method 字段 + Browser TTS 前端集成 (Web Speech API)
- TwitterHand: 12 个 action 全部替换为 Twitter API v2 真实 HTTP 调用
- chatStore/useAutomationEvents 双路径 TTS 触发
2026-03-30 09:24:50 +08:00
iven
5595083b96 feat(skills): SemanticSkillRouter — TF-IDF + Embedding 混合路由
实现 SemanticSkillRouter 核心模块 (zclaw-skills):
- Embedder trait + NoOpEmbedder (TF-IDF fallback)
- SkillTfidfIndex 全文索引
- retrieve_candidates() Top-K 检索
- route() 置信度阈值路由 (0.85)
- cosine_similarity 公共函数
- 单元测试覆盖

Kernel 适配层 (zclaw-kernel):
- EmbeddingAdapter: zclaw-growth EmbeddingClient → Embedder

文档同步:
- 01-intelligent-routing.md Phase 1+2 标记完成
2026-03-30 00:54:11 +08:00
iven
eed26a1ce4 feat(pipeline): Pipeline 图持久化 — GraphStore 实现
新增 GraphStore trait 和 MemoryGraphStore 实现:
- save/load/delete/list_ids 异步接口
- 可选文件持久化到 JSON 目录
- 启动时从磁盘加载已保存的图

SkillOrchestrationDriver 集成:
- 新增 with_graph_store() 构造函数
- graph_id 路径从硬编码错误改为从 GraphStore 查找
- 无 store 时返回明确的错误信息

修复了 "Graph loading by ID not yet implemented" 的 TODO
2026-03-30 00:25:38 +08:00
iven
f3f586efef feat(kernel): Agent 导入/导出 + message_count 跟踪
Sprint 3.1 message_count 修复:
- AgentRegistry 新增 message_counts 字段跟踪每个 agent 的消息数
- increment_message_count() 在 send_message 和 send_message_stream 中调用
- get_info() 返回实际计数值

Sprint 3.3 Agent 导入/导出:
- Kernel 新增 get_agent_config() 方法返回原始 AgentConfig
- 新增 agent_export Tauri 命令,导出配置为 JSON
- 新增 agent_import Tauri 命令,从 JSON 导入并自动生成新 ID
- 注册到 Tauri invoke_handler
2026-03-30 00:19:02 +08:00
iven
6040d98b18 fix(kernel): message_count 始终为 0 的 bug
- AgentRegistry 新增 message_counts: DashMap<AgentId, u64> 跟踪字段
- 添加 increment_message_count() 方法
- Kernel.send_message() 和 send_message_stream() 中递增计数
- get_info() 返回实际计数值而非硬编码 0
2026-03-30 00:04:55 +08:00
iven
ee29b7b752 fix(pipeline): BREAK-04 接入 pipeline-complete 事件监听
PipelinesPanel 新增 useEffect 订阅 PipelineClient.onComplete(),
处理用户导航离开后的后台 Pipeline 完成通知。

- 后台完成时 toast 提示成功/失败
- 跳过当前选中 pipeline 的重复通知(轮询路径已处理)
- 组件卸载时自动清理监听器
2026-03-29 23:51:55 +08:00
iven
7e90cea117 fix(kernel): BREAK-02 记忆提取链路闭合 + BREAK-03 审批 HandRun 跟踪
BREAK-02 记忆提取链路闭合:
- Kernel 新增 viking: Arc<VikingAdapter> 共享存储后端
- VikingAdapter 在 boot() 中初始化, 全生命周期共享
- create_middleware_chain() 注册 MemoryMiddleware (priority 150)
- CompactionMiddleware 的 growth 参数从 None 改为 GrowthIntegration
- zclaw-runtime 重新导出 VikingAdapter

BREAK-03 审批后 HandRun 跟踪:
- respond_to_approval() 添加完整 HandRun 生命周期跟踪
- Pending → Running → Completed/Failed 状态转换
- 支持 duration_ms 计时和 cancellation 注册
- 与 execute_hand() 保持一致的跟踪粒度
2026-03-29 23:45:52 +08:00
iven
09df242cf8 fix(saas): Sprint 1 P0 阻塞修复
1.1 补全 docker-compose.yml (PostgreSQL 16 + SaaS 后端容器)
1.2 Migration 系统化:
    - provider_keys.max_rpm/max_tpm 改为 BIGINT 匹配 Rust Option<i64>
    - 移除 seed_demo_data 中的 ALTER TABLE 运行时修补
    - seed 数据绑定类型 i32→i64 对齐列定义
1.3 saas-config.toml 修复:
    - 添加 cors_origins (开发环境 localhost)
    - 添加 [scheduler] section (注释示例)
    - 数据库密码改为开发默认值 + ZCLAW_DATABASE_URL 环境变量覆盖
    - 添加配置文档注释 (JWT/TOTP/管理员环境变量)
2026-03-29 23:27:24 +08:00
iven
04c366fe8b feat(runtime): DeerFlow 模式中间件链 Phase 1-4 全部完成
借鉴 DeerFlow 架构,实现完整中间件链系统:

Phase 1 - Agent 中间件链基础设施
- MiddlewareChain Clone 支持
- LoopRunner 双路径集成 (middleware/legacy)
- Kernel create_middleware_chain() 工厂方法

Phase 2 - 技能按需注入
- SkillIndexMiddleware (priority 200)
- SkillLoadTool 工具
- SkillDetail/SkillIndexEntry 结构体
- KernelSkillExecutor trait 扩展

Phase 3 - Guardrail 安全护栏
- GuardrailMiddleware (priority 400, fail_open)
- ShellExecRule / FileWriteRule / WebFetchRule

Phase 4 - 记忆闭环统一
- MemoryMiddleware (priority 150, 30s 防抖)
- after_completion 双路径调用

中间件注册顺序:
100 Compaction | 150 Memory | 200 SkillIndex
400 Guardrail  | 500 LoopGuard | 700 TokenCalibration

向后兼容:Option<MiddlewareChain> 默认 None 走旧路径
2026-03-29 23:19:41 +08:00
iven
7de294375b feat(auth): 添加异步密码哈希和验证函数
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
refactor(relay): 复用HTTP客户端和请求体序列化结果

feat(kernel): 添加获取单个审批记录的方法

fix(store): 改进SaaS连接错误分类和降级处理

docs: 更新审计文档和系统架构文档

refactor(prompt): 优化SQL查询参数化绑定

refactor(migration): 使用静态SQL和COALESCE更新配置项

feat(commands): 添加审批执行状态追踪和事件通知

chore: 更新启动脚本以支持Admin后台

fix(auth-guard): 优化授权状态管理和错误处理

refactor(db): 使用异步密码哈希函数

refactor(totp): 使用异步密码验证函数

style: 清理无用文件和注释

docs: 更新功能全景和审计文档

refactor(service): 优化HTTP客户端重用和请求处理

fix(connection): 改进SaaS不可用时的降级处理

refactor(handlers): 使用异步密码验证函数

chore: 更新依赖和工具链配置
2026-03-29 21:45:29 +08:00
iven
b7ec317d2c docs: 更新功能文档 — 反映架构重构成果
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
- docs/features/README.md — 技能数 69→70, Hands 11个, 成熟度更新
- 智能层文档成熟度上调 (身份演化 L3, 反思引擎 L3)
- 后端集成文档更新 SaaS 迁移系统说明
- 知识库添加架构重构记录
2026-03-29 19:42:37 +08:00
iven
a0ca35c9dd feat(saas): SQL 迁移系统 + TIMESTAMPTZ + 热路径重构
P0: SQL 迁移系统
- crates/zclaw-saas/migrations/ — 独立 SQL 迁移文件目录
- 20260329000001_initial_schema.sql — TIMESTAMPTZ 完整 schema
- 20260329000002_seed_data.sql — 角色种子数据
- db.rs: 移除 335 行内联 SCHEMA_SQL,改为文件加载
- 版本追踪: saas_schema_version 表管理迁移状态
- 向后兼容: 已有 TEXT 时间戳数据库不受影响

P1: 安全重构
- relay/service.rs: update_task_status 从 format!() 改为 3 条独立参数化查询
- config.rs: 移除 TODO 注释,补充字段文档说明
- state.rs: 添加 dispatch_log_operation 异步日志派发方法

P2: Worker 集成
- state.rs: WorkerDispatcher 接入 AppState
- 所有异步后台任务基础设施就绪
2026-03-29 19:41:03 +08:00
iven
77374121dd fix(saas): 清理 role/mod.rs 重复路由定义
移除重复的 routes() 函数,将 get_role_permissions 路由指向 handlers_ext
2026-03-29 19:23:40 +08:00
iven
8b9d506893 refactor(saas): 架构重构 + 性能优化 — 借鉴 loco-rs 模式
Phase 0: 知识库
- docs/knowledge-base/loco-rs-patterns.md — loco-rs 10 个可借鉴模式研究

Phase 1: 数据层重构
- crates/zclaw-saas/src/models/ — 15 个 FromRow 类型化模型
- Login 3 次查询合并为 1 次 AccountLoginRow 查询
- 所有 service 文件从元组解构迁移到 FromRow 结构体

Phase 2: Worker + Scheduler 系统
- crates/zclaw-saas/src/workers/ — Worker trait + 5 个具体实现
- crates/zclaw-saas/src/scheduler.rs — TOML 声明式调度器
- crates/zclaw-saas/src/tasks/ — CLI 任务系统

Phase 3: 性能修复
- Relay N+1 查询 → 精准 SQL (relay/handlers.rs)
- Config RwLock → AtomicU32 无锁 rate limit (state.rs, middleware.rs)
- SSE std::sync::Mutex → tokio::sync::Mutex (relay/service.rs)
- /auth/refresh 阻塞清理 → Scheduler 定期执行

Phase 4: 多环境配置
- config/saas-{development,production,test}.toml
- ZCLAW_ENV 环境选择 + ZCLAW_SAAS_CONFIG 精确覆盖
- scheduler 配置集成到 TOML
2026-03-29 19:21:48 +08:00
iven
5fdf96c3f5 chore: 提交所有工作进度 — SaaS 后端增强、Admin UI、桌面端集成
包含大量 SaaS 平台改进、Admin 管理后台更新、桌面端集成完善、
文档同步、测试文件重构等内容。为 QA 测试准备干净工作树。
2026-03-29 10:46:41 +08:00
iven
9a5fad2b59 feat(saas): 合并 SaaS 后端、Admin 管理后台、桌面端集成
- 14 commits from worktree-saas-backend
- crates/zclaw-saas: Axum 后端 (auth, accounts, models, relay, config-sync)
- admin/: Next.js 管理后台
- desktop/: SaaS 客户端集成 (saasStore, 2FA, relay, config sync)
- saas-config.toml, docker-compose.yml, Dockerfile
- 84 files, 15558 insertions
2026-03-28 00:54:53 +08:00
410 changed files with 39392 additions and 5091 deletions

Submodule .claude/worktrees/saas-backend added at 4d8d560d1f

4
.gitignore vendored
View File

@@ -12,6 +12,10 @@ build/
.env.local .env.local
.env.*.local .env.*.local
# SaaS config (contains database credentials)
saas-config.toml
!saas-config.toml.example
# Logs # Logs
logs/ logs/
*.log *.log

0
Authorization Normal file
View File

View File

@@ -36,17 +36,20 @@ 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/ # 通道适配器 (仅 ConsoleChannel 测试适配器) │ ├── zclaw-protocols/ # 协议支持 (MCP, A2A)
│ └── zclaw-protocols/ # 协议支持 (MCP, A2A) │ └── zclaw-saas/ # SaaS 后端 (账号, 模型配置, 中转, 配置同步)
├── admin/ # Next.js 管理后台
├── desktop/ # Tauri 桌面应用 ├── desktop/ # Tauri 桌面应用
│ ├── src/ │ ├── src/
│ │ ├── components/ # React UI 组件 │ │ ├── components/ # React UI 组件 (含 SaaS 集成)
│ │ ├── store/ # Zustand 状态管理 │ │ ├── store/ # Zustand 状态管理 (含 saasStore)
│ │ └── lib/ # 客户端通信 / 工具函数 │ │ └── lib/ # 客户端通信 / 工具函数 (含 saas-client)
│ └── src-tauri/ # Tauri Rust 后端 (集成 Kernel) │ └── src-tauri/ # Tauri Rust 后端 (集成 Kernel)
├── skills/ # SKILL.md 技能定义 ├── skills/ # SKILL.md 技能定义
├── hands/ # HAND.toml 自主能力配置 ├── hands/ # HAND.toml 自主能力配置
├── config/ # TOML 配置文件 ├── config/ # TOML 配置文件
├── saas-config.toml # SaaS 后端配置 (PostgreSQL 连接等)
├── docker-compose.yml # PostgreSQL 容器配置
├── docs/ # 架构文档和知识库 ├── docs/ # 架构文档和知识库
└── tests/ # Vitest 回归测试 └── tests/ # Vitest 回归测试
``` ```
@@ -66,7 +69,9 @@ ZCLAW/
| 桌面框架 | Tauri 2.x | | 桌面框架 | Tauri 2.x |
| 样式方案 | Tailwind CSS | | 样式方案 | Tailwind CSS |
| 配置格式 | TOML | | 配置格式 | TOML |
| 后端核心 | Rust Workspace (8 crates) | | 后端核心 | Rust Workspace (9 crates) |
| SaaS 后端 | Axum + PostgreSQL (zclaw-saas) |
| 管理后台 | Next.js (admin/) |
### 2.3 Crate 依赖关系 ### 2.3 Crate 依赖关系
@@ -79,7 +84,9 @@ zclaw-runtime (→ types, memory)
zclaw-kernel (→ types, memory, runtime) zclaw-kernel (→ types, memory, runtime)
desktop/src-tauri (→ kernel, skills, hands, channels, protocols) zclaw-saas (→ types, 独立运行于 8080 端口)
desktop/src-tauri (→ kernel, skills, hands, protocols)
``` ```
*** ***
@@ -191,10 +198,10 @@ ZCLAW 提供 11 个自主能力包:
| Predictor | 预测分析 | ❌ 已禁用 (enabled=false),无 Rust 实现 | | Predictor | 预测分析 | ❌ 已禁用 (enabled=false),无 Rust 实现 |
| Lead | 销售线索发现 | ❌ 已禁用 (enabled=false),无 Rust 实现 | | Lead | 销售线索发现 | ❌ 已禁用 (enabled=false),无 Rust 实现 |
| Clip | 视频处理 | ⚠️ 需 FFmpeg | | Clip | 视频处理 | ⚠️ 需 FFmpeg |
| Twitter | Twitter 自动化 | ⚠️ 需 API Key | | Twitter | Twitter 自动化 | ✅ 可用12 个 API v2 真实调用,写操作需 OAuth 1.0a |
| Whiteboard | 白板演示 | ✅ 可用(导出功能开发中,标注 demo | | Whiteboard | 白板演示 | ✅ 可用(导出功能开发中,标注 demo |
| Slideshow | 幻灯片生成 | ✅ 可用 | | Slideshow | 幻灯片生成 | ✅ 可用 |
| Speech | 语音合成 | ✅ 可用 | | Speech | 语音合成 | ✅ 可用Browser TTS 前端集成完成) |
| Quiz | 测验生成 | ✅ 可用 | | Quiz | 测验生成 | ✅ 可用 |
**触发 Hand 时:** **触发 Hand 时:**
@@ -260,6 +267,18 @@ docs/
- **面向未来** - 文档要帮助未来的开发者快速理解 - **面向未来** - 文档要帮助未来的开发者快速理解
- **中文优先** - 所有面向用户的文档使用中文 - **中文优先** - 所有面向用户的文档使用中文
### 8.3 完成工作后的文档同步(强制)
每次完成功能实现、架构变更、问题修复后,**必须**同步更新以下文档:
1. **CLAUDE.md** — 如果涉及项目结构、技术栈、工作流程、命令的变化
2. **docs/features/** — 如果涉及新功能、功能变更、功能状态更新
3. **docs/knowledge-base/** — 如果涉及新知识、故障排查经验、配置说明
4. **saas-config.toml 注释** — 如果涉及 SaaS 配置项变更
5. **CHANGELOG** — 如果涉及对外可见的行为变化
**执行时机:** 代码编译通过且验证成功后,在标记任务完成之前,立即执行文档更新。文档更新是任务完成的必要条件,不是可选步骤。
*** ***
## 9. 常见问题排查 ## 9. 常见问题排查

946
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -9,7 +9,6 @@ members = [
# ZCLAW Extension Crates # ZCLAW Extension Crates
"crates/zclaw-skills", "crates/zclaw-skills",
"crates/zclaw-hands", "crates/zclaw-hands",
"crates/zclaw-channels",
"crates/zclaw-protocols", "crates/zclaw-protocols",
"crates/zclaw-pipeline", "crates/zclaw-pipeline",
"crates/zclaw-growth", "crates/zclaw-growth",
@@ -57,7 +56,7 @@ 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", "sqlite", "postgres"] }
libsqlite3-sys = { version = "0.27", features = ["bundled"] } libsqlite3-sys = { version = "0.27", features = ["bundled"] }
# HTTP client (for LLM drivers) # HTTP client (for LLM drivers)
@@ -94,6 +93,10 @@ regex = "1"
# Shell parsing # Shell parsing
shlex = "1" shlex = "1"
# WASM runtime
wasmtime = { version = "43", default-features = false, features = ["cranelift"] }
wasmtime-wasi = { version = "43" }
# Testing # Testing
tempfile = "3" tempfile = "3"
@@ -101,7 +104,7 @@ tempfile = "3"
axum = { version = "0.7", features = ["macros"] } axum = { version = "0.7", features = ["macros"] }
axum-extra = { version = "0.9", features = ["typed-header"] } axum-extra = { version = "0.9", features = ["typed-header"] }
tower = { version = "0.4", features = ["util"] } tower = { version = "0.4", features = ["util"] }
tower-http = { version = "0.5", features = ["cors", "trace", "limit"] } tower-http = { version = "0.5", features = ["cors", "trace", "limit", "timeout"] }
jsonwebtoken = "9" jsonwebtoken = "9"
argon2 = "0.5" argon2 = "0.5"
totp-rs = "5" totp-rs = "5"
@@ -114,7 +117,6 @@ zclaw-runtime = { path = "crates/zclaw-runtime" }
zclaw-kernel = { path = "crates/zclaw-kernel" } zclaw-kernel = { path = "crates/zclaw-kernel" }
zclaw-skills = { path = "crates/zclaw-skills" } zclaw-skills = { path = "crates/zclaw-skills" }
zclaw-hands = { path = "crates/zclaw-hands" } zclaw-hands = { path = "crates/zclaw-hands" }
zclaw-channels = { path = "crates/zclaw-channels" }
zclaw-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" }

24
admin-v2/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

73
admin-v2/README.md Normal file
View File

@@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

23
admin-v2/eslint.config.js Normal file
View File

@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

13
admin-v2/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>admin-v2</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

39
admin-v2/package.json Normal file
View File

@@ -0,0 +1,39 @@
{
"name": "admin-v2",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@ant-design/charts": "^2.6.7",
"@ant-design/icons": "^6.1.1",
"@ant-design/pro-components": "^2.8.10",
"@ant-design/pro-layout": "^7.22.7",
"@tanstack/react-query": "^5.95.2",
"antd": "^6.3.4",
"axios": "^1.14.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-router-dom": "^7.13.2",
"zustand": "^5.0.12"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@types/node": "^24.12.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^9.39.4",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.4.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.57.0",
"vite": "^8.0.1"
}
}

5008
admin-v2/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

24
admin-v2/public/icons.svg Normal file
View File

@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

View File

@@ -0,0 +1,103 @@
// ============================================================
// AdminLayout — ProLayout 管理后台布局
// ============================================================
import { Outlet, useNavigate, useLocation } from 'react-router-dom'
import ProLayout from '@ant-design/pro-layout'
import {
DashboardOutlined,
TeamOutlined,
CloudServerOutlined,
ApiOutlined,
KeyOutlined,
BarChartOutlined,
SwapOutlined,
SettingOutlined,
FileTextOutlined,
MessageOutlined,
RobotOutlined,
LogoutOutlined,
} from '@ant-design/icons'
import { useAuthStore } from '@/stores/authStore'
import { Avatar, Dropdown, message } from 'antd'
import type { MenuDataItem } from '@ant-design/pro-layout'
const menuConfig: MenuDataItem[] = [
{ path: '/', name: '仪表盘', icon: <DashboardOutlined /> },
{ path: '/accounts', name: '账号管理', icon: <TeamOutlined />, permission: 'account:admin' },
{ path: '/providers', name: '服务商', icon: <CloudServerOutlined />, permission: 'provider:manage' },
{ path: '/models', name: '模型管理', icon: <ApiOutlined />, permission: 'model:read' },
{ path: '/agent-templates', name: 'Agent 模板', icon: <RobotOutlined />, permission: 'model:read' },
{ path: '/api-keys', name: 'API 密钥', icon: <KeyOutlined />, permission: 'admin:full' },
{ path: '/usage', name: '用量统计', icon: <BarChartOutlined />, permission: 'admin:full' },
{ path: '/relay', name: '中转任务', icon: <SwapOutlined />, permission: 'relay:use' },
{ path: '/config', name: '系统配置', icon: <SettingOutlined />, permission: 'config:read' },
{ path: '/prompts', name: '提示词管理', icon: <MessageOutlined />, permission: 'prompt:read' },
{ path: '/logs', name: '操作日志', icon: <FileTextOutlined />, permission: 'admin:full' },
]
function filterMenuByPermission(
items: MenuDataItem[],
hasPermission: (p: string) => boolean,
): MenuDataItem[] {
return items
.filter((item) => !item.permission || hasPermission(item.permission as string))
.map(({ permission, ...rest }) => ({
...rest,
children: rest.children ? filterMenuByPermission(rest.children, hasPermission) : undefined,
}))
}
export default function AdminLayout() {
const navigate = useNavigate()
const location = useLocation()
const { account, hasPermission, logout } = useAuthStore()
const menuData = filterMenuByPermission(menuConfig, hasPermission)
const handleLogout = () => {
logout()
message.success('已退出登录')
navigate('/login', { replace: true })
}
return (
<ProLayout
title="ZCLAW"
logo={<div style={{ width: 28, height: 28, background: '#1677ff', borderRadius: 6, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#fff', fontWeight: 700, fontSize: 14 }}>Z</div>}
layout="mix"
fixSiderbar
fixedHeader
location={{ pathname: location.pathname }}
menuDataRender={() => menuData}
menuItemRender={(item, dom) => (
<div onClick={() => item.path && navigate(item.path)}>{dom}</div>
)}
avatarProps={{
src: undefined,
title: account?.display_name || account?.username || 'Admin',
size: 'small',
render: (_, dom) => (
<Dropdown
menu={{
items: [
{
key: 'logout',
icon: <LogoutOutlined />,
label: '退出登录',
onClick: handleLogout,
},
],
}}
>
{dom}
</Dropdown>
),
}}
suppressSiderWhenMenuEmpty
contentStyle={{ padding: 24 }}
>
<Outlet />
</ProLayout>
)
}

26
admin-v2/src/main.tsx Normal file
View File

@@ -0,0 +1,26 @@
import { createRoot } from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { RouterProvider } from 'react-router-dom'
import { ConfigProvider, App as AntApp } from 'antd'
import zhCN from 'antd/locale/zh_CN'
import { router } from './router'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 1,
refetchOnWindowFocus: false,
staleTime: 30_000,
},
},
})
createRoot(document.getElementById('root')!).render(
<ConfigProvider locale={zhCN}>
<AntApp>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</AntApp>
</ConfigProvider>,
)

View File

@@ -0,0 +1,170 @@
// ============================================================
// 账号管理
// ============================================================
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Button, message, Tag, Modal, Form, Input, Select, Popconfirm, Space } from 'antd'
import { PlusOutlined } from '@ant-design/icons'
import type { ProColumns } from '@ant-design/pro-components'
import { ProTable } from '@ant-design/pro-components'
import { accountService } from '@/services/accounts'
import type { AccountPublic } from '@/types'
const roleLabels: Record<string, string> = {
super_admin: '超级管理员',
admin: '管理员',
user: '用户',
}
const roleColors: Record<string, string> = {
super_admin: 'red',
admin: 'blue',
user: 'default',
}
const statusLabels: Record<string, string> = {
active: '正常',
disabled: '已禁用',
suspended: '已封禁',
}
const statusColors: Record<string, string> = {
active: 'green',
disabled: 'default',
suspended: 'red',
}
export default function Accounts() {
const queryClient = useQueryClient()
const [form] = Form.useForm()
const [modalOpen, setModalOpen] = useState(false)
const [editingId, setEditingId] = useState<string | null>(null)
const { data, isLoading } = useQuery({
queryKey: ['accounts'],
queryFn: () => accountService.list(),
})
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: Partial<AccountPublic> }) =>
accountService.update(id, data),
onSuccess: () => {
message.success('更新成功')
queryClient.invalidateQueries({ queryKey: ['accounts'] })
setModalOpen(false)
},
onError: (err: Error) => message.error(err.message || '更新失败'),
})
const statusMutation = useMutation({
mutationFn: ({ id, status }: { id: string; status: AccountPublic['status'] }) =>
accountService.updateStatus(id, { status }),
onSuccess: () => {
message.success('状态更新成功')
queryClient.invalidateQueries({ queryKey: ['accounts'] })
},
onError: (err: Error) => message.error(err.message || '状态更新失败'),
})
const columns: ProColumns<AccountPublic>[] = [
{ title: '用户名', dataIndex: 'username', width: 120 },
{ title: '显示名', dataIndex: 'display_name', width: 120 },
{ title: '邮箱', dataIndex: 'email', width: 180 },
{
title: '角色',
dataIndex: 'role',
width: 120,
render: (_, record) => <Tag color={roleColors[record.role]}>{roleLabels[record.role] || record.role}</Tag>,
},
{
title: '状态',
dataIndex: 'status',
width: 100,
render: (_, record) => <Tag color={statusColors[record.status]}>{statusLabels[record.status] || record.status}</Tag>,
},
{
title: '2FA',
dataIndex: 'totp_enabled',
width: 80,
render: (_, record) => record.totp_enabled ? <Tag color="green"></Tag> : <Tag></Tag>,
},
{
title: '最后登录',
dataIndex: 'last_login_at',
width: 180,
render: (_, record) => record.last_login_at ? new Date(record.last_login_at).toLocaleString('zh-CN') : '-',
},
{
title: '操作',
width: 200,
render: (_, record) => (
<Space>
<Button size="small" onClick={() => { setEditingId(record.id); form.setFieldsValue(record); setModalOpen(true) }}>
</Button>
{record.status === 'active' ? (
<Popconfirm title="确定禁用此账号?" onConfirm={() => statusMutation.mutate({ id: record.id, status: 'disabled' })}>
<Button size="small" danger></Button>
</Popconfirm>
) : (
<Popconfirm title="确定启用此账号?" onConfirm={() => statusMutation.mutate({ id: record.id, status: 'active' })}>
<Button size="small" type="primary"></Button>
</Popconfirm>
)}
</Space>
),
},
]
const handleSave = async () => {
const values = await form.validateFields()
if (editingId) {
updateMutation.mutate({ id: editingId, data: values })
}
}
return (
<div>
<ProTable<AccountPublic>
columns={columns}
dataSource={data?.items ?? []}
loading={isLoading}
rowKey="id"
search={false}
toolBarRender={() => []}
pagination={{
total: data?.total ?? 0,
pageSize: data?.page_size ?? 20,
current: data?.page ?? 1,
showSizeChanger: false,
}}
/>
<Modal
title="编辑账号"
open={modalOpen}
onOk={handleSave}
onCancel={() => { setModalOpen(false); setEditingId(null); form.resetFields() }}
confirmLoading={updateMutation.isPending}
>
<Form form={form} layout="vertical">
<Form.Item name="display_name" label="显示名">
<Input />
</Form.Item>
<Form.Item name="email" label="邮箱">
<Input type="email" />
</Form.Item>
<Form.Item name="role" label="角色">
<Select options={[
{ value: 'super_admin', label: '超级管理员' },
{ value: 'admin', label: '管理员' },
{ value: 'user', label: '用户' },
]} />
</Form.Item>
</Form>
</Modal>
</div>
)
}
import { useState } from 'react'

View File

@@ -0,0 +1,190 @@
// ============================================================
// Agent 模板管理
// ============================================================
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Button, message, Tag, Modal, Form, Input, Select, InputNumber, Space, Popconfirm, Descriptions } from 'antd'
import { PlusOutlined } from '@ant-design/icons'
import type { ProColumns } from '@ant-design/pro-components'
import { ProTable } from '@ant-design/pro-components'
import { agentTemplateService } from '@/services/agent-templates'
import type { AgentTemplate } from '@/types'
const { TextArea } = Input
const sourceLabels: Record<string, string> = { builtin: '内置', custom: '自定义' }
const visibilityLabels: Record<string, string> = { public: '公开', team: '团队', private: '私有' }
const statusLabels: Record<string, string> = { active: '活跃', archived: '已归档' }
const statusColors: Record<string, string> = { active: 'green', archived: 'default' }
export default function AgentTemplates() {
const queryClient = useQueryClient()
const [form] = Form.useForm()
const [modalOpen, setModalOpen] = useState(false)
const [detailRecord, setDetailRecord] = useState<AgentTemplate | null>(null)
const { data, isLoading } = useQuery({
queryKey: ['agent-templates'],
queryFn: () => agentTemplateService.list(),
})
const createMutation = useMutation({
mutationFn: (data: Parameters<typeof agentTemplateService.create>[0]) =>
agentTemplateService.create(data),
onSuccess: () => {
message.success('创建成功')
queryClient.invalidateQueries({ queryKey: ['agent-templates'] })
setModalOpen(false)
form.resetFields()
},
onError: (err: Error) => message.error(err.message || '创建失败'),
})
const archiveMutation = useMutation({
mutationFn: (id: string) => agentTemplateService.archive(id),
onSuccess: () => {
message.success('已归档')
queryClient.invalidateQueries({ queryKey: ['agent-templates'] })
},
onError: (err: Error) => message.error(err.message || '归档失败'),
})
const columns: ProColumns<AgentTemplate>[] = [
{ title: '名称', dataIndex: 'name', width: 160 },
{ title: '分类', dataIndex: 'category', width: 100 },
{ title: '模型', dataIndex: 'model', width: 140, render: (_, r) => r.model || '-' },
{
title: '来源',
dataIndex: 'source',
width: 80,
render: (_, r) => <Tag>{sourceLabels[r.source] || r.source}</Tag>,
},
{
title: '可见性',
dataIndex: 'visibility',
width: 80,
render: (_, r) => <Tag color="blue">{visibilityLabels[r.visibility] || r.visibility}</Tag>,
},
{
title: '状态',
dataIndex: 'status',
width: 80,
render: (_, r) => <Tag color={statusColors[r.status]}>{statusLabels[r.status] || r.status}</Tag>,
},
{ title: '版本', dataIndex: 'current_version', width: 70 },
{
title: '操作',
width: 180,
render: (_, record) => (
<Space>
<Button size="small" onClick={() => setDetailRecord(record)}></Button>
{record.status === 'active' && (
<Popconfirm title="确定归档此模板?" onConfirm={() => archiveMutation.mutate(record.id)}>
<Button size="small" danger></Button>
</Popconfirm>
)}
</Space>
),
},
]
const handleCreate = async () => {
const values = await form.validateFields()
createMutation.mutate(values)
}
return (
<div>
<ProTable<AgentTemplate>
columns={columns}
dataSource={data?.items ?? []}
loading={isLoading}
rowKey="id"
search={false}
toolBarRender={() => [
<Button key="add" type="primary" icon={<PlusOutlined />} onClick={() => { form.resetFields(); setModalOpen(true) }}>
</Button>,
]}
pagination={{
total: data?.total ?? 0,
pageSize: data?.page_size ?? 20,
current: data?.page ?? 1,
showSizeChanger: false,
}}
/>
<Modal
title="新建 Agent 模板"
open={modalOpen}
onOk={handleCreate}
onCancel={() => { setModalOpen(false); form.resetFields() }}
confirmLoading={createMutation.isPending}
width={640}
>
<Form form={form} layout="vertical">
<Form.Item name="name" label="名称" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="description" label="描述">
<TextArea rows={2} />
</Form.Item>
<Form.Item name="category" label="分类">
<Input placeholder="如 assistant, tool" />
</Form.Item>
<Form.Item name="model" label="默认模型">
<Input placeholder="如 gpt-4o" />
</Form.Item>
<Form.Item name="system_prompt" label="系统提示词">
<TextArea rows={4} />
</Form.Item>
<Form.Item name="temperature" label="Temperature">
<InputNumber min={0} max={2} step={0.1} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="max_tokens" label="最大 Token">
<InputNumber min={1} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="visibility" label="可见性">
<Select options={[
{ value: 'public', label: '公开' },
{ value: 'team', label: '团队' },
{ value: 'private', label: '私有' },
]} />
</Form.Item>
</Form>
</Modal>
<Modal
title="模板详情"
open={!!detailRecord}
onCancel={() => setDetailRecord(null)}
footer={null}
width={640}
>
{detailRecord && (
<Descriptions column={2} bordered size="small">
<Descriptions.Item label="名称">{detailRecord.name}</Descriptions.Item>
<Descriptions.Item label="分类">{detailRecord.category}</Descriptions.Item>
<Descriptions.Item label="模型">{detailRecord.model || '-'}</Descriptions.Item>
<Descriptions.Item label="来源">{sourceLabels[detailRecord.source]}</Descriptions.Item>
<Descriptions.Item label="可见性">{visibilityLabels[detailRecord.visibility]}</Descriptions.Item>
<Descriptions.Item label="状态">{statusLabels[detailRecord.status]}</Descriptions.Item>
<Descriptions.Item label="描述" span={2}>{detailRecord.description || '-'}</Descriptions.Item>
<Descriptions.Item label="系统提示词" span={2}>
<div style={{ whiteSpace: 'pre-wrap', maxHeight: 200, overflow: 'auto' }}>
{detailRecord.system_prompt || '-'}
</div>
</Descriptions.Item>
<Descriptions.Item label="工具" span={2}>
{detailRecord.tools?.map((t) => <Tag key={t}>{t}</Tag>) || '-'}
</Descriptions.Item>
<Descriptions.Item label="能力" span={2}>
{detailRecord.capabilities?.map((c) => <Tag key={c} color="blue">{c}</Tag>) || '-'}
</Descriptions.Item>
</Descriptions>
)}
</Modal>
</div>
)
}

View File

@@ -0,0 +1,165 @@
// ============================================================
// API 密钥管理
// ============================================================
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Button, message, Tag, Modal, Form, Input, InputNumber, Select, Popconfirm, Space, Typography } from 'antd'
import { PlusOutlined, CopyOutlined } from '@ant-design/icons'
import type { ProColumns } from '@ant-design/pro-components'
import { ProTable } from '@ant-design/pro-components'
import { apiKeyService } from '@/services/api-keys'
import type { TokenInfo } from '@/types'
const { Text } = Typography
export default function ApiKeys() {
const queryClient = useQueryClient()
const [form] = Form.useForm()
const [modalOpen, setModalOpen] = useState(false)
const [newToken, setNewToken] = useState<string | null>(null)
const { data, isLoading } = useQuery({
queryKey: ['api-keys'],
queryFn: () => apiKeyService.list(),
})
const createMutation = useMutation({
mutationFn: (data: { name: string; expires_days?: number; permissions: string[] }) =>
apiKeyService.create(data),
onSuccess: (result: TokenInfo) => {
message.success('创建成功')
queryClient.invalidateQueries({ queryKey: ['api-keys'] })
if (result.token) {
setNewToken(result.token)
}
setModalOpen(false)
form.resetFields()
},
onError: (err: Error) => message.error(err.message || '创建失败'),
})
const revokeMutation = useMutation({
mutationFn: (id: string) => apiKeyService.revoke(id),
onSuccess: () => {
message.success('已撤销')
queryClient.invalidateQueries({ queryKey: ['api-keys'] })
},
onError: (err: Error) => message.error(err.message || '撤销失败'),
})
const columns: ProColumns<TokenInfo>[] = [
{ title: '名称', dataIndex: 'name', width: 160 },
{ title: '前缀', dataIndex: 'token_prefix', width: 120, render: (_, r) => <Text code>{r.token_prefix}...</Text> },
{
title: '权限',
dataIndex: 'permissions',
width: 200,
render: (_, r) => r.permissions?.map((p) => <Tag key={p}>{p}</Tag>),
},
{
title: '过期时间',
dataIndex: 'expires_at',
width: 180,
render: (_, r) => r.expires_at ? new Date(r.expires_at).toLocaleString('zh-CN') : '永不过期',
},
{
title: '最后使用',
dataIndex: 'last_used_at',
width: 180,
render: (_, r) => r.last_used_at ? new Date(r.last_used_at).toLocaleString('zh-CN') : '-',
},
{
title: '创建时间',
dataIndex: 'created_at',
width: 180,
render: (_, r) => new Date(r.created_at).toLocaleString('zh-CN'),
},
{
title: '操作',
width: 100,
render: (_, record) => (
<Popconfirm title="确定撤销此密钥?撤销后无法恢复。" onConfirm={() => revokeMutation.mutate(record.id)}>
<Button size="small" danger></Button>
</Popconfirm>
),
},
]
const handleCreate = async () => {
const values = await form.validateFields()
createMutation.mutate(values)
}
const copyToken = () => {
if (newToken) {
navigator.clipboard.writeText(newToken)
message.success('已复制到剪贴板')
}
}
return (
<div>
<ProTable<TokenInfo>
columns={columns}
dataSource={data?.items ?? []}
loading={isLoading}
rowKey="id"
search={false}
toolBarRender={() => [
<Button key="add" type="primary" icon={<PlusOutlined />} onClick={() => { form.resetFields(); setModalOpen(true) }}>
</Button>,
]}
pagination={{
total: data?.total ?? 0,
pageSize: data?.page_size ?? 20,
current: data?.page ?? 1,
showSizeChanger: false,
}}
/>
<Modal
title="创建 API 密钥"
open={modalOpen}
onOk={handleCreate}
onCancel={() => { setModalOpen(false); form.resetFields() }}
confirmLoading={createMutation.isPending}
>
<Form form={form} layout="vertical">
<Form.Item name="name" label="名称" rules={[{ required: true }]}>
<Input placeholder="给密钥起个名字" />
</Form.Item>
<Form.Item name="expires_days" label="有效期 (天)">
<InputNumber min={1} placeholder="留空则永不过期" style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="permissions" label="权限" rules={[{ required: true }]}>
<Select mode="multiple" placeholder="选择权限" options={[
{ value: 'relay:use', label: '中转使用' },
{ value: 'model:read', label: '模型读取' },
{ value: 'config:read', label: '配置读取' },
]} />
</Form.Item>
</Form>
</Modal>
<Modal
title="密钥创建成功"
open={!!newToken}
onOk={() => setNewToken(null)}
onCancel={() => setNewToken(null)}
>
<p></p>
<Input.TextArea
value={newToken || ''}
rows={3}
readOnly
addonAfter={<CopyOutlined onClick={copyToken} style={{ cursor: 'pointer' }} />}
/>
<Button type="primary" icon={<CopyOutlined />} onClick={copyToken} style={{ marginTop: 8 }}>
</Button>
</Modal>
</div>
)
}

View File

@@ -0,0 +1,110 @@
// ============================================================
// 系统配置
// ============================================================
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Card, Tabs, message, Tag, Input, Button, Space, Typography } from 'antd'
import type { ProColumns } from '@ant-design/pro-components'
import { ProTable } from '@ant-design/pro-components'
import { configService } from '@/services/config'
import type { ConfigItem } from '@/types'
const { Title } = Typography
export default function Config() {
const queryClient = useQueryClient()
const [category, setCategory] = useState<string>('general')
const [editingId, setEditingId] = useState<string | null>(null)
const [editValue, setEditValue] = useState('')
const { data, isLoading } = useQuery({
queryKey: ['config', category],
queryFn: () => configService.list({ category }),
})
const updateMutation = useMutation({
mutationFn: ({ id, value }: { id: string; value: string }) =>
configService.update(id, { value }),
onSuccess: () => {
message.success('配置已更新')
queryClient.invalidateQueries({ queryKey: ['config', category] })
setEditingId(null)
},
onError: (err: Error) => message.error(err.message || '更新失败'),
})
const columns: ProColumns<ConfigItem>[] = [
{ title: '配置路径', dataIndex: 'key_path', width: 200, render: (_, r) => <code>{r.key_path}</code> },
{
title: '当前值',
dataIndex: 'current_value',
width: 250,
render: (_, record) => {
if (editingId === record.id) {
return (
<Space>
<Input
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
style={{ width: 180 }}
onPressEnter={() => updateMutation.mutate({ id: record.id, value: editValue })}
/>
<Button size="small" type="primary" onClick={() => updateMutation.mutate({ id: record.id, value: editValue })}>
</Button>
<Button size="small" onClick={() => setEditingId(null)}></Button>
</Space>
)
}
return (
<span
onClick={() => { setEditingId(record.id); setEditValue(record.current_value || '') }}
style={{ cursor: 'pointer', color: '#1677ff' }}
>
{record.current_value || <Tag></Tag>}
</span>
)
},
},
{ title: '默认值', dataIndex: 'default_value', width: 200, render: (_, r) => r.default_value || '-' },
{ title: '类型', dataIndex: 'value_type', width: 80, render: (_, r) => <Tag>{r.value_type}</Tag> },
{ title: '描述', dataIndex: 'description', width: 200, ellipsis: true },
{
title: '需要重启',
dataIndex: 'requires_restart',
width: 90,
render: (_, r) => r.requires_restart ? <Tag color="orange"></Tag> : <Tag></Tag>,
},
]
return (
<div>
<Title level={4} style={{ marginBottom: 24 }}></Title>
<Tabs
activeKey={category}
onChange={(key) => { setCategory(key); setEditingId(null) }}
items={[
{ key: 'general', label: '通用' },
{ key: 'auth', label: '认证' },
{ key: 'relay', label: '中转' },
{ key: 'model', label: '模型' },
{ key: 'rate_limit', label: '限流' },
{ key: 'log', label: '日志' },
]}
/>
<ProTable<ConfigItem>
columns={columns}
dataSource={data ?? []}
loading={isLoading}
rowKey="id"
search={false}
toolBarRender={false}
pagination={false}
size="small"
/>
</div>
)
}

View File

@@ -0,0 +1,121 @@
// ============================================================
// 仪表盘页面
// ============================================================
import { useQuery } from '@tanstack/react-query'
import { Card, Col, Row, Statistic, Table, Tag, Typography, Spin, Alert } from 'antd'
import {
TeamOutlined,
CloudServerOutlined,
ApiOutlined,
ThunderboltOutlined,
ColumnWidthOutlined,
} from '@ant-design/icons'
import { statsService } from '@/services/stats'
import { logService } from '@/services/logs'
import type { OperationLog } from '@/types'
const { Title } = Typography
const actionLabels: Record<string, string> = {
login: '登录', logout: '登出',
create_account: '创建账号', update_account: '更新账号', delete_account: '删除账号',
create_provider: '创建服务商', update_provider: '更新服务商', delete_provider: '删除服务商',
create_model: '创建模型', update_model: '更新模型', delete_model: '删除模型',
create_token: '创建密钥', revoke_token: '撤销密钥',
update_config: '更新配置',
create_prompt: '创建提示词', update_prompt: '更新提示词', archive_prompt: '归档提示词',
desktop_audit: '桌面端审计',
}
const actionColors: Record<string, string> = {
login: 'green', logout: 'default',
create_account: 'blue', update_account: 'orange', delete_account: 'red',
create_provider: 'blue', update_provider: 'orange', delete_provider: 'red',
create_model: 'blue', update_model: 'orange', delete_model: 'red',
create_token: 'blue', revoke_token: 'red',
update_config: 'orange',
create_prompt: 'blue', update_prompt: 'orange', archive_prompt: 'red',
desktop_audit: 'default',
}
export default function Dashboard() {
const { data: stats, isLoading: statsLoading, error: statsError } = useQuery({
queryKey: ['dashboard-stats'],
queryFn: () => statsService.dashboard(),
})
const { data: logsData, isLoading: logsLoading } = useQuery({
queryKey: ['recent-logs'],
queryFn: () => logService.list({ page: 1, page_size: 10 }),
})
if (statsError) {
return <Alert type="error" message="加载仪表盘数据失败" description={(statsError as Error).message} showIcon />
}
const statCards = [
{ title: '总账号', value: stats?.total_accounts ?? 0, icon: <TeamOutlined />, color: '#1677ff' },
{ title: '活跃服务商', value: stats?.active_providers ?? 0, icon: <CloudServerOutlined />, color: '#52c41a' },
{ title: '活跃模型', value: stats?.active_models ?? 0, icon: <ApiOutlined />, color: '#722ed1' },
{ title: '今日请求', value: stats?.tasks_today ?? 0, icon: <ThunderboltOutlined />, color: '#fa8c16' },
{ title: '今日 Token', value: ((stats?.tokens_today_input ?? 0) + (stats?.tokens_today_output ?? 0)), icon: <ColumnWidthOutlined />, color: '#eb2f96' },
]
const logColumns = [
{
title: '操作类型',
dataIndex: 'action',
key: 'action',
width: 140,
render: (action: string) => (
<Tag color={actionColors[action] || 'default'}>
{actionLabels[action] || action}
</Tag>
),
},
{ title: '目标类型', dataIndex: 'target_type', key: 'target_type', width: 100, render: (v: string | null) => v || '-' },
{
title: '时间',
dataIndex: 'created_at',
key: 'created_at',
width: 180,
render: (v: string) => new Date(v).toLocaleString('zh-CN'),
},
]
return (
<div>
<Title level={4} style={{ marginBottom: 24 }}></Title>
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
{statsLoading ? (
<Col span={24}><Spin /></Col>
) : (
statCards.map((card) => (
<Col xs={24} sm={12} md={8} lg={4} key={card.title}>
<Card>
<Statistic
title={card.title}
value={card.value}
prefix={<span style={{ color: card.color }}>{card.icon}</span>}
/>
</Card>
</Col>
))
)}
</Row>
<Card title="最近操作日志" size="small">
<Table<OperationLog>
columns={logColumns}
dataSource={logsData?.items ?? []}
loading={logsLoading}
rowKey="id"
pagination={false}
size="small"
/>
</Card>
</div>
)
}

View File

@@ -0,0 +1,138 @@
// ============================================================
// 登录页面
// ============================================================
import { useState } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { LoginForm, ProFormText } from '@ant-design/pro-components'
import { LockOutlined, UserOutlined, SafetyOutlined } from '@ant-design/icons'
import { message, Divider, Typography } from 'antd'
import { authService } from '@/services/auth'
import { useAuthStore } from '@/stores/authStore'
import type { LoginRequest } from '@/types'
const { Title, Text } = Typography
export default function Login() {
const navigate = useNavigate()
const [searchParams] = useSearchParams()
const loginStore = useAuthStore((s) => s.login)
const [needTotp, setNeedTotp] = useState(false)
const [loading, setLoading] = useState(false)
const handleSubmit = async (values: Record<string, string>) => {
setLoading(true)
try {
const data: LoginRequest = {
username: values.username?.trim() || '',
password: values.password || '',
totp_code: values.totp_code?.trim() || undefined,
}
const res = await authService.login(data)
loginStore(res.token, res.refresh_token, res.account)
message.success('登录成功')
const from = searchParams.get('from') || '/'
navigate(from, { replace: true })
} catch (err: unknown) {
const error = err as { message?: string; status?: number }
const msg = error.message || ''
if (msg.includes('TOTP') || msg.includes('totp') || msg.includes('2FA') || msg.includes('验证码') || error.status === 403) {
setNeedTotp(true)
message.warning(msg || '请输入两步验证码')
} else {
message.error(msg || '登录失败,请检查用户名和密码')
}
} finally {
setLoading(false)
}
}
return (
<div style={{ minHeight: '100vh', display: 'flex' }}>
{/* 左侧品牌区 */}
<div
style={{
flex: '1 1 0',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
background: 'linear-gradient(135deg, #001529 0%, #003a70 50%, #001529 100%)',
position: 'relative',
overflow: 'hidden',
}}
>
<Title level={1} style={{ color: '#fff', marginBottom: 8, letterSpacing: 4 }}>
ZCLAW
</Title>
<Text style={{ color: 'rgba(255,255,255,0.65)', fontSize: 16 }}>AI Agent </Text>
<Divider style={{ borderColor: 'rgba(22,119,255,0.3)', width: 100, minWidth: 100 }} />
<Text style={{ color: 'rgba(255,255,255,0.45)', fontSize: 13, maxWidth: 320, textAlign: 'center' }}>
AI API
</Text>
</div>
{/* 右侧登录表单 */}
<div
style={{
flex: '0 0 480px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: 48,
}}
>
<div style={{ width: '100%', maxWidth: 360 }}>
<Title level={3} style={{ marginBottom: 4 }}></Title>
<Text type="secondary" style={{ display: 'block', marginBottom: 32 }}>
</Text>
<LoginForm
onFinish={handleSubmit}
submitter={{
searchConfig: { submitText: '登录' },
submitButtonProps: { loading, block: true },
}}
>
<ProFormText
name="username"
fieldProps={{
size: 'large',
prefix: <UserOutlined />,
autoComplete: 'username',
}}
placeholder="请输入用户名"
rules={[{ required: true, message: '请输入用户名' }]}
/>
<ProFormText.Password
name="password"
fieldProps={{
size: 'large',
prefix: <LockOutlined />,
autoComplete: 'current-password',
}}
placeholder="请输入密码"
rules={[{ required: true, message: '请输入密码' }]}
/>
{needTotp && (
<ProFormText
name="totp_code"
fieldProps={{
size: 'large',
prefix: <SafetyOutlined />,
maxLength: 6,
autoComplete: 'one-time-code',
}}
placeholder="请输入 6 位验证码"
rules={[{ required: true, message: '请输入验证码' }]}
/>
)}
</LoginForm>
</div>
</div>
</div>
)
}

112
admin-v2/src/pages/Logs.tsx Normal file
View File

@@ -0,0 +1,112 @@
// ============================================================
// 操作日志
// ============================================================
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Tag, Select, Typography } from 'antd'
import type { ProColumns } from '@ant-design/pro-components'
import { ProTable } from '@ant-design/pro-components'
import { logService } from '@/services/logs'
import type { OperationLog } from '@/types'
const { Title } = Typography
const actionLabels: Record<string, string> = {
login: '登录', logout: '登出',
create_account: '创建账号', update_account: '更新账号', delete_account: '删除账号',
create_provider: '创建服务商', update_provider: '更新服务商', delete_provider: '删除服务商',
create_model: '创建模型', update_model: '更新模型', delete_model: '删除模型',
create_token: '创建密钥', revoke_token: '撤销密钥',
update_config: '更新配置',
create_prompt: '创建提示词', update_prompt: '更新提示词', archive_prompt: '归档提示词',
desktop_audit: '桌面端审计',
}
const actionColors: Record<string, string> = {
login: 'green', logout: 'default',
create_account: 'blue', update_account: 'orange', delete_account: 'red',
create_provider: 'blue', update_provider: 'orange', delete_provider: 'red',
create_model: 'blue', update_model: 'orange', delete_model: 'red',
create_token: 'blue', revoke_token: 'red',
update_config: 'orange',
create_prompt: 'blue', update_prompt: 'orange', archive_prompt: 'red',
desktop_audit: 'default',
}
const actionOptions = Object.entries(actionLabels).map(([value, label]) => ({ value, label }))
export default function Logs() {
const [page, setPage] = useState(1)
const [actionFilter, setActionFilter] = useState<string | undefined>(undefined)
const { data, isLoading } = useQuery({
queryKey: ['logs', page, actionFilter],
queryFn: () => logService.list({ page, page_size: 20, action: actionFilter }),
})
const columns: ProColumns<OperationLog>[] = [
{
title: '操作类型',
dataIndex: 'action',
width: 140,
render: (_, r) => (
<Tag color={actionColors[r.action] || 'default'}>
{actionLabels[r.action] || r.action}
</Tag>
),
},
{ title: '目标类型', dataIndex: 'target_type', width: 100, render: (_, r) => r.target_type || '-' },
{ title: '目标 ID', dataIndex: 'target_id', width: 120, render: (_, r) => r.target_id ? <code>{r.target_id.substring(0, 8)}...</code> : '-' },
{
title: '详情',
dataIndex: 'details',
width: 250,
ellipsis: true,
render: (_, r) => {
if (!r.details) return '-'
if (typeof r.details === 'string') return r.details
return JSON.stringify(r.details)
},
},
{ title: 'IP 地址', dataIndex: 'ip_address', width: 130, render: (_, r) => <code>{r.ip_address || '-'}</code> },
{
title: '时间',
dataIndex: 'created_at',
width: 180,
render: (_, r) => new Date(r.created_at).toLocaleString('zh-CN'),
},
]
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
<Title level={4} style={{ margin: 0 }}></Title>
<Select
value={actionFilter}
onChange={(v) => { setActionFilter(v === 'all' ? undefined : v); setPage(1) }}
placeholder="操作类型筛选"
style={{ width: 160 }}
allowClear
options={[{ value: 'all', label: '全部操作' }, ...actionOptions]}
/>
</div>
<ProTable<OperationLog>
columns={columns}
dataSource={data?.items ?? []}
loading={isLoading}
rowKey="id"
search={false}
toolBarRender={false}
pagination={{
total: data?.total ?? 0,
pageSize: 20,
current: page,
onChange: setPage,
showSizeChanger: false,
}}
/>
</div>
)
}

View File

@@ -0,0 +1,186 @@
// ============================================================
// 模型管理
// ============================================================
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Button, message, Tag, Modal, Form, Input, InputNumber, Switch, Select, Space, Popconfirm } from 'antd'
import { PlusOutlined } from '@ant-design/icons'
import type { ProColumns } from '@ant-design/pro-components'
import { ProTable } from '@ant-design/pro-components'
import { modelService } from '@/services/models'
import { providerService } from '@/services/providers'
import type { Model } from '@/types'
export default function Models() {
const queryClient = useQueryClient()
const [form] = Form.useForm()
const [modalOpen, setModalOpen] = useState(false)
const [editingId, setEditingId] = useState<string | null>(null)
const { data, isLoading } = useQuery({
queryKey: ['models'],
queryFn: () => modelService.list(),
})
const { data: providersData } = useQuery({
queryKey: ['providers-for-select'],
queryFn: () => providerService.list(),
})
const createMutation = useMutation({
mutationFn: (data: Partial<Omit<Model, 'id'>>) => modelService.create(data),
onSuccess: () => {
message.success('创建成功')
queryClient.invalidateQueries({ queryKey: ['models'] })
setModalOpen(false)
form.resetFields()
},
onError: (err: Error) => message.error(err.message || '创建失败'),
})
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: Partial<Omit<Model, 'id'>> }) =>
modelService.update(id, data),
onSuccess: () => {
message.success('更新成功')
queryClient.invalidateQueries({ queryKey: ['models'] })
setModalOpen(false)
},
onError: (err: Error) => message.error(err.message || '更新失败'),
})
const deleteMutation = useMutation({
mutationFn: (id: string) => modelService.delete(id),
onSuccess: () => {
message.success('删除成功')
queryClient.invalidateQueries({ queryKey: ['models'] })
},
onError: (err: Error) => message.error(err.message || '删除失败'),
})
const columns: ProColumns<Model>[] = [
{ title: '模型 ID', dataIndex: 'model_id', width: 180, render: (_, r) => <code>{r.model_id}</code> },
{ title: '别名', dataIndex: 'alias', width: 140 },
{
title: '服务商',
dataIndex: 'provider_id',
width: 140,
render: (_, r) => {
const provider = providersData?.items?.find((p) => p.id === r.provider_id)
return provider?.display_name || r.provider_id.substring(0, 8)
},
},
{ title: '上下文窗口', dataIndex: 'context_window', width: 110, render: (_, r) => r.context_window?.toLocaleString() },
{ title: '最大输出', dataIndex: 'max_output_tokens', width: 100, render: (_, r) => r.max_output_tokens?.toLocaleString() },
{
title: '流式',
dataIndex: 'supports_streaming',
width: 70,
render: (_, r) => r.supports_streaming ? <Tag color="green"></Tag> : <Tag></Tag>,
},
{
title: '视觉',
dataIndex: 'supports_vision',
width: 70,
render: (_, r) => r.supports_vision ? <Tag color="blue"></Tag> : <Tag></Tag>,
},
{
title: '状态',
dataIndex: 'enabled',
width: 70,
render: (_, r) => r.enabled ? <Tag color="green"></Tag> : <Tag></Tag>,
},
{
title: '操作',
width: 160,
render: (_, record) => (
<Space>
<Button size="small" onClick={() => { setEditingId(record.id); form.setFieldsValue(record); setModalOpen(true) }}>
</Button>
<Popconfirm title="确定删除此模型?" onConfirm={() => deleteMutation.mutate(record.id)}>
<Button size="small" danger></Button>
</Popconfirm>
</Space>
),
},
]
const handleSave = async () => {
const values = await form.validateFields()
if (editingId) {
updateMutation.mutate({ id: editingId, data: values })
} else {
createMutation.mutate(values)
}
}
return (
<div>
<ProTable<Model>
columns={columns}
dataSource={data?.items ?? []}
loading={isLoading}
rowKey="id"
search={false}
toolBarRender={() => [
<Button key="add" type="primary" icon={<PlusOutlined />} onClick={() => { setEditingId(null); form.resetFields(); setModalOpen(true) }}>
</Button>,
]}
pagination={{
total: data?.total ?? 0,
pageSize: data?.page_size ?? 20,
current: data?.page ?? 1,
showSizeChanger: false,
}}
/>
<Modal
title={editingId ? '编辑模型' : '新建模型'}
open={modalOpen}
onOk={handleSave}
onCancel={() => { setModalOpen(false); setEditingId(null); form.resetFields() }}
confirmLoading={createMutation.isPending || updateMutation.isPending}
width={600}
>
<Form form={form} layout="vertical">
<Form.Item name="provider_id" label="服务商" rules={[{ required: true }]}>
<Select
options={(providersData?.items ?? []).map((p) => ({ value: p.id, label: p.display_name }))}
placeholder="选择服务商"
/>
</Form.Item>
<Form.Item name="model_id" label="模型 ID" rules={[{ required: true }]}>
<Input placeholder="如 gpt-4o" />
</Form.Item>
<Form.Item name="alias" label="别名">
<Input />
</Form.Item>
<Form.Item name="context_window" label="上下文窗口">
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="max_output_tokens" label="最大输出 Token">
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="enabled" label="启用" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item name="supports_streaming" label="支持流式" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item name="supports_vision" label="支持视觉" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item name="pricing_input" label="输入价格 (每百万 Token)">
<InputNumber min={0} step={0.01} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="pricing_output" label="输出价格 (每百万 Token)">
<InputNumber min={0} step={0.01} style={{ width: '100%' }} />
</Form.Item>
</Form>
</Modal>
</div>
)
}

View File

@@ -0,0 +1,228 @@
// ============================================================
// 提示词管理
// ============================================================
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Button, message, Tag, Modal, Form, Input, Select, Space, Popconfirm, Descriptions, Tabs, Typography } from 'antd'
import { PlusOutlined } from '@ant-design/icons'
import type { ProColumns } from '@ant-design/pro-components'
import { ProTable } from '@ant-design/pro-components'
import { promptService } from '@/services/prompts'
import type { PromptTemplate, PromptVersion } from '@/types'
const { TextArea } = Input
const { Text } = Typography
const sourceLabels: Record<string, string> = { builtin: '内置', custom: '自定义' }
const statusLabels: Record<string, string> = { active: '活跃', deprecated: '已废弃', archived: '已归档' }
const statusColors: Record<string, string> = { active: 'green', deprecated: 'orange', archived: 'default' }
export default function Prompts() {
const queryClient = useQueryClient()
const [form] = Form.useForm()
const [createOpen, setCreateOpen] = useState(false)
const [detailName, setDetailName] = useState<string | null>(null)
const { data, isLoading } = useQuery({
queryKey: ['prompts'],
queryFn: () => promptService.list(),
})
const { data: detailData } = useQuery({
queryKey: ['prompt-detail', detailName],
queryFn: () => promptService.get(detailName!),
enabled: !!detailName,
})
const { data: versionsData } = useQuery({
queryKey: ['prompt-versions', detailName],
queryFn: () => promptService.listVersions(detailName!),
enabled: !!detailName,
})
const createMutation = useMutation({
mutationFn: (data: Parameters<typeof promptService.create>[0]) => promptService.create(data),
onSuccess: () => {
message.success('创建成功')
queryClient.invalidateQueries({ queryKey: ['prompts'] })
setCreateOpen(false)
form.resetFields()
},
onError: (err: Error) => message.error(err.message || '创建失败'),
})
const archiveMutation = useMutation({
mutationFn: (name: string) => promptService.archive(name),
onSuccess: () => {
message.success('已归档')
queryClient.invalidateQueries({ queryKey: ['prompts'] })
},
onError: (err: Error) => message.error(err.message || '归档失败'),
})
const rollbackMutation = useMutation({
mutationFn: ({ name, version }: { name: string; version: number }) =>
promptService.rollback(name, version),
onSuccess: () => {
message.success('回滚成功')
queryClient.invalidateQueries({ queryKey: ['prompts'] })
queryClient.invalidateQueries({ queryKey: ['prompt-detail', detailName] })
queryClient.invalidateQueries({ queryKey: ['prompt-versions', detailName] })
},
onError: (err: Error) => message.error(err.message || '回滚失败'),
})
const columns: ProColumns<PromptTemplate>[] = [
{ title: '名称', dataIndex: 'name', width: 200, render: (_, r) => <Text code>{r.name}</Text> },
{ title: '分类', dataIndex: 'category', width: 100 },
{ title: '描述', dataIndex: 'description', width: 200, ellipsis: true },
{
title: '来源',
dataIndex: 'source',
width: 80,
render: (_, r) => <Tag>{sourceLabels[r.source]}</Tag>,
},
{ title: '版本', dataIndex: 'current_version', width: 70 },
{
title: '状态',
dataIndex: 'status',
width: 90,
render: (_, r) => <Tag color={statusColors[r.status]}>{statusLabels[r.status]}</Tag>,
},
{
title: '操作',
width: 180,
render: (_, record) => (
<Space>
<Button size="small" onClick={() => setDetailName(record.name)}></Button>
{record.status === 'active' && (
<Popconfirm title="确定归档此提示词?" onConfirm={() => archiveMutation.mutate(record.name)}>
<Button size="small" danger></Button>
</Popconfirm>
)}
</Space>
),
},
]
const handleCreate = async () => {
const values = await form.validateFields()
createMutation.mutate(values)
}
const versionColumns: ProColumns<PromptVersion>[] = [
{ title: '版本', dataIndex: 'version', width: 60 },
{ title: '更新说明', dataIndex: 'changelog', width: 200, ellipsis: true },
{ title: '最低版本', dataIndex: 'min_app_version', width: 100, render: (_, r) => r.min_app_version || '-' },
{
title: '创建时间',
dataIndex: 'created_at',
width: 180,
render: (_, r) => new Date(r.created_at).toLocaleString('zh-CN'),
},
{
title: '操作',
width: 80,
render: (_, record) => (
<Popconfirm
title={`确定回滚到版本 ${record.version}`}
onConfirm={() => detailName && rollbackMutation.mutate({ name: detailName, version: record.version })}
>
<Button size="small"></Button>
</Popconfirm>
),
},
]
return (
<div>
<ProTable<PromptTemplate>
columns={columns}
dataSource={data?.items ?? []}
loading={isLoading}
rowKey="id"
search={false}
toolBarRender={() => [
<Button key="add" type="primary" icon={<PlusOutlined />} onClick={() => { form.resetFields(); setCreateOpen(true) }}>
</Button>,
]}
pagination={{
total: data?.total ?? 0,
pageSize: data?.page_size ?? 20,
current: data?.page ?? 1,
showSizeChanger: false,
}}
/>
<Modal
title="新建提示词"
open={createOpen}
onOk={handleCreate}
onCancel={() => { setCreateOpen(false); form.resetFields() }}
confirmLoading={createMutation.isPending}
width={640}
>
<Form form={form} layout="vertical">
<Form.Item name="name" label="名称" rules={[{ required: true }]}>
<Input placeholder="唯一标识" />
</Form.Item>
<Form.Item name="category" label="分类" rules={[{ required: true }]}>
<Input placeholder="如 system, tool" />
</Form.Item>
<Form.Item name="description" label="描述">
<TextArea rows={2} />
</Form.Item>
<Form.Item name="system_prompt" label="系统提示词" rules={[{ required: true }]}>
<TextArea rows={6} />
</Form.Item>
<Form.Item name="user_prompt_template" label="用户提示词模板">
<TextArea rows={4} />
</Form.Item>
</Form>
</Modal>
<Modal
title={`提示词详情: ${detailName || ''}`}
open={!!detailName}
onCancel={() => setDetailName(null)}
footer={null}
width={800}
>
<Tabs items={[
{
key: 'info',
label: '基本信息',
children: detailData ? (
<Descriptions column={2} bordered size="small">
<Descriptions.Item label="名称">{detailData.name}</Descriptions.Item>
<Descriptions.Item label="分类">{detailData.category}</Descriptions.Item>
<Descriptions.Item label="来源">{sourceLabels[detailData.source]}</Descriptions.Item>
<Descriptions.Item label="状态">{statusLabels[detailData.status]}</Descriptions.Item>
<Descriptions.Item label="当前版本">{detailData.current_version}</Descriptions.Item>
<Descriptions.Item label="描述" span={2}>{detailData.description || '-'}</Descriptions.Item>
</Descriptions>
) : null,
},
{
key: 'versions',
label: '版本历史',
children: (
<ProTable<PromptVersion>
columns={versionColumns}
dataSource={versionsData ?? []}
rowKey="id"
search={false}
toolBarRender={false}
pagination={false}
size="small"
loading={!versionsData}
/>
),
},
]} />
</Modal>
</div>
)
}

View File

@@ -0,0 +1,188 @@
// ============================================================
// 服务商管理
// ============================================================
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Button, message, Tag, Modal, Form, Input, InputNumber, Switch, Space, Popconfirm, Typography } from 'antd'
import { PlusOutlined } from '@ant-design/icons'
import type { ProColumns } from '@ant-design/pro-components'
import { ProTable } from '@ant-design/pro-components'
import { providerService } from '@/services/providers'
import type { Provider, ProviderKey } from '@/types'
const { Text } = Typography
export default function Providers() {
const queryClient = useQueryClient()
const [form] = Form.useForm()
const [modalOpen, setModalOpen] = useState(false)
const [editingId, setEditingId] = useState<string | null>(null)
const [keyModalProviderId, setKeyModalProviderId] = useState<string | null>(null)
const { data, isLoading } = useQuery({
queryKey: ['providers'],
queryFn: () => providerService.list(),
})
const { data: keysData, isLoading: keysLoading } = useQuery({
queryKey: ['provider-keys', keyModalProviderId],
queryFn: () => providerService.listKeys(keyModalProviderId!),
enabled: !!keyModalProviderId,
})
const createMutation = useMutation({
mutationFn: (data: Partial<Omit<Provider, 'id' | 'created_at' | 'updated_at'>>) =>
providerService.create(data),
onSuccess: () => {
message.success('创建成功')
queryClient.invalidateQueries({ queryKey: ['providers'] })
setModalOpen(false)
form.resetFields()
},
onError: (err: Error) => message.error(err.message || '创建失败'),
})
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: Partial<Omit<Provider, 'id' | 'created_at' | 'updated_at'>> }) =>
providerService.update(id, data),
onSuccess: () => {
message.success('更新成功')
queryClient.invalidateQueries({ queryKey: ['providers'] })
setModalOpen(false)
},
onError: (err: Error) => message.error(err.message || '更新失败'),
})
const deleteMutation = useMutation({
mutationFn: (id: string) => providerService.delete(id),
onSuccess: () => {
message.success('删除成功')
queryClient.invalidateQueries({ queryKey: ['providers'] })
},
onError: (err: Error) => message.error(err.message || '删除失败'),
})
const columns: ProColumns<Provider>[] = [
{ title: '名称', dataIndex: 'display_name', width: 140 },
{ title: '标识', dataIndex: 'name', width: 120, render: (_, r) => <Text code>{r.name}</Text> },
{ title: '协议', dataIndex: 'api_protocol', width: 100 },
{ title: 'RPM 限制', dataIndex: 'rate_limit_rpm', width: 100, render: (_, r) => r.rate_limit_rpm ?? '-' },
{
title: '状态',
dataIndex: 'enabled',
width: 80,
render: (_, r) => r.enabled ? <Tag color="green"></Tag> : <Tag></Tag>,
},
{
title: '操作',
width: 260,
render: (_, record) => (
<Space>
<Button size="small" onClick={() => { setEditingId(record.id); form.setFieldsValue(record); setModalOpen(true) }}>
</Button>
<Button size="small" onClick={() => setKeyModalProviderId(record.id)}>
Key Pool
</Button>
<Popconfirm title="确定删除此服务商?" onConfirm={() => deleteMutation.mutate(record.id)}>
<Button size="small" danger></Button>
</Popconfirm>
</Space>
),
},
]
const keyColumns: ProColumns<ProviderKey>[] = [
{ title: '标签', dataIndex: 'key_label', width: 120 },
{ title: '优先级', dataIndex: 'priority', width: 80 },
{ title: '请求数', dataIndex: 'total_requests', width: 80 },
{ title: 'Token 数', dataIndex: 'total_tokens', width: 100 },
{
title: '状态',
dataIndex: 'is_active',
width: 80,
render: (_, r) => r.is_active ? <Tag color="green"></Tag> : <Tag></Tag>,
},
]
const handleSave = async () => {
const values = await form.validateFields()
if (editingId) {
updateMutation.mutate({ id: editingId, data: values })
} else {
createMutation.mutate(values)
}
}
return (
<div>
<ProTable<Provider>
columns={columns}
dataSource={data?.items ?? []}
loading={isLoading}
rowKey="id"
search={false}
toolBarRender={() => [
<Button key="add" type="primary" icon={<PlusOutlined />} onClick={() => { setEditingId(null); form.resetFields(); setModalOpen(true) }}>
</Button>,
]}
pagination={{
total: data?.total ?? 0,
pageSize: data?.page_size ?? 20,
current: data?.page ?? 1,
showSizeChanger: false,
}}
/>
<Modal
title={editingId ? '编辑服务商' : '新建服务商'}
open={modalOpen}
onOk={handleSave}
onCancel={() => { setModalOpen(false); setEditingId(null); form.resetFields() }}
confirmLoading={createMutation.isPending || updateMutation.isPending}
>
<Form form={form} layout="vertical">
<Form.Item name="name" label="标识" rules={[{ required: true }]}>
<Input disabled={!!editingId} />
</Form.Item>
<Form.Item name="display_name" label="显示名称" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="base_url" label="Base URL" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="api_protocol" label="API 协议">
<Input placeholder="openai" />
</Form.Item>
<Form.Item name="enabled" label="启用" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item name="rate_limit_rpm" label="RPM 限制">
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
</Form>
</Modal>
<Modal
title="Key Pool"
open={!!keyModalProviderId}
onCancel={() => setKeyModalProviderId(null)}
footer={null}
width={700}
>
<ProTable<ProviderKey>
columns={keyColumns}
dataSource={keysData ?? []}
loading={keysLoading}
rowKey="id"
search={false}
toolBarRender={false}
pagination={false}
size="small"
/>
</Modal>
</div>
)
}

View File

@@ -0,0 +1,109 @@
// ============================================================
// 中转任务
// ============================================================
import { useQuery } from '@tanstack/react-query'
import { Tag, Select, Typography } from 'antd'
import type { ProColumns } from '@ant-design/pro-components'
import { ProTable } from '@ant-design/pro-components'
import { relayService } from '@/services/relay'
import { useState } from 'react'
import type { RelayTask } from '@/types'
const { Title } = Typography
const statusLabels: Record<string, string> = {
queued: '排队中',
running: '运行中',
completed: '已完成',
failed: '失败',
cancelled: '已取消',
}
const statusColors: Record<string, string> = {
queued: 'default',
running: 'processing',
completed: 'green',
failed: 'red',
cancelled: 'default',
}
export default function Relay() {
const [statusFilter, setStatusFilter] = useState<string | undefined>(undefined)
const [page, setPage] = useState(1)
const { data, isLoading } = useQuery({
queryKey: ['relay-tasks', page, statusFilter],
queryFn: () => relayService.list({ page, page_size: 20, status: statusFilter }),
})
const columns: ProColumns<RelayTask>[] = [
{ title: 'ID', dataIndex: 'id', width: 120, render: (_, r) => <code>{r.id.substring(0, 8)}...</code> },
{
title: '状态',
dataIndex: 'status',
width: 100,
render: (_, r) => <Tag color={statusColors[r.status] || 'default'}>{statusLabels[r.status] || r.status}</Tag>,
},
{ title: '模型', dataIndex: 'model_id', width: 160 },
{ title: '优先级', dataIndex: 'priority', width: 70 },
{ title: '尝试次数', dataIndex: 'attempt_count', width: 80 },
{
title: 'Token',
width: 140,
render: (_, r) => `${r.input_tokens.toLocaleString()} / ${r.output_tokens.toLocaleString()}`,
},
{ title: '错误信息', dataIndex: 'error_message', width: 200, ellipsis: true },
{
title: '排队时间',
dataIndex: 'queued_at',
width: 180,
render: (_, r) => new Date(r.queued_at).toLocaleString('zh-CN'),
},
{
title: '完成时间',
dataIndex: 'completed_at',
width: 180,
render: (_, r) => r.completed_at ? new Date(r.completed_at).toLocaleString('zh-CN') : '-',
},
]
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
<Title level={4} style={{ margin: 0 }}></Title>
<Select
value={statusFilter}
onChange={(v) => { setStatusFilter(v === 'all' ? undefined : v); setPage(1) }}
placeholder="状态筛选"
style={{ width: 140 }}
allowClear
options={[
{ value: 'all', label: '全部' },
{ value: 'queued', label: '排队中' },
{ value: 'running', label: '运行中' },
{ value: 'completed', label: '已完成' },
{ value: 'failed', label: '失败' },
{ value: 'cancelled', label: '已取消' },
]}
/>
</div>
<ProTable<RelayTask>
columns={columns}
dataSource={data?.items ?? []}
loading={isLoading}
rowKey="id"
search={false}
toolBarRender={false}
pagination={{
total: data?.total ?? 0,
pageSize: 20,
current: page,
onChange: setPage,
showSizeChanger: false,
}}
/>
</div>
)
}

View File

@@ -0,0 +1,120 @@
// ============================================================
// 用量统计
// ============================================================
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Card, Row, Col, Select, Spin, Alert, Statistic, Typography } from 'antd'
import { ColumnWidthOutlined, ThunderboltOutlined } from '@ant-design/icons'
import type { ProColumns } from '@ant-design/pro-components'
import { ProTable } from '@ant-design/pro-components'
import { usageService } from '@/services/usage'
import { telemetryService } from '@/services/telemetry'
import type { DailyUsageStat, ModelUsageStat } from '@/types'
const { Title } = Typography
export default function Usage() {
const [days, setDays] = useState(30)
const { data: dailyData, isLoading: dailyLoading, error: dailyError } = useQuery({
queryKey: ['usage-daily', days],
queryFn: () => telemetryService.dailyStats({ days }),
})
const { data: modelData, isLoading: modelLoading } = useQuery({
queryKey: ['usage-model', days],
queryFn: () => telemetryService.modelStats({}),
})
if (dailyError) {
return <Alert type="error" message="加载用量数据失败" description={(dailyError as Error).message} showIcon />
}
const totalRequests = dailyData?.reduce((s, d) => s + d.request_count, 0) ?? 0
const totalTokens = dailyData?.reduce((s, d) => s + d.input_tokens + d.output_tokens, 0) ?? 0
const dailyColumns: ProColumns<DailyUsageStat>[] = [
{ title: '日期', dataIndex: 'day', width: 120 },
{ title: '请求数', dataIndex: 'request_count', width: 100, render: (_, r) => r.request_count.toLocaleString() },
{ title: '输入 Token', dataIndex: 'input_tokens', width: 120, render: (_, r) => r.input_tokens.toLocaleString() },
{ title: '输出 Token', dataIndex: 'output_tokens', width: 120, render: (_, r) => r.output_tokens.toLocaleString() },
{ title: '设备数', dataIndex: 'unique_devices', width: 80 },
]
const modelColumns: ProColumns<ModelUsageStat>[] = [
{ title: '模型', dataIndex: 'model_id', width: 200 },
{ title: '请求数', dataIndex: 'request_count', width: 100, render: (_, r) => r.request_count.toLocaleString() },
{ title: '输入 Token', dataIndex: 'input_tokens', width: 120, render: (_, r) => r.input_tokens.toLocaleString() },
{ title: '输出 Token', dataIndex: 'output_tokens', width: 120, render: (_, r) => r.output_tokens.toLocaleString() },
{
title: '平均延迟',
dataIndex: 'avg_latency_ms',
width: 100,
render: (_, r) => r.avg_latency_ms ? `${Math.round(r.avg_latency_ms)}ms` : '-',
},
{
title: '成功率',
dataIndex: 'success_rate',
width: 100,
render: (_, r) => `${(r.success_rate * 100).toFixed(1)}%`,
},
]
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
<Title level={4} style={{ margin: 0 }}></Title>
<Select
value={days}
onChange={setDays}
options={[
{ value: 7, label: '最近 7 天' },
{ value: 30, label: '最近 30 天' },
{ value: 90, label: '最近 90 天' },
]}
style={{ width: 140 }}
/>
</div>
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
<Col span={12}>
<Card>
<Statistic title="总请求数" value={totalRequests} prefix={<ThunderboltOutlined />} />
</Card>
</Col>
<Col span={12}>
<Card>
<Statistic title="总 Token 数" value={totalTokens} prefix={<ColumnWidthOutlined />} />
</Card>
</Col>
</Row>
<Card title="每日统计" style={{ marginBottom: 24 }} size="small">
<ProTable<DailyUsageStat>
columns={dailyColumns}
dataSource={dailyData ?? []}
loading={dailyLoading}
rowKey="day"
search={false}
toolBarRender={false}
pagination={false}
size="small"
/>
</Card>
<Card title="按模型统计" size="small">
<ProTable<ModelUsageStat>
columns={modelColumns}
dataSource={modelData ?? []}
loading={modelLoading}
rowKey="model_id"
search={false}
toolBarRender={false}
pagination={false}
size="small"
/>
</Card>
</div>
)
}

View File

@@ -0,0 +1,17 @@
// ============================================================
// 路由守卫 — 未登录重定向到 /login
// ============================================================
import { Navigate, useLocation } from 'react-router-dom'
import { useAuthStore } from '@/stores/authStore'
export function AuthGuard({ children }: { children: React.ReactNode }) {
const token = useAuthStore((s) => s.token)
const location = useLocation()
if (!token) {
return <Navigate to="/login" state={{ from: location }} replace />
}
return <>{children}</>
}

View File

@@ -0,0 +1,35 @@
// ============================================================
// 路由定义
// ============================================================
import { createBrowserRouter } from 'react-router-dom'
import { AuthGuard } from './AuthGuard'
import AdminLayout from '@/layouts/AdminLayout'
export const router = createBrowserRouter([
{
path: '/login',
lazy: () => import('@/pages/Login').then((m) => ({ Component: m.default })),
},
{
path: '/',
element: (
<AuthGuard>
<AdminLayout />
</AuthGuard>
),
children: [
{ index: true, lazy: () => import('@/pages/Dashboard').then((m) => ({ Component: m.default })) },
{ path: 'accounts', lazy: () => import('@/pages/Accounts').then((m) => ({ Component: m.default })) },
{ path: 'providers', lazy: () => import('@/pages/Providers').then((m) => ({ Component: m.default })) },
{ path: 'models', lazy: () => import('@/pages/Models').then((m) => ({ Component: m.default })) },
{ path: 'agent-templates', lazy: () => import('@/pages/AgentTemplates').then((m) => ({ Component: m.default })) },
{ path: 'api-keys', lazy: () => import('@/pages/ApiKeys').then((m) => ({ Component: m.default })) },
{ path: 'usage', lazy: () => import('@/pages/Usage').then((m) => ({ Component: m.default })) },
{ path: 'relay', lazy: () => import('@/pages/Relay').then((m) => ({ Component: m.default })) },
{ path: 'config', lazy: () => import('@/pages/Config').then((m) => ({ Component: m.default })) },
{ path: 'prompts', lazy: () => import('@/pages/Prompts').then((m) => ({ Component: m.default })) },
{ path: 'logs', lazy: () => import('@/pages/Logs').then((m) => ({ Component: m.default })) },
],
},
])

View File

@@ -0,0 +1,16 @@
import request from './request'
import type { AccountPublic, PaginatedResponse } from '@/types'
export const accountService = {
list: (params?: Record<string, unknown>) =>
request.get<PaginatedResponse<AccountPublic>>('/accounts', { params }).then((r) => r.data),
get: (id: string) =>
request.get<AccountPublic>(`/accounts/${id}`).then((r) => r.data),
update: (id: string, data: Partial<Pick<AccountPublic, 'display_name' | 'email' | 'role'>>) =>
request.patch<AccountPublic>(`/accounts/${id}`, data).then((r) => r.data),
updateStatus: (id: string, data: { status: AccountPublic['status'] }) =>
request.patch(`/accounts/${id}/status`, data).then((r) => r.data),
}

View File

@@ -0,0 +1,28 @@
import request from './request'
import type { AgentTemplate, PaginatedResponse } from '@/types'
export const agentTemplateService = {
list: (params?: Record<string, unknown>) =>
request.get<PaginatedResponse<AgentTemplate>>('/agent-templates', { params }).then((r) => r.data),
get: (id: string) =>
request.get<AgentTemplate>(`/agent-templates/${id}`).then((r) => r.data),
create: (data: {
name: string; description?: string; category?: string; source?: string
model?: string; system_prompt?: string; tools?: string[]
capabilities?: string[]; temperature?: number; max_tokens?: number
visibility?: string
}) =>
request.post<AgentTemplate>('/agent-templates', data).then((r) => r.data),
update: (id: string, data: {
description?: string; model?: string; system_prompt?: string
tools?: string[]; capabilities?: string[]; temperature?: number
max_tokens?: number; visibility?: string; status?: string
}) =>
request.post<AgentTemplate>(`/agent-templates/${id}`, data).then((r) => r.data),
archive: (id: string) =>
request.delete<AgentTemplate>(`/agent-templates/${id}`).then((r) => r.data),
}

View File

@@ -0,0 +1,13 @@
import request from './request'
import type { TokenInfo, CreateTokenRequest, PaginatedResponse } from '@/types'
export const apiKeyService = {
list: (params?: Record<string, unknown>) =>
request.get<PaginatedResponse<TokenInfo>>('/keys', { params }).then((r) => r.data),
create: (data: CreateTokenRequest) =>
request.post<TokenInfo>('/keys', data).then((r) => r.data),
revoke: (id: string) =>
request.delete(`/keys/${id}`).then((r) => r.data),
}

View File

@@ -0,0 +1,10 @@
import request from './request'
import type { AccountPublic, LoginRequest, LoginResponse } from '@/types'
export const authService = {
login: (data: LoginRequest) =>
request.post<LoginResponse>('/auth/login', data).then((r) => r.data),
me: () =>
request.get<AccountPublic>('/auth/me').then((r) => r.data),
}

View File

@@ -0,0 +1,11 @@
import request from './request'
import type { ConfigItem, PaginatedResponse } from '@/types'
export const configService = {
list: (params?: Record<string, unknown>) =>
request.get<PaginatedResponse<ConfigItem>>('/config/items', { params })
.then((r) => r.data.items),
update: (id: string, data: { value: string | number | boolean }) =>
request.patch<ConfigItem>(`/config/items/${id}`, data).then((r) => r.data),
}

View File

@@ -0,0 +1,7 @@
import request from './request'
import type { OperationLog, PaginatedResponse } from '@/types'
export const logService = {
list: (params?: Record<string, unknown>) =>
request.get<PaginatedResponse<OperationLog>>('/logs/operations', { params }).then((r) => r.data),
}

View File

@@ -0,0 +1,16 @@
import request from './request'
import type { Model, PaginatedResponse } from '@/types'
export const modelService = {
list: (params?: Record<string, unknown>) =>
request.get<PaginatedResponse<Model>>('/models', { params }).then((r) => r.data),
create: (data: Partial<Omit<Model, 'id'>>) =>
request.post<Model>('/models', data).then((r) => r.data),
update: (id: string, data: Partial<Omit<Model, 'id'>>) =>
request.patch<Model>(`/models/${id}`, data).then((r) => r.data),
delete: (id: string) =>
request.delete(`/models/${id}`).then((r) => r.data),
}

View File

@@ -0,0 +1,35 @@
import request from './request'
import type { PromptTemplate, PromptVersion, PaginatedResponse } from '@/types'
export const promptService = {
list: (params?: Record<string, unknown>) =>
request.get<PaginatedResponse<PromptTemplate>>('/prompts', { params }).then((r) => r.data),
get: (name: string) =>
request.get<PromptTemplate>(`/prompts/${encodeURIComponent(name)}`).then((r) => r.data),
create: (data: {
name: string; category: string; description?: string; source?: string
system_prompt: string; user_prompt_template?: string
variables?: unknown[]; min_app_version?: string
}) =>
request.post<PromptTemplate>('/prompts', data).then((r) => r.data),
update: (name: string, data: { description?: string; status?: string }) =>
request.put<PromptTemplate>(`/prompts/${encodeURIComponent(name)}`, data).then((r) => r.data),
archive: (name: string) =>
request.delete<PromptTemplate>(`/prompts/${encodeURIComponent(name)}`).then((r) => r.data),
listVersions: (name: string) =>
request.get<PromptVersion[]>(`/prompts/${encodeURIComponent(name)}/versions`).then((r) => r.data),
createVersion: (name: string, data: {
system_prompt: string; user_prompt_template?: string
variables?: unknown[]; changelog?: string; min_app_version?: string
}) =>
request.post<PromptVersion>(`/prompts/${encodeURIComponent(name)}/versions`, data).then((r) => r.data),
rollback: (name: string, version: number) =>
request.post<PromptTemplate>(`/prompts/${encodeURIComponent(name)}/rollback/${version}`).then((r) => r.data),
}

View File

@@ -0,0 +1,31 @@
import request from './request'
import type { Provider, ProviderKey, PaginatedResponse } from '@/types'
export const providerService = {
list: (params?: Record<string, unknown>) =>
request.get<PaginatedResponse<Provider>>('/providers', { params }).then((r) => r.data),
create: (data: Partial<Omit<Provider, 'id' | 'created_at' | 'updated_at'>>) =>
request.post<Provider>('/providers', data).then((r) => r.data),
update: (id: string, data: Partial<Omit<Provider, 'id' | 'created_at' | 'updated_at'>>) =>
request.patch<Provider>(`/providers/${id}`, data).then((r) => r.data),
delete: (id: string) =>
request.delete(`/providers/${id}`).then((r) => r.data),
listKeys: (providerId: string) =>
request.get<ProviderKey[]>(`/providers/${providerId}/keys`).then((r) => r.data),
addKey: (providerId: string, data: {
key_label: string; key_value: string; priority?: number
max_rpm?: number; max_tpm?: number; quota_reset_interval?: string
}) =>
request.post<{ ok: boolean; key_id: string }>(`/providers/${providerId}/keys`, data).then((r) => r.data),
toggleKey: (providerId: string, keyId: string, active: boolean) =>
request.put<{ ok: boolean }>(`/providers/${providerId}/keys/${keyId}/toggle`, { active }).then((r) => r.data),
deleteKey: (providerId: string, keyId: string) =>
request.delete<{ ok: boolean }>(`/providers/${providerId}/keys/${keyId}`).then((r) => r.data),
}

View File

@@ -0,0 +1,10 @@
import request from './request'
import type { RelayTask, PaginatedResponse } from '@/types'
export const relayService = {
list: (params?: Record<string, unknown>) =>
request.get<PaginatedResponse<RelayTask>>('/relay/tasks', { params }).then((r) => r.data),
get: (id: string) =>
request.get<RelayTask>(`/relay/tasks/${id}`).then((r) => r.data),
}

View File

@@ -0,0 +1,108 @@
// ============================================================
// ZCLAW Admin V2 — Axios 实例 + JWT 拦截器
// ============================================================
import axios from 'axios'
import type { AxiosError, InternalAxiosRequestConfig } from 'axios'
import type { ApiError } from '@/types'
import { useAuthStore } from '@/stores/authStore'
const BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api/v1'
const TIMEOUT_MS = 30_000
/** API 业务错误 */
export class ApiRequestError extends Error {
constructor(
public status: number,
public body: ApiError,
) {
super(body.message || `Request failed with status ${status}`)
this.name = 'ApiRequestError'
}
}
const request = axios.create({
baseURL: BASE_URL,
timeout: TIMEOUT_MS,
headers: { 'Content-Type': 'application/json' },
})
// ── 请求拦截器:自动附加 JWT ──────────────────────────────
request.interceptors.request.use((config: InternalAxiosRequestConfig) => {
const token = useAuthStore.getState().token
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
// ── 响应拦截器401 自动刷新 ──────────────────────────────
let isRefreshing = false
let pendingRequests: Array<(token: string) => void> = []
function onTokenRefreshed(newToken: string) {
pendingRequests.forEach((cb) => cb(newToken))
pendingRequests = []
}
request.interceptors.response.use(
(response) => response,
async (error: AxiosError<{ error?: string; message?: string }>) => {
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean }
// 401 → 尝试刷新 Token
if (error.response?.status === 401 && !originalRequest._retry) {
const store = useAuthStore.getState()
if (!store.refreshToken) {
store.logout()
window.location.href = '/login'
return Promise.reject(error)
}
if (isRefreshing) {
return new Promise((resolve) => {
pendingRequests.push((newToken: string) => {
originalRequest.headers.Authorization = `Bearer ${newToken}`
resolve(request(originalRequest))
})
})
}
originalRequest._retry = true
isRefreshing = true
try {
const res = await axios.post(`${BASE_URL}/auth/refresh`, null, {
headers: { Authorization: `Bearer ${store.refreshToken}` },
})
const newToken = res.data.token as string
store.setToken(newToken)
onTokenRefreshed(newToken)
originalRequest.headers.Authorization = `Bearer ${newToken}`
return request(originalRequest)
} catch {
store.logout()
window.location.href = '/login'
return Promise.reject(error)
} finally {
isRefreshing = false
}
}
// 构造 ApiRequestError
if (error.response) {
const body: ApiError = {
error: error.response.data?.error || 'unknown',
message: error.response.data?.message || `请求失败 (${error.response.status})`,
status: error.response.status,
}
return Promise.reject(new ApiRequestError(error.response.status, body))
}
return Promise.reject(error)
},
)
export default request

View File

@@ -0,0 +1,7 @@
import request from './request'
import type { DashboardStats } from '@/types'
export const statsService = {
dashboard: () =>
request.get<DashboardStats>('/stats/dashboard').then((r) => r.data),
}

View File

@@ -0,0 +1,10 @@
import request from './request'
import type { ModelUsageStat, DailyUsageStat } from '@/types'
export const telemetryService = {
modelStats: (params?: Record<string, unknown>) =>
request.get<ModelUsageStat[]>('/telemetry/stats', { params }).then((r) => r.data),
dailyStats: (params?: { days?: number }) =>
request.get<DailyUsageStat[]>('/telemetry/daily', { params }).then((r) => r.data),
}

View File

@@ -0,0 +1,12 @@
import request from './request'
import type { UsageRecord, UsageByModel } from '@/types'
export const usageService = {
daily: (params?: { days?: number }) =>
request.get<{ by_day: UsageRecord[] }>('/usage', { params: { ...params, group_by: 'day' } })
.then((r) => r.data.by_day || []),
byModel: (params?: { days?: number }) =>
request.get<{ by_model: UsageByModel[] }>('/usage', { params: { ...params, group_by: 'model' } })
.then((r) => r.data.by_model || []),
}

View File

@@ -0,0 +1,89 @@
// ============================================================
// ZCLAW Admin V2 — Zustand 认证状态管理
// ============================================================
import { create } from 'zustand'
import type { AccountPublic } from '@/types'
/** 权限常量 — 与后端 db.rs SEED_ROLES 保持同步 */
const ROLE_PERMISSIONS: Record<string, string[]> = {
super_admin: [
'admin:full', 'account:admin', 'provider:manage', 'model:manage',
'relay:admin', 'config:write', 'prompt:read', 'prompt:write',
'prompt:publish', 'prompt:admin',
],
admin: [
'account:read', 'account:admin', 'provider:manage', 'model:read',
'model:manage', 'relay:use', 'relay:admin', 'config:read',
'config:write', 'prompt:read', 'prompt:write', 'prompt:publish',
],
user: ['model:read', 'relay:use', 'config:read', 'prompt:read'],
}
const TOKEN_KEY = 'zclaw_admin_token'
const REFRESH_KEY = 'zclaw_admin_refresh_token'
const ACCOUNT_KEY = 'zclaw_admin_account'
function loadFromStorage(): { token: string | null; refreshToken: string | null; account: AccountPublic | null } {
const token = localStorage.getItem(TOKEN_KEY)
const refreshToken = localStorage.getItem(REFRESH_KEY)
const raw = localStorage.getItem(ACCOUNT_KEY)
let account: AccountPublic | null = null
if (raw) {
try { account = JSON.parse(raw) } catch { /* ignore */ }
}
return { token, refreshToken, account }
}
interface AuthState {
token: string | null
refreshToken: string | null
account: AccountPublic | null
permissions: string[]
setToken: (token: string) => void
login: (token: string, refreshToken: string, account: AccountPublic) => void
logout: () => void
hasPermission: (permission: string) => boolean
}
export const useAuthStore = create<AuthState>((set, get) => {
const stored = loadFromStorage()
const perms = stored.account ? (ROLE_PERMISSIONS[stored.account.role] ?? []) : []
return {
token: stored.token,
refreshToken: stored.refreshToken,
account: stored.account,
permissions: perms,
setToken: (token: string) => {
localStorage.setItem(TOKEN_KEY, token)
set({ token })
},
login: (token: string, refreshToken: string, account: AccountPublic) => {
localStorage.setItem(TOKEN_KEY, token)
localStorage.setItem(REFRESH_KEY, refreshToken)
localStorage.setItem(ACCOUNT_KEY, JSON.stringify(account))
set({
token,
refreshToken,
account,
permissions: ROLE_PERMISSIONS[account.role] ?? [],
})
},
logout: () => {
localStorage.removeItem(TOKEN_KEY)
localStorage.removeItem(REFRESH_KEY)
localStorage.removeItem(ACCOUNT_KEY)
set({ token: null, refreshToken: null, account: null, permissions: [] })
},
hasPermission: (permission: string) => {
const { permissions } = get()
return permissions.includes(permission) || permissions.includes('admin:full')
},
}
})

267
admin-v2/src/types/index.ts Normal file
View File

@@ -0,0 +1,267 @@
// ============================================================
// ZCLAW SaaS Admin — 全局类型定义
// ============================================================
/** 公共账号信息 */
export interface AccountPublic {
id: string
username: string
email: string
display_name: string
role: 'super_admin' | 'admin' | 'user'
status: 'active' | 'disabled' | 'suspended'
totp_enabled: boolean
last_login_at: string | null
created_at: string
}
/** 登录请求 */
export interface LoginRequest {
username: string
password: string
totp_code?: string
}
/** 登录响应 */
export interface LoginResponse {
token: string
refresh_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
api_key?: string
base_url: string
api_protocol: string
enabled: boolean
rate_limit_rpm: number | null
rate_limit_tpm: number | null
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: string
priority: number
attempt_count: number
max_attempts: number
input_tokens: number
output_tokens: number
error_message: string | null
queued_at: string
started_at: string | null
completed_at: string | null
created_at: string
}
/** 用量记录 */
export interface UsageRecord {
day: string
count: number
input_tokens: number
output_tokens: number
}
/** 按模型用量 */
export interface UsageByModel {
model_id: string
count: number
input_tokens: number
output_tokens: number
}
/** 系统配置项 */
export interface ConfigItem {
id: string
category: string
key_path: string
value_type: string
current_value: string | null
default_value: string | null
source: string
description: string | null
requires_restart: boolean
created_at: string
updated_at: string
}
/** 操作日志 */
export interface OperationLog {
id: number
account_id: string | null
action: string
target_type: string | null
target_id: string | null
details: Record<string, unknown> | null
ip_address: string | null
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
}
/** API 错误响应 */
export interface ApiError {
error: string
message: string
status?: number
}
/** 提示词模板 */
export interface PromptTemplate {
id: string
name: string
category: string
description?: string
source: 'builtin' | 'custom'
current_version: number
status: 'active' | 'deprecated' | 'archived'
created_at: string
updated_at: string
}
/** 提示词版本 */
export interface PromptVersion {
id: string
template_id: string
version: number
system_prompt: string
user_prompt_template?: string
variables: PromptVariable[]
changelog?: string
min_app_version?: string
created_at: string
}
/** 提示词变量定义 */
export interface PromptVariable {
name: string
type: 'string' | 'number' | 'select' | 'boolean'
default_value?: string
description?: string
required?: boolean
}
/** Agent 模板 */
export interface AgentTemplate {
id: string
name: string
description?: string
category: string
source: 'builtin' | 'custom'
model?: string
system_prompt?: string
tools: string[]
capabilities: string[]
temperature?: number
max_tokens?: number
visibility: 'public' | 'team' | 'private'
status: 'active' | 'archived'
current_version: number
created_at: string
updated_at: string
}
/** Provider Key */
export interface ProviderKey {
id: string
provider_id: string
key_label: string
priority: number
max_rpm?: number
max_tpm?: number
quota_reset_interval?: string
is_active: boolean
last_429_at?: string
cooldown_until?: string
total_requests: number
total_tokens: number
created_at: string
updated_at: string
}
/** 按模型聚合的用量统计 */
export interface ModelUsageStat {
model_id: string
request_count: number
input_tokens: number
output_tokens: number
avg_latency_ms: number | null
success_rate: number
}
/** 按天的用量统计 */
export interface DailyUsageStat {
day: string
request_count: number
input_tokens: number
output_tokens: number
unique_devices: number
}

View File

@@ -0,0 +1,34 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2023",
"useDefineForClassFields": true,
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Path alias */
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
},
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

7
admin-v2/tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

21
admin-v2/vite.config.ts Normal file
View File

@@ -0,0 +1,21 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
},
},
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
},
},
},
})

View File

@@ -1,4 +1,13 @@
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const nextConfig = {} const nextConfig = {
async rewrites() {
return [
{
source: '/api/:path*',
destination: 'http://localhost:8080/api/:path*',
},
]
},
}
module.exports = nextConfig module.exports = nextConfig

View File

@@ -11,10 +11,10 @@
"dependencies": { "dependencies": {
"@radix-ui/react-dialog": "^1.1.14", "@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-select": "^2.2.5", "@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-switch": "^1.2.5", "@radix-ui/react-switch": "^1.2.5",
"@radix-ui/react-tabs": "^1.1.12", "@radix-ui/react-tabs": "^1.1.12",
"@radix-ui/react-tooltip": "^1.2.7", "@radix-ui/react-tooltip": "^1.2.7",
"@radix-ui/react-separator": "^1.1.7",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-react": "^0.484.0", "lucide-react": "^0.484.0",
@@ -22,6 +22,7 @@
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"recharts": "^2.15.3", "recharts": "^2.15.3",
"swr": "^2.4.1",
"tailwind-merge": "^3.0.2" "tailwind-merge": "^3.0.2"
}, },
"devDependencies": { "devDependencies": {

29
admin/pnpm-lock.yaml generated
View File

@@ -47,6 +47,9 @@ importers:
recharts: recharts:
specifier: ^2.15.3 specifier: ^2.15.3
version: 2.15.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) version: 2.15.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
swr:
specifier: ^2.4.1
version: 2.4.1(react@18.3.1)
tailwind-merge: tailwind-merge:
specifier: ^3.0.2 specifier: ^3.0.2
version: 3.5.0 version: 3.5.0
@@ -719,6 +722,10 @@ packages:
decimal.js-light@2.5.1: decimal.js-light@2.5.1:
resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==}
dequal@2.0.3:
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
engines: {node: '>=6'}
detect-node-es@1.1.0: detect-node-es@1.1.0:
resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==}
@@ -1093,6 +1100,11 @@ packages:
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
swr@2.4.1:
resolution: {integrity: sha512-2CC6CiKQtEwaEeNiqWTAw9PGykW8SR5zZX8MZk6TeAvEAnVS7Visz8WzphqgtQ8v2xz/4Q5K+j+SeMaKXeeQIA==}
peerDependencies:
react: ^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
tailwind-merge@3.5.0: tailwind-merge@3.5.0:
resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==} resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==}
@@ -1159,6 +1171,11 @@ packages:
'@types/react': '@types/react':
optional: true optional: true
use-sync-external-store@1.6.0:
resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
util-deprecate@1.0.2: util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
@@ -1744,6 +1761,8 @@ snapshots:
decimal.js-light@2.5.1: {} decimal.js-light@2.5.1: {}
dequal@2.0.3: {}
detect-node-es@1.1.0: {} detect-node-es@1.1.0: {}
didyoumean@1.2.2: {} didyoumean@1.2.2: {}
@@ -2073,6 +2092,12 @@ snapshots:
supports-preserve-symlinks-flag@1.0.0: {} supports-preserve-symlinks-flag@1.0.0: {}
swr@2.4.1(react@18.3.1):
dependencies:
dequal: 2.0.3
react: 18.3.1
use-sync-external-store: 1.6.0(react@18.3.1)
tailwind-merge@3.5.0: {} tailwind-merge@3.5.0: {}
tailwindcss@3.4.19: tailwindcss@3.4.19:
@@ -2151,6 +2176,10 @@ snapshots:
optionalDependencies: optionalDependencies:
'@types/react': 18.3.28 '@types/react': 18.3.28
use-sync-external-store@1.6.0(react@18.3.1):
dependencies:
react: 18.3.1
util-deprecate@1.0.2: {} util-deprecate@1.0.2: {}
victory-vendor@36.9.2: victory-vendor@36.9.2:

View File

@@ -1,6 +1,7 @@
'use client' 'use client'
import { useEffect, useState, useCallback } from 'react' import { useState } from 'react'
import useSWR from 'swr'
import { import {
Search, Search,
Plus, Plus,
@@ -40,7 +41,10 @@ import {
} from '@/components/ui/select' } from '@/components/ui/select'
import { api } from '@/lib/api-client' import { api } from '@/lib/api-client'
import { ApiRequestError } from '@/lib/api-client' import { ApiRequestError } from '@/lib/api-client'
import { formatDate } from '@/lib/utils' import { formatDate, getSwrErrorMessage } from '@/lib/utils'
import { ErrorBanner, EmptyState } from '@/components/ui/state'
import { TableSkeleton } from '@/components/ui/skeleton'
import { useDebounce } from '@/hooks/use-debounce'
import type { AccountPublic } from '@/lib/types' import type { AccountPublic } from '@/lib/types'
const PAGE_SIZE = 20 const PAGE_SIZE = 20
@@ -64,14 +68,28 @@ const statusLabels: Record<string, string> = {
} }
export default function AccountsPage() { export default function AccountsPage() {
const [accounts, setAccounts] = useState<AccountPublic[]>([])
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const [roleFilter, setRoleFilter] = useState<string>('all') const [roleFilter, setRoleFilter] = useState<string>('all')
const [statusFilter, setStatusFilter] = useState<string>('all') const [statusFilter, setStatusFilter] = useState<string>('all')
const [loading, setLoading] = useState(true) const [mutationError, setMutationError] = useState('')
const [error, setError] = useState('')
const debouncedSearch = useDebounce(search, 300)
const { data, error: swrError, isLoading, mutate } = useSWR(
['accounts', page, debouncedSearch, roleFilter, statusFilter],
() => {
const params: Record<string, unknown> = { page, page_size: PAGE_SIZE }
if (debouncedSearch.trim()) params.search = debouncedSearch.trim()
if (roleFilter !== 'all') params.role = roleFilter
if (statusFilter !== 'all') params.status = statusFilter
return api.accounts.list(params)
},
)
const accounts = data?.items ?? []
const total = data?.total ?? 0
const error = getSwrErrorMessage(swrError) || mutationError
// 编辑 Dialog // 编辑 Dialog
const [editTarget, setEditTarget] = useState<AccountPublic | null>(null) const [editTarget, setEditTarget] = useState<AccountPublic | null>(null)
@@ -82,33 +100,6 @@ export default function AccountsPage() {
const [confirmTarget, setConfirmTarget] = useState<{ id: string; action: string; status: string } | null>(null) const [confirmTarget, setConfirmTarget] = useState<{ id: string; action: string; status: string } | null>(null)
const [confirmSaving, setConfirmSaving] = useState(false) const [confirmSaving, setConfirmSaving] = useState(false)
const fetchAccounts = useCallback(async () => {
setLoading(true)
setError('')
try {
const params: Record<string, unknown> = { page, page_size: PAGE_SIZE }
if (search.trim()) params.search = search.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, search, roleFilter, statusFilter])
useEffect(() => {
fetchAccounts()
}, [fetchAccounts])
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)) const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
function openEditDialog(account: AccountPublic) { function openEditDialog(account: AccountPublic) {
@@ -130,10 +121,10 @@ export default function AccountsPage() {
role: editForm.role as AccountPublic['role'], role: editForm.role as AccountPublic['role'],
}) })
setEditTarget(null) setEditTarget(null)
fetchAccounts() mutate()
} catch (err) { } catch (err) {
if (err instanceof ApiRequestError) { if (err instanceof ApiRequestError) {
setError(err.body.message) setMutationError(err.body.message)
} }
} finally { } finally {
setEditSaving(false) setEditSaving(false)
@@ -157,10 +148,10 @@ export default function AccountsPage() {
status: confirmTarget.status as AccountPublic['status'], status: confirmTarget.status as AccountPublic['status'],
}) })
setConfirmTarget(null) setConfirmTarget(null)
fetchAccounts() mutate()
} catch (err) { } catch (err) {
if (err instanceof ApiRequestError) { if (err instanceof ApiRequestError) {
setError(err.body.message) setMutationError(err.body.message)
} }
} finally { } finally {
setConfirmSaving(false) setConfirmSaving(false)
@@ -205,24 +196,13 @@ export default function AccountsPage() {
</div> </div>
{/* 错误提示 */} {/* 错误提示 */}
{error && ( {error && <ErrorBanner message={error} onDismiss={() => { setMutationError('') }} />}
<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 ? ( {isLoading ? (
<div className="flex h-64 items-center justify-center"> <TableSkeleton rows={6} cols={7} />
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /> ) : error ? null : accounts.length === 0 ? (
</div> <EmptyState />
) : accounts.length === 0 ? (
<div className="flex h-64 items-center justify-center text-muted-foreground text-sm">
</div>
) : ( ) : (
<> <>
<Table> <Table>

View File

@@ -0,0 +1,290 @@
'use client'
import { useState } from 'react'
import useSWR from 'swr'
import { api } from '@/lib/api-client'
import type { AgentTemplate } from '@/lib/types'
import { ErrorBanner, EmptyState } from '@/components/ui/state'
import { TableSkeleton } from '@/components/ui/skeleton'
export default function AgentTemplatesPage() {
const [page, setPage] = useState(1)
const [error, setError] = useState('')
const [showCreate, setShowCreate] = useState(false)
const [editingId, setEditingId] = useState<string | null>(null)
const { data, isLoading, mutate } = useSWR(
['agentTemplates.list', page],
() => api.agentTemplates.list({ page, page_size: 50 }),
)
const templates = data?.items ?? []
const total = data?.total ?? 0
const handleCreate = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
const fd = new FormData(e.currentTarget)
try {
const tools = (fd.get('tools') as string || '').split(',').map(s => s.trim()).filter(Boolean)
const capabilities = (fd.get('capabilities') as string || '').split(',').map(s => s.trim()).filter(Boolean)
await api.agentTemplates.create({
name: fd.get('name') as string,
description: (fd.get('description') as string) || undefined,
category: (fd.get('category') as string) || 'general',
model: (fd.get('model') as string) || undefined,
system_prompt: (fd.get('system_prompt') as string) || undefined,
tools: tools.length > 0 ? tools : undefined,
capabilities: capabilities.length > 0 ? capabilities : undefined,
temperature: (fd.get('temperature') as string) ? parseFloat(fd.get('temperature') as string) : undefined,
max_tokens: (fd.get('max_tokens') as string) ? parseInt(fd.get('max_tokens') as string, 10) : undefined,
visibility: (fd.get('visibility') as string) || 'public',
})
setShowCreate(false)
mutate()
} catch {
setError('创建失败')
}
}
const handleArchive = async (id: string, name: string) => {
if (!confirm(`确认归档模板 "${name}"`)) return
try {
await api.agentTemplates.archive(id)
mutate()
} catch {
setError('归档失败')
}
}
const statusBadge = (status: string) => {
const colors: Record<string, string> = {
active: 'bg-emerald-500/20 text-emerald-400',
archived: 'bg-zinc-500/20 text-zinc-400',
}
return <span className={`px-2 py-0.5 text-xs rounded-full ${colors[status] || colors.archived}`}>{status}</span>
}
const sourceBadge = (source: string) => {
const colors: Record<string, string> = {
builtin: 'bg-blue-500/20 text-blue-400',
custom: 'bg-purple-500/20 text-purple-400',
}
return (
<span className={`px-2 py-0.5 text-xs rounded-full ${colors[source] || ''}`}>
{source === 'builtin' ? '内置' : '自定义'}
</span>
)
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-white">Agent </h1>
<p className="text-sm text-zinc-400 mt-1"> Agent </p>
</div>
<button
onClick={() => setShowCreate(true)}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm"
>
+
</button>
</div>
{error && <ErrorBanner message={error} onDismiss={() => setError('')} />}
<div className="bg-zinc-900 rounded-xl border border-zinc-800 overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-zinc-800">
<th className="text-left px-4 py-3 text-zinc-400 font-medium"></th>
<th className="text-left px-4 py-3 text-zinc-400 font-medium"></th>
<th className="text-left px-4 py-3 text-zinc-400 font-medium"></th>
<th className="text-left px-4 py-3 text-zinc-400 font-medium"></th>
<th className="text-left px-4 py-3 text-zinc-400 font-medium"></th>
<th className="text-left px-4 py-3 text-zinc-400 font-medium"></th>
<th className="text-left px-4 py-3 text-zinc-400 font-medium"></th>
<th className="text-left px-4 py-3 text-zinc-400 font-medium"></th>
<th className="text-right px-4 py-3 text-zinc-400 font-medium"></th>
</tr>
</thead>
<tbody>
{isLoading ? (
<tr>
<td colSpan={9}>
<TableSkeleton rows={5} cols={9} hasToolbar={false} />
</td>
</tr>
) : templates.length === 0 ? (
<tr><td colSpan={9}><EmptyState message="暂无 Agent 模板" /></td></tr>
) : (
templates.map(t => (
<tr key={t.id} className="border-b border-zinc-800/50 hover:bg-zinc-800/30">
<td className="px-4 py-3">
<div>
<span className="text-white font-medium">{t.name}</span>
{t.description && (
<p className="text-xs text-zinc-500 mt-0.5 truncate max-w-[200px]">{t.description}</p>
)}
</div>
</td>
<td className="px-4 py-3 text-zinc-400">{t.category}</td>
<td className="px-4 py-3">{sourceBadge(t.source)}</td>
<td className="px-4 py-3 text-zinc-300 font-mono text-xs">{t.model || '-'}</td>
<td className="px-4 py-3 text-zinc-400">{t.tools.length}</td>
<td className="px-4 py-3 text-zinc-400">{t.visibility}</td>
<td className="px-4 py-3">{statusBadge(t.status)}</td>
<td className="px-4 py-3 text-zinc-500 text-xs">
{new Date(t.updated_at).toLocaleString('zh-CN')}
</td>
<td className="px-4 py-3 text-right">
<button
onClick={() => setEditingId(editingId === t.id ? null : t.id)}
className="text-zinc-400 hover:text-white mr-2"
>
</button>
{t.source === 'custom' && (
<button
onClick={() => handleArchive(t.id, t.name)}
className="text-red-400 hover:text-red-300"
>
</button>
)}
</td>
</tr>
))
)}
</tbody>
</table>
<div className="px-4 py-2 text-xs text-zinc-500 border-t border-zinc-800">
{total}
</div>
</div>
{/* 展开详情 */}
{editingId && (() => {
const t = templates.find(t => t.id === editingId)
if (!t) return null
return (
<div className="bg-zinc-900 rounded-xl border border-zinc-800 p-4">
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold text-white">{t.name} </h2>
<button onClick={() => setEditingId(null)} className="text-zinc-400 hover:text-white text-sm"></button>
</div>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-zinc-500"></span>
<span className="text-zinc-300">{t.category}</span>
</div>
<div>
<span className="text-zinc-500"></span>
<span className="text-zinc-300 font-mono">{t.model || '未指定'}</span>
</div>
<div>
<span className="text-zinc-500"></span>
<span className="text-zinc-300">{t.temperature?.toFixed(2) || '默认'}</span>
</div>
<div>
<span className="text-zinc-500"> Token</span>
<span className="text-zinc-300">{t.max_tokens || '未限制'}</span>
</div>
<div className="col-span-2">
<span className="text-zinc-500"></span>
<div className="flex flex-wrap gap-1 mt-1">
{t.tools.length > 0 ? t.tools.map(tool => (
<span key={tool} className="px-2 py-0.5 bg-zinc-800 rounded text-xs text-zinc-300">{tool}</span>
)) : <span className="text-zinc-600"></span>}
</div>
</div>
<div className="col-span-2">
<span className="text-zinc-500"></span>
<div className="flex flex-wrap gap-1 mt-1">
{t.capabilities.length > 0 ? t.capabilities.map(cap => (
<span key={cap} className="px-2 py-0.5 bg-blue-500/10 rounded text-xs text-blue-400">{cap}</span>
)) : <span className="text-zinc-600"></span>}
</div>
</div>
{t.system_prompt && (
<div className="col-span-2">
<span className="text-zinc-500"></span>
<pre className="text-xs text-zinc-400 bg-zinc-800/50 rounded p-2 mt-1 overflow-x-auto max-h-32">
{t.system_prompt.substring(0, 500)}{t.system_prompt.length > 500 ? '...' : ''}
</pre>
</div>
)}
</div>
</div>
)
})()}
{/* Create Modal */}
{showCreate && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<form onSubmit={handleCreate} className="bg-zinc-900 rounded-xl border border-zinc-700 p-6 w-full max-w-lg space-y-4 max-h-[80vh] overflow-y-auto">
<h2 className="text-lg font-semibold text-white"> Agent </h2>
<div>
<label className="block text-sm text-zinc-400 mb-1"> *</label>
<input name="name" required className="w-full px-3 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-white text-sm" placeholder="my_agent" />
</div>
<div>
<label className="block text-sm text-zinc-400 mb-1"></label>
<input name="description" className="w-full px-3 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-white text-sm" placeholder="可选" />
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm text-zinc-400 mb-1"></label>
<select name="category" className="w-full px-3 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-white text-sm">
<option value="general"></option>
<option value="coding"></option>
<option value="research"></option>
<option value="creative"></option>
<option value="assistant"></option>
</select>
</div>
<div>
<label className="block text-sm text-zinc-400 mb-1"></label>
<input name="model" className="w-full px-3 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-white text-sm" placeholder="如 glm-4-plus" />
</div>
</div>
<div>
<label className="block text-sm text-zinc-400 mb-1"></label>
<textarea name="system_prompt" rows={4} className="w-full px-3 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-white text-sm font-mono" placeholder="Agent 系统提示词" />
</div>
<div>
<label className="block text-sm text-zinc-400 mb-1"></label>
<input name="tools" className="w-full px-3 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-white text-sm" placeholder="browser, file_system, code_execute" />
</div>
<div>
<label className="block text-sm text-zinc-400 mb-1"></label>
<input name="capabilities" className="w-full px-3 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-white text-sm" placeholder="streaming, vision, function_calling" />
</div>
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-sm text-zinc-400 mb-1"></label>
<input name="temperature" type="number" step="0.1" className="w-full px-3 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-white text-sm" placeholder="默认" />
</div>
<div>
<label className="block text-sm text-zinc-400 mb-1"> Token</label>
<input name="max_tokens" type="number" className="w-full px-3 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-white text-sm" placeholder="不限" />
</div>
<div>
<label className="block text-sm text-zinc-400 mb-1"></label>
<select name="visibility" className="w-full px-3 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-white text-sm">
<option value="public"></option>
<option value="team"></option>
<option value="private"></option>
</select>
</div>
</div>
<div className="flex gap-2 justify-end">
<button type="button" onClick={() => setShowCreate(false)} className="px-4 py-2 bg-zinc-700 text-white rounded-lg hover:bg-zinc-600 text-sm"></button>
<button type="submit" className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 text-sm"></button>
</div>
</form>
</div>
)}
</div>
)
}

View File

@@ -1,6 +1,7 @@
'use client' 'use client'
import { useEffect, useState, useCallback } from 'react' import { useState } from 'react'
import useSWR from 'swr'
import { import {
Plus, Plus,
Loader2, Loader2,
@@ -32,8 +33,10 @@ import {
DialogDescription, DialogDescription,
} from '@/components/ui/dialog' } from '@/components/ui/dialog'
import { api } from '@/lib/api-client' import { api } from '@/lib/api-client'
import { ErrorBanner, EmptyState } from '@/components/ui/state'
import { ApiRequestError } from '@/lib/api-client' import { ApiRequestError } from '@/lib/api-client'
import { formatDate } from '@/lib/utils' import { formatDate, getSwrErrorMessage } from '@/lib/utils'
import { TableSkeleton } from '@/components/ui/skeleton'
import type { TokenInfo } from '@/lib/types' import type { TokenInfo } from '@/lib/types'
const PAGE_SIZE = 20 const PAGE_SIZE = 20
@@ -45,11 +48,17 @@ const allPermissions = [
] ]
export default function ApiKeysPage() { export default function ApiKeysPage() {
const [tokens, setTokens] = useState<TokenInfo[]>([])
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
const [loading, setLoading] = useState(true) const [mutationError, setMutationError] = useState('')
const [error, setError] = useState('')
const { data, error: swrError, isLoading, mutate } = useSWR(
['tokens', page],
() => api.tokens.list({ page, page_size: PAGE_SIZE }),
)
const tokens = data?.items ?? []
const total = data?.total ?? 0
const error = getSwrErrorMessage(swrError) || mutationError
// 创建 Dialog // 创建 Dialog
const [createOpen, setCreateOpen] = useState(false) const [createOpen, setCreateOpen] = useState(false)
@@ -64,25 +73,6 @@ export default function ApiKeysPage() {
const [revokeTarget, setRevokeTarget] = useState<TokenInfo | null>(null) const [revokeTarget, setRevokeTarget] = useState<TokenInfo | null>(null)
const [revoking, setRevoking] = useState(false) 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)) const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
function togglePermission(perm: string) { function togglePermission(perm: string) {
@@ -107,9 +97,9 @@ export default function ApiKeysPage() {
setCreateOpen(false) setCreateOpen(false)
setCreatedToken(res) setCreatedToken(res)
setCreateForm({ name: '', expires_days: '', permissions: ['chat'] }) setCreateForm({ name: '', expires_days: '', permissions: ['chat'] })
fetchTokens() mutate()
} catch (err) { } catch (err) {
if (err instanceof ApiRequestError) setError(err.body.message) if (err instanceof ApiRequestError) setMutationError(err.body.message)
} finally { } finally {
setCreating(false) setCreating(false)
} }
@@ -121,9 +111,9 @@ export default function ApiKeysPage() {
try { try {
await api.tokens.revoke(revokeTarget.id) await api.tokens.revoke(revokeTarget.id)
setRevokeTarget(null) setRevokeTarget(null)
fetchTokens() mutate()
} catch (err) { } catch (err) {
if (err instanceof ApiRequestError) setError(err.body.message) if (err instanceof ApiRequestError) setMutationError(err.body.message)
} finally { } finally {
setRevoking(false) setRevoking(false)
} }
@@ -158,21 +148,12 @@ export default function ApiKeysPage() {
</Button> </Button>
</div> </div>
{error && ( {error && <ErrorBanner message={error} onDismiss={() => setMutationError('')} />}
<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 ? ( {isLoading ? (
<div className="flex h-64 items-center justify-center"> <TableSkeleton rows={6} cols={7} />
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /> ) : error ? null : tokens.length === 0 ? (
</div> <EmptyState />
) : tokens.length === 0 ? (
<div className="flex h-64 items-center justify-center text-muted-foreground text-sm">
</div>
) : ( ) : (
<> <>
<Table> <Table>

View File

@@ -1,6 +1,7 @@
'use client' 'use client'
import { useEffect, useState, useCallback } from 'react' import { useState } from 'react'
import useSWR from 'swr'
import { import {
Loader2, Loader2,
Pencil, Pencil,
@@ -35,6 +36,8 @@ import {
} from '@/components/ui/dialog' } from '@/components/ui/dialog'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { api } from '@/lib/api-client' import { api } from '@/lib/api-client'
import { TableSkeleton } from '@/components/ui/skeleton'
import { ErrorBanner, EmptyState } from '@/components/ui/state'
import { ApiRequestError } from '@/lib/api-client' import { ApiRequestError } from '@/lib/api-client'
import type { ConfigItem } from '@/lib/types' import type { ConfigItem } from '@/lib/types'
@@ -51,39 +54,27 @@ const sourceVariants: Record<string, 'secondary' | 'info' | 'default'> = {
} }
export default function ConfigPage() { export default function ConfigPage() {
const [configs, setConfigs] = useState<ConfigItem[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('') const [error, setError] = useState('')
const [activeTab, setActiveTab] = useState('all') const [activeTab, setActiveTab] = useState('all')
// SWR for config list
const { data: configs = [], isLoading, mutate } = useSWR(
['config', activeTab],
() => {
const params: Record<string, unknown> = {}
if (activeTab !== 'all') params.category = activeTab
return api.config.list(params)
}
)
// 编辑 Dialog // 编辑 Dialog
const [editTarget, setEditTarget] = useState<ConfigItem | null>(null) const [editTarget, setEditTarget] = useState<ConfigItem | null>(null)
const [editValue, setEditValue] = useState('') const [editValue, setEditValue] = useState('')
const [saving, setSaving] = useState(false) 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) { function openEditDialog(config: ConfigItem) {
setEditTarget(config) setEditTarget(config)
setEditValue(config.current_value !== undefined ? String(config.current_value) : '') setEditValue(config.current_value ?? '')
} }
async function handleSave() { async function handleSave() {
@@ -98,7 +89,7 @@ export default function ConfigPage() {
} }
await api.config.update(editTarget.id, { value: parsedValue }) await api.config.update(editTarget.id, { value: parsedValue })
setEditTarget(null) setEditTarget(null)
fetchConfigs(activeTab) mutate()
} catch (err) { } catch (err) {
if (err instanceof ApiRequestError) setError(err.body.message) if (err instanceof ApiRequestError) setError(err.body.message)
} finally { } finally {
@@ -112,7 +103,15 @@ export default function ConfigPage() {
return String(value) return String(value)
} }
const categories = ['all', 'auth', 'relay', 'model', 'system'] const categoryLabels: Record<string, string> = {
all: '全部',
server: '服务器',
agent: 'Agent',
memory: '记忆',
llm: 'LLM',
security: '安全策略',
}
const categories = Object.keys(categoryLabels)
return ( return (
<div className="space-y-4"> <div className="space-y-4">
@@ -121,27 +120,18 @@ export default function ConfigPage() {
<TabsList> <TabsList>
{categories.map((cat) => ( {categories.map((cat) => (
<TabsTrigger key={cat} value={cat}> <TabsTrigger key={cat} value={cat}>
{cat === 'all' ? '全部' : cat} {categoryLabels[cat] || cat}
</TabsTrigger> </TabsTrigger>
))} ))}
</TabsList> </TabsList>
</Tabs> </Tabs>
{error && ( {error && <ErrorBanner message={error} onDismiss={() => setError('')} />}
<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 ? ( {isLoading ? (
<div className="flex h-64 items-center justify-center"> <TableSkeleton rows={8} cols={8} hasToolbar={false} />
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /> ) : error ? null : configs.length === 0 ? (
</div> <EmptyState message="暂无配置项" />
) : configs.length === 0 ? (
<div className="flex h-64 items-center justify-center text-muted-foreground text-sm">
</div>
) : ( ) : (
<Table> <Table>
<TableHeader> <TableHeader>
@@ -220,7 +210,7 @@ export default function ConfigPage() {
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label> <Label>
{editTarget?.default_value !== undefined && ( {editTarget?.default_value != null && (
<span className="text-xs text-muted-foreground ml-2"> <span className="text-xs text-muted-foreground ml-2">
(: {formatValue(editTarget.default_value)}) (: {formatValue(editTarget.default_value)})
</span> </span>
@@ -249,7 +239,7 @@ export default function ConfigPage() {
<Button <Button
variant="outline" variant="outline"
onClick={() => { onClick={() => {
if (editTarget?.default_value !== undefined) { if (editTarget?.default_value != null) {
setEditValue(String(editTarget.default_value)) setEditValue(String(editTarget.default_value))
} }
}} }}

View File

@@ -13,6 +13,8 @@ import {
ArrowLeftRight, ArrowLeftRight,
Settings, Settings,
FileText, FileText,
MessageSquare,
Bot,
LogOut, LogOut,
ChevronLeft, ChevronLeft,
Menu, Menu,
@@ -22,16 +24,30 @@ import { AuthGuard, useAuth } from '@/components/auth-guard'
import { logout } from '@/lib/auth' import { logout } from '@/lib/auth'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
/** 权限常量 — 与后端 db.rs SEED_ROLES 保持同步 */
const ROLE_PERMISSIONS: Record<string, string[]> = {
super_admin: ['admin:full', 'account:admin', 'provider:manage', 'model:manage', 'relay:admin', 'config:write', 'prompt:read', 'prompt:write', 'prompt:publish', 'prompt:admin'],
admin: ['account:read', 'account:admin', 'provider:manage', 'model:read', 'model:manage', 'relay:use', 'relay:admin', 'config:read', 'config:write', 'prompt:read', 'prompt:write', 'prompt:publish'],
user: ['model:read', 'relay:use', 'config:read', 'prompt:read'],
}
/** 根据 role 获取权限列表 */
function getPermissionsForRole(role: string): string[] {
return ROLE_PERMISSIONS[role] ?? []
}
const navItems = [ const navItems = [
{ href: '/', label: '仪表盘', icon: LayoutDashboard }, { href: '/', label: '仪表盘', icon: LayoutDashboard },
{ href: '/accounts', label: '账号管理', icon: Users }, { href: '/accounts', label: '账号管理', icon: Users, permission: 'account:admin' },
{ href: '/providers', label: '服务商', icon: Server }, { href: '/providers', label: '服务商', icon: Server, permission: 'provider:manage' },
{ href: '/models', label: '模型管理', icon: Cpu }, { href: '/models', label: '模型管理', icon: Cpu, permission: 'model:read' },
{ href: '/api-keys', label: 'API 密钥', icon: Key }, { href: '/agent-templates', label: 'Agent 模板', icon: Bot, permission: 'model:read' },
{ href: '/usage', label: '用量统计', icon: BarChart3 }, { href: '/api-keys', label: 'API 密钥', icon: Key, permission: 'admin:full' },
{ href: '/relay', label: '中转任务', icon: ArrowLeftRight }, { href: '/usage', label: '用量统计', icon: BarChart3, permission: 'admin:full' },
{ href: '/config', label: '系统配置', icon: Settings }, { href: '/relay', label: '中转任务', icon: ArrowLeftRight, permission: 'relay:use' },
{ href: '/logs', label: '操作日志', icon: FileText }, { href: '/config', label: '系统配置', icon: Settings, permission: 'config:read' },
{ href: '/prompts', label: '提示词管理', icon: MessageSquare, permission: 'prompt:read' },
{ href: '/logs', label: '操作日志', icon: FileText, permission: 'admin:full' },
] ]
function Sidebar({ function Sidebar({
@@ -45,11 +61,18 @@ function Sidebar({
const router = useRouter() const router = useRouter()
const { account } = useAuth() const { account } = useAuth()
const permissions = account ? getPermissionsForRole(account.role) : []
function handleLogout() { function handleLogout() {
logout() logout()
router.replace('/login') router.replace('/login')
} }
const filteredNavItems = navItems.filter((item) => {
if (!item.permission) return true
return permissions.includes(item.permission) || permissions.includes('admin:full')
})
return ( return (
<aside <aside
className={cn( className={cn(
@@ -75,7 +98,7 @@ function Sidebar({
{/* 导航 */} {/* 导航 */}
<nav className="flex-1 overflow-y-auto scrollbar-thin py-2 px-2"> <nav className="flex-1 overflow-y-auto scrollbar-thin py-2 px-2">
<ul className="space-y-1"> <ul className="space-y-1">
{navItems.map((item) => { {filteredNavItems.map((item) => {
const isActive = const isActive =
item.href === '/' item.href === '/'
? pathname === '/' ? pathname === '/'

View File

@@ -1,6 +1,7 @@
'use client' 'use client'
import { useEffect, useState, useCallback } from 'react' import { useState } from 'react'
import useSWR from 'swr'
import { import {
Plus, Plus,
Loader2, Loader2,
@@ -37,6 +38,8 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select' } from '@/components/ui/select'
import { TableSkeleton } from '@/components/ui/skeleton'
import { ErrorBanner, EmptyState } from '@/components/ui/state'
import { api } from '@/lib/api-client' import { api } from '@/lib/api-client'
import { ApiRequestError } from '@/lib/api-client' import { ApiRequestError } from '@/lib/api-client'
import { formatNumber } from '@/lib/utils' import { formatNumber } from '@/lib/utils'
@@ -71,14 +74,29 @@ const emptyForm: ModelForm = {
} }
export default function ModelsPage() { 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 [page, setPage] = useState(1)
const [providerFilter, setProviderFilter] = useState<string>('all') const [providerFilter, setProviderFilter] = useState<string>('all')
const [loading, setLoading] = useState(true)
const [error, setError] = useState('') const [error, setError] = useState('')
// SWR for models list
const { data, isLoading, mutate } = useSWR(
['models', page, providerFilter],
() => {
const params: Record<string, unknown> = { page, page_size: PAGE_SIZE }
if (providerFilter !== 'all') params.provider_id = providerFilter
return api.models.list(params)
}
)
const models = data?.items ?? []
const total = data?.total ?? 0
// SWR for providers list (dropdown)
const { data: providersData } = useSWR(
['providers.all'],
() => api.providers.list({ page: 1, page_size: 100 })
)
const providers = providersData?.items ?? []
// Dialog // Dialog
const [dialogOpen, setDialogOpen] = useState(false) const [dialogOpen, setDialogOpen] = useState(false)
const [editTarget, setEditTarget] = useState<Model | null>(null) const [editTarget, setEditTarget] = useState<Model | null>(null)
@@ -89,37 +107,6 @@ export default function ModelsPage() {
const [deleteTarget, setDeleteTarget] = useState<Model | null>(null) const [deleteTarget, setDeleteTarget] = useState<Model | null>(null)
const [deleting, setDeleting] = useState(false) 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({ page: 1, page_size: 100 })
setProviders(res.items)
} catch {
// ignore
}
}, [])
useEffect(() => {
fetchModels()
fetchProviders()
}, [fetchModels, fetchProviders])
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)) const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
const providerMap = new Map(providers.map((p) => [p.id, p.display_name || p.name])) const providerMap = new Map(providers.map((p) => [p.id, p.display_name || p.name]))
@@ -169,7 +156,7 @@ export default function ModelsPage() {
await api.models.create(payload) await api.models.create(payload)
} }
setDialogOpen(false) setDialogOpen(false)
fetchModels() mutate()
} catch (err) { } catch (err) {
if (err instanceof ApiRequestError) setError(err.body.message) if (err instanceof ApiRequestError) setError(err.body.message)
} finally { } finally {
@@ -183,7 +170,7 @@ export default function ModelsPage() {
try { try {
await api.models.delete(deleteTarget.id) await api.models.delete(deleteTarget.id)
setDeleteTarget(null) setDeleteTarget(null)
fetchModels() mutate()
} catch (err) { } catch (err) {
if (err instanceof ApiRequestError) setError(err.body.message) if (err instanceof ApiRequestError) setError(err.body.message)
} finally { } finally {
@@ -213,21 +200,12 @@ export default function ModelsPage() {
</Button> </Button>
</div> </div>
{error && ( {error && <ErrorBanner message={error} onDismiss={() => setError('')} />}
<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 ? ( {isLoading ? (
<div className="flex h-64 items-center justify-center"> <TableSkeleton rows={8} cols={9} hasToolbar={false} />
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /> ) : error ? null : models.length === 0 ? (
</div> <EmptyState />
) : models.length === 0 ? (
<div className="flex h-64 items-center justify-center text-muted-foreground text-sm">
</div>
) : ( ) : (
<> <>
<Table> <Table>

View File

@@ -1,12 +1,10 @@
'use client' 'use client'
import { useEffect, useState } from 'react'
import { import {
Users, Users,
Server, Server,
ArrowLeftRight, ArrowLeftRight,
Zap, Zap,
Loader2,
TrendingUp, TrendingUp,
} from 'lucide-react' } from 'lucide-react'
import { import {
@@ -21,8 +19,12 @@ import {
Bar, Bar,
Legend, Legend,
} from 'recharts' } from 'recharts'
import useSWR from 'swr'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { StatsSkeleton } from '@/components/ui/skeleton'
import { ChartSkeleton } from '@/components/ui/skeleton'
import { TableSkeleton } from '@/components/ui/skeleton'
import { import {
Table, Table,
TableBody, TableBody,
@@ -86,61 +88,24 @@ function StatusBadge({ status }: { status: string }) {
} }
export default function DashboardPage() { export default function DashboardPage() {
const [stats, setStats] = useState<DashboardStats | null>(null) const { data: stats, isLoading: statsLoading } = useSWR(
const [usageData, setUsageData] = useState<UsageRecord[]>([]) ['stats.dashboard'],
const [recentLogs, setRecentLogs] = useState<OperationLog[]>([]) () => api.stats.dashboard(),
const [loading, setLoading] = useState(true) )
const [error, setError] = useState('')
useEffect(() => { const { data: usageData = [], isLoading: usageLoading } = useSWR(
async function fetchData() { ['usage.daily.30'],
try { () => api.usage.daily({ days: 30 }),
const [statsRes, usageRes, logsRes] = await Promise.allSettled([ )
api.stats.dashboard(),
api.usage.daily({ days: 30 }),
api.logs.list({ page: 1, page_size: 5 }),
])
if (statsRes.status === 'fulfilled') setStats(statsRes.value) const { data: logsData, isLoading: logsLoading } = useSWR(
if (usageRes.status === 'fulfilled') setUsageData(usageRes.value) ['logs.recent'],
if (logsRes.status === 'fulfilled') setRecentLogs(logsRes.value.items) () => api.logs.list({ page: 1, page_size: 5 }),
} catch (err) { )
setError('加载数据失败,请检查后端服务是否启动')
} finally {
setLoading(false)
}
}
fetchData()
}, [])
if (loading) { const recentLogs: OperationLog[] = logsData?.items ?? []
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) { const chartData = usageData.map((r: UsageRecord) => ({
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 = usageData.map((r) => ({
day: r.day.slice(5), // MM-DD day: r.day.slice(5), // MM-DD
请求量: r.count, 请求量: r.count,
Input: r.input_tokens, Input: r.input_tokens,
@@ -150,139 +115,151 @@ export default function DashboardPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* 统计卡片 */} {/* 统计卡片 */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4"> {statsLoading ? (
<StatCard <StatsSkeleton count={4} />
title="总账号数" ) : (
value={stats?.total_accounts ?? '-'} <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
icon={<Users className="h-5 w-5 text-blue-400" />} <StatCard
color="bg-blue-500/10" title="总账号数"
subtitle={`活跃 ${stats?.active_accounts ?? 0}`} value={stats?.total_accounts ?? '-'}
/> icon={<Users className="h-5 w-5 text-blue-400" />}
<StatCard color="bg-blue-500/10"
title="活跃服务商" subtitle={`活跃 ${stats?.active_accounts ?? 0}`}
value={stats?.active_providers ?? '-'} />
icon={<Server className="h-5 w-5 text-green-400" />} <StatCard
color="bg-green-500/10" title="活跃服务商"
subtitle={`模型 ${stats?.active_models ?? 0}`} value={stats?.active_providers ?? '-'}
/> icon={<Server className="h-5 w-5 text-green-400" />}
<StatCard color="bg-green-500/10"
title="今日请求" subtitle={`模型 ${stats?.active_models ?? 0}`}
value={stats?.tasks_today ?? '-'} />
icon={<ArrowLeftRight className="h-5 w-5 text-purple-400" />} <StatCard
color="bg-purple-500/10" title="今日请求"
subtitle="中转任务" value={stats?.tasks_today ?? '-'}
/> icon={<ArrowLeftRight className="h-5 w-5 text-purple-400" />}
<StatCard color="bg-purple-500/10"
title="今日 Token" subtitle="中转任务"
value={formatNumber((stats?.tokens_today_input ?? 0) + (stats?.tokens_today_output ?? 0))} />
icon={<Zap className="h-5 w-5 text-orange-400" />} <StatCard
color="bg-orange-500/10" title="今日 Token"
subtitle={`In: ${formatNumber(stats?.tokens_today_input ?? 0)} / Out: ${formatNumber(stats?.tokens_today_output ?? 0)}`} value={formatNumber((stats?.tokens_today_input ?? 0) + (stats?.tokens_today_output ?? 0))}
/> icon={<Zap className="h-5 w-5 text-orange-400" />}
</div> 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"> <div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
{/* 请求趋势 */} {/* 请求趋势 */}
<Card> {usageLoading ? (
<CardHeader> <ChartSkeleton height={280} />
<CardTitle className="flex items-center gap-2 text-base"> ) : (
<TrendingUp className="h-4 w-4 text-primary" /> <Card>
(30 ) <CardHeader>
</CardTitle> <CardTitle className="flex items-center gap-2 text-base">
</CardHeader> <TrendingUp className="h-4 w-4 text-primary" />
<CardContent> (30 )
{chartData.length > 0 ? ( </CardTitle>
<ResponsiveContainer width="100%" height={280}> </CardHeader>
<AreaChart data={chartData}> <CardContent>
<defs> {chartData.length > 0 ? (
<linearGradient id="colorRequests" x1="0" y1="0" x2="0" y2="1"> <ResponsiveContainer width="100%" height={280}>
<stop offset="5%" stopColor="#22C55E" stopOpacity={0.3} /> <AreaChart data={chartData}>
<stop offset="95%" stopColor="#22C55E" stopOpacity={0} /> <defs>
</linearGradient> <linearGradient id="colorRequests" x1="0" y1="0" x2="0" y2="1">
</defs> <stop offset="5%" stopColor="#22C55E" stopOpacity={0.3} />
<CartesianGrid strokeDasharray="3 3" stroke="#1E293B" /> <stop offset="95%" stopColor="#22C55E" stopOpacity={0} />
<XAxis </linearGradient>
dataKey="day" </defs>
tick={{ fontSize: 12, fill: '#94A3B8' }} <CartesianGrid strokeDasharray="3 3" stroke="#1E293B" />
axisLine={{ stroke: '#1E293B' }} <XAxis
/> dataKey="day"
<YAxis tick={{ fontSize: 12, fill: '#94A3B8' }}
tick={{ fontSize: 12, fill: '#94A3B8' }} axisLine={{ stroke: '#1E293B' }}
axisLine={{ stroke: '#1E293B' }} />
/> <YAxis
<Tooltip tick={{ fontSize: 12, fill: '#94A3B8' }}
contentStyle={{ axisLine={{ stroke: '#1E293B' }}
backgroundColor: '#0F172A', />
border: '1px solid #1E293B', <Tooltip
borderRadius: '8px', contentStyle={{
color: '#F8FAFC', backgroundColor: '#0F172A',
fontSize: '12px', border: '1px solid #1E293B',
}} borderRadius: '8px',
/> color: '#F8FAFC',
<Area fontSize: '12px',
type="monotone" }}
dataKey="请求量" />
stroke="#22C55E" <Area
fillOpacity={1} type="monotone"
fill="url(#colorRequests)" dataKey="请求量"
strokeWidth={2} stroke="#22C55E"
/> fillOpacity={1}
</AreaChart> fill="url(#colorRequests)"
</ResponsiveContainer> strokeWidth={2}
) : ( />
<div className="flex h-[280px] items-center justify-center text-muted-foreground text-sm"> </AreaChart>
</ResponsiveContainer>
</div> ) : (
)} <div className="flex h-[280px] items-center justify-center text-muted-foreground text-sm">
</CardContent>
</Card> </div>
)}
</CardContent>
</Card>
)}
{/* Token 用量 */} {/* Token 用量 */}
<Card> {usageLoading ? (
<CardHeader> <ChartSkeleton height={280} />
<CardTitle className="flex items-center gap-2 text-base"> ) : (
<Zap className="h-4 w-4 text-orange-400" /> <Card>
Token (30 ) <CardHeader>
</CardTitle> <CardTitle className="flex items-center gap-2 text-base">
</CardHeader> <Zap className="h-4 w-4 text-orange-400" />
<CardContent> Token (30 )
{chartData.length > 0 ? ( </CardTitle>
<ResponsiveContainer width="100%" height={280}> </CardHeader>
<BarChart data={chartData}> <CardContent>
<CartesianGrid strokeDasharray="3 3" stroke="#1E293B" /> {chartData.length > 0 ? (
<XAxis <ResponsiveContainer width="100%" height={280}>
dataKey="day" <BarChart data={chartData}>
tick={{ fontSize: 12, fill: '#94A3B8' }} <CartesianGrid strokeDasharray="3 3" stroke="#1E293B" />
axisLine={{ stroke: '#1E293B' }} <XAxis
/> dataKey="day"
<YAxis tick={{ fontSize: 12, fill: '#94A3B8' }}
tick={{ fontSize: 12, fill: '#94A3B8' }} axisLine={{ stroke: '#1E293B' }}
axisLine={{ stroke: '#1E293B' }} />
/> <YAxis
<Tooltip tick={{ fontSize: 12, fill: '#94A3B8' }}
contentStyle={{ axisLine={{ stroke: '#1E293B' }}
backgroundColor: '#0F172A', />
border: '1px solid #1E293B', <Tooltip
borderRadius: '8px', contentStyle={{
color: '#F8FAFC', backgroundColor: '#0F172A',
fontSize: '12px', border: '1px solid #1E293B',
}} borderRadius: '8px',
/> color: '#F8FAFC',
<Legend fontSize: '12px',
wrapperStyle={{ fontSize: '12px', color: '#94A3B8' }} }}
/> />
<Bar dataKey="Input" fill="#3B82F6" radius={[2, 2, 0, 0]} /> <Legend
<Bar dataKey="Output" fill="#F97316" radius={[2, 2, 0, 0]} /> wrapperStyle={{ fontSize: '12px', color: '#94A3B8' }}
</BarChart> />
</ResponsiveContainer> <Bar dataKey="Input" fill="#3B82F6" radius={[2, 2, 0, 0]} />
) : ( <Bar dataKey="Output" fill="#F97316" radius={[2, 2, 0, 0]} />
<div className="flex h-[280px] items-center justify-center text-muted-foreground text-sm"> </BarChart>
</ResponsiveContainer>
</div> ) : (
)} <div className="flex h-[280px] items-center justify-center text-muted-foreground text-sm">
</CardContent>
</Card> </div>
)}
</CardContent>
</Card>
)}
</div> </div>
{/* 最近操作日志 */} {/* 最近操作日志 */}
@@ -291,7 +268,9 @@ export default function DashboardPage() {
<CardTitle className="text-base"></CardTitle> <CardTitle className="text-base"></CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{recentLogs.length > 0 ? ( {logsLoading ? (
<TableSkeleton rows={5} cols={5} hasToolbar={false} />
) : recentLogs.length > 0 ? (
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>

View File

@@ -0,0 +1,341 @@
'use client'
import { useState } from 'react'
import useSWR from 'swr'
import { api } from '@/lib/api-client'
import type { PromptTemplate, PromptVersion } from '@/lib/types'
import { EmptyState } from '@/components/ui/state'
import { TableSkeleton } from '@/components/ui/skeleton'
export default function PromptsPage() {
const [page, setPage] = useState(1)
const [selectedName, setSelectedName] = useState<string | null>(null)
const [versions, setVersions] = useState<PromptVersion[]>([])
const [showCreate, setShowCreate] = useState(false)
const [showNewVersion, setShowNewVersion] = useState(false)
const [filter, setFilter] = useState<{ source?: string; status?: string }>({})
const { data, error, isLoading, mutate } = useSWR(
['prompts.list', page, filter.source, filter.status],
() => api.prompts.list({ page, page_size: 50, ...filter }),
)
const templates = data?.items ?? []
const total = data?.total ?? 0
const fetchVersions = async (name: string) => {
try {
const res = await api.prompts.listVersions(name)
setVersions(res)
setSelectedName(name)
} catch (err) {
console.error('Failed to fetch versions:', err)
}
}
const handleCreate = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
const fd = new FormData(e.currentTarget)
try {
await api.prompts.create({
name: fd.get('name') as string,
category: fd.get('category') as string,
description: (fd.get('description') as string) || undefined,
source: 'custom',
system_prompt: fd.get('system_prompt') as string,
})
setShowCreate(false)
mutate()
} catch (err) {
console.error('Failed to create prompt:', err)
}
}
const handleNewVersion = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
if (!selectedName) return
const fd = new FormData(e.currentTarget)
try {
await api.prompts.createVersion(selectedName, {
system_prompt: fd.get('system_prompt') as string,
changelog: (fd.get('changelog') as string) || undefined,
})
setShowNewVersion(false)
fetchVersions(selectedName)
} catch (err) {
console.error('Failed to create version:', err)
}
}
const handleRollback = async (name: string, version: number) => {
if (!confirm(`确认回退到版本 ${version}`)) return
try {
await api.prompts.rollback(name, version)
fetchVersions(name)
mutate()
} catch (err) {
console.error('Failed to rollback:', err)
}
}
const handleArchive = async (name: string) => {
if (!confirm(`确认归档 ${name}`)) return
try {
await api.prompts.archive(name)
mutate()
} catch (err) {
console.error('Failed to archive:', err)
}
}
const statusBadge = (status: string) => {
const colors: Record<string, string> = {
active: 'bg-emerald-500/20 text-emerald-400',
deprecated: 'bg-amber-500/20 text-amber-400',
archived: 'bg-zinc-500/20 text-zinc-400',
}
return (
<span className={`px-2 py-0.5 text-xs rounded-full ${colors[status] || colors.archived}`}>
{status}
</span>
)
}
const sourceBadge = (source: string) => {
const colors: Record<string, string> = {
builtin: 'bg-blue-500/20 text-blue-400',
custom: 'bg-purple-500/20 text-purple-400',
}
return (
<span className={`px-2 py-0.5 text-xs rounded-full ${colors[source] || ''}`}>
{source === 'builtin' ? '内置' : '自定义'}
</span>
)
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-white"></h1>
<p className="text-sm text-zinc-400 mt-1"> OTA </p>
</div>
<button
onClick={() => setShowCreate(true)}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm"
>
+
</button>
</div>
{/* Filters */}
<div className="flex gap-2">
{(['all', 'builtin', 'custom'] as const).map(s => (
<button
key={s}
onClick={() => setFilter(s === 'all' ? {} : { source: s })}
className={`px-3 py-1 text-sm rounded-lg transition-colors ${
(filter.source || 'all') === s
? 'bg-zinc-700 text-white'
: 'bg-zinc-800 text-zinc-400 hover:text-white'
}`}
>
{s === 'all' ? '全部' : s === 'builtin' ? '内置' : '自定义'}
</button>
))}
</div>
{/* Template List */}
<div className="bg-zinc-900 rounded-xl border border-zinc-800 overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-zinc-800">
<th className="text-left px-4 py-3 text-zinc-400 font-medium"></th>
<th className="text-left px-4 py-3 text-zinc-400 font-medium"></th>
<th className="text-left px-4 py-3 text-zinc-400 font-medium"></th>
<th className="text-left px-4 py-3 text-zinc-400 font-medium"></th>
<th className="text-left px-4 py-3 text-zinc-400 font-medium"></th>
<th className="text-left px-4 py-3 text-zinc-400 font-medium"></th>
<th className="text-right px-4 py-3 text-zinc-400 font-medium"></th>
</tr>
</thead>
<tbody>
{isLoading ? (
<tr>
<td colSpan={7}>
<TableSkeleton rows={5} cols={7} hasToolbar={false} />
</td>
</tr>
) : error ? (
<tr><td colSpan={7} className="px-4 py-8 text-center text-red-400"></td></tr>
) : templates.length === 0 ? (
<tr><td colSpan={7}><EmptyState message="暂无提示词模板" /></td></tr>
) : (
templates.map(t => (
<tr key={t.id} className="border-b border-zinc-800/50 hover:bg-zinc-800/30">
<td className="px-4 py-3">
<button
onClick={() => fetchVersions(t.name)}
className="text-blue-400 hover:text-blue-300 font-mono"
>
{t.name}
</button>
</td>
<td className="px-4 py-3 text-zinc-400">{t.category}</td>
<td className="px-4 py-3">{sourceBadge(t.source)}</td>
<td className="px-4 py-3 text-zinc-300">v{t.current_version}</td>
<td className="px-4 py-3">{statusBadge(t.status)}</td>
<td className="px-4 py-3 text-zinc-500 text-xs">
{new Date(t.updated_at).toLocaleString('zh-CN')}
</td>
<td className="px-4 py-3 text-right">
<button
onClick={() => fetchVersions(t.name)}
className="text-zinc-400 hover:text-white mr-2"
>
</button>
{t.source === 'custom' && (
<button
onClick={() => handleArchive(t.name)}
className="text-red-400 hover:text-red-300"
>
</button>
)}
</td>
</tr>
))
)}
</tbody>
</table>
<div className="px-4 py-2 text-xs text-zinc-500 border-t border-zinc-800">
{total}
</div>
</div>
{/* Version History Panel */}
{selectedName && (
<div className="bg-zinc-900 rounded-xl border border-zinc-800 p-4">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-white">
{selectedName}
</h2>
<div className="flex gap-2">
<button
onClick={() => setShowNewVersion(true)}
className="px-3 py-1.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 text-xs"
>
</button>
<button
onClick={() => { setSelectedName(null); setVersions([]) }}
className="px-3 py-1.5 bg-zinc-700 text-white rounded-lg hover:bg-zinc-600 text-xs"
>
</button>
</div>
</div>
<div className="space-y-3">
{versions.map(v => (
<div key={v.id} className="bg-zinc-800/50 rounded-lg p-3">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-mono text-zinc-300">v{v.version}</span>
<div className="flex items-center gap-2">
<span className="text-xs text-zinc-500">
{new Date(v.created_at).toLocaleString('zh-CN')}
</span>
{v.changelog && (
<span className="text-xs text-zinc-400"> {v.changelog}</span>
)}
{v.min_app_version && (
<span className="text-xs text-amber-400">: {v.min_app_version}</span>
)}
</div>
</div>
<pre className="text-xs text-zinc-400 bg-zinc-900 rounded p-2 overflow-x-auto max-h-32">
{v.system_prompt.substring(0, 300)}{v.system_prompt.length > 300 ? '...' : ''}
</pre>
<div className="mt-2 flex gap-2">
<button
onClick={() => {
navigator.clipboard.writeText(v.system_prompt)
}}
className="text-xs text-zinc-500 hover:text-white"
>
</button>
<button
onClick={() => handleRollback(selectedName, v.version)}
className="text-xs text-amber-500 hover:text-amber-400"
>
退
</button>
</div>
</div>
))}
{versions.length === 0 && (
<EmptyState message="暂无版本历史" />
)}
</div>
</div>
)}
{/* Create Modal */}
{showCreate && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<form onSubmit={handleCreate} className="bg-zinc-900 rounded-xl border border-zinc-700 p-6 w-full max-w-lg space-y-4">
<h2 className="text-lg font-semibold text-white"></h2>
<div>
<label className="block text-sm text-zinc-400 mb-1"></label>
<input name="name" required className="w-full px-3 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-white text-sm" placeholder="my_prompt" />
</div>
<div>
<label className="block text-sm text-zinc-400 mb-1"></label>
<select name="category" className="w-full px-3 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-white text-sm">
<option value="custom_system"></option>
<option value="custom_extraction"></option>
<option value="custom_compaction"></option>
<option value="custom_other"></option>
</select>
</div>
<div>
<label className="block text-sm text-zinc-400 mb-1"></label>
<input name="description" className="w-full px-3 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-white text-sm" placeholder="可选" />
</div>
<div>
<label className="block text-sm text-zinc-400 mb-1"></label>
<textarea name="system_prompt" required rows={6} className="w-full px-3 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-white text-sm font-mono" />
</div>
<div className="flex gap-2 justify-end">
<button type="button" onClick={() => setShowCreate(false)} className="px-4 py-2 bg-zinc-700 text-white rounded-lg hover:bg-zinc-600 text-sm"></button>
<button type="submit" className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 text-sm"></button>
</div>
</form>
</div>
)}
{/* New Version Modal */}
{showNewVersion && selectedName && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<form onSubmit={handleNewVersion} className="bg-zinc-900 rounded-xl border border-zinc-700 p-6 w-full max-w-lg space-y-4">
<h2 className="text-lg font-semibold text-white"> {selectedName} </h2>
<div>
<label className="block text-sm text-zinc-400 mb-1"></label>
<textarea name="system_prompt" required rows={6} className="w-full px-3 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-white text-sm font-mono" />
</div>
<div>
<label className="block text-sm text-zinc-400 mb-1"></label>
<input name="changelog" className="w-full px-3 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-white text-sm" placeholder="描述本次变更" />
</div>
<div className="flex gap-2 justify-end">
<button type="button" onClick={() => setShowNewVersion(false)} className="px-4 py-2 bg-zinc-700 text-white rounded-lg hover:bg-zinc-600 text-sm"></button>
<button type="submit" className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 text-sm"></button>
</div>
</form>
</div>
)}
</div>
)
}

View File

@@ -1,6 +1,7 @@
'use client' 'use client'
import { useEffect, useState, useCallback } from 'react' import { useState } from 'react'
import useSWR from 'swr'
import { import {
Plus, Plus,
Loader2, Loader2,
@@ -8,6 +9,9 @@ import {
ChevronRight, ChevronRight,
Pencil, Pencil,
Trash2, Trash2,
KeyRound,
Power,
PowerOff,
} from 'lucide-react' } from 'lucide-react'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
@@ -37,10 +41,18 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select' } from '@/components/ui/select'
import { TableSkeleton } from '@/components/ui/skeleton'
import { ErrorBanner, EmptyState } from '@/components/ui/state'
import { api } from '@/lib/api-client' import { api } from '@/lib/api-client'
import { ApiRequestError } from '@/lib/api-client' import { ApiRequestError } from '@/lib/api-client'
import { formatDate, maskApiKey } from '@/lib/utils' import { formatDate, maskApiKey } from '@/lib/utils'
import type { Provider } from '@/lib/types'
function formatTokens(tokens: number): string {
if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`
if (tokens >= 1_000) return `${(tokens / 1_000).toFixed(1)}K`
return String(tokens)
}
import type { Provider, ProviderKey } from '@/lib/types'
const PAGE_SIZE = 20 const PAGE_SIZE = 20
@@ -67,12 +79,17 @@ const emptyForm: ProviderForm = {
} }
export default function ProvidersPage() { export default function ProvidersPage() {
const [providers, setProviders] = useState<Provider[]>([])
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('') const [error, setError] = useState('')
// SWR for providers list
const { data, isLoading, mutate } = useSWR(
['providers', page],
() => api.providers.list({ page, page_size: PAGE_SIZE })
)
const providers = data?.items ?? []
const total = data?.total ?? 0
// 创建/编辑 Dialog // 创建/编辑 Dialog
const [dialogOpen, setDialogOpen] = useState(false) const [dialogOpen, setDialogOpen] = useState(false)
const [editTarget, setEditTarget] = useState<Provider | null>(null) const [editTarget, setEditTarget] = useState<Provider | null>(null)
@@ -83,24 +100,24 @@ export default function ProvidersPage() {
const [deleteTarget, setDeleteTarget] = useState<Provider | null>(null) const [deleteTarget, setDeleteTarget] = useState<Provider | null>(null)
const [deleting, setDeleting] = useState(false) const [deleting, setDeleting] = useState(false)
const fetchProviders = useCallback(async () => { // Key Pool 管理
setLoading(true) const [keyPoolProvider, setKeyPoolProvider] = useState<Provider | null>(null)
setError('') const [showAddKey, setShowAddKey] = useState(false)
try { const [addKeyForm, setAddKeyForm] = useState({
const res = await api.providers.list({ page, page_size: PAGE_SIZE }) key_label: '',
setProviders(res.items) key_value: '',
setTotal(res.total) priority: 0,
} catch (err) { max_rpm: '',
if (err instanceof ApiRequestError) setError(err.body.message) max_tpm: '',
else setError('加载失败') quota_reset_interval: '',
} finally { })
setLoading(false) const [addingKey, setAddingKey] = useState(false)
}
}, [page])
useEffect(() => { // SWR for key pool — only fetches when dialog is open
fetchProviders() const { data: providerKeys = [], isLoading: keysLoading, mutate: mutateKeys } = useSWR(
}, [fetchProviders]) keyPoolProvider ? ['provider.keys', keyPoolProvider.id] : null,
() => api.providers.listKeys(keyPoolProvider!.id)
)
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)) const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
@@ -145,7 +162,7 @@ export default function ProvidersPage() {
await api.providers.create(payload) await api.providers.create(payload)
} }
setDialogOpen(false) setDialogOpen(false)
fetchProviders() mutate()
} catch (err) { } catch (err) {
if (err instanceof ApiRequestError) setError(err.body.message) if (err instanceof ApiRequestError) setError(err.body.message)
} finally { } finally {
@@ -159,7 +176,7 @@ export default function ProvidersPage() {
try { try {
await api.providers.delete(deleteTarget.id) await api.providers.delete(deleteTarget.id)
setDeleteTarget(null) setDeleteTarget(null)
fetchProviders() mutate()
} catch (err) { } catch (err) {
if (err instanceof ApiRequestError) setError(err.body.message) if (err instanceof ApiRequestError) setError(err.body.message)
} finally { } finally {
@@ -167,6 +184,55 @@ export default function ProvidersPage() {
} }
} }
// ── Key Pool 管理 ─────────────────────────────────────
function openKeyPool(provider: Provider) {
setKeyPoolProvider(provider)
setShowAddKey(false)
}
async function handleAddKey() {
if (!keyPoolProvider || !addKeyForm.key_label.trim() || !addKeyForm.key_value.trim()) return
setAddingKey(true)
try {
await api.providers.addKey(keyPoolProvider.id, {
key_label: addKeyForm.key_label.trim(),
key_value: addKeyForm.key_value.trim(),
priority: addKeyForm.priority,
max_rpm: addKeyForm.max_rpm ? parseInt(addKeyForm.max_rpm, 10) : undefined,
max_tpm: addKeyForm.max_tpm ? parseInt(addKeyForm.max_tpm, 10) : undefined,
quota_reset_interval: addKeyForm.quota_reset_interval.trim() || undefined,
})
setAddKeyForm({ key_label: '', key_value: '', priority: 0, max_rpm: '', max_tpm: '', quota_reset_interval: '' })
setShowAddKey(false)
mutateKeys()
} catch (err) {
if (err instanceof ApiRequestError) setError(err.body.message)
} finally {
setAddingKey(false)
}
}
async function handleToggleKey(keyId: string, active: boolean) {
if (!keyPoolProvider) return
try {
await api.providers.toggleKey(keyPoolProvider.id, keyId, active)
mutateKeys()
} catch (err) {
if (err instanceof ApiRequestError) setError(err.body.message)
}
}
async function handleDeleteKey(keyId: string) {
if (!keyPoolProvider || !confirm('确认删除此 Key')) return
try {
await api.providers.deleteKey(keyPoolProvider.id, keyId)
mutateKeys()
} catch (err) {
if (err instanceof ApiRequestError) setError(err.body.message)
}
}
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{/* 工具栏 */} {/* 工具栏 */}
@@ -178,21 +244,12 @@ export default function ProvidersPage() {
</Button> </Button>
</div> </div>
{error && ( {error && <ErrorBanner message={error} onDismiss={() => setError('')} />}
<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 ? ( {isLoading ? (
<div className="flex h-64 items-center justify-center"> <TableSkeleton rows={6} cols={9} hasToolbar={false} />
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /> ) : error ? null : providers.length === 0 ? (
</div> <EmptyState />
) : providers.length === 0 ? (
<div className="flex h-64 items-center justify-center text-muted-foreground text-sm">
</div>
) : ( ) : (
<> <>
<Table> <Table>
@@ -238,6 +295,9 @@ export default function ProvidersPage() {
</TableCell> </TableCell>
<TableCell className="text-right"> <TableCell className="text-right">
<div className="flex items-center justify-end gap-1"> <div className="flex items-center justify-end gap-1">
<Button variant="ghost" size="icon" onClick={() => openKeyPool(p)} title="Key Pool">
<KeyRound className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" onClick={() => openEditDialog(p)} title="编辑"> <Button variant="ghost" size="icon" onClick={() => openEditDialog(p)} title="编辑">
<Pencil className="h-4 w-4" /> <Pencil className="h-4 w-4" />
</Button> </Button>
@@ -381,6 +441,165 @@ export default function ProvidersPage() {
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{/* Key Pool 管理 Dialog */}
<Dialog open={!!keyPoolProvider} onOpenChange={() => setKeyPoolProvider(null)}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Key Pool {keyPoolProvider?.display_name || keyPoolProvider?.name}</DialogTitle>
<DialogDescription>
API Key
</DialogDescription>
</DialogHeader>
<div className="max-h-[50vh] overflow-y-auto scrollbar-thin">
{keysLoading ? (
<TableSkeleton rows={4} cols={8} hasToolbar={false} />
) : providerKeys.length === 0 && !showAddKey ? (
<div className="text-center py-8 text-muted-foreground text-sm">
<p> Key Pool</p>
<p className="mt-1 text-xs">使 API Key 退</p>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead>RPM</TableHead>
<TableHead>TPM</TableHead>
<TableHead></TableHead>
<TableHead>/Token</TableHead>
<TableHead> 429</TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{providerKeys.map((k) => {
const isCooling = k.cooldown_until && new Date(k.cooldown_until) > new Date()
return (
<TableRow key={k.id} className={isCooling ? 'opacity-60' : ''}>
<TableCell className="font-medium">{k.key_label}</TableCell>
<TableCell>{k.priority}</TableCell>
<TableCell className="text-muted-foreground">{k.max_rpm ?? '-'}</TableCell>
<TableCell className="text-muted-foreground">{k.max_tpm ?? '-'}</TableCell>
<TableCell>
<Badge variant={k.is_active ? 'success' : 'secondary'}>
{isCooling ? '冷却中' : k.is_active ? '活跃' : '禁用'}
</Badge>
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{k.total_requests} / {formatTokens(k.total_tokens)}
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{k.last_429_at ? formatDate(k.last_429_at) : '-'}
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => handleToggleKey(k.id, !k.is_active)}
title={k.is_active ? '禁用' : '启用'}
>
{k.is_active ? <PowerOff className="h-3.5 w-3.5 text-amber-500" /> : <Power className="h-3.5 w-3.5 text-green-500" />}
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => handleDeleteKey(k.id)}
title="删除"
>
<Trash2 className="h-3.5 w-3.5 text-destructive" />
</Button>
</div>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
)}
</div>
{!showAddKey ? (
<DialogFooter>
<Button variant="outline" onClick={() => setKeyPoolProvider(null)}></Button>
<Button onClick={() => setShowAddKey(true)}>
<Plus className="h-4 w-4 mr-2" />
Key
</Button>
</DialogFooter>
) : (
<div className="space-y-3 border-t pt-4">
<p className="text-sm font-medium"> Key</p>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label className="text-xs"> *</Label>
<Input
value={addKeyForm.key_label}
onChange={(e) => setAddKeyForm({ ...addKeyForm, key_label: e.target.value })}
placeholder="如 zhipu-coding-1"
/>
</div>
<div className="space-y-1">
<Label className="text-xs"></Label>
<Input
type="number"
value={addKeyForm.priority}
onChange={(e) => setAddKeyForm({ ...addKeyForm, priority: parseInt(e.target.value, 10) || 0 })}
placeholder="0"
/>
</div>
<div className="col-span-2 space-y-1">
<Label className="text-xs">API Key *</Label>
<Input
type="password"
value={addKeyForm.key_value}
onChange={(e) => setAddKeyForm({ ...addKeyForm, key_value: e.target.value })}
placeholder="输入 API Key"
/>
</div>
<div className="space-y-1">
<Label className="text-xs">RPM </Label>
<Input
type="number"
value={addKeyForm.max_rpm}
onChange={(e) => setAddKeyForm({ ...addKeyForm, max_rpm: e.target.value })}
placeholder="不限"
/>
</div>
<div className="space-y-1">
<Label className="text-xs">TPM </Label>
<Input
type="number"
value={addKeyForm.max_tpm}
onChange={(e) => setAddKeyForm({ ...addKeyForm, max_tpm: e.target.value })}
placeholder="不限"
/>
</div>
<div className="col-span-2 space-y-1">
<Label className="text-xs"></Label>
<Input
value={addKeyForm.quota_reset_interval}
onChange={(e) => setAddKeyForm({ ...addKeyForm, quota_reset_interval: e.target.value })}
placeholder="如 5h, 1d可选"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => { setShowAddKey(false); setAddKeyForm({ key_label: '', key_value: '', priority: 0, max_rpm: '', max_tpm: '', quota_reset_interval: '' }) }}>
</Button>
<Button onClick={handleAddKey} disabled={addingKey || !addKeyForm.key_label.trim() || !addKeyForm.key_value.trim()}>
{addingKey && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
</Button>
</DialogFooter>
</div>
)}
</DialogContent>
</Dialog>
</div> </div>
) )
} }

View File

@@ -1,6 +1,7 @@
'use client' 'use client'
import { useEffect, useState, useCallback } from 'react' import { useState } from 'react'
import useSWR from 'swr'
import { import {
Search, Search,
Loader2, Loader2,
@@ -28,7 +29,9 @@ import {
} from '@/components/ui/table' } from '@/components/ui/table'
import { api } from '@/lib/api-client' import { api } from '@/lib/api-client'
import { ApiRequestError } from '@/lib/api-client' import { ApiRequestError } from '@/lib/api-client'
import { formatDate, formatNumber } from '@/lib/utils' import { formatDate, formatNumber, getSwrErrorMessage } from '@/lib/utils'
import { ErrorBanner, EmptyState } from '@/components/ui/state'
import { TableSkeleton } from '@/components/ui/skeleton'
import type { RelayTask } from '@/lib/types' import type { RelayTask } from '@/lib/types'
const PAGE_SIZE = 20 const PAGE_SIZE = 20
@@ -48,34 +51,22 @@ const statusLabels: Record<string, string> = {
} }
export default function RelayPage() { export default function RelayPage() {
const [tasks, setTasks] = useState<RelayTask[]>([])
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
const [statusFilter, setStatusFilter] = useState<string>('all') const [statusFilter, setStatusFilter] = useState<string>('all')
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [expandedId, setExpandedId] = useState<string | null>(null) const [expandedId, setExpandedId] = useState<string | null>(null)
const fetchTasks = useCallback(async () => { const { data, error: swrError, isLoading } = useSWR(
setLoading(true) ['relay', page, statusFilter],
setError('') () => {
try {
const params: Record<string, unknown> = { page, page_size: PAGE_SIZE } const params: Record<string, unknown> = { page, page_size: PAGE_SIZE }
if (statusFilter !== 'all') params.status = statusFilter if (statusFilter !== 'all') params.status = statusFilter
const res = await api.relay.list(params) return 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(() => { const tasks = data?.items ?? []
fetchTasks() const total = data?.total ?? 0
}, [fetchTasks]) const error = getSwrErrorMessage(swrError)
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)) const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
@@ -101,21 +92,12 @@ export default function RelayPage() {
</Select> </Select>
</div> </div>
{error && ( {error && <ErrorBanner message={error} onDismiss={() => {}} />}
<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 ? ( {isLoading ? (
<div className="flex h-64 items-center justify-center"> <TableSkeleton rows={6} cols={10} />
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /> ) : error ? null : tasks.length === 0 ? (
</div> <EmptyState />
) : tasks.length === 0 ? (
<div className="flex h-64 items-center justify-center text-muted-foreground text-sm">
</div>
) : ( ) : (
<> <>
<Table> <Table>

View File

@@ -1,7 +1,8 @@
'use client' 'use client'
import { useEffect, useState, useCallback } from 'react' import { useState } from 'react'
import { Loader2, Zap } from 'lucide-react' import useSWR from 'swr'
import { Zap, Monitor, Smartphone } from 'lucide-react'
import { import {
LineChart, LineChart,
Line, Line,
@@ -15,6 +16,8 @@ import {
Legend, Legend,
} from 'recharts' } from 'recharts'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { ErrorBanner, EmptyState } from '@/components/ui/state'
import { TableSkeleton, ChartSkeleton } from '@/components/ui/skeleton'
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -22,84 +25,87 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select' } from '@/components/ui/select'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Badge } from '@/components/ui/badge'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { api } from '@/lib/api-client' import { api } from '@/lib/api-client'
import { ApiRequestError } from '@/lib/api-client'
import { formatNumber } from '@/lib/utils' import { formatNumber } from '@/lib/utils'
import type { UsageRecord, UsageByModel } from '@/lib/types' import type { UsageRecord, UsageByModel, ModelUsageStat, DailyUsageStat } from '@/lib/types'
export default function UsagePage() { export default function UsagePage() {
const [days, setDays] = useState(7) const [days, setDays] = useState(7)
const [dailyData, setDailyData] = useState<UsageRecord[]>([]) const [activeTab, setActiveTab] = useState('relay')
const [modelData, setModelData] = useState<UsageByModel[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('') const [error, setError] = useState('')
const fetchData = useCallback(async () => { // 4 parallel SWR calls — each loads independently
setLoading(true) const { data: dailyData = [], isLoading: dailyLoading } = useSWR(
setError('') ['usage.daily', days],
try { () => api.usage.daily({ days })
const [dailyRes, modelRes] = await Promise.allSettled([ )
api.usage.daily({ days }), const { data: modelData = [], isLoading: modelLoading } = useSWR(
api.usage.byModel({ days }), ['usage.byModel', days],
]) () => api.usage.byModel({ days })
if (dailyRes.status === 'fulfilled') setDailyData(dailyRes.value) )
else throw new Error('Failed to fetch daily usage') const { data: telemetryModels = [] } = useSWR(
if (modelRes.status === 'fulfilled') setModelData(modelRes.value) ['telemetry.modelStats'],
} catch (err) { () => api.telemetry.modelStats()
if (err instanceof ApiRequestError) setError(err.body.message) )
else setError('加载数据失败') const { data: telemetryDaily = [] } = useSWR(
} finally { ['telemetry.dailyStats', days],
setLoading(false) () => api.telemetry.dailyStats({ days })
} )
}, [days])
useEffect(() => { const relayLoading = dailyLoading || modelLoading
fetchData() const telemetryLoading = !telemetryModels.length && !telemetryDaily.length && (dailyLoading || modelLoading)
}, [fetchData])
const lineChartData = dailyData.map((r) => ({ // === Relay 用量图表数据 ===
const relayLineData = dailyData.map((r) => ({
day: r.day.slice(5), day: r.day.slice(5),
Input: r.input_tokens, Input: r.input_tokens,
Output: r.output_tokens, Output: r.output_tokens,
})) }))
const barChartData = modelData.map((r) => ({ const relayBarData = modelData.map((r) => ({
model: r.model_id, model: r.model_id,
请求量: r.count, 请求量: r.count,
Input: r.input_tokens, Input: r.input_tokens,
Output: r.output_tokens, Output: r.output_tokens,
})) }))
const totalInput = dailyData.reduce((s, r) => s + r.input_tokens, 0) const relayTotalInput = dailyData.reduce((s, r) => s + r.input_tokens, 0)
const totalOutput = dailyData.reduce((s, r) => s + r.output_tokens, 0) const relayTotalOutput = dailyData.reduce((s, r) => s + r.output_tokens, 0)
const totalRequests = dailyData.reduce((s, r) => s + r.count, 0) const relayTotalRequests = dailyData.reduce((s, r) => s + r.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) { const telemetryLineData = telemetryDaily.map((r) => ({
return ( day: r.day.slice(5),
<div className="flex h-[60vh] items-center justify-center"> Input: r.input_tokens,
<div className="text-center"> Output: r.output_tokens,
<p className="text-destructive">{error}</p> 设备数: r.unique_devices,
<button onClick={() => fetchData()} className="mt-4 text-sm text-primary hover:underline cursor-pointer"> }))
</button> const telemetryTotalInput = telemetryDaily.reduce((s, r) => s + r.input_tokens, 0)
</div> const telemetryTotalOutput = telemetryDaily.reduce((s, r) => s + r.output_tokens, 0)
</div> const telemetryTotalRequests = telemetryDaily.reduce((s, r) => s + r.request_count, 0)
)
} // === 合计 ===
const totalInput = relayTotalInput + telemetryTotalInput
const totalOutput = relayTotalOutput + telemetryTotalOutput
const totalRequests = relayTotalRequests + telemetryTotalRequests
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{error && <ErrorBanner message={error} onDismiss={() => setError('')} />}
{/* 时间范围 */} {/* 时间范围 */}
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className="text-sm text-muted-foreground">:</span> <span className="text-sm text-muted-foreground">:</span>
@@ -115,8 +121,8 @@ export default function UsagePage() {
</Select> </Select>
</div> </div>
{/* 汇总统计 */} {/* 汇总统计 — render immediately, use 0 while loading */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-5">
<Card> <Card>
<CardContent className="p-6"> <CardContent className="p-6">
<p className="text-sm text-muted-foreground"></p> <p className="text-sm text-muted-foreground"></p>
@@ -127,7 +133,7 @@ export default function UsagePage() {
</Card> </Card>
<Card> <Card>
<CardContent className="p-6"> <CardContent className="p-6">
<p className="text-sm text-muted-foreground">Input Tokens</p> <p className="text-sm text-muted-foreground"> Input Tokens</p>
<p className="mt-1 text-2xl font-bold text-blue-400"> <p className="mt-1 text-2xl font-bold text-blue-400">
{formatNumber(totalInput)} {formatNumber(totalInput)}
</p> </p>
@@ -135,101 +141,190 @@ export default function UsagePage() {
</Card> </Card>
<Card> <Card>
<CardContent className="p-6"> <CardContent className="p-6">
<p className="text-sm text-muted-foreground">Output Tokens</p> <p className="text-sm text-muted-foreground"> Output Tokens</p>
<p className="mt-1 text-2xl font-bold text-orange-400"> <p className="mt-1 text-2xl font-bold text-orange-400">
{formatNumber(totalOutput)} {formatNumber(totalOutput)}
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
<Card>
<CardContent className="p-6">
<div className="flex items-center gap-2">
<Monitor className="h-4 w-4 text-green-400" />
<p className="text-sm text-muted-foreground"></p>
</div>
<p className="mt-1 text-2xl font-bold text-green-400">
{formatNumber(relayTotalRequests)}
</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-6">
<div className="flex items-center gap-2">
<Smartphone className="h-4 w-4 text-purple-400" />
<p className="text-sm text-muted-foreground"></p>
</div>
<p className="mt-1 text-2xl font-bold text-purple-400">
{formatNumber(telemetryTotalRequests)}
</p>
</CardContent>
</Card>
</div> </div>
{/* Token 用量趋势 */} {/* Tab 切换 */}
<Card> <Tabs value={activeTab} onValueChange={setActiveTab}>
<CardHeader> <TabsList>
<CardTitle className="flex items-center gap-2 text-base"> <TabsTrigger value="relay">
<Zap className="h-4 w-4 text-primary" /> <Monitor className="h-4 w-4 mr-1" />
Token
</CardTitle> </TabsTrigger>
</CardHeader> <TabsTrigger value="telemetry">
<CardContent> <Smartphone className="h-4 w-4 mr-1" />
{lineChartData.length > 0 ? (
<ResponsiveContainer width="100%" height={320}> </TabsTrigger>
<LineChart data={lineChartData}> </TabsList>
<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>
{/* 按模型分布 */} {/* Relay 用量 Tab */}
<Card> <TabsContent value="relay" className="space-y-6">
<CardHeader> {relayLoading ? (
<CardTitle className="text-base"></CardTitle> <>
</CardHeader> <ChartSkeleton height={320} />
<CardContent> <ChartSkeleton height={280} />
{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"> <>
<Card>
</div> <CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Zap className="h-4 w-4 text-primary" />
Token
</CardTitle>
</CardHeader>
<CardContent>
{relayLineData.length > 0 ? (
<ResponsiveContainer width="100%" height={320}>
<LineChart data={relayLineData}>
<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>
) : (
<EmptyState message="暂无中转数据" />
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base"></CardTitle>
</CardHeader>
<CardContent>
{relayBarData.length > 0 ? (
<ResponsiveContainer width="100%" height={Math.max(200, relayBarData.length * 40)}>
<BarChart data={relayBarData} 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>
) : (
<EmptyState />
)}
</CardContent>
</Card>
</>
)} )}
</CardContent> </TabsContent>
</Card>
{/* 遥测 Tab */}
<TabsContent value="telemetry" className="space-y-6">
{telemetryLoading ? (
<>
<ChartSkeleton height={320} />
<TableSkeleton rows={5} cols={6} hasToolbar={false} />
</>
) : (
<>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Smartphone className="h-4 w-4 text-purple-400" />
Token
</CardTitle>
</CardHeader>
<CardContent>
{telemetryLineData.length > 0 ? (
<ResponsiveContainer width="100%" height={320}>
<LineChart data={telemetryLineData}>
<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>
) : (
<EmptyState message="暂无桌面端遥测数据(需要桌面端上报)" />
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base"></CardTitle>
</CardHeader>
<CardContent>
{telemetryModels.length > 0 ? (
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right">Input Tokens</TableHead>
<TableHead className="text-right">Output Tokens</TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{telemetryModels.map((stat) => (
<TableRow key={stat.model_id}>
<TableCell className="font-mono text-sm">{stat.model_id}</TableCell>
<TableCell className="text-right">{formatNumber(stat.request_count)}</TableCell>
<TableCell className="text-right text-blue-400">{formatNumber(stat.input_tokens)}</TableCell>
<TableCell className="text-right text-orange-400">{formatNumber(stat.output_tokens)}</TableCell>
<TableCell className="text-right">
{stat.avg_latency_ms !== null ? `${Math.round(stat.avg_latency_ms)}ms` : '-'}
</TableCell>
<TableCell className="text-right">
<Badge variant={stat.success_rate >= 0.95 ? 'default' : 'destructive'}>
{(stat.success_rate * 100).toFixed(1)}%
</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
) : (
<EmptyState />
)}
</CardContent>
</Card>
</>
)}
</TabsContent>
</Tabs>
</div> </div>
) )
} }

4
admin/src/app/icon.svg Normal file
View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<rect width="32" height="32" rx="6" fill="#0f172a"/>
<text x="16" y="22" font-family="system-ui, sans-serif" font-size="16" font-weight="700" fill="#60a5fa" text-anchor="middle">Z</text>
</svg>

After

Width:  |  Height:  |  Size: 282 B

View File

@@ -1,4 +1,5 @@
import type { Metadata } from 'next' import type { Metadata } from 'next'
import { SWRProvider } from '@/lib/swr-provider'
import './globals.css' import './globals.css'
export const metadata: Metadata = { export const metadata: Metadata = {
@@ -20,7 +21,9 @@ export default function RootLayout({
/> />
</head> </head>
<body className="min-h-screen bg-background font-sans antialiased"> <body className="min-h-screen bg-background font-sans antialiased">
{children} <SWRProvider>
{children}
</SWRProvider>
</body> </body>
</html> </html>
) )

View File

@@ -2,7 +2,7 @@
import { useState, type FormEvent } from 'react' import { useState, type FormEvent } from 'react'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { Lock, User, Loader2, Eye, EyeOff } from 'lucide-react' import { Lock, User, Loader2, Eye, EyeOff, ShieldCheck } from 'lucide-react'
import { api } from '@/lib/api-client' import { api } from '@/lib/api-client'
import { login } from '@/lib/auth' import { login } from '@/lib/auth'
import { ApiRequestError } from '@/lib/api-client' import { ApiRequestError } from '@/lib/api-client'
@@ -11,7 +11,9 @@ export default function LoginPage() {
const router = useRouter() const router = useRouter()
const [username, setUsername] = useState('') const [username, setUsername] = useState('')
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
const [totpCode, setTotpCode] = useState('')
const [showPassword, setShowPassword] = useState(false) const [showPassword, setShowPassword] = useState(false)
const [needTotp, setNeedTotp] = useState(false)
const [remember, setRemember] = useState(false) const [remember, setRemember] = useState(false)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [error, setError] = useState('') const [error, setError] = useState('')
@@ -31,12 +33,25 @@ export default function LoginPage() {
setLoading(true) setLoading(true)
try { try {
const res = await api.auth.login({ username: username.trim(), password }) const res = await api.auth.login({
username: username.trim(),
password,
totp_code: totpCode.trim() || undefined,
})
login(res.token, res.account) login(res.token, res.account)
router.replace('/') // 用 window.location.href 替代 router.replace 避免 Next.js RSC flight
// 导致 client component 树重建和 SWR abort 循环
window.location.href = '/'
} catch (err) { } catch (err) {
if (err instanceof ApiRequestError) { if (err instanceof ApiRequestError) {
setError(err.body.message || '登录失败,请检查用户名和密码') const msg = err.body.message || ''
// 后端返回 "需要 TOTP" 时显示 TOTP 输入框
if (msg.includes('TOTP') || msg.includes('totp') || msg.includes('2FA') || msg.includes('验证码') || err.status === 403) {
setNeedTotp(true)
setError(msg || '请输入两步验证码')
} else {
setError(msg || '登录失败,请检查用户名和密码')
}
} else { } else {
setError('网络错误,请稍后重试') setError('网络错误,请稍后重试')
} }
@@ -152,6 +167,35 @@ export default function LoginPage() {
</div> </div>
</div> </div>
{/* TOTP 验证码 */}
{needTotp && (
<div className="space-y-2">
<label
htmlFor="totp"
className="text-sm font-medium text-foreground"
>
</label>
<div className="relative">
<ShieldCheck className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<input
id="totp"
type="text"
placeholder="请输入 6 位验证码"
value={totpCode}
onChange={(e) => setTotpCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
maxLength={6}
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 tracking-widest"
autoComplete="one-time-code"
inputMode="numeric"
/>
</div>
<p className="text-xs text-muted-foreground">
使 App Google Authenticator
</p>
</div>
)}
{/* 记住我 */} {/* 记住我 */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<input <input

View File

@@ -1,48 +1,44 @@
'use client' 'use client'
import { useEffect, useState, type ReactNode } from 'react' import { useEffect, type ReactNode } from 'react'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { isAuthenticated, getAccount } from '@/lib/auth' import { isAuthenticated, clearAuth } from '@/lib/auth'
import type { AccountPublic } from '@/lib/types' import { api, ApiRequestError } from '@/lib/api-client'
interface AuthGuardProps { interface AuthGuardProps {
children: ReactNode children: ReactNode
} }
/**
* AuthGuard — 纯 useEffect redirect始终渲染 children
*
* 不做任何 loading/authorized 状态切换,避免组件卸载。
* useEffect 在客户端 hydration 后执行,检查认证状态。
*/
export function AuthGuard({ children }: AuthGuardProps) { export function AuthGuard({ children }: AuthGuardProps) {
const router = useRouter() const router = useRouter()
const [authorized, setAuthorized] = useState(false)
const [account, setAccount] = useState<AccountPublic | null>(null)
useEffect(() => { useEffect(() => {
if (!isAuthenticated()) { if (!isAuthenticated()) {
router.replace('/login') router.replace('/login')
return return
} }
setAccount(getAccount()) // 后台验证 token
setAuthorized(true) api.auth.me().catch((err) => {
if (err instanceof ApiRequestError && (err.status === 401 || err.status === 403)) {
clearAuth()
router.replace('/login')
}
})
}, [router]) }, [router])
if (!authorized) {
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>
)
}
return <>{children}</> return <>{children}</>
} }
export function useAuth() { export function useAuth() {
const [account, setAccount] = useState<AccountPublic | null>(null) // 简化版 — 直接读 localStorage
const [loading, setLoading] = useState(true) const account = typeof window !== 'undefined'
? JSON.parse(localStorage.getItem('zclaw_admin_account') || 'null')
useEffect(() => { : null
const acc = getAccount() return { account, loading: false, isAuthenticated: !!localStorage.getItem('zclaw_admin_token') }
setAccount(acc)
setLoading(false)
}, [])
return { account, loading, isAuthenticated: isAuthenticated() }
} }

View File

@@ -0,0 +1,115 @@
// ============================================================
// Skeleton 组件 — 替代全屏 spinner 的骨架屏
// ============================================================
import { cn } from '@/lib/utils'
function SkeletonBase({ className }: { className?: string }) {
return (
<div
className={cn(
'animate-pulse rounded-md bg-muted',
className,
)}
/>
)
}
/** 表格骨架屏 */
export function TableSkeleton({
rows = 5,
cols = 5,
hasToolbar = true,
}: {
rows?: number
cols?: number
hasToolbar?: boolean
}) {
return (
<div className="space-y-4">
{hasToolbar && (
<div className="flex items-center justify-between">
<SkeletonBase className="h-9 w-[200px]" />
<SkeletonBase className="h-9 w-[120px]" />
</div>
)}
<div className="rounded-md border border-border overflow-hidden">
{/* Header */}
<div className="border-b border-border bg-muted/30 px-4 py-3">
<div className="flex gap-4">
{Array.from({ length: cols }).map((_, i) => (
<SkeletonBase
key={i}
className={cn(
'h-4',
i === 0 ? 'w-[120px]' : i === cols - 1 ? 'w-[80px]' : 'w-[100px]',
)}
/>
))}
</div>
</div>
{/* Rows */}
{Array.from({ length: rows }).map((_, rowIdx) => (
<div
key={rowIdx}
className={cn(
'px-4 py-3',
rowIdx < rows - 1 && 'border-b border-border',
)}
>
<div className="flex gap-4">
{Array.from({ length: cols }).map((_, colIdx) => (
<SkeletonBase
key={colIdx}
className={cn(
'h-4',
colIdx === 0 ? 'w-[120px]' : colIdx === cols - 1 ? 'w-[80px]' : 'w-[100px]',
)}
/>
))}
</div>
</div>
))}
</div>
{/* Pagination */}
<div className="flex items-center justify-between">
<SkeletonBase className="h-4 w-[140px]" />
<div className="flex gap-2">
<SkeletonBase className="h-8 w-[80px]" />
<SkeletonBase className="h-8 w-[80px]" />
</div>
</div>
</div>
)
}
/** 统计卡片骨架屏 */
export function StatsSkeleton({ count = 4 }: { count?: number }) {
return (
<div className={`grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-${count}`}>
{Array.from({ length: count }).map((_, i) => (
<div key={i} className="rounded-lg border border-border p-6">
<SkeletonBase className="h-4 w-[80px]" />
<SkeletonBase className="mt-2 h-8 w-[100px]" />
<SkeletonBase className="mt-1 h-3 w-[120px]" />
</div>
))}
</div>
)
}
/** 图表骨架屏 */
export function ChartSkeleton({ height }: { height?: number }) {
return (
<div className="rounded-lg border border-border">
<div className="border-b border-border px-6 py-4">
<SkeletonBase className="h-5 w-[140px]" />
</div>
<div className="p-6">
<SkeletonBase className="w-full" />
</div>
</div>
)
}
export { SkeletonBase as Skeleton }

View File

@@ -0,0 +1,63 @@
'use client'
import { AlertCircle, Inbox } from 'lucide-react'
/** 统一的错误提示横幅 */
export function ErrorBanner({
message,
onDismiss,
}: {
message: string
onDismiss?: () => void
}) {
return (
<div className="rounded-md bg-destructive/10 border border-destructive/20 px-4 py-3 text-sm text-destructive flex items-center gap-2">
<AlertCircle className="h-4 w-4 shrink-0" />
<span className="flex-1">{message}</span>
{onDismiss && (
<button
onClick={onDismiss}
className="underline cursor-pointer shrink-0"
>
</button>
)}
</div>
)
}
/** 统一的空状态占位 */
export function EmptyState({
message = '暂无数据',
}: {
message?: string
}) {
return (
<div className="flex h-64 flex-col items-center justify-center gap-2 text-muted-foreground">
<Inbox className="h-8 w-8" />
<span className="text-sm">{message}</span>
</div>
)
}
/** 统一的加载失败提示 + 重试 */
export function ErrorRetry({
message = '请求失败,请重试',
onRetry,
}: {
message?: string
onRetry: () => void
}) {
return (
<div className="flex h-64 flex-col items-center justify-center gap-3 text-muted-foreground">
<AlertCircle className="h-8 w-8 text-destructive" />
<span className="text-sm">{message}</span>
<button
onClick={onRetry}
className="rounded-md bg-primary px-4 py-2 text-sm text-primary-foreground hover:bg-primary/90 transition-colors cursor-pointer"
>
</button>
</div>
)
}

View File

@@ -0,0 +1,16 @@
// ============================================================
// useDebounce — 防抖 hook
// ============================================================
import { useState, useEffect } from 'react'
export function useDebounce<T>(value: T, delay = 300): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value)
useEffect(() => {
const handler = setTimeout(() => setDebouncedValue(value), delay)
return () => clearTimeout(handler)
}, [value, delay])
return debouncedValue
}

View File

@@ -2,19 +2,25 @@
// ZCLAW SaaS Admin — 类型化 HTTP 客户端 // ZCLAW SaaS Admin — 类型化 HTTP 客户端
// ============================================================ // ============================================================
import { getToken, logout } from './auth' import { getToken, login as saveToken, logout, getAccount } from './auth'
import type { import type {
AccountPublic, AccountPublic,
AgentTemplate,
ApiError, ApiError,
ConfigItem, ConfigItem,
CreateTokenRequest, CreateTokenRequest,
DashboardStats, DashboardStats,
DailyUsageStat,
LoginRequest, LoginRequest,
LoginResponse, LoginResponse,
Model, Model,
ModelUsageStat,
OperationLog, OperationLog,
PaginatedResponse, PaginatedResponse,
PromptTemplate,
PromptVersion,
Provider, Provider,
ProviderKey,
RelayTask, RelayTask,
TokenInfo, TokenInfo,
UsageByModel, UsageByModel,
@@ -35,51 +41,132 @@ export class ApiRequestError extends Error {
// ── 基础请求 ────────────────────────────────────────────── // ── 基础请求 ──────────────────────────────────────────────
const BASE_URL = process.env.NEXT_PUBLIC_SAAS_API_URL || 'http://localhost:8080' const BASE_URL = process.env.NEXT_PUBLIC_SAAS_API_URL || '/api/v1'
const DEFAULT_TIMEOUT_MS = 10_000
const MAX_RETRIES = 2
function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms))
}
/** 判断是否为可重试的网络错误(不含 AbortError */
function isRetryableNetworkError(err: unknown): boolean {
// AbortError 不重试:可能是组件卸载或路由切换导致的外部取消
if (err instanceof DOMException && err.name === 'AbortError') return false
if (err instanceof TypeError) {
const msg = (err as TypeError).message
return msg.includes('Failed to fetch') || msg.includes('NetworkError') || msg.includes('ECONNREFUSED')
}
return false
}
/** 尝试刷新 Token成功返回新 token失败返回 null */
async function tryRefreshToken(): Promise<string | null> {
try {
const token = getToken()
if (!token) return null
const res = await fetch(`${BASE_URL}/auth/refresh`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
})
if (!res.ok) return null
const data = await res.json()
const newToken = data.token as string
const account = getAccount()
if (account && newToken) {
saveToken(newToken, account)
}
return newToken
} catch {
return null
}
}
async function request<T>( async function request<T>(
method: string, method: string,
path: string, path: string,
body?: unknown, body?: unknown,
_isRetry = false,
externalSignal?: AbortSignal,
): Promise<T> { ): Promise<T> {
const token = getToken() let lastError: unknown
const headers: Record<string, string> = {
'Content-Type': 'application/json',
}
if (token) {
headers['Authorization'] = `Bearer ${token}`
}
const res = await fetch(`${BASE_URL}${path}`, { for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
method, // Merge external signal (e.g. from SWR) with a timeout signal
headers, const signals: AbortSignal[] = [AbortSignal.timeout(DEFAULT_TIMEOUT_MS)]
body: body ? JSON.stringify(body) : undefined, if (externalSignal) signals.push(externalSignal)
}) const signal = signals.length === 1 ? signals[0] : AbortSignal.any(signals)
if (res.status === 401) {
logout()
if (typeof window !== 'undefined') {
window.location.href = '/login'
}
throw new ApiRequestError(401, { error: 'unauthorized', message: '登录已过期,请重新登录' })
}
if (!res.ok) {
let errorBody: ApiError
try { try {
errorBody = await res.json() const token = getToken()
} catch { const headers: Record<string, string> = {
errorBody = { error: 'unknown', message: `请求失败 (${res.status})` } 'Content-Type': 'application/json',
}
if (token) {
headers['Authorization'] = `Bearer ${token}`
}
const res = await fetch(`${BASE_URL}${path}`, {
method,
headers,
body: body ? JSON.stringify(body) : undefined,
signal,
})
// 401: 尝试刷新 Token 后重试
if (res.status === 401 && !_isRetry) {
const newToken = await tryRefreshToken()
if (newToken) {
return request<T>(method, path, body, true)
}
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})` }
}
throw new ApiRequestError(res.status, errorBody)
}
// 204 No Content
if (res.status === 204) {
return undefined as T
}
return res.json() as Promise<T>
} catch (err) {
// API 错误和外部取消的 AbortError 直接抛出,不重试
if (err instanceof ApiRequestError) throw err
if (err instanceof DOMException && err.name === 'AbortError') throw err
lastError = err
// 仅对可重试的网络错误重试
if (attempt < MAX_RETRIES && isRetryableNetworkError(err)) {
await sleep(1000 * Math.pow(2, attempt))
continue
}
throw err
} }
throw new ApiRequestError(res.status, errorBody)
} }
// 204 No Content throw lastError
if (res.status === 204) {
return undefined as T
}
return res.json() as Promise<T>
} }
// ── API 客户端 ──────────────────────────────────────────── // ── API 客户端 ────────────────────────────────────────────
@@ -88,7 +175,7 @@ export const api = {
// ── 认证 ────────────────────────────────────────────── // ── 认证 ──────────────────────────────────────────────
auth: { auth: {
async login(data: LoginRequest): Promise<LoginResponse> { async login(data: LoginRequest): Promise<LoginResponse> {
return request<LoginResponse>('POST', '/api/auth/login', data) return request<LoginResponse>('POST', '/auth/login', data)
}, },
async register(data: { async register(data: {
@@ -97,11 +184,11 @@ export const api = {
email: string email: string
display_name?: string display_name?: string
}): Promise<LoginResponse> { }): Promise<LoginResponse> {
return request<LoginResponse>('POST', '/api/auth/register', data) return request<LoginResponse>('POST', '/auth/register', data)
}, },
async me(): Promise<AccountPublic> { async me(): Promise<AccountPublic> {
return request<AccountPublic>('GET', '/api/auth/me') return request<AccountPublic>('GET', '/auth/me')
}, },
}, },
@@ -115,25 +202,25 @@ export const api = {
status?: string status?: string
}): Promise<PaginatedResponse<AccountPublic>> { }): Promise<PaginatedResponse<AccountPublic>> {
const qs = buildQueryString(params) const qs = buildQueryString(params)
return request<PaginatedResponse<AccountPublic>>('GET', `/api/accounts${qs}`) return request<PaginatedResponse<AccountPublic>>('GET', `/accounts${qs}`)
}, },
async get(id: string): Promise<AccountPublic> { async get(id: string): Promise<AccountPublic> {
return request<AccountPublic>('GET', `/api/accounts/${id}`) return request<AccountPublic>('GET', `/accounts/${id}`)
}, },
async update( async update(
id: string, id: string,
data: Partial<Pick<AccountPublic, 'display_name' | 'email' | 'role'>>, data: Partial<Pick<AccountPublic, 'display_name' | 'email' | 'role'>>,
): Promise<AccountPublic> { ): Promise<AccountPublic> {
return request<AccountPublic>('PATCH', `/api/accounts/${id}`, data) return request<AccountPublic>('PATCH', `/accounts/${id}`, data)
}, },
async updateStatus( async updateStatus(
id: string, id: string,
data: { status: AccountPublic['status'] }, data: { status: AccountPublic['status'] },
): Promise<void> { ): Promise<void> {
return request<void>('PATCH', `/api/accounts/${id}/status`, data) return request<void>('PATCH', `/accounts/${id}/status`, data)
}, },
}, },
@@ -144,22 +231,46 @@ export const api = {
page_size?: number page_size?: number
}): Promise<PaginatedResponse<Provider>> { }): Promise<PaginatedResponse<Provider>> {
const qs = buildQueryString(params) const qs = buildQueryString(params)
return request<PaginatedResponse<Provider>>('GET', `/api/providers${qs}`) return request<PaginatedResponse<Provider>>('GET', `/providers${qs}`)
}, },
async create(data: Partial<Omit<Provider, 'id' | 'created_at' | 'updated_at'>>): Promise<Provider> { async create(data: Partial<Omit<Provider, 'id' | 'created_at' | 'updated_at'>>): Promise<Provider> {
return request<Provider>('POST', '/api/providers', data) return request<Provider>('POST', '/providers', data)
}, },
async update( async update(
id: string, id: string,
data: Partial<Omit<Provider, 'id' | 'created_at' | 'updated_at'>>, data: Partial<Omit<Provider, 'id' | 'created_at' | 'updated_at'>>,
): Promise<Provider> { ): Promise<Provider> {
return request<Provider>('PATCH', `/api/providers/${id}`, data) return request<Provider>('PATCH', `/providers/${id}`, data)
}, },
async delete(id: string): Promise<void> { async delete(id: string): Promise<void> {
return request<void>('DELETE', `/api/providers/${id}`) return request<void>('DELETE', `/providers/${id}`)
},
// Key Pool 管理
async listKeys(providerId: string): Promise<ProviderKey[]> {
return request<ProviderKey[]>('GET', `/providers/${providerId}/keys`)
},
async addKey(providerId: string, data: {
key_label: string
key_value: string
priority?: number
max_rpm?: number
max_tpm?: number
quota_reset_interval?: string
}): Promise<{ ok: boolean; key_id: string }> {
return request<{ ok: boolean; key_id: string }>('POST', `/providers/${providerId}/keys`, data)
},
async toggleKey(providerId: string, keyId: string, active: boolean): Promise<{ ok: boolean }> {
return request<{ ok: boolean }>('PUT', `/providers/${providerId}/keys/${keyId}/toggle`, { active })
},
async deleteKey(providerId: string, keyId: string): Promise<{ ok: boolean }> {
return request<{ ok: boolean }>('DELETE', `/providers/${providerId}/keys/${keyId}`)
}, },
}, },
@@ -171,19 +282,19 @@ export const api = {
provider_id?: string provider_id?: string
}): Promise<PaginatedResponse<Model>> { }): Promise<PaginatedResponse<Model>> {
const qs = buildQueryString(params) const qs = buildQueryString(params)
return request<PaginatedResponse<Model>>('GET', `/api/models${qs}`) return request<PaginatedResponse<Model>>('GET', `/models${qs}`)
}, },
async create(data: Partial<Omit<Model, 'id'>>): Promise<Model> { async create(data: Partial<Omit<Model, 'id'>>): Promise<Model> {
return request<Model>('POST', '/api/models', data) return request<Model>('POST', '/models', data)
}, },
async update(id: string, data: Partial<Omit<Model, 'id'>>): Promise<Model> { async update(id: string, data: Partial<Omit<Model, 'id'>>): Promise<Model> {
return request<Model>('PATCH', `/api/models/${id}`, data) return request<Model>('PATCH', `/models/${id}`, data)
}, },
async delete(id: string): Promise<void> { async delete(id: string): Promise<void> {
return request<void>('DELETE', `/api/models/${id}`) return request<void>('DELETE', `/models/${id}`)
}, },
}, },
@@ -194,28 +305,30 @@ export const api = {
page_size?: number page_size?: number
}): Promise<PaginatedResponse<TokenInfo>> { }): Promise<PaginatedResponse<TokenInfo>> {
const qs = buildQueryString(params) const qs = buildQueryString(params)
return request<PaginatedResponse<TokenInfo>>('GET', `/api/tokens${qs}`) return request<PaginatedResponse<TokenInfo>>('GET', `/keys${qs}`)
}, },
async create(data: CreateTokenRequest): Promise<TokenInfo> { async create(data: CreateTokenRequest): Promise<TokenInfo> {
return request<TokenInfo>('POST', '/api/tokens', data) return request<TokenInfo>('POST', '/keys', data)
}, },
async revoke(id: string): Promise<void> { async revoke(id: string): Promise<void> {
return request<void>('DELETE', `/api/tokens/${id}`) return request<void>('DELETE', `/keys/${id}`)
}, },
}, },
// ── 用量统计 ────────────────────────────────────────── // ── 用量统计 ──────────────────────────────────────────
usage: { usage: {
async daily(params?: { days?: number }): Promise<UsageRecord[]> { async daily(params?: { days?: number }): Promise<UsageRecord[]> {
const qs = buildQueryString(params) const qs = buildQueryString({ ...params, group_by: 'day' })
return request<UsageRecord[]>('GET', `/api/usage/daily${qs}`) const result = await request<{ by_day: UsageRecord[] }>('GET', `/usage${qs}`)
return result.by_day || []
}, },
async byModel(params?: { days?: number }): Promise<UsageByModel[]> { async byModel(params?: { days?: number }): Promise<UsageByModel[]> {
const qs = buildQueryString(params) const qs = buildQueryString({ ...params, group_by: 'model' })
return request<UsageByModel[]>('GET', `/api/usage/by-model${qs}`) const result = await request<{ by_model: UsageByModel[] }>('GET', `/usage${qs}`)
return result.by_model || []
}, },
}, },
@@ -227,11 +340,11 @@ export const api = {
status?: string status?: string
}): Promise<PaginatedResponse<RelayTask>> { }): Promise<PaginatedResponse<RelayTask>> {
const qs = buildQueryString(params) const qs = buildQueryString(params)
return request<PaginatedResponse<RelayTask>>('GET', `/api/relay/tasks${qs}`) return request<PaginatedResponse<RelayTask>>('GET', `/relay/tasks${qs}`)
}, },
async get(id: string): Promise<RelayTask> { async get(id: string): Promise<RelayTask> {
return request<RelayTask>('GET', `/api/relay/tasks/${id}`) return request<RelayTask>('GET', `/relay/tasks/${id}`)
}, },
}, },
@@ -239,13 +352,16 @@ export const api = {
config: { config: {
async list(params?: { async list(params?: {
category?: string category?: string
page?: number
page_size?: number
}): Promise<ConfigItem[]> { }): Promise<ConfigItem[]> {
const qs = buildQueryString(params) const qs = buildQueryString(params)
return request<ConfigItem[]>('GET', `/api/config${qs}`) const result = await request<PaginatedResponse<ConfigItem>>('GET', `/config/items${qs}`)
return result.items
}, },
async update(id: string, data: { value: string | number | boolean }): Promise<ConfigItem> { async update(id: string, data: { value: string | number | boolean }): Promise<ConfigItem> {
return request<ConfigItem>('PATCH', `/api/config/${id}`, data) return request<ConfigItem>('PATCH', `/config/items/${id}`, data)
}, },
}, },
@@ -257,14 +373,149 @@ export const api = {
action?: string action?: string
}): Promise<PaginatedResponse<OperationLog>> { }): Promise<PaginatedResponse<OperationLog>> {
const qs = buildQueryString(params) const qs = buildQueryString(params)
return request<PaginatedResponse<OperationLog>>('GET', `/api/logs${qs}`) return request<PaginatedResponse<OperationLog>>('GET', `/logs/operations${qs}`)
}, },
}, },
// ── 仪表盘 ──────────────────────────────────────────── // ── 仪表盘 ────────────────────────────────────────────
stats: { stats: {
async dashboard(): Promise<DashboardStats> { async dashboard(): Promise<DashboardStats> {
return request<DashboardStats>('GET', '/api/stats/dashboard') return request<DashboardStats>('GET', '/stats/dashboard')
},
},
// ── 提示词管理 ────────────────────────────────────────
prompts: {
async list(params?: {
category?: string
source?: string
status?: string
page?: number
page_size?: number
}): Promise<PaginatedResponse<PromptTemplate>> {
const qs = buildQueryString(params)
return request<PaginatedResponse<PromptTemplate>>('GET', `/prompts${qs}`)
},
async get(name: string): Promise<PromptTemplate> {
return request<PromptTemplate>('GET', `/prompts/${encodeURIComponent(name)}`)
},
async create(data: {
name: string
category: string
description?: string
source?: string
system_prompt: string
user_prompt_template?: string
variables?: unknown[]
min_app_version?: string
}): Promise<PromptTemplate> {
return request<PromptTemplate>('POST', '/prompts', data)
},
async update(name: string, data: {
description?: string
status?: string
}): Promise<PromptTemplate> {
return request<PromptTemplate>('PUT', `/prompts/${encodeURIComponent(name)}`, data)
},
async archive(name: string): Promise<PromptTemplate> {
return request<PromptTemplate>('DELETE', `/prompts/${encodeURIComponent(name)}`)
},
async listVersions(name: string): Promise<PromptVersion[]> {
return request<PromptVersion[]>('GET', `/prompts/${encodeURIComponent(name)}/versions`)
},
async createVersion(name: string, data: {
system_prompt: string
user_prompt_template?: string
variables?: unknown[]
changelog?: string
min_app_version?: string
}): Promise<PromptVersion> {
return request<PromptVersion>('POST', `/prompts/${encodeURIComponent(name)}/versions`, data)
},
async rollback(name: string, version: number): Promise<PromptTemplate> {
return request<PromptTemplate>('POST', `/prompts/${encodeURIComponent(name)}/rollback/${version}`)
},
},
// ── Agent 配置模板 ──────────────────────────────────
agentTemplates: {
async list(params?: {
category?: string
source?: string
visibility?: string
status?: string
page?: number
page_size?: number
}): Promise<PaginatedResponse<AgentTemplate>> {
const qs = buildQueryString(params)
return request<PaginatedResponse<AgentTemplate>>('GET', `/agent-templates${qs}`)
},
async get(id: string): Promise<AgentTemplate> {
return request<AgentTemplate>('GET', `/agent-templates/${id}`)
},
async create(data: {
name: string
description?: string
category?: string
source?: string
model?: string
system_prompt?: string
tools?: string[]
capabilities?: string[]
temperature?: number
max_tokens?: number
visibility?: string
}): Promise<AgentTemplate> {
return request<AgentTemplate>('POST', '/agent-templates', data)
},
async update(id: string, data: {
description?: string
model?: string
system_prompt?: string
tools?: string[]
capabilities?: string[]
temperature?: number
max_tokens?: number
visibility?: string
status?: string
}): Promise<AgentTemplate> {
return request<AgentTemplate>('POST', `/agent-templates/${id}`, data)
},
async archive(id: string): Promise<AgentTemplate> {
return request<AgentTemplate>('DELETE', `/agent-templates/${id}`)
},
},
// ── 遥测统计 ──────────────────────────────────────────
telemetry: {
/** 按模型聚合用量统计 */
async modelStats(params?: {
from?: string
to?: string
model_id?: string
connection_mode?: string
}): Promise<ModelUsageStat[]> {
const qs = buildQueryString(params)
return request<ModelUsageStat[]>('GET', `/telemetry/stats${qs}`)
},
/** 按天聚合用量统计 */
async dailyStats(params?: {
days?: number
}): Promise<DailyUsageStat[]> {
const qs = buildQueryString(params)
return request<DailyUsageStat[]>('GET', `/telemetry/daily${qs}`)
}, },
}, },
} }

View File

@@ -0,0 +1,13 @@
// ============================================================
// API Error 类 — 与 swr-fetcher 共享
// ============================================================
export class ApiRequestError extends Error {
constructor(
public status: number,
public body: { error?: string; message?: string },
) {
super(body.message || `Request failed with status ${status}`)
this.name = 'ApiRequestError'
}
}

View File

@@ -21,6 +21,13 @@ export function logout(): void {
localStorage.removeItem(ACCOUNT_KEY) localStorage.removeItem(ACCOUNT_KEY)
} }
/** 清除认证状态(用于 Token 验证失败时) */
export function clearAuth(): void {
if (typeof window === 'undefined') return
localStorage.removeItem(TOKEN_KEY)
localStorage.removeItem(ACCOUNT_KEY)
}
/** 获取 JWT token */ /** 获取 JWT token */
export function getToken(): string | null { export function getToken(): string | null {
if (typeof window === 'undefined') return null if (typeof window === 'undefined') return null

View File

@@ -0,0 +1,75 @@
// ============================================================
// SWR fetcher — 将 SWR key 映射到 api-client 调用
// ============================================================
import { api } from './api-client'
import { ApiRequestError } from './api-client'
type ApiMethod = typeof api
/** SWR fetcher: key 可以是字符串或 [method-path, params] 元组 */
type SwrKey =
| string
| [string, ...unknown[]]
/** SWR fetcher 支持 AbortSignal 传递 */
type SwrFetcherArgs = { signal?: AbortSignal } | null
async function resolveApiCall(key: SwrKey, args: SwrFetcherArgs): Promise<unknown> {
if (typeof key === 'string') {
// 简单字符串 key直接 fetch
return fetchGeneric(key, args?.signal)
}
const [path, ...rest] = key
return callByPath(path, rest, args?.signal)
}
async function fetchGeneric(path: string, signal?: AbortSignal): Promise<unknown> {
const res = await fetch(path, {
headers: {
'Content-Type': 'application/json',
},
signal,
})
if (!res.ok) {
const body = await res.json().catch(() => ({ error: 'unknown', message: `请求失败 (${res.status})` }))
throw new ApiRequestError(res.status, body)
}
if (res.status === 204) return null
return res.json()
}
/** 根据 path 调用对应的 api 方法 */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async function callByPath(path: string, callArgs: unknown[], signal?: AbortSignal): Promise<unknown> {
const parts = path.split('.')
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let target: any = api
for (const part of parts) {
target = target[part]
if (!target) throw new Error(`API method not found: ${path}`)
}
// Append signal as last argument if the target is the request function
// For api.xxx() calls that ultimately use request(), we pass signal through
// The simplest approach: pass signal as part of an options bag
return target(...callArgs, signal ? { signal } : undefined)
}
/**
* SWR fetcher — 接受 SWR 自动传入的 AbortSignal
*
* 用法: useSWR(key, swrFetcher)
* SWR 会自动在组件卸载或 key 变化时 abort 请求
*/
export function swrFetcher<T = unknown>(key: SwrKey, args: SwrFetcherArgs): Promise<T> {
return resolveApiCall(key, args) as Promise<T>
}
/** 创建 SWR key helper — 类型安全 */
export function createKey<TMethod extends string>(
method: TMethod,
...args: unknown[]
): [TMethod, ...unknown[]] {
return [method, ...args]
}

View File

@@ -0,0 +1,47 @@
'use client'
import { SWRConfig } from 'swr'
import type { ReactNode } from 'react'
/** 判断是否为请求被中断(页面导航等场景) */
function isAbortError(err: unknown): boolean {
if (err instanceof DOMException && err.name === 'AbortError') return true
if (err instanceof Error && err.message?.includes('aborted')) return true
return false
}
export function SWRProvider({ children }: { children: ReactNode }) {
return (
<SWRConfig
value={{
// 关闭所有自动 revalidation — 只在手动 mutate 或 key 变化时刷新
revalidateOnFocus: false,
revalidateOnReconnect: false,
// 60s 去重窗口Dashboard 数据变化不频繁,避免短时间内重复请求
dedupingInterval: 60_000,
// 保留旧数据直到新数据返回,避免 loading 闪烁
keepPreviousData: true,
// 最多重试 1 次,间隔 3s
errorRetryCount: 1,
errorRetryInterval: 3000,
shouldRetryOnError: (err: unknown) => {
if (isAbortError(err)) return false
if (err && typeof err === 'object' && 'status' in err) {
const status = (err as { status: number }).status
return status !== 401 && status !== 403 && status !== 404
}
return true
},
onError: (err: unknown) => {
if (isAbortError(err)) return
},
}}
>
{children}
</SWRConfig>
)
}

View File

@@ -11,6 +11,7 @@ export interface AccountPublic {
role: 'super_admin' | 'admin' | 'user' role: 'super_admin' | 'admin' | 'user'
status: 'active' | 'disabled' | 'suspended' status: 'active' | 'disabled' | 'suspended'
totp_enabled: boolean totp_enabled: boolean
last_login_at: string | null
created_at: string created_at: string
} }
@@ -18,11 +19,13 @@ export interface AccountPublic {
export interface LoginRequest { export interface LoginRequest {
username: string username: string
password: string password: string
totp_code?: string
} }
/** 登录响应 */ /** 登录响应 */
export interface LoginResponse { export interface LoginResponse {
token: string token: string
refresh_token: string
account: AccountPublic account: AccountPublic
} }
@@ -49,10 +52,10 @@ export interface Provider {
display_name: string display_name: string
api_key?: string api_key?: string
base_url: string base_url: string
api_protocol: 'openai' | 'anthropic' api_protocol: string
enabled: boolean enabled: boolean
rate_limit_rpm?: number rate_limit_rpm: number | null
rate_limit_tpm?: number rate_limit_tpm: number | null
created_at: string created_at: string
updated_at: string updated_at: string
} }
@@ -97,15 +100,16 @@ export interface RelayTask {
account_id: string account_id: string
provider_id: string provider_id: string
model_id: string model_id: string
status: 'queued' | 'processing' | 'completed' | 'failed' status: string
priority: number priority: number
attempt_count: number attempt_count: number
max_attempts: number
input_tokens: number input_tokens: number
output_tokens: number output_tokens: number
error_message?: string error_message: string | null
queued_at?: string queued_at: string
started_at?: string started_at: string | null
completed_at?: string completed_at: string | null
created_at: string created_at: string
} }
@@ -130,23 +134,25 @@ export interface ConfigItem {
id: string id: string
category: string category: string
key_path: string key_path: string
value_type: 'string' | 'number' | 'boolean' value_type: string
current_value?: string | number | boolean current_value: string | null
default_value?: string | number | boolean default_value: string | null
source: 'default' | 'env' | 'db' source: string
description?: string description: string | null
requires_restart: boolean requires_restart: boolean
created_at: string
updated_at: string
} }
/** 操作日志 */ /** 操作日志 */
export interface OperationLog { export interface OperationLog {
id: string id: number
account_id: string account_id: string | null
action: string action: string
target_type: string target_type: string | null
target_id: string target_id: string | null
details?: string details: Record<string, unknown> | null
ip_address?: string ip_address: string | null
created_at: string created_at: string
} }
@@ -167,3 +173,127 @@ export interface ApiError {
message: string message: string
status?: number status?: number
} }
// ── 提示词模板 ────────────────────────────────────────────
/** 提示词模板 */
export interface PromptTemplate {
id: string
name: string
category: string
description?: string
source: 'builtin' | 'custom'
current_version: number
status: 'active' | 'deprecated' | 'archived'
created_at: string
updated_at: string
}
/** 提示词版本 */
export interface PromptVersion {
id: string
template_id: string
version: number
system_prompt: string
user_prompt_template?: string
variables: PromptVariable[]
changelog?: string
min_app_version?: string
created_at: string
}
/** 提示词变量定义 */
export interface PromptVariable {
name: string
type: 'string' | 'number' | 'select' | 'boolean'
default_value?: string
description?: string
required?: boolean
}
/** OTA 更新检查请求 */
export interface PromptCheckRequest {
device_id: string
versions: Record<string, number>
}
/** OTA 更新响应 */
export interface PromptCheckResponse {
updates: PromptUpdatePayload[]
server_time: string
}
/** 单个更新载荷 */
export interface PromptUpdatePayload {
name: string
version: number
system_prompt: string
user_prompt_template?: string
variables: PromptVariable[]
source: string
min_app_version?: string
changelog?: string
}
// ── Agent 配置模板 ────────────────────────────────────────
/** Agent 模板 */
export interface AgentTemplate {
id: string
name: string
description?: string
category: string
source: 'builtin' | 'custom'
model?: string
system_prompt?: string
tools: string[]
capabilities: string[]
temperature?: number
max_tokens?: number
visibility: 'public' | 'team' | 'private'
status: 'active' | 'archived'
current_version: number
created_at: string
updated_at: string
}
// ── Provider Key Pool ─────────────────────────────────────
/** Provider Key */
export interface ProviderKey {
id: string
provider_id: string
key_label: string
priority: number
max_rpm?: number
max_tpm?: number
quota_reset_interval?: string
is_active: boolean
last_429_at?: string
cooldown_until?: string
total_requests: number
total_tokens: number
created_at: string
updated_at: string
}
// ── 遥测统计 ────────────────────────────────────────────
/** 按模型聚合的用量统计 */
export interface ModelUsageStat {
model_id: string
request_count: number
input_tokens: number
output_tokens: number
avg_latency_ms: number | null
success_rate: number
}
/** 按天的用量统计 */
export interface DailyUsageStat {
day: string
request_count: number
input_tokens: number
output_tokens: number
unique_devices: number
}

View File

@@ -32,3 +32,14 @@ export function maskApiKey(key?: string): string {
export function sleep(ms: number): Promise<void> { export function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms)) return new Promise(resolve => setTimeout(resolve, ms))
} }
/** 从 SWR error 中提取用户可见消息,过滤 abort 错误 */
export function getSwrErrorMessage(err: unknown): string | undefined {
if (!err) return undefined
if (err instanceof DOMException && err.name === 'AbortError') return undefined
if (err instanceof Error) {
if (err.name === 'AbortError' || err.message?.includes('aborted')) return undefined
return err.message
}
return String(err)
}

View File

@@ -0,0 +1,33 @@
# ZCLAW SaaS 开发环境配置
# 通过 ZCLAW_ENV=development 或默认使用此配置
[server]
host = "0.0.0.0"
port = 8080
cors_origins = [] # 空 = 开发模式允许所有来源
[database]
url = "postgres://postgres:123123@localhost:5432/zclaw"
[auth]
jwt_expiration_hours = 24
totp_issuer = "ZCLAW SaaS (dev)"
refresh_token_hours = 168
[relay]
max_queue_size = 1000
max_concurrent_per_provider = 5
batch_window_ms = 50
retry_delay_ms = 1000
max_attempts = 3
[rate_limit]
requests_per_minute = 120
burst = 20
[scheduler]
jobs = [
{ name = "cleanup_rate_limit", interval = "5m", task = "cleanup_rate_limit", run_on_start = false },
{ name = "cleanup_refresh_tokens", interval = "1h", task = "cleanup_refresh_tokens", run_on_start = false },
{ name = "cleanup_devices", interval = "24h", task = "cleanup_devices", run_on_start = false },
]

View File

@@ -0,0 +1,35 @@
# ZCLAW SaaS 生产环境配置
# 通过 ZCLAW_ENV=production 使用此配置
[server]
host = "0.0.0.0"
port = 8080
# 生产环境必须配置 CORS 白名单
cors_origins = ["https://admin.zclaw.ai", "https://zclaw.ai"]
[database]
# 生产环境通过 ZCLAW_DATABASE_URL 环境变量覆盖,此处为占位
url = "postgres://zclaw:CHANGE_ME@db:5432/zclaw"
[auth]
jwt_expiration_hours = 12
totp_issuer = "ZCLAW SaaS"
refresh_token_hours = 168
[relay]
max_queue_size = 5000
max_concurrent_per_provider = 10
batch_window_ms = 50
retry_delay_ms = 2000
max_attempts = 3
[rate_limit]
requests_per_minute = 60
burst = 10
[scheduler]
jobs = [
{ name = "cleanup_rate_limit", interval = "5m", task = "cleanup_rate_limit", run_on_start = false },
{ name = "cleanup_refresh_tokens", interval = "1h", task = "cleanup_refresh_tokens", run_on_start = false },
{ name = "cleanup_devices", interval = "24h", task = "cleanup_devices", run_on_start = true },
]

31
config/saas-test.toml Normal file
View File

@@ -0,0 +1,31 @@
# ZCLAW SaaS 测试环境配置
# 通过 ZCLAW_ENV=test 使用此配置
[server]
host = "127.0.0.1"
port = 8090
cors_origins = []
[database]
# 测试环境使用独立数据库
url = "postgres://postgres:123123@localhost:5432/zclaw_test"
[auth]
jwt_expiration_hours = 1
totp_issuer = "ZCLAW SaaS (test)"
refresh_token_hours = 24
[relay]
max_queue_size = 100
max_concurrent_per_provider = 2
batch_window_ms = 10
retry_delay_ms = 100
max_attempts = 2
[rate_limit]
requests_per_minute = 200
burst = 50
[scheduler]
# 测试环境不启动定时任务
jobs = []

View File

@@ -1,21 +0,0 @@
[package]
name = "zclaw-channels"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
rust-version.workspace = true
description = "ZCLAW Channels - external platform adapters"
[dependencies]
zclaw-types = { workspace = true }
tokio = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
async-trait = { workspace = true }
reqwest = { workspace = true }
chrono = { workspace = true }

View File

@@ -1,71 +0,0 @@
//! Console channel adapter for testing
use async_trait::async_trait;
use std::sync::Arc;
use tokio::sync::mpsc;
use zclaw_types::Result;
use crate::{Channel, ChannelConfig, ChannelStatus, IncomingMessage, OutgoingMessage};
/// Console channel adapter (for testing)
pub struct ConsoleChannel {
config: ChannelConfig,
status: Arc<tokio::sync::RwLock<ChannelStatus>>,
}
impl ConsoleChannel {
pub fn new(config: ChannelConfig) -> Self {
Self {
config,
status: Arc::new(tokio::sync::RwLock::new(ChannelStatus::Disconnected)),
}
}
}
#[async_trait]
impl Channel for ConsoleChannel {
fn config(&self) -> &ChannelConfig {
&self.config
}
async fn connect(&self) -> Result<()> {
let mut status = self.status.write().await;
*status = ChannelStatus::Connected;
tracing::info!("Console channel connected");
Ok(())
}
async fn disconnect(&self) -> Result<()> {
let mut status = self.status.write().await;
*status = ChannelStatus::Disconnected;
tracing::info!("Console channel disconnected");
Ok(())
}
async fn status(&self) -> ChannelStatus {
self.status.read().await.clone()
}
async fn send(&self, message: OutgoingMessage) -> Result<String> {
// Print to console for testing
let msg_id = format!("console_{}", chrono::Utc::now().timestamp());
match &message.content {
crate::MessageContent::Text { text } => {
tracing::info!("[Console] To {}: {}", message.conversation_id, text);
}
_ => {
tracing::info!("[Console] To {}: {:?}", message.conversation_id, message.content);
}
}
Ok(msg_id)
}
async fn receive(&self) -> Result<mpsc::Receiver<IncomingMessage>> {
let (_tx, rx) = mpsc::channel(100);
// Console channel doesn't receive messages automatically
// Messages would need to be injected via a separate method
Ok(rx)
}
}

View File

@@ -1,5 +0,0 @@
//! Channel adapters
mod console;
pub use console::ConsoleChannel;

View File

@@ -1,94 +0,0 @@
//! Channel bridge manager
//!
//! Coordinates multiple channel adapters and routes messages.
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::RwLock;
use zclaw_types::Result;
use super::{Channel, ChannelConfig, OutgoingMessage};
/// Channel bridge manager
pub struct ChannelBridge {
channels: RwLock<HashMap<String, Arc<dyn Channel>>>,
configs: RwLock<HashMap<String, ChannelConfig>>,
}
impl ChannelBridge {
pub fn new() -> Self {
Self {
channels: RwLock::new(HashMap::new()),
configs: RwLock::new(HashMap::new()),
}
}
/// Register a channel adapter
pub async fn register(&self, channel: Arc<dyn Channel>) {
let config = channel.config().clone();
let mut channels = self.channels.write().await;
let mut configs = self.configs.write().await;
channels.insert(config.id.clone(), channel);
configs.insert(config.id.clone(), config);
}
/// Get a channel by ID
pub async fn get(&self, id: &str) -> Option<Arc<dyn Channel>> {
let channels = self.channels.read().await;
channels.get(id).cloned()
}
/// Get channel configuration
pub async fn get_config(&self, id: &str) -> Option<ChannelConfig> {
let configs = self.configs.read().await;
configs.get(id).cloned()
}
/// List all channels
pub async fn list(&self) -> Vec<ChannelConfig> {
let configs = self.configs.read().await;
configs.values().cloned().collect()
}
/// Connect all channels
pub async fn connect_all(&self) -> Result<()> {
let channels = self.channels.read().await;
for channel in channels.values() {
channel.connect().await?;
}
Ok(())
}
/// Disconnect all channels
pub async fn disconnect_all(&self) -> Result<()> {
let channels = self.channels.read().await;
for channel in channels.values() {
channel.disconnect().await?;
}
Ok(())
}
/// Send message through a specific channel
pub async fn send(&self, channel_id: &str, message: OutgoingMessage) -> Result<String> {
let channel = self.get(channel_id).await
.ok_or_else(|| zclaw_types::ZclawError::NotFound(format!("Channel not found: {}", channel_id)))?;
channel.send(message).await
}
/// Remove a channel
pub async fn remove(&self, id: &str) {
let mut channels = self.channels.write().await;
let mut configs = self.configs.write().await;
channels.remove(id);
configs.remove(id);
}
}
impl Default for ChannelBridge {
fn default() -> Self {
Self::new()
}
}

View File

@@ -1,109 +0,0 @@
//! Channel trait and types
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use zclaw_types::{Result, AgentId};
/// Channel configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChannelConfig {
/// Unique channel identifier
pub id: String,
/// Channel type (telegram, discord, slack, etc.)
pub channel_type: String,
/// Human-readable name
pub name: String,
/// Whether the channel is enabled
#[serde(default = "default_enabled")]
pub enabled: bool,
/// Channel-specific configuration
#[serde(default)]
pub config: serde_json::Value,
/// Associated agent for this channel
pub agent_id: Option<AgentId>,
}
fn default_enabled() -> bool { true }
/// Incoming message from a channel
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IncomingMessage {
/// Message ID from the platform
pub platform_id: String,
/// Channel/conversation ID
pub conversation_id: String,
/// Sender information
pub sender: MessageSender,
/// Message content
pub content: MessageContent,
/// Timestamp
pub timestamp: i64,
/// Reply-to message ID if any
pub reply_to: Option<String>,
}
/// Message sender information
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MessageSender {
pub id: String,
pub name: Option<String>,
pub username: Option<String>,
pub is_bot: bool,
}
/// Message content types
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum MessageContent {
Text { text: String },
Image { url: String, caption: Option<String> },
File { url: String, filename: String },
Audio { url: String },
Video { url: String },
Location { latitude: f64, longitude: f64 },
Sticker { emoji: Option<String>, url: Option<String> },
}
/// Outgoing message to a channel
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OutgoingMessage {
/// Conversation/channel ID to send to
pub conversation_id: String,
/// Message content
pub content: MessageContent,
/// Reply-to message ID if any
pub reply_to: Option<String>,
/// Whether to send silently (no notification)
pub silent: bool,
}
/// Channel connection status
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum ChannelStatus {
Disconnected,
Connecting,
Connected,
Error(String),
}
/// Channel trait for platform adapters
#[async_trait]
pub trait Channel: Send + Sync {
/// Get channel configuration
fn config(&self) -> &ChannelConfig;
/// Connect to the platform
async fn connect(&self) -> Result<()>;
/// Disconnect from the platform
async fn disconnect(&self) -> Result<()>;
/// Get current connection status
async fn status(&self) -> ChannelStatus;
/// Send a message
async fn send(&self, message: OutgoingMessage) -> Result<String>;
/// Receive incoming messages (streaming)
async fn receive(&self) -> Result<tokio::sync::mpsc::Receiver<IncomingMessage>>;
}

View File

@@ -1,11 +0,0 @@
//! ZCLAW Channels
//!
//! External platform adapters for unified message handling.
mod channel;
mod bridge;
mod adapters;
pub use channel::*;
pub use bridge::*;
pub use adapters::*;

View File

@@ -27,7 +27,7 @@ pub struct SqliteStorage {
} }
/// Database row structure for memory entry /// Database row structure for memory entry
struct MemoryRow { pub(crate) struct MemoryRow {
uri: String, uri: String,
memory_type: String, memory_type: String,
content: String, content: String,
@@ -289,6 +289,44 @@ impl sqlx::FromRow<'_, SqliteRow> for MemoryRow {
} }
} }
/// Private helper methods on SqliteStorage (NOT in impl VikingStorage block)
impl SqliteStorage {
/// Fetch memories by scope with importance-based ordering.
/// Used internally by find() for scope-based queries.
pub(crate) async fn fetch_by_scope_priv(&self, scope: Option<&str>, limit: usize) -> Result<Vec<MemoryRow>> {
let rows = if let Some(scope) = scope {
sqlx::query_as::<_, MemoryRow>(
r#"
SELECT uri, memory_type, content, keywords, importance, access_count, created_at, last_accessed, overview, abstract_summary
FROM memories
WHERE uri LIKE ?
ORDER BY importance DESC, access_count DESC
LIMIT ?
"#
)
.bind(format!("{}%", scope))
.bind(limit as i64)
.fetch_all(&self.pool)
.await
.map_err(|e| ZclawError::StorageError(format!("Failed to fetch by scope: {}", e)))?
} else {
sqlx::query_as::<_, MemoryRow>(
r#"
SELECT uri, memory_type, content, keywords, importance, access_count, created_at, last_accessed, overview, abstract_summary
FROM memories
ORDER BY importance DESC
LIMIT ?
"#
)
.bind(limit as i64)
.fetch_all(&self.pool)
.await
.map_err(|e| ZclawError::StorageError(format!("Failed to fetch by scope: {}", e)))?
};
Ok(rows)
}
}
#[async_trait] #[async_trait]
impl VikingStorage for SqliteStorage { impl VikingStorage for SqliteStorage {
async fn store(&self, entry: &MemoryEntry) -> Result<()> { async fn store(&self, entry: &MemoryEntry) -> Result<()> {
@@ -374,22 +412,61 @@ impl VikingStorage for SqliteStorage {
} }
async fn find(&self, query: &str, options: FindOptions) -> Result<Vec<MemoryEntry>> { async fn find(&self, query: &str, options: FindOptions) -> Result<Vec<MemoryEntry>> {
// Get all matching entries let limit = options.limit.unwrap_or(50).max(20); // Fetch more candidates for reranking
let rows = if let Some(ref scope) = options.scope {
sqlx::query_as::<_, MemoryRow>( // Strategy: use FTS5 for initial filtering when query is non-empty,
"SELECT uri, memory_type, content, keywords, importance, access_count, created_at, last_accessed, overview, abstract_summary FROM memories WHERE uri LIKE ?" // then score candidates with TF-IDF / embedding for precise ranking.
) // Fallback to scope-only scan when query is empty (e.g., "list all").
.bind(format!("{}%", scope)) let rows = if !query.is_empty() {
.fetch_all(&self.pool) // FTS5-powered candidate retrieval (fast, index-based)
.await let fts_candidates = if let Some(ref scope) = options.scope {
.map_err(|e| ZclawError::StorageError(format!("Failed to find memories: {}", e)))? sqlx::query_as::<_, MemoryRow>(
r#"
SELECT m.uri, m.memory_type, m.content, m.keywords, m.importance,
m.access_count, m.created_at, m.last_accessed, m.overview, m.abstract_summary
FROM memories m
INNER JOIN memories_fts f ON m.uri = f.uri
WHERE f.memories_fts MATCH ?
AND m.uri LIKE ?
ORDER BY f.rank
LIMIT ?
"#
)
.bind(query)
.bind(format!("{}%", scope))
.bind(limit as i64)
.fetch_all(&self.pool)
.await
} else {
sqlx::query_as::<_, MemoryRow>(
r#"
SELECT m.uri, m.memory_type, m.content, m.keywords, m.importance,
m.access_count, m.created_at, m.last_accessed, m.overview, m.abstract_summary
FROM memories m
INNER JOIN memories_fts f ON m.uri = f.uri
WHERE f.memories_fts MATCH ?
ORDER BY f.rank
LIMIT ?
"#
)
.bind(query)
.bind(limit as i64)
.fetch_all(&self.pool)
.await
};
match fts_candidates {
Ok(rows) if !rows.is_empty() => rows,
Ok(_) | Err(_) => {
// FTS5 returned nothing or query syntax was invalid —
// fallback to scope-based scan (no full table scan unless no scope)
tracing::debug!("[SqliteStorage] FTS5 returned no results, falling back to scope scan");
self.fetch_by_scope_priv(options.scope.as_deref(), limit).await?
}
}
} else { } else {
sqlx::query_as::<_, MemoryRow>( // Empty query: scope-based scan only (no FTS5 needed)
"SELECT uri, memory_type, content, keywords, importance, access_count, created_at, last_accessed, overview, abstract_summary FROM memories" self.fetch_by_scope_priv(options.scope.as_deref(), limit).await?
)
.fetch_all(&self.pool)
.await
.map_err(|e| ZclawError::StorageError(format!("Failed to find memories: {}", e)))?
}; };
// Convert to entries and compute semantic scores // Convert to entries and compute semantic scores
@@ -464,16 +541,8 @@ 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 = self.fetch_by_scope_priv(Some(prefix), 100).await?;
"SELECT uri, memory_type, content, keywords, importance, access_count, created_at, last_accessed, overview, abstract_summary FROM memories WHERE uri LIKE ?"
)
.bind(format!("{}%", prefix))
.fetch_all(&self.pool)
.await
.map_err(|e| ZclawError::StorageError(format!("Failed to find by prefix: {}", e)))?;
let entries = rows.iter().map(|row| self.row_to_entry(row)).collect(); let entries = rows.iter().map(|row| self.row_to_entry(row)).collect();
Ok(entries) Ok(entries)
} }
@@ -484,13 +553,13 @@ impl VikingStorage for SqliteStorage {
.await .await
.map_err(|e| ZclawError::StorageError(format!("Failed to delete memory: {}", e)))?; .map_err(|e| ZclawError::StorageError(format!("Failed to delete memory: {}", e)))?;
// Remove from FTS // Remove from FTS index
let _ = sqlx::query("DELETE FROM memories_fts WHERE uri = ?") let _ = sqlx::query("DELETE FROM memories_fts WHERE uri = ?")
.bind(uri) .bind(uri)
.execute(&self.pool) .execute(&self.pool)
.await; .await;
// Remove from scorer // Remove from in-memory scorer
let mut scorer = self.scorer.write().await; let mut scorer = self.scorer.write().await;
scorer.remove_entry(uri); scorer.remove_entry(uri);

View File

@@ -20,3 +20,6 @@ thiserror = { workspace = true }
tracing = { workspace = true } tracing = { workspace = true }
async-trait = { workspace = true } async-trait = { workspace = true }
reqwest = { workspace = true } reqwest = { workspace = true }
hmac = "0.12"
sha1 = "0.10"
base64 = { workspace = true }

View File

@@ -157,11 +157,22 @@ impl BrowserHand {
} }
} }
/// Check if WebDriver is available /// Check if WebDriver is available by probing common ports
fn check_webdriver(&self) -> bool { fn check_webdriver(&self) -> bool {
// Check if ChromeDriver or GeckoDriver is running use std::net::TcpStream;
// For now, return true as the actual check would require network access use std::time::Duration;
true
// Probe default WebDriver ports: ChromeDriver (9515), GeckoDriver (4444), Edge (17556)
let ports = [9515, 4444, 17556];
for port in ports {
let addr = format!("127.0.0.1:{}", port);
if let Ok(addr) = addr.parse() {
if TcpStream::connect_timeout(&addr, Duration::from_millis(500)).is_ok() {
return true;
}
}
}
false
} }
} }

View File

@@ -233,17 +233,32 @@ impl SpeechHand {
state.playback = PlaybackState::Playing; state.playback = PlaybackState::Playing;
state.current_text = Some(text.clone()); state.current_text = Some(text.clone());
// In real implementation, would call TTS API // Determine TTS method based on provider:
// - Browser: frontend uses Web Speech API (zero deps, works offline)
// - OpenAI: frontend calls speech_tts command (high-quality, needs API key)
// - Others: future support
let tts_method = match state.config.provider {
TtsProvider::Browser => "browser",
TtsProvider::OpenAI => "openai_api",
TtsProvider::Azure => "azure_api",
TtsProvider::ElevenLabs => "elevenlabs_api",
TtsProvider::Local => "local_engine",
};
let estimated_duration_ms = (text.chars().count() as f64 / 5.0 * 1000.0) as u64;
Ok(HandResult::success(serde_json::json!({ Ok(HandResult::success(serde_json::json!({
"status": "speaking", "status": "speaking",
"tts_method": tts_method,
"text": text, "text": text,
"voice": voice_id, "voice": voice_id,
"language": lang, "language": lang,
"rate": actual_rate, "rate": actual_rate,
"pitch": actual_pitch, "pitch": actual_pitch,
"volume": actual_volume, "volume": actual_volume,
"provider": state.config.provider, "provider": format!("{:?}", state.config.provider).to_lowercase(),
"duration_ms": text.len() as u64 * 80, // Rough estimate "duration_ms": estimated_duration_ms,
"instruction": "Frontend should play this via TTS engine"
}))) })))
} }
SpeechAction::SpeakSsml { ssml, voice } => { SpeechAction::SpeakSsml { ssml, voice } => {

View File

@@ -289,117 +289,435 @@ impl TwitterHand {
c.clone() c.clone()
} }
/// Execute tweet action /// Execute tweet action — POST /2/tweets
async fn execute_tweet(&self, config: &TweetConfig) -> Result<Value> { async fn execute_tweet(&self, config: &TweetConfig) -> Result<Value> {
let _creds = self.get_credentials().await let creds = self.get_credentials().await
.ok_or_else(|| zclaw_types::ZclawError::HandError("Twitter credentials not configured".to_string()))?; .ok_or_else(|| zclaw_types::ZclawError::HandError("Twitter credentials not configured".to_string()))?;
// Simulated tweet response (actual implementation would use Twitter API) let client = reqwest::Client::new();
// In production, this would call Twitter API v2: POST /2/tweets let body = json!({ "text": config.text });
let response = client.post("https://api.twitter.com/2/tweets")
.header("Authorization", format!("Bearer {}", creds.bearer_token.as_deref().unwrap_or("")))
.header("Content-Type", "application/json")
.header("User-Agent", "ZCLAW/1.0")
.json(&body)
.send()
.await
.map_err(|e| zclaw_types::ZclawError::HandError(format!("Twitter API request failed: {}", e)))?;
let status = response.status();
let response_text = response.text().await
.map_err(|e| zclaw_types::ZclawError::HandError(format!("Failed to read response: {}", e)))?;
if !status.is_success() {
tracing::warn!("[TwitterHand] Tweet failed: {} - {}", status, response_text);
return Ok(json!({
"success": false,
"error": format!("Twitter API returned {}: {}", status, response_text),
"status_code": status.as_u16()
}));
}
// Parse the response to extract tweet_id
let parsed: Value = serde_json::from_str(&response_text).unwrap_or(json!({"raw": response_text}));
Ok(json!({ Ok(json!({
"success": true, "success": true,
"tweet_id": format!("simulated_{}", chrono::Utc::now().timestamp()), "tweet_id": parsed["data"]["id"].as_str().unwrap_or("unknown"),
"text": config.text, "text": config.text,
"created_at": chrono::Utc::now().to_rfc3339(), "raw_response": parsed,
"message": "Tweet posted successfully (simulated)", "message": "Tweet posted successfully"
"note": "Connect Twitter API credentials for actual posting"
})) }))
} }
/// Execute search action /// Execute search action — GET /2/tweets/search/recent
async fn execute_search(&self, config: &SearchConfig) -> Result<Value> { async fn execute_search(&self, config: &SearchConfig) -> Result<Value> {
let _creds = self.get_credentials().await let creds = self.get_credentials().await
.ok_or_else(|| zclaw_types::ZclawError::HandError("Twitter credentials not configured".to_string()))?; .ok_or_else(|| zclaw_types::ZclawError::HandError("Twitter credentials not configured".to_string()))?;
// Simulated search response let client = reqwest::Client::new();
// In production, this would call Twitter API v2: GET /2/tweets/search/recent let max = config.max_results.max(10).min(100);
let response = client.get("https://api.twitter.com/2/tweets/search/recent")
.header("Authorization", format!("Bearer {}", creds.bearer_token.as_deref().unwrap_or("")))
.header("User-Agent", "ZCLAW/1.0")
.query(&[
("query", config.query.as_str()),
("max_results", max.to_string().as_str()),
("tweet.fields", "created_at,author_id,public_metrics,lang"),
])
.send()
.await
.map_err(|e| zclaw_types::ZclawError::HandError(format!("Twitter search failed: {}", e)))?;
let status = response.status();
let response_text = response.text().await
.map_err(|e| zclaw_types::ZclawError::HandError(format!("Failed to read response: {}", e)))?;
if !status.is_success() {
return Ok(json!({
"success": false,
"error": format!("Twitter API returned {}: {}", status, response_text),
"status_code": status.as_u16()
}));
}
let parsed: Value = serde_json::from_str(&response_text).unwrap_or(json!({"raw": response_text}));
Ok(json!({ Ok(json!({
"success": true, "success": true,
"query": config.query, "query": config.query,
"tweets": [], "tweets": parsed["data"].as_array().cloned().unwrap_or_default(),
"meta": { "meta": parsed["meta"].clone(),
"result_count": 0, "message": "Search completed"
"newest_id": null,
"oldest_id": null,
"next_token": null
},
"message": "Search completed (simulated - no actual results without API)",
"note": "Connect Twitter API credentials for actual search results"
})) }))
} }
/// Execute timeline action /// Execute timeline action — GET /2/users/:id/timelines/reverse_chronological
async fn execute_timeline(&self, config: &TimelineConfig) -> Result<Value> { async fn execute_timeline(&self, config: &TimelineConfig) -> Result<Value> {
let _creds = self.get_credentials().await let creds = self.get_credentials().await
.ok_or_else(|| zclaw_types::ZclawError::HandError("Twitter credentials not configured".to_string()))?; .ok_or_else(|| zclaw_types::ZclawError::HandError("Twitter credentials not configured".to_string()))?;
// Simulated timeline response let client = reqwest::Client::new();
let user_id = config.user_id.as_deref().unwrap_or("me");
let url = format!("https://api.twitter.com/2/users/{}/timelines/reverse_chronological", user_id);
let max = config.max_results.max(5).min(100);
let response = client.get(&url)
.header("Authorization", format!("Bearer {}", creds.bearer_token.as_deref().unwrap_or("")))
.header("User-Agent", "ZCLAW/1.0")
.query(&[
("max_results", max.to_string().as_str()),
("tweet.fields", "created_at,author_id,public_metrics"),
])
.send()
.await
.map_err(|e| zclaw_types::ZclawError::HandError(format!("Timeline fetch failed: {}", e)))?;
let status = response.status();
let response_text = response.text().await
.map_err(|e| zclaw_types::ZclawError::HandError(format!("Failed to read response: {}", e)))?;
if !status.is_success() {
return Ok(json!({
"success": false,
"error": format!("Twitter API returned {}: {}", status, response_text),
"status_code": status.as_u16()
}));
}
let parsed: Value = serde_json::from_str(&response_text).unwrap_or(json!({"raw": response_text}));
Ok(json!({ Ok(json!({
"success": true, "success": true,
"user_id": config.user_id, "user_id": user_id,
"tweets": [], "tweets": parsed["data"].as_array().cloned().unwrap_or_default(),
"meta": { "meta": parsed["meta"].clone(),
"result_count": 0, "message": "Timeline fetched"
"newest_id": null,
"oldest_id": null,
"next_token": null
},
"message": "Timeline fetched (simulated)",
"note": "Connect Twitter API credentials for actual timeline"
})) }))
} }
/// Get tweet by ID /// Get tweet by ID — GET /2/tweets/:id
async fn execute_get_tweet(&self, tweet_id: &str) -> Result<Value> { async fn execute_get_tweet(&self, tweet_id: &str) -> Result<Value> {
let _creds = self.get_credentials().await let creds = self.get_credentials().await
.ok_or_else(|| zclaw_types::ZclawError::HandError("Twitter credentials not configured".to_string()))?; .ok_or_else(|| zclaw_types::ZclawError::HandError("Twitter credentials not configured".to_string()))?;
let client = reqwest::Client::new();
let url = format!("https://api.twitter.com/2/tweets/{}", tweet_id);
let response = client.get(&url)
.header("Authorization", format!("Bearer {}", creds.bearer_token.as_deref().unwrap_or("")))
.header("User-Agent", "ZCLAW/1.0")
.query(&[("tweet.fields", "created_at,author_id,public_metrics,lang")])
.send()
.await
.map_err(|e| zclaw_types::ZclawError::HandError(format!("Tweet lookup failed: {}", e)))?;
let status = response.status();
let response_text = response.text().await
.map_err(|e| zclaw_types::ZclawError::HandError(format!("Failed to read response: {}", e)))?;
if !status.is_success() {
return Ok(json!({
"success": false,
"error": format!("Twitter API returned {}: {}", status, response_text),
"status_code": status.as_u16()
}));
}
let parsed: Value = serde_json::from_str(&response_text).unwrap_or(json!({"raw": response_text}));
Ok(json!({ Ok(json!({
"success": true, "success": true,
"tweet_id": tweet_id, "tweet_id": tweet_id,
"tweet": null, "tweet": parsed["data"].clone(),
"message": "Tweet lookup (simulated)", "message": "Tweet fetched"
"note": "Connect Twitter API credentials for actual tweet data"
})) }))
} }
/// Get user by username /// Get user by username — GET /2/users/by/username/:username
async fn execute_get_user(&self, username: &str) -> Result<Value> { async fn execute_get_user(&self, username: &str) -> Result<Value> {
let _creds = self.get_credentials().await let creds = self.get_credentials().await
.ok_or_else(|| zclaw_types::ZclawError::HandError("Twitter credentials not configured".to_string()))?; .ok_or_else(|| zclaw_types::ZclawError::HandError("Twitter credentials not configured".to_string()))?;
let client = reqwest::Client::new();
let url = format!("https://api.twitter.com/2/users/by/username/{}", username);
let response = client.get(&url)
.header("Authorization", format!("Bearer {}", creds.bearer_token.as_deref().unwrap_or("")))
.header("User-Agent", "ZCLAW/1.0")
.query(&[("user.fields", "created_at,description,public_metrics,verified")])
.send()
.await
.map_err(|e| zclaw_types::ZclawError::HandError(format!("User lookup failed: {}", e)))?;
let status = response.status();
let response_text = response.text().await
.map_err(|e| zclaw_types::ZclawError::HandError(format!("Failed to read response: {}", e)))?;
if !status.is_success() {
return Ok(json!({
"success": false,
"error": format!("Twitter API returned {}: {}", status, response_text),
"status_code": status.as_u16()
}));
}
let parsed: Value = serde_json::from_str(&response_text).unwrap_or(json!({"raw": response_text}));
Ok(json!({ Ok(json!({
"success": true, "success": true,
"username": username, "username": username,
"user": null, "user": parsed["data"].clone(),
"message": "User lookup (simulated)", "message": "User fetched"
"note": "Connect Twitter API credentials for actual user data"
})) }))
} }
/// Execute like action /// Execute like action — PUT /2/users/:id/likes
async fn execute_like(&self, tweet_id: &str) -> Result<Value> { async fn execute_like(&self, tweet_id: &str) -> Result<Value> {
let _creds = self.get_credentials().await let creds = self.get_credentials().await
.ok_or_else(|| zclaw_types::ZclawError::HandError("Twitter credentials not configured".to_string()))?; .ok_or_else(|| zclaw_types::ZclawError::HandError("Twitter credentials not configured".to_string()))?;
let client = reqwest::Client::new();
// Note: For like/retweet, we need OAuth 1.0a user context
// Using Bearer token as fallback (may not work for all endpoints)
let url = "https://api.twitter.com/2/users/me/likes";
let response = client.post(url)
.header("Authorization", format!("Bearer {}", creds.bearer_token.as_deref().unwrap_or("")))
.header("Content-Type", "application/json")
.header("User-Agent", "ZCLAW/1.0")
.json(&json!({"tweet_id": tweet_id}))
.send()
.await
.map_err(|e| zclaw_types::ZclawError::HandError(format!("Like failed: {}", e)))?;
let status = response.status();
let response_text = response.text().await.unwrap_or_default();
Ok(json!({ Ok(json!({
"success": true, "success": status.is_success(),
"tweet_id": tweet_id, "tweet_id": tweet_id,
"action": "liked", "action": "liked",
"message": "Tweet liked (simulated)" "status_code": status.as_u16(),
"message": if status.is_success() { "Tweet liked" } else { &response_text }
})) }))
} }
/// Execute retweet action /// Execute retweet action — POST /2/users/:id/retweets
async fn execute_retweet(&self, tweet_id: &str) -> Result<Value> { async fn execute_retweet(&self, tweet_id: &str) -> Result<Value> {
let _creds = self.get_credentials().await let creds = self.get_credentials().await
.ok_or_else(|| zclaw_types::ZclawError::HandError("Twitter credentials not configured".to_string()))?; .ok_or_else(|| zclaw_types::ZclawError::HandError("Twitter credentials not configured".to_string()))?;
let client = reqwest::Client::new();
let url = "https://api.twitter.com/2/users/me/retweets";
let response = client.post(url)
.header("Authorization", format!("Bearer {}", creds.bearer_token.as_deref().unwrap_or("")))
.header("Content-Type", "application/json")
.header("User-Agent", "ZCLAW/1.0")
.json(&json!({"tweet_id": tweet_id}))
.send()
.await
.map_err(|e| zclaw_types::ZclawError::HandError(format!("Retweet failed: {}", e)))?;
let status = response.status();
let response_text = response.text().await.unwrap_or_default();
Ok(json!({
"success": status.is_success(),
"tweet_id": tweet_id,
"action": "retweeted",
"status_code": status.as_u16(),
"message": if status.is_success() { "Tweet retweeted" } else { &response_text }
}))
}
/// Execute delete tweet — DELETE /2/tweets/:id
async fn execute_delete_tweet(&self, tweet_id: &str) -> Result<Value> {
let creds = self.get_credentials().await
.ok_or_else(|| zclaw_types::ZclawError::HandError("Twitter credentials not configured".to_string()))?;
let client = reqwest::Client::new();
let url = format!("https://api.twitter.com/2/tweets/{}", tweet_id);
let response = client.delete(&url)
.header("Authorization", format!("Bearer {}", creds.bearer_token.as_deref().unwrap_or("")))
.header("User-Agent", "ZCLAW/1.0")
.send()
.await
.map_err(|e| zclaw_types::ZclawError::HandError(format!("Delete tweet failed: {}", e)))?;
let status = response.status();
let response_text = response.text().await.unwrap_or_default();
Ok(json!({
"success": status.is_success(),
"tweet_id": tweet_id,
"action": "deleted",
"status_code": status.as_u16(),
"message": if status.is_success() { "Tweet deleted" } else { &response_text }
}))
}
/// Execute unretweet — DELETE /2/users/:id/retweets/:tweet_id
async fn execute_unretweet(&self, tweet_id: &str) -> Result<Value> {
let creds = self.get_credentials().await
.ok_or_else(|| zclaw_types::ZclawError::HandError("Twitter credentials not configured".to_string()))?;
let client = reqwest::Client::new();
let url = format!("https://api.twitter.com/2/users/me/retweets/{}", tweet_id);
let response = client.delete(&url)
.header("Authorization", format!("Bearer {}", creds.bearer_token.as_deref().unwrap_or("")))
.header("User-Agent", "ZCLAW/1.0")
.send()
.await
.map_err(|e| zclaw_types::ZclawError::HandError(format!("Unretweet failed: {}", e)))?;
let status = response.status();
let response_text = response.text().await.unwrap_or_default();
Ok(json!({
"success": status.is_success(),
"tweet_id": tweet_id,
"action": "unretweeted",
"status_code": status.as_u16(),
"message": if status.is_success() { "Tweet unretweeted" } else { &response_text }
}))
}
/// Execute unlike — DELETE /2/users/:id/likes/:tweet_id
async fn execute_unlike(&self, tweet_id: &str) -> Result<Value> {
let creds = self.get_credentials().await
.ok_or_else(|| zclaw_types::ZclawError::HandError("Twitter credentials not configured".to_string()))?;
let client = reqwest::Client::new();
let url = format!("https://api.twitter.com/2/users/me/likes/{}", tweet_id);
let response = client.delete(&url)
.header("Authorization", format!("Bearer {}", creds.bearer_token.as_deref().unwrap_or("")))
.header("User-Agent", "ZCLAW/1.0")
.send()
.await
.map_err(|e| zclaw_types::ZclawError::HandError(format!("Unlike failed: {}", e)))?;
let status = response.status();
let response_text = response.text().await.unwrap_or_default();
Ok(json!({
"success": status.is_success(),
"tweet_id": tweet_id,
"action": "unliked",
"status_code": status.as_u16(),
"message": if status.is_success() { "Tweet unliked" } else { &response_text }
}))
}
/// Execute followers fetch — GET /2/users/:id/followers
async fn execute_followers(&self, user_id: &str, max_results: Option<u32>) -> Result<Value> {
let creds = self.get_credentials().await
.ok_or_else(|| zclaw_types::ZclawError::HandError("Twitter credentials not configured".to_string()))?;
let client = reqwest::Client::new();
let url = format!("https://api.twitter.com/2/users/{}/followers", user_id);
let max = max_results.unwrap_or(100).max(1).min(1000);
let response = client.get(&url)
.header("Authorization", format!("Bearer {}", creds.bearer_token.as_deref().unwrap_or("")))
.header("User-Agent", "ZCLAW/1.0")
.query(&[
("max_results", max.to_string()),
("user.fields", "created_at,description,public_metrics,verified,profile_image_url".to_string()),
])
.send()
.await
.map_err(|e| zclaw_types::ZclawError::HandError(format!("Followers fetch failed: {}", e)))?;
let status = response.status();
let response_text = response.text().await
.map_err(|e| zclaw_types::ZclawError::HandError(format!("Failed to read response: {}", e)))?;
if !status.is_success() {
return Ok(json!({
"success": false,
"error": format!("Twitter API returned {}: {}", status, response_text),
"status_code": status.as_u16()
}));
}
let parsed: Value = serde_json::from_str(&response_text).unwrap_or(json!({"raw": response_text}));
Ok(json!({ Ok(json!({
"success": true, "success": true,
"tweet_id": tweet_id, "user_id": user_id,
"action": "retweeted", "followers": parsed["data"].as_array().cloned().unwrap_or_default(),
"message": "Tweet retweeted (simulated)" "meta": parsed["meta"].clone(),
"message": "Followers fetched"
}))
}
/// Execute following fetch — GET /2/users/:id/following
async fn execute_following(&self, user_id: &str, max_results: Option<u32>) -> Result<Value> {
let creds = self.get_credentials().await
.ok_or_else(|| zclaw_types::ZclawError::HandError("Twitter credentials not configured".to_string()))?;
let client = reqwest::Client::new();
let url = format!("https://api.twitter.com/2/users/{}/following", user_id);
let max = max_results.unwrap_or(100).max(1).min(1000);
let response = client.get(&url)
.header("Authorization", format!("Bearer {}", creds.bearer_token.as_deref().unwrap_or("")))
.header("User-Agent", "ZCLAW/1.0")
.query(&[
("max_results", max.to_string()),
("user.fields", "created_at,description,public_metrics,verified,profile_image_url".to_string()),
])
.send()
.await
.map_err(|e| zclaw_types::ZclawError::HandError(format!("Following fetch failed: {}", e)))?;
let status = response.status();
let response_text = response.text().await
.map_err(|e| zclaw_types::ZclawError::HandError(format!("Failed to read response: {}", e)))?;
if !status.is_success() {
return Ok(json!({
"success": false,
"error": format!("Twitter API returned {}: {}", status, response_text),
"status_code": status.as_u16()
}));
}
let parsed: Value = serde_json::from_str(&response_text).unwrap_or(json!({"raw": response_text}));
Ok(json!({
"success": true,
"user_id": user_id,
"following": parsed["data"].as_array().cloned().unwrap_or_default(),
"meta": parsed["meta"].clone(),
"message": "Following fetched"
})) }))
} }
@@ -461,54 +779,17 @@ impl Hand for TwitterHand {
let result = match action { let result = match action {
TwitterAction::Tweet { config } => self.execute_tweet(&config).await?, TwitterAction::Tweet { config } => self.execute_tweet(&config).await?,
TwitterAction::DeleteTweet { tweet_id } => { TwitterAction::DeleteTweet { tweet_id } => self.execute_delete_tweet(&tweet_id).await?,
json!({
"success": true,
"tweet_id": tweet_id,
"action": "deleted",
"message": "Tweet deleted (simulated)"
})
}
TwitterAction::Retweet { tweet_id } => self.execute_retweet(&tweet_id).await?, TwitterAction::Retweet { tweet_id } => self.execute_retweet(&tweet_id).await?,
TwitterAction::Unretweet { tweet_id } => { TwitterAction::Unretweet { tweet_id } => self.execute_unretweet(&tweet_id).await?,
json!({
"success": true,
"tweet_id": tweet_id,
"action": "unretweeted",
"message": "Tweet unretweeted (simulated)"
})
}
TwitterAction::Like { tweet_id } => self.execute_like(&tweet_id).await?, TwitterAction::Like { tweet_id } => self.execute_like(&tweet_id).await?,
TwitterAction::Unlike { tweet_id } => { TwitterAction::Unlike { tweet_id } => self.execute_unlike(&tweet_id).await?,
json!({
"success": true,
"tweet_id": tweet_id,
"action": "unliked",
"message": "Tweet unliked (simulated)"
})
}
TwitterAction::Search { config } => self.execute_search(&config).await?, TwitterAction::Search { config } => self.execute_search(&config).await?,
TwitterAction::Timeline { config } => self.execute_timeline(&config).await?, TwitterAction::Timeline { config } => self.execute_timeline(&config).await?,
TwitterAction::GetTweet { tweet_id } => self.execute_get_tweet(&tweet_id).await?, TwitterAction::GetTweet { tweet_id } => self.execute_get_tweet(&tweet_id).await?,
TwitterAction::GetUser { username } => self.execute_get_user(&username).await?, TwitterAction::GetUser { username } => self.execute_get_user(&username).await?,
TwitterAction::Followers { user_id, max_results } => { TwitterAction::Followers { user_id, max_results } => self.execute_followers(&user_id, max_results).await?,
json!({ TwitterAction::Following { user_id, max_results } => self.execute_following(&user_id, max_results).await?,
"success": true,
"user_id": user_id,
"followers": [],
"max_results": max_results.unwrap_or(100),
"message": "Followers fetched (simulated)"
})
}
TwitterAction::Following { user_id, max_results } => {
json!({
"success": true,
"user_id": user_id,
"following": [],
"max_results": max_results.unwrap_or(100),
"message": "Following fetched (simulated)"
})
}
TwitterAction::CheckCredentials => self.execute_check_credentials().await?, TwitterAction::CheckCredentials => self.execute_check_credentials().await?,
}; };

View File

@@ -54,6 +54,11 @@ pub struct LlmConfig {
/// Temperature /// Temperature
#[serde(default = "default_temperature")] #[serde(default = "default_temperature")]
pub temperature: f32, pub temperature: f32,
/// Context window size in tokens (default: 128000)
/// Used to calculate dynamic compaction threshold.
#[serde(default = "default_context_window")]
pub context_window: u32,
} }
impl LlmConfig { impl LlmConfig {
@@ -66,6 +71,7 @@ impl LlmConfig {
api_protocol: ApiProtocol::OpenAI, api_protocol: ApiProtocol::OpenAI,
max_tokens: default_max_tokens(), max_tokens: default_max_tokens(),
temperature: default_temperature(), temperature: default_temperature(),
context_window: default_context_window(),
} }
} }
@@ -140,6 +146,10 @@ fn default_temperature() -> f32 {
0.7 0.7
} }
fn default_context_window() -> u32 {
128000
}
impl Default for KernelConfig { impl Default for KernelConfig {
fn default() -> Self { fn default() -> Self {
Self { Self {
@@ -151,6 +161,7 @@ impl Default for KernelConfig {
api_protocol: ApiProtocol::OpenAI, api_protocol: ApiProtocol::OpenAI,
max_tokens: default_max_tokens(), max_tokens: default_max_tokens(),
temperature: default_temperature(), temperature: default_temperature(),
context_window: default_context_window(),
}, },
skills_dir: default_skills_dir(), skills_dir: default_skills_dir(),
} }
@@ -345,6 +356,17 @@ impl KernelConfig {
pub fn temperature(&self) -> f32 { pub fn temperature(&self) -> f32 {
self.llm.temperature self.llm.temperature
} }
/// Get context window size in tokens
pub fn context_window(&self) -> u32 {
self.llm.context_window
}
/// Dynamic compaction threshold = context_window * 0.6
/// Leaves 40% headroom for system prompt + response tokens
pub fn compaction_threshold(&self) -> usize {
(self.llm.context_window as f64 * 0.6) as usize
}
} }
// === Preset configurations for common providers === // === Preset configurations for common providers ===

File diff suppressed because it is too large Load Diff

View File

@@ -8,6 +8,8 @@ mod capabilities;
mod events; mod events;
pub mod trigger_manager; pub mod trigger_manager;
pub mod config; pub mod config;
pub mod scheduler;
pub mod skill_router;
#[cfg(feature = "multi-agent")] #[cfg(feature = "multi-agent")]
pub mod director; pub mod director;
pub mod generation; pub mod generation;
@@ -21,8 +23,16 @@ pub use config::*;
pub use trigger_manager::{TriggerManager, TriggerEntry, TriggerUpdateRequest, TriggerManagerConfig}; pub use trigger_manager::{TriggerManager, TriggerEntry, TriggerUpdateRequest, TriggerManagerConfig};
#[cfg(feature = "multi-agent")] #[cfg(feature = "multi-agent")]
pub use director::*; pub use director::*;
#[cfg(feature = "multi-agent")]
pub use zclaw_protocols::{
A2aRouter, A2aAgentProfile, A2aCapability, A2aEnvelope, A2aMessageType, A2aRecipient,
A2aReceiver,
BasicA2aClient,
A2aClient,
};
pub use generation::*; pub use generation::*;
pub use export::{ExportFormat, ExportOptions, ExportResult, Exporter, export_classroom}; pub use export::{ExportFormat, ExportOptions, ExportResult, Exporter, export_classroom};
// Re-export hands types for convenience // Re-export hands types for convenience
pub use zclaw_hands::{HandRegistry, HandContext, HandResult, HandConfig, Hand, HandStatus}; pub use zclaw_hands::{HandRegistry, HandContext, HandResult, HandConfig, Hand, HandStatus};
pub use scheduler::SchedulerService;

View File

@@ -9,6 +9,7 @@ pub struct AgentRegistry {
agents: DashMap<AgentId, AgentConfig>, agents: DashMap<AgentId, AgentConfig>,
states: DashMap<AgentId, AgentState>, states: DashMap<AgentId, AgentState>,
created_at: DashMap<AgentId, chrono::DateTime<Utc>>, created_at: DashMap<AgentId, chrono::DateTime<Utc>>,
message_counts: DashMap<AgentId, u64>,
} }
impl AgentRegistry { impl AgentRegistry {
@@ -17,6 +18,7 @@ impl AgentRegistry {
agents: DashMap::new(), agents: DashMap::new(),
states: DashMap::new(), states: DashMap::new(),
created_at: DashMap::new(), created_at: DashMap::new(),
message_counts: DashMap::new(),
} }
} }
@@ -33,6 +35,13 @@ impl AgentRegistry {
self.agents.remove(id); self.agents.remove(id);
self.states.remove(id); self.states.remove(id);
self.created_at.remove(id); self.created_at.remove(id);
self.message_counts.remove(id);
}
/// Update an agent's configuration (preserves state and message count)
pub fn update(&self, config: AgentConfig) {
let id = config.id;
self.agents.insert(id, config);
} }
/// Get an agent by ID /// Get an agent by ID
@@ -53,7 +62,7 @@ impl AgentRegistry {
model: config.model.model.clone(), model: config.model.model.clone(),
provider: config.model.provider.clone(), provider: config.model.provider.clone(),
state, state,
message_count: 0, // TODO: Track this message_count: self.message_counts.get(id).map(|c| *c as usize).unwrap_or(0),
created_at, created_at,
updated_at: Utc::now(), updated_at: Utc::now(),
}) })
@@ -83,6 +92,11 @@ impl AgentRegistry {
pub fn count(&self) -> usize { pub fn count(&self) -> usize {
self.agents.len() self.agents.len()
} }
/// Increment message count for an agent
pub fn increment_message_count(&self, id: &AgentId) {
self.message_counts.entry(*id).and_modify(|c| *c += 1).or_insert(1);
}
} }
impl Default for AgentRegistry { impl Default for AgentRegistry {

View File

@@ -0,0 +1,341 @@
//! Scheduler service for automatic trigger execution
//!
//! Periodically scans scheduled triggers and fires them at the appropriate time.
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use chrono::{Datelike, Timelike};
use tokio::sync::RwLock;
use tokio::time::{self, Duration};
use zclaw_types::Result;
use crate::Kernel;
/// Scheduler service that runs in the background and executes scheduled triggers
pub struct SchedulerService {
kernel: Arc<RwLock<Option<Kernel>>>,
running: Arc<AtomicBool>,
check_interval: Duration,
}
impl SchedulerService {
/// Create a new scheduler service
pub fn new(kernel: Arc<RwLock<Option<Kernel>>>, check_interval_secs: u64) -> Self {
Self {
kernel,
running: Arc::new(AtomicBool::new(false)),
check_interval: Duration::from_secs(check_interval_secs),
}
}
/// Start the scheduler loop in the background
pub fn start(&self) {
if self.running.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst).is_err() {
tracing::warn!("[Scheduler] Already running, ignoring start request");
return;
}
let kernel = self.kernel.clone();
let running = self.running.clone();
let interval = self.check_interval;
tokio::spawn(async move {
tracing::info!("[Scheduler] Starting scheduler loop with {}s interval", interval.as_secs());
let mut ticker = time::interval(interval);
// First tick fires immediately — skip it
ticker.tick().await;
while running.load(Ordering::Relaxed) {
ticker.tick().await;
if !running.load(Ordering::Relaxed) {
break;
}
if let Err(e) = Self::check_and_fire_scheduled_triggers(&kernel).await {
tracing::error!("[Scheduler] Error checking triggers: {}", e);
}
}
tracing::info!("[Scheduler] Scheduler loop stopped");
});
}
/// Stop the scheduler loop
pub fn stop(&self) {
self.running.store(false, Ordering::Relaxed);
tracing::info!("[Scheduler] Stop requested");
}
/// Check if the scheduler is running
pub fn is_running(&self) -> bool {
self.running.load(Ordering::Relaxed)
}
/// Check all scheduled triggers and fire those that are due
async fn check_and_fire_scheduled_triggers(
kernel_lock: &Arc<RwLock<Option<Kernel>>>,
) -> Result<()> {
let kernel_read = kernel_lock.read().await;
let kernel = match kernel_read.as_ref() {
Some(k) => k,
None => return Ok(()),
};
// Get all triggers
let triggers = kernel.list_triggers().await;
let now = chrono::Utc::now();
// Filter to enabled Schedule triggers
let scheduled: Vec<_> = triggers.iter()
.filter(|t| {
t.config.enabled && matches!(t.config.trigger_type, zclaw_hands::TriggerType::Schedule { .. })
})
.collect();
if scheduled.is_empty() {
return Ok(());
}
tracing::debug!("[Scheduler] Checking {} scheduled triggers", scheduled.len());
// Drop the read lock before executing
let to_execute: Vec<(String, String, String)> = scheduled.iter()
.filter_map(|t| {
if let zclaw_hands::TriggerType::Schedule { ref cron } = t.config.trigger_type {
// Simple cron matching: check if we should fire now
if Self::should_fire_cron(cron, &now) {
Some((t.config.id.clone(), t.config.hand_id.clone(), cron.clone()))
} else {
None
}
} else {
None
}
})
.collect();
drop(kernel_read);
// Execute due triggers (with write lock since execute_hand may need it)
for (trigger_id, hand_id, cron_expr) in to_execute {
tracing::info!(
"[Scheduler] Firing scheduled trigger '{}' → hand '{}' (cron: {})",
trigger_id, hand_id, cron_expr
);
let kernel_read = kernel_lock.read().await;
if let Some(kernel) = kernel_read.as_ref() {
let trigger_source = zclaw_types::TriggerSource::Scheduled {
trigger_id: trigger_id.clone(),
};
let input = serde_json::json!({
"trigger_id": trigger_id,
"trigger_type": "schedule",
"cron": cron_expr,
"fired_at": now.to_rfc3339(),
});
match kernel.execute_hand_with_source(&hand_id, input, trigger_source).await {
Ok((_result, run_id)) => {
tracing::info!(
"[Scheduler] Successfully fired trigger '{}' → run {}",
trigger_id, run_id
);
}
Err(e) => {
tracing::error!(
"[Scheduler] Failed to execute trigger '{}': {}",
trigger_id, e
);
}
}
}
}
Ok(())
}
/// Simple cron expression matcher
///
/// Supports basic cron format: `minute hour day month weekday`
/// Also supports interval shorthand: `every:Ns`, `every:Nm`, `every:Nh`
fn should_fire_cron(cron: &str, now: &chrono::DateTime<chrono::Utc>) -> bool {
let cron = cron.trim();
// Handle interval shorthand: "every:30s", "every:5m", "every:1h"
if let Some(interval_str) = cron.strip_prefix("every:") {
return Self::check_interval_shorthand(interval_str, now);
}
// Handle ISO timestamp for one-shot: "2026-03-29T10:00:00Z"
if cron.contains('T') && cron.contains('-') {
if let Ok(target) = chrono::DateTime::parse_from_rfc3339(cron) {
let target_utc = target.with_timezone(&chrono::Utc);
// Fire if within the check window (± check_interval/2, approx 30s)
let diff = (*now - target_utc).num_seconds().abs();
return diff <= 30;
}
}
// Standard 5-field cron: minute hour day_of_month month day_of_week
let parts: Vec<&str> = cron.split_whitespace().collect();
if parts.len() != 5 {
tracing::warn!("[Scheduler] Invalid cron expression (expected 5 fields): '{}'", cron);
return false;
}
let minute = now.minute() as i32;
let hour = now.hour() as i32;
let day = now.day() as i32;
let month = now.month() as i32;
let weekday = now.weekday().num_days_from_monday() as i32; // Mon=0..Sun=6
Self::cron_field_matches(parts[0], minute)
&& Self::cron_field_matches(parts[1], hour)
&& Self::cron_field_matches(parts[2], day)
&& Self::cron_field_matches(parts[3], month)
&& Self::cron_field_matches(parts[4], weekday)
}
/// Check if a single cron field matches the current value
fn cron_field_matches(field: &str, value: i32) -> bool {
if field == "*" || field == "?" {
return true;
}
// Handle step: */N
if let Some(step_str) = field.strip_prefix("*/") {
if let Ok(step) = step_str.parse::<i32>() {
if step > 0 {
return value % step == 0;
}
}
return false;
}
// Handle range: N-M
if field.contains('-') {
let range_parts: Vec<&str> = field.split('-').collect();
if range_parts.len() == 2 {
if let (Ok(start), Ok(end)) = (range_parts[0].parse::<i32>(), range_parts[1].parse::<i32>()) {
return value >= start && value <= end;
}
}
return false;
}
// Handle list: N,M,O
if field.contains(',') {
return field.split(',').any(|part| {
part.trim().parse::<i32>().map(|p| p == value).unwrap_or(false)
});
}
// Simple value
field.parse::<i32>().map(|p| p == value).unwrap_or(false)
}
/// Check interval shorthand expressions
fn check_interval_shorthand(interval: &str, now: &chrono::DateTime<chrono::Utc>) -> bool {
let (num_str, unit) = if interval.ends_with('s') {
(&interval[..interval.len()-1], 's')
} else if interval.ends_with('m') {
(&interval[..interval.len()-1], 'm')
} else if interval.ends_with('h') {
(&interval[..interval.len()-1], 'h')
} else {
return false;
};
let num: i64 = match num_str.parse() {
Ok(n) => n,
Err(_) => return false,
};
if num <= 0 {
return false;
}
let interval_secs = match unit {
's' => num,
'm' => num * 60,
'h' => num * 3600,
_ => return false,
};
// Check if current timestamp aligns with the interval
let timestamp = now.timestamp();
timestamp % interval_secs == 0
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Timelike;
#[test]
fn test_cron_field_wildcard() {
assert!(SchedulerService::cron_field_matches("*", 5));
assert!(SchedulerService::cron_field_matches("?", 5));
}
#[test]
fn test_cron_field_exact() {
assert!(SchedulerService::cron_field_matches("5", 5));
assert!(!SchedulerService::cron_field_matches("5", 6));
}
#[test]
fn test_cron_field_step() {
assert!(SchedulerService::cron_field_matches("*/5", 0));
assert!(SchedulerService::cron_field_matches("*/5", 5));
assert!(SchedulerService::cron_field_matches("*/5", 10));
assert!(!SchedulerService::cron_field_matches("*/5", 3));
}
#[test]
fn test_cron_field_range() {
assert!(SchedulerService::cron_field_matches("1-5", 1));
assert!(SchedulerService::cron_field_matches("1-5", 3));
assert!(SchedulerService::cron_field_matches("1-5", 5));
assert!(!SchedulerService::cron_field_matches("1-5", 0));
assert!(!SchedulerService::cron_field_matches("1-5", 6));
}
#[test]
fn test_cron_field_list() {
assert!(SchedulerService::cron_field_matches("1,3,5", 1));
assert!(SchedulerService::cron_field_matches("1,3,5", 3));
assert!(SchedulerService::cron_field_matches("1,3,5", 5));
assert!(!SchedulerService::cron_field_matches("1,3,5", 2));
}
#[test]
fn test_should_fire_every_minute() {
let now = chrono::Utc::now();
assert!(SchedulerService::should_fire_cron("every:1m", &now));
}
#[test]
fn test_should_fire_cron_wildcard() {
let now = chrono::Utc::now();
// Every minute match
assert!(SchedulerService::should_fire_cron(
&format!("{} * * * *", now.minute()),
&now,
));
}
#[test]
fn test_should_not_fire_cron() {
let now = chrono::Utc::now();
let wrong_minute = if now.minute() < 59 { now.minute() + 1 } else { 0 };
assert!(!SchedulerService::should_fire_cron(
&format!("{} * * * *", wrong_minute),
&now,
));
}
}

Some files were not shown because too many files have changed in this diff Show More