Compare commits
6 Commits
e262200f1e
...
6f72442531
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f72442531 | ||
|
|
3518fc8ece | ||
|
|
3a7631e035 | ||
|
|
dfeb286591 | ||
|
|
c856673936 | ||
|
|
552efb513b |
577
CLAUDE.md
@@ -1,54 +1,63 @@
|
||||
# ZCLAW 协作与实现规则
|
||||
|
||||
> 目标:把 ZCLAW 做成**真实可交付**的 OpenFang 桌面客户端,而不是"看起来能用"的演示 UI。
|
||||
> **ZCLAW 是一个独立成熟的 AI Agent 桌面客户端**,专注于提供真实可用的 AI 能力,而不是演示 UI。
|
||||
|
||||
## 1. 项目目标
|
||||
## 1. 项目定位
|
||||
|
||||
ZCLAW 是基于 **OpenFang** (Rust Agent OS) 的 AI Agent 桌面端,核心价值不是单纯聊天,而是:
|
||||
### 1.1 ZCLAW 是什么
|
||||
|
||||
- 真实连接 OpenFang Kernel
|
||||
- 真实驱动 Agents / Skills / Hands / Workflows
|
||||
- 真实读写 TOML 配置与工作区
|
||||
- 真实反映运行时状态与审计日志
|
||||
ZCLAW 是面向中文用户的 AI Agent 桌面端,核心能力包括:
|
||||
|
||||
判断标准:
|
||||
- **智能对话** - 多模型支持、流式响应、上下文管理
|
||||
- **自主能力** - 8 个 Hands(浏览器、数据采集、研究、预测等)
|
||||
- **技能系统** - 可扩展的 SKILL.md 技能定义
|
||||
- **工作流编排** - 多步骤自动化任务
|
||||
- **安全审计** - 完整的操作日志和权限控制
|
||||
|
||||
> 一个页面或按钮如果**没有改变 OpenFang Runtime 的真实行为 / 真实配置 / 真实路由 / 真实工作区上下文**,那它大概率还只是演示态,不算交付完成。
|
||||
### 1.2 决策原则
|
||||
|
||||
**任何改动都要问:这对 ZCLAW 有用吗?对 ZCLAW 有影响吗?**
|
||||
|
||||
- ✅ 对 ZCLAW 用户有价值的功能 → 优先实现
|
||||
- ✅ 提升 ZCLAW 稳定性和可用性 → 必须做
|
||||
- ❌ 只为兼容其他系统的妥协 → 谨慎评估
|
||||
- ❌ 增加复杂度但无实际价值 → 不做
|
||||
|
||||
---
|
||||
|
||||
## 2. 项目结构
|
||||
|
||||
```text
|
||||
ZClaw/
|
||||
ZCLAW/
|
||||
├── desktop/ # Tauri 桌面应用
|
||||
│ ├── src/
|
||||
│ │ ├── components/ # React UI
|
||||
│ │ ├── store/ # Zustand stores
|
||||
│ │ └── lib/ # OpenFang client / helpers
|
||||
│ └── src-tauri/ # Tauri Rust backend
|
||||
│ │ ├── components/ # React UI 组件
|
||||
│ │ ├── store/ # Zustand 状态管理
|
||||
│ │ └── lib/ # 客户端通信 / 工具函数
|
||||
│ └── src-tauri/ # Tauri Rust 后端
|
||||
├── skills/ # SKILL.md 技能定义
|
||||
├── hands/ # HAND.toml 自主能力包
|
||||
├── config/ # OpenFang TOML 配置
|
||||
├── docs/ # 架构、排障、知识库
|
||||
├── hands/ # HAND.toml 自主能力配置
|
||||
├── config/ # TOML 配置文件
|
||||
├── docs/ # 架构文档和知识库
|
||||
└── tests/ # Vitest 回归测试
|
||||
```
|
||||
|
||||
核心数据流:
|
||||
### 2.1 核心数据流
|
||||
|
||||
```text
|
||||
React UI → Zustand Store → OpenFangClient → OpenFang Kernel → Skills / Hands / Channels
|
||||
用户操作 → React UI → Zustand Store → Gateway Client → 后端服务 → Skills / Hands
|
||||
```
|
||||
|
||||
**OpenFang vs OpenClaw 关键差异**:
|
||||
### 2.2 技术栈
|
||||
|
||||
| 方面 | OpenClaw | OpenFang |
|
||||
|------|----------|----------|
|
||||
| 语言 | TypeScript/Node.js | Rust |
|
||||
| 端口 | 18789 | 4200 |
|
||||
| 配置 | YAML/JSON | TOML |
|
||||
| 插件 | TypeScript | SKILL.md + WASM |
|
||||
| 安全 | 3 层 | 16 层纵深防御 |
|
||||
| 层级 | 技术 |
|
||||
|------|------|
|
||||
| 前端框架 | React 18 + TypeScript |
|
||||
| 状态管理 | Zustand |
|
||||
| 桌面框架 | Tauri 2.x |
|
||||
| 样式方案 | Tailwind CSS |
|
||||
| 配置格式 | TOML |
|
||||
| 后端服务 | Rust (端口 50051) |
|
||||
|
||||
---
|
||||
|
||||
@@ -56,430 +65,250 @@ React UI → Zustand Store → OpenFangClient → OpenFang Kernel → Skills / H
|
||||
|
||||
### 3.1 交付导向
|
||||
|
||||
- 先做**最高杠杆**问题
|
||||
- 优先恢复真实能力,再考虑局部美化
|
||||
- 不保留"假数据看起来正常"的占位实现
|
||||
- **先做最高杠杆问题** - 解决用户最痛的点
|
||||
- **真实能力优先** - 不做假数据占位
|
||||
- **完整闭环** - 每个功能都要能真正使用
|
||||
|
||||
### 3.2 根因优先
|
||||
|
||||
- 先确认问题属于:
|
||||
- 协议错配 (WebSocket vs REST)
|
||||
- 状态管理错误
|
||||
- UI 没接真实能力
|
||||
- 配置解析 / 持久化错误 (TOML 格式)
|
||||
- 运行时 / 环境问题
|
||||
- 不在根因未明时盲目堆补丁
|
||||
遇到问题时,先确认属于哪一类:
|
||||
|
||||
1. **协议问题** - API 端点、请求格式、响应解析
|
||||
2. **状态问题** - Store 更新、组件同步
|
||||
3. **UI 问题** - 交互逻辑、样式显示
|
||||
4. **配置问题** - TOML 解析、环境变量
|
||||
5. **运行时问题** - 服务启动、端口占用
|
||||
|
||||
不在根因未明时盲目堆补丁。
|
||||
|
||||
### 3.3 闭环工作法
|
||||
|
||||
每次改动尽量形成完整闭环:
|
||||
每次改动形成完整闭环:
|
||||
|
||||
1. 定位问题
|
||||
2. 建立最小可信心智模型
|
||||
3. 实现最小有效修复
|
||||
4. 跑自动化验证
|
||||
5. 记录知识沉淀
|
||||
1. 定位问题 → 2. 建立心智模型 → 3. 最小修复 → 4. 自动验证 → 5. 记录沉淀
|
||||
|
||||
---
|
||||
|
||||
## 4. 解决问题的标准流程
|
||||
## 4. 实现规则
|
||||
|
||||
### 4.1 先看真实协议和真实运行时
|
||||
### 4.1 通信层
|
||||
|
||||
当桌面端与 OpenFang 行为不一致时:
|
||||
所有与后端的通信必须通过统一的客户端层:
|
||||
|
||||
- 先检查当前 REST API schema / WebSocket 事件格式
|
||||
- 不要只相信旧前端封装或历史调用方式
|
||||
- 如果源码与实际运行行为冲突,以**当前 OpenFang Kernel**为准
|
||||
- `desktop/src/lib/gateway-client.ts` - 主要通信客户端
|
||||
- `desktop/src/lib/tauri-gateway.ts` - Tauri 原生命令
|
||||
|
||||
尤其是以下能力必须以真实 OpenFang 为准:
|
||||
**禁止**在组件内直接创建 WebSocket 或拼装 HTTP 请求。
|
||||
|
||||
- `/api/chat` (聊天)
|
||||
- `/api/agents` (Agent 管理)
|
||||
- `/api/hands/*` (Hands 触发)
|
||||
- `/api/workflows/*` (工作流)
|
||||
- `/api/config` (TOML 配置)
|
||||
- `/api/audit/logs` (审计日志)
|
||||
- WebSocket 事件 (`stream`, `hand`, `workflow`)
|
||||
### 4.2 状态管理
|
||||
|
||||
### 4.2 先打通读,再打通写
|
||||
|
||||
任何配置类页面都按这个顺序推进:
|
||||
|
||||
1. 先确认页面能读取真实配置
|
||||
2. 再确认页面能显示真实当前值
|
||||
3. 最后再接保存
|
||||
|
||||
禁止直接做"本地 state 假切换"冒充已完成。
|
||||
|
||||
### 4.3 区分"前端概念"和"运行时概念"
|
||||
|
||||
如果前端有自己的本地实体,例如:
|
||||
|
||||
- agent / clone
|
||||
- conversation / session
|
||||
- temporary model selection
|
||||
|
||||
必须明确它是否真的对应 OpenFang 中的:
|
||||
|
||||
- `agent_id`
|
||||
- `session_id`
|
||||
- `default_model`
|
||||
|
||||
不要把本地 UI 标识直接当成 OpenFang runtime 标识发送。
|
||||
|
||||
### 4.4 调试优先顺序
|
||||
|
||||
遇到问题时,优先按这个顺序排查:
|
||||
|
||||
1. 是否连到了正确的 OpenFang (端口 4200)
|
||||
2. 是否握手/认证成功
|
||||
3. 请求方法名是否正确 (REST endpoint / WebSocket message type)
|
||||
4. 请求参数是否符合当前 schema
|
||||
5. 返回结构是否与前端解析一致
|
||||
6. 页面是否只是改了本地 state,没有写回 runtime
|
||||
7. 是否存在旧 fallback / placeholder 掩盖真实错误
|
||||
|
||||
---
|
||||
|
||||
## 5. 实现规则
|
||||
|
||||
### 5.1 Gateway 通信
|
||||
|
||||
IMPORTANT: 所有与 OpenFang 的通信必须通过:
|
||||
|
||||
- `desktop/src/lib/openfang-client.ts` (OpenFang)
|
||||
- `desktop/src/lib/gateway-client.ts` (OpenClaw 兼容层)
|
||||
|
||||
禁止在组件内直接创建 WebSocket 或拼装协议帧。
|
||||
|
||||
### 5.2 后端切换
|
||||
|
||||
通过环境变量或 localStorage 切换后端:
|
||||
|
||||
```typescript
|
||||
// 环境变量
|
||||
const USE_OPENFANG = import.meta.env.VITE_USE_OPENFANG === 'true';
|
||||
|
||||
// localStorage
|
||||
const backendType = localStorage.getItem('zclaw-backend') || 'openclaw';
|
||||
```
|
||||
UI 组件 → 只负责展示和交互
|
||||
Store → 负责状态组织和流程编排
|
||||
Client → 负责网络通信
|
||||
lib/ → 工具函数和协议适配
|
||||
```
|
||||
|
||||
### 5.3 状态管理
|
||||
### 4.3 代码规范
|
||||
|
||||
- UI 负责展示和交互
|
||||
- Store 负责状态组织、流程编排
|
||||
- OpenFangClient 负责 REST / WebSocket 通信
|
||||
- 配置读写和协议适配逻辑放在 `lib/` 助手层
|
||||
**TypeScript:**
|
||||
- 避免 `any`,优先 `unknown + 类型守卫`
|
||||
- 外部数据必须做容错解析
|
||||
- 不假设 API 响应永远只有一种格式
|
||||
|
||||
避免把协议细节散落在多个组件里。
|
||||
**React:**
|
||||
- 使用函数组件 + hooks
|
||||
- 复杂副作用收敛到 store
|
||||
- 组件保持"展示层"职责
|
||||
|
||||
### 5.4 React 组件
|
||||
|
||||
- 使用函数组件与 hooks
|
||||
- 复杂副作用收敛到 store 或 helper
|
||||
- 组件尽量保持"展示层"职责
|
||||
- 一个组件里如果同时出现协议拼装、复杂状态机、配置改写逻辑,优先拆分
|
||||
|
||||
### 5.5 TypeScript
|
||||
|
||||
- 避免 `any`
|
||||
- 优先 `unknown + 类型守卫`
|
||||
- 外部返回结构必须做容错解析
|
||||
- 不要假设 OpenFang 响应永远只有一种 shape
|
||||
|
||||
### 5.6 配置处理 (TOML)
|
||||
|
||||
OpenFang 使用 **TOML** 配置格式:
|
||||
|
||||
```toml
|
||||
# ~/.openfang/config.toml
|
||||
|
||||
[server]
|
||||
host = "127.0.0.1"
|
||||
port = 4200
|
||||
|
||||
[agent]
|
||||
default_model = "gpt-4"
|
||||
|
||||
[[llm.providers]]
|
||||
name = "openai"
|
||||
api_key = "${OPENAI_API_KEY}"
|
||||
```
|
||||
|
||||
对配置的处理:
|
||||
|
||||
- 使用 TOML 解析器,不要手动解析
|
||||
- 写回时保持 TOML 格式
|
||||
**配置处理:**
|
||||
- 使用 TOML 解析器
|
||||
- 支持环境变量插值 `${VAR_NAME}`
|
||||
- 写回时保持格式一致
|
||||
|
||||
---
|
||||
|
||||
## 6. UI 完成度规则
|
||||
## 5. UI 完成度标准
|
||||
|
||||
### 6.1 允许存在的 UI
|
||||
### 5.1 允许存在的 UI
|
||||
|
||||
- 已接真实能力的 UI
|
||||
- 明确标注"未实现 / 只读 / 待接入"的 UI
|
||||
- 已接入真实后端能力的 UI
|
||||
- 明确标注"开发中 / 只读"的 UI
|
||||
- 有降级方案的 UI
|
||||
|
||||
### 6.2 不允许存在的 UI
|
||||
### 5.2 不允许存在的 UI
|
||||
|
||||
- 看似可编辑但不会生效的设置项
|
||||
- 展示假状态却不对应真实运行时的面板
|
||||
- 用 mock 数据掩盖未完成能力但不做说明
|
||||
- 看似可编辑但不会生效的设置
|
||||
- 展示假状态的面板
|
||||
- 用 mock 数据掩盖未完成能力
|
||||
|
||||
### 6.3 OpenFang 新特性 UI
|
||||
### 5.3 核心功能 UI
|
||||
|
||||
以下 OpenFang 特有功能需要新增 UI:
|
||||
|
||||
- **Hands 面板**: 触发和管理 7 个自主能力包
|
||||
- **Workflow 编辑器**: 多步骤工作流编排
|
||||
- **Trigger 管理器**: 事件触发器配置
|
||||
- **审计日志**: Merkle 哈希链审计查看
|
||||
| 功能 | 状态 | 说明 |
|
||||
|------|------|------|
|
||||
| 聊天界面 | ✅ 完成 | 流式响应、多模型切换 |
|
||||
| 分身管理 | ✅ 完成 | 创建、配置、切换 Agent |
|
||||
| 自动化面板 | ✅ 完成 | Hands 触发、参数配置 |
|
||||
| 技能市场 | 🚧 进行中 | 技能浏览和安装 |
|
||||
| 工作流编辑 | 📋 计划中 | 多步骤任务编排 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 测试与验证规则
|
||||
## 6. 自主能力系统 (Hands)
|
||||
|
||||
### 7.1 改动后必须验证
|
||||
ZCLAW 提供 8 个自主能力包:
|
||||
|
||||
修改以下内容后,必须至少运行相关测试:
|
||||
| Hand | 功能 | 状态 |
|
||||
|------|------|------|
|
||||
| Browser | 浏览器自动化 | ✅ 可用 |
|
||||
| Collector | 数据收集聚合 | ✅ 可用 |
|
||||
| Researcher | 深度研究 | ✅ 可用 |
|
||||
| Predictor | 预测分析 | ✅ 可用 |
|
||||
| Lead | 销售线索发现 | ✅ 可用 |
|
||||
| Trader | 交易分析 | ✅ 可用 |
|
||||
| Clip | 视频处理 | ⚠️ 需 FFmpeg |
|
||||
| Twitter | Twitter 自动化 | ⚠️ 需 API Key |
|
||||
|
||||
- chat / stream
|
||||
- openfang client / gateway store
|
||||
- settings / config
|
||||
- protocol helpers
|
||||
**触发 Hand 时:**
|
||||
1. 检查依赖是否满足
|
||||
2. 收集必要参数
|
||||
3. 处理 `needs_approval` 状态
|
||||
4. 记录执行日志
|
||||
|
||||
优先命令:
|
||||
---
|
||||
|
||||
## 7. 测试与验证
|
||||
|
||||
### 7.1 必测场景
|
||||
|
||||
修改以下内容后必须验证:
|
||||
|
||||
- 聊天 / 流式响应
|
||||
- Store 状态更新
|
||||
- 配置读写
|
||||
- Hand 触发
|
||||
|
||||
### 7.2 验证命令
|
||||
|
||||
```bash
|
||||
pnpm vitest run tests/desktop/chatStore.test.ts tests/desktop/gatewayStore.test.ts tests/desktop/general-settings.test.tsx
|
||||
# TypeScript 类型检查
|
||||
pnpm tsc --noEmit
|
||||
|
||||
# 单元测试
|
||||
pnpm vitest run
|
||||
|
||||
# 启动开发环境
|
||||
pnpm start:dev
|
||||
```
|
||||
|
||||
如果新增了独立 helper,应补最小回归测试。
|
||||
### 7.3 人工验证清单
|
||||
|
||||
### 7.2 测试设计原则
|
||||
|
||||
- 测根因,不只测表象
|
||||
- 测协议参数是否正确 (REST endpoint / WebSocket type)
|
||||
- 测状态是否在失败时保持一致
|
||||
- 测真实边界条件:
|
||||
- agent_id 生命周期
|
||||
- session_id 作用域
|
||||
- TOML 配置语法容错
|
||||
- Hand 触发与审批
|
||||
|
||||
### 7.3 人工验证
|
||||
|
||||
自动化通过后,关键链路仍应做手工 smoke:
|
||||
|
||||
- 能否连接 OpenFang (端口 4200)
|
||||
- 能否发送消息并正常流式返回
|
||||
- 模型切换是否真实生效
|
||||
- Hand 触发是否正常执行
|
||||
- 保存配置后是否真正影响新会话/运行时
|
||||
- [ ] 能否正常连接后端服务
|
||||
- [ ] 能否发送消息并获得流式响应
|
||||
- [ ] 模型切换是否生效
|
||||
- [ ] Hand 触发是否正常执行
|
||||
- [ ] 配置保存是否持久化
|
||||
|
||||
---
|
||||
|
||||
## 8. 文档管理规则
|
||||
## 8. 文档管理
|
||||
|
||||
### 8.1 文档结构
|
||||
|
||||
```text
|
||||
docs/
|
||||
├── features/ # 功能全景文档
|
||||
│ ├── README.md # 功能索引和优先级矩阵
|
||||
│ ├── brainstorming-notes.md # 头脑风暴记录
|
||||
│ ├── 00-architecture/ # 架构层功能
|
||||
│ ├── 01-core-features/ # 核心功能
|
||||
│ ├── 02-intelligence-layer/ # 智能层 (L4 自演化)
|
||||
│ ├── 03-context-database/ # 上下文数据库
|
||||
│ ├── 04-skills-ecosystem/ # Skills 生态
|
||||
│ ├── 05-hands-system/ # Hands 系统
|
||||
│ └── 06-tauri-backend/ # Tauri 后端
|
||||
├── knowledge-base/ # 技术知识库
|
||||
│ ├── openfang-technical-reference.md
|
||||
│ ├── openfang-websocket-protocol.md
|
||||
│ └── troubleshooting.md
|
||||
└── WORK_SUMMARY_*.md # 工作日志
|
||||
├── features/ # 功能文档
|
||||
│ ├── README.md # 功能索引
|
||||
│ └── */ # 各功能详细文档
|
||||
├── knowledge-base/ # 技术知识库
|
||||
│ ├── troubleshooting.md
|
||||
│ └── *.md
|
||||
└── archive/ # 归档文档
|
||||
```
|
||||
|
||||
### 8.2 功能文档维护规范
|
||||
### 8.2 文档更新原则
|
||||
|
||||
**何时更新功能文档**:
|
||||
|
||||
| 触发条件 | 更新内容 |
|
||||
|---------|---------|
|
||||
| 新增功能 | 创建新文档,填写设计初衷 |
|
||||
| 功能修改 | 更新技术设计、预期作用 |
|
||||
| 功能完成 | 更新实际效果、测试覆盖 |
|
||||
| 发现问题 | 更新已知问题、风险挑战 |
|
||||
| 用户反馈 | 更新用户反馈、演化路线 |
|
||||
|
||||
**功能文档模板**:
|
||||
|
||||
```markdown
|
||||
# [功能名称]
|
||||
|
||||
> **分类**: [架构层/核心功能/智能层/上下文数据库/Skills/Hands/Tauri]
|
||||
> **优先级**: [P0-决定性 / P1-重要 / P2-增强]
|
||||
> **成熟度**: [L0-概念 / L1-原型 / L2-可用 / L3-成熟 / L4-生产]
|
||||
> **最后更新**: YYYY-MM-DD
|
||||
|
||||
## 一、功能概述
|
||||
## 二、设计初衷(问题背景、设计目标、竞品参考、设计约束)
|
||||
## 三、技术设计(核心接口、数据流、状态管理)
|
||||
## 四、预期作用(用户价值、系统价值、成功指标)
|
||||
## 五、实际效果(已实现、测试覆盖、已知问题、用户反馈)
|
||||
## 六、演化路线(短期/中期/长期)
|
||||
## 七、头脑风暴笔记(待讨论问题、创意想法、风险挑战)
|
||||
```
|
||||
|
||||
### 8.3 知识库更新规则
|
||||
|
||||
凡是出现以下情况,应更新 `docs/knowledge-base/` 或相关文档:
|
||||
|
||||
- 新的协议坑 (REST/WebSocket)
|
||||
- 新的握手/配置/模型排障结论
|
||||
- 真实 runtime 与旧实现不一致
|
||||
- OpenFang 特有问题 (Hands, Workflows, 安全层)
|
||||
- 某个问题的最短排障路径已经明确
|
||||
|
||||
原则:**修完就记,避免二次踩坑。**
|
||||
|
||||
### 8.4 文档质量检查清单
|
||||
|
||||
每次更新文档后,检查:
|
||||
|
||||
- [ ] 文件路径引用正确
|
||||
- [ ] 技术术语统一
|
||||
- [ ] ICE 评分已更新
|
||||
- [ ] 成熟度等级已更新
|
||||
- [ ] 已知问题列表已更新
|
||||
- **修完就记** - 解决问题后立即更新文档
|
||||
- **面向未来** - 文档要帮助未来的开发者快速理解
|
||||
- **中文优先** - 所有面向用户的文档使用中文
|
||||
|
||||
---
|
||||
|
||||
## 9. 常见高风险点
|
||||
## 9. 常见问题排查
|
||||
|
||||
- 把前端本地 id 当作 OpenFang `agent_id`
|
||||
- 只改 Zustand,不改 OpenFang 配置
|
||||
- 把 OpenClaw 协议字段发给 OpenFang
|
||||
- fallback 逻辑覆盖真实错误
|
||||
- 直接手动解析 TOML,忽略格式容错
|
||||
- 让 UI 显示"已完成",实际只是 placeholder
|
||||
- 混淆 OpenClaw 端口 (18789) 和 OpenFang 端口 (4200)
|
||||
### 9.1 连接问题
|
||||
|
||||
1. 检查后端服务是否启动(端口 50051)
|
||||
2. 检查 Vite 代理配置
|
||||
3. 检查防火墙设置
|
||||
|
||||
### 9.2 状态问题
|
||||
|
||||
1. 检查 Store 是否正确订阅
|
||||
2. 检查组件是否在正确的 Store 获取数据
|
||||
3. 检查是否有多个 Store 实例
|
||||
|
||||
### 9.3 配置问题
|
||||
|
||||
1. 检查 TOML 语法
|
||||
2. 检查环境变量是否设置
|
||||
3. 检查配置文件路径
|
||||
|
||||
---
|
||||
|
||||
## 10. OpenFang 特有注意事项
|
||||
|
||||
### 10.1 Hands 系统
|
||||
|
||||
OpenFang 提供 7 个自主能力包:
|
||||
|
||||
| Hand | 功能 | 触发方式 |
|
||||
|------|------|----------|
|
||||
| Clip | 视频处理、竖屏生成 | 手动/自动 |
|
||||
| Lead | 销售线索发现 | 定时 |
|
||||
| Collector | 数据收集聚合 | 定时/事件 |
|
||||
| Predictor | 预测分析 | 手动 |
|
||||
| Researcher | 深度研究 | 手动 |
|
||||
| Twitter | Twitter 自动化 | 定时/事件 |
|
||||
| Browser | 浏览器自动化 | 手动/工作流 |
|
||||
|
||||
触发 Hand 时必须:
|
||||
- 检查 RBAC 权限
|
||||
- 处理 `needs_approval` 状态
|
||||
- 记录审计日志
|
||||
|
||||
### 10.2 安全层
|
||||
|
||||
OpenFang 有 16 层安全防护,前端需要:
|
||||
|
||||
- 正确处理认证失败 (Ed25519 + JWT)
|
||||
- 尊重 RBAC 能力门控
|
||||
- 显示审计日志入口
|
||||
- 处理速率限制错误
|
||||
|
||||
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. 常用命令
|
||||
## 10. 常用命令
|
||||
|
||||
```bash
|
||||
# 安装依赖
|
||||
pnpm install
|
||||
pnpm dev
|
||||
pnpm tauri:dev
|
||||
|
||||
# 开发模式
|
||||
pnpm start:dev
|
||||
|
||||
# 仅启动桌面端
|
||||
pnpm desktop
|
||||
|
||||
# 构建生产版本
|
||||
pnpm build
|
||||
pnpm setup
|
||||
pnpm vitest run tests/desktop/chatStore.test.ts tests/desktop/gatewayStore.test.ts tests/desktop/general-settings.test.tsx
|
||||
|
||||
# 类型检查
|
||||
pnpm tsc --noEmit
|
||||
|
||||
# 运行测试
|
||||
pnpm vitest run
|
||||
|
||||
# 停止所有服务
|
||||
pnpm start:stop
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. 参考文档
|
||||
## 11. 提交规范
|
||||
|
||||
- `docs/openfang-technical-reference.md` - OpenFang 技术参考
|
||||
- `docs/openclaw-to-openfang-migration-brainstorm.md` - 迁移分析
|
||||
- `docs/DEVELOPMENT.md` - 开发指南
|
||||
- `skills/` - SKILL.md 技能示例
|
||||
- `hands/` - HAND.toml 配置示例
|
||||
|
||||
---
|
||||
|
||||
## 13. 提交信息建议
|
||||
|
||||
```text
|
||||
<type>(<scope>): <summary>
|
||||
```
|
||||
<type>(<scope>): <description>
|
||||
```
|
||||
|
||||
示例:
|
||||
**类型:**
|
||||
- `feat` - 新功能
|
||||
- `fix` - 修复问题
|
||||
- `refactor` - 重构
|
||||
- `docs` - 文档更新
|
||||
- `test` - 测试相关
|
||||
- `chore` - 杂项
|
||||
|
||||
```text
|
||||
feat(openfang): add OpenFangClient with WebSocket support
|
||||
feat(hands): add researcher hand trigger UI
|
||||
fix(chat): align stream events with OpenFang protocol
|
||||
fix(config): handle TOML format correctly
|
||||
perf(gateway): optimize connection pooling
|
||||
docs(knowledge-base): capture OpenFang RBAC permission issues
|
||||
**示例:**
|
||||
```
|
||||
feat(hands): 添加参数预设保存功能
|
||||
fix(chat): 修复流式响应中断问题
|
||||
refactor(store): 统一 Store 数据获取方式
|
||||
```
|
||||
|
||||
推荐类型:
|
||||
|
||||
- `feat`
|
||||
- `fix`
|
||||
- `refactor`
|
||||
- `test`
|
||||
- `docs`
|
||||
- `chore`
|
||||
- `perf`
|
||||
|
||||
---
|
||||
|
||||
## 14. 迁移检查清单
|
||||
## 12. 安全注意事项
|
||||
|
||||
从 OpenClaw 迁移到 OpenFang 时,确保:
|
||||
|
||||
- [ ] 端口从 18789 改为 4200
|
||||
- [ ] 配置格式从 YAML/JSON 改为 TOML
|
||||
- [ ] WebSocket URL 添加 `/ws` 路径
|
||||
- [ ] RPC 方法改为 REST API 或新 WebSocket 协议
|
||||
- [ ] 插件从 TypeScript 改为 SKILL.md
|
||||
- [ ] 添加 Hands/Workflow 相关 UI
|
||||
- [ ] 处理 16 层安全防护的交互
|
||||
|
||||
---
|
||||
|
||||
## 16. 参考文档更新
|
||||
|
||||
- `docs/features/README.md` - 功能索引和优先级矩阵
|
||||
- `docs/features/brainstorming-notes.md` - 头脑风暴记录
|
||||
- `docs/knowledge-base/openfang-technical-reference.md` - OpenFang 技术参考
|
||||
- `docs/knowledge-base/openfang-websocket-protocol.md` - WebSocket 协议
|
||||
- `docs/knowledge-base/troubleshooting.md` - 排障指南
|
||||
- `skills/` - SKILL.md 技能定义
|
||||
- `hands/` - HAND.toml 自主能力包
|
||||
- 不在代码中硬编码密钥
|
||||
- 用户输入必须验证
|
||||
- 敏感操作需要确认
|
||||
- 保留操作审计日志
|
||||
|
||||
@@ -18,12 +18,12 @@
|
||||
# ============================================================
|
||||
|
||||
[server]
|
||||
# gRPC server host and port
|
||||
# gRPC server host and port (default 4200 from runtime-manifest.json)
|
||||
host = "127.0.0.1"
|
||||
port = 50051
|
||||
port = 4200
|
||||
|
||||
# WebSocket configuration
|
||||
websocket_port = 50051
|
||||
websocket_port = 4200
|
||||
websocket_path = "/ws"
|
||||
|
||||
# CORS settings for desktop client
|
||||
|
||||
@@ -22,6 +22,9 @@
|
||||
"tauri:build:msi:debug": "pnpm prepare:openfang-runtime && node scripts/tauri-build-bundled.mjs --debug --bundles msi",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:e2e": "playwright test --project chromium --config=tests/e2e/playwright.config.ts",
|
||||
"test:e2e:ui": "playwright test --project chromium-ui --config=tests/e2e/playwright.config.ts --grep 'UI'",
|
||||
"test:e2e:headed": "playwright test --project chromium-headed --headed",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
17
desktop/src-tauri/Cargo.lock
generated
@@ -735,6 +735,7 @@ dependencies = [
|
||||
"dirs 5.0.1",
|
||||
"fantoccini",
|
||||
"futures",
|
||||
"keyring",
|
||||
"regex",
|
||||
"reqwest 0.11.27",
|
||||
"serde",
|
||||
@@ -2117,6 +2118,16 @@ dependencies = [
|
||||
"unicode-segmentation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "keyring"
|
||||
version = "3.6.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c"
|
||||
dependencies = [
|
||||
"log",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kuchikiki"
|
||||
version = "0.8.8-speedreader"
|
||||
@@ -5913,6 +5924,12 @@ dependencies = [
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zeroize"
|
||||
version = "1.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
|
||||
|
||||
[[package]]
|
||||
name = "zerotrie"
|
||||
version = "0.2.3"
|
||||
|
||||
@@ -35,3 +35,6 @@ base64 = "0.22"
|
||||
thiserror = "2"
|
||||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
|
||||
# Secure storage (OS keyring/keychain)
|
||||
keyring = "3"
|
||||
|
||||
|
||||
@@ -15,6 +15,9 @@ mod llm;
|
||||
// Browser automation module (Fantoccini-based Browser Hand)
|
||||
mod browser;
|
||||
|
||||
// Secure storage module for OS keyring/keychain
|
||||
mod secure_storage;
|
||||
|
||||
use serde::Serialize;
|
||||
use serde_json::{json, Value};
|
||||
use std::fs;
|
||||
@@ -1066,7 +1069,12 @@ pub fn run() {
|
||||
browser::commands::browser_element_screenshot,
|
||||
browser::commands::browser_get_source,
|
||||
browser::commands::browser_scrape_page,
|
||||
browser::commands::browser_fill_form
|
||||
browser::commands::browser_fill_form,
|
||||
// Secure storage commands (OS keyring/keychain)
|
||||
secure_storage::secure_store_set,
|
||||
secure_storage::secure_store_get,
|
||||
secure_storage::secure_store_delete,
|
||||
secure_storage::secure_store_is_available
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
/* ZCLAW Desktop App - Minimal Legacy Styles */
|
||||
/* Most styling is handled by Tailwind CSS and index.css design system */
|
||||
|
||||
/* Vite Logo Animation - Keep for any Vite default pages */
|
||||
.logo.vite:hover {
|
||||
filter: drop-shadow(0 0 2em #747bff);
|
||||
}
|
||||
@@ -5,22 +9,8 @@
|
||||
.logo.react:hover {
|
||||
filter: drop-shadow(0 0 2em #61dafb);
|
||||
}
|
||||
:root {
|
||||
font-family: Inter, Avenir, Helvetica, Arial, sans-serif;
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
font-weight: 400;
|
||||
|
||||
color: #0f0f0f;
|
||||
background-color: #f6f6f6;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
/* Container utilities */
|
||||
.container {
|
||||
margin: 0;
|
||||
padding-top: 10vh;
|
||||
@@ -46,71 +36,12 @@
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
a {
|
||||
font-weight: 500;
|
||||
color: #646cff;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: #535bf2;
|
||||
}
|
||||
|
||||
/* Typography */
|
||||
h1 {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
input,
|
||||
button {
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
padding: 0.6em 1.2em;
|
||||
font-size: 1em;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
color: #0f0f0f;
|
||||
background-color: #ffffff;
|
||||
transition: border-color 0.25s;
|
||||
box-shadow: 0 2px 2px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
border-color: #396cd8;
|
||||
}
|
||||
button:active {
|
||||
border-color: #396cd8;
|
||||
background-color: #e8e8e8;
|
||||
}
|
||||
|
||||
input,
|
||||
button {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Greet input utility */
|
||||
#greet-input {
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
color: #f6f6f6;
|
||||
background-color: #2f2f2f;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: #24c8db;
|
||||
}
|
||||
|
||||
input,
|
||||
button {
|
||||
color: #ffffff;
|
||||
background-color: #0f0f0f98;
|
||||
}
|
||||
button:active {
|
||||
background-color: #0f0f0f69;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,19 +5,21 @@ import { Sidebar, MainViewType } from './components/Sidebar';
|
||||
import { ChatArea } from './components/ChatArea';
|
||||
import { RightPanel } from './components/RightPanel';
|
||||
import { SettingsLayout } from './components/Settings/SettingsLayout';
|
||||
import { HandTaskPanel } from './components/HandTaskPanel';
|
||||
import { SchedulerPanel } from './components/SchedulerPanel';
|
||||
import { AutomationPanel } from './components/Automation';
|
||||
import { TeamCollaborationView } from './components/TeamCollaborationView';
|
||||
import { TeamOrchestrator } from './components/TeamOrchestrator';
|
||||
import { SwarmDashboard } from './components/SwarmDashboard';
|
||||
import { SkillMarket } from './components/SkillMarket';
|
||||
import { AgentOnboardingWizard } from './components/AgentOnboardingWizard';
|
||||
import { HandApprovalModal } from './components/HandApprovalModal';
|
||||
import { TopBar } from './components/TopBar';
|
||||
import { DetailDrawer } from './components/DetailDrawer';
|
||||
import { useGatewayStore, type HandRun } from './store/gatewayStore';
|
||||
import { useTeamStore } from './store/teamStore';
|
||||
import { useChatStore } from './store/chatStore';
|
||||
import { getStoredGatewayToken } from './lib/gateway-client';
|
||||
import { pageVariants, defaultTransition, fadeInVariants } from './lib/animations';
|
||||
import { Bot, Users, Loader2 } from 'lucide-react';
|
||||
import { Users, Loader2, Settings } from 'lucide-react';
|
||||
import { EmptyState } from './components/ui';
|
||||
import { isTauriRuntime, getLocalGatewayStatus, startLocalGateway } from './lib/tauri-gateway';
|
||||
import { useOnboarding } from './lib/use-onboarding';
|
||||
@@ -40,19 +42,20 @@ function BootstrapScreen({ status }: { status: string }) {
|
||||
function App() {
|
||||
const [view, setView] = useState<View>('main');
|
||||
const [mainContentView, setMainContentView] = useState<MainViewType>('chat');
|
||||
const [selectedHandId, setSelectedHandId] = useState<string | undefined>(undefined);
|
||||
const [selectedTeamId, setSelectedTeamId] = useState<string | undefined>(undefined);
|
||||
const [bootstrapping, setBootstrapping] = useState(true);
|
||||
const [bootstrapStatus, setBootstrapStatus] = useState('Initializing...');
|
||||
const [showOnboarding, setShowOnboarding] = useState(false);
|
||||
const [showDetailDrawer, setShowDetailDrawer] = useState(false);
|
||||
|
||||
// Hand Approval state
|
||||
const [pendingApprovalRun, setPendingApprovalRun] = useState<HandRun | null>(null);
|
||||
const [showApprovalModal, setShowApprovalModal] = useState(false);
|
||||
const [teamViewMode, setTeamViewMode] = useState<'collaboration' | 'orchestrator'>('collaboration');
|
||||
|
||||
const { connect, hands, approveHand, loadHands } = useGatewayStore();
|
||||
const { activeTeam, setActiveTeam, teams } = useTeamStore();
|
||||
const { setCurrentAgent } = useChatStore();
|
||||
const { setCurrentAgent, newConversation } = useChatStore();
|
||||
const { isNeeded: onboardingNeeded, isLoading: onboardingLoading, markCompleted } = useOnboarding();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -182,13 +185,15 @@ function App() {
|
||||
setShowOnboarding(false);
|
||||
};
|
||||
|
||||
// 当切换到非 hands 视图时清除选中的 Hand
|
||||
// 处理主视图切换
|
||||
const handleMainViewChange = (view: MainViewType) => {
|
||||
setMainContentView(view);
|
||||
if (view !== 'hands') {
|
||||
// 可选:清除选中的 Hand
|
||||
// setSelectedHandId(undefined);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理新对话
|
||||
const handleNewChat = () => {
|
||||
newConversation();
|
||||
setMainContentView('chat');
|
||||
};
|
||||
|
||||
const handleSelectTeam = (teamId: string) => {
|
||||
@@ -227,84 +232,121 @@ function App() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-screen flex overflow-hidden text-gray-800 text-sm">
|
||||
<div className="h-screen flex overflow-hidden text-gray-800 text-sm bg-white dark:bg-gray-950">
|
||||
{/* 左侧边栏 */}
|
||||
<Sidebar
|
||||
onOpenSettings={() => setView('settings')}
|
||||
onMainViewChange={handleMainViewChange}
|
||||
selectedHandId={selectedHandId}
|
||||
onSelectHand={setSelectedHandId}
|
||||
selectedTeamId={selectedTeamId}
|
||||
onSelectTeam={handleSelectTeam}
|
||||
onNewChat={handleNewChat}
|
||||
/>
|
||||
|
||||
{/* 中间区域 */}
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.main
|
||||
key={mainContentView}
|
||||
variants={pageVariants}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
transition={defaultTransition}
|
||||
className="flex-1 flex flex-col bg-white relative overflow-hidden"
|
||||
>
|
||||
{mainContentView === 'hands' && selectedHandId ? (
|
||||
<HandTaskPanel
|
||||
handId={selectedHandId}
|
||||
onBack={() => setSelectedHandId(undefined)}
|
||||
/>
|
||||
) : mainContentView === 'hands' ? (
|
||||
<EmptyState
|
||||
icon={<Bot className="w-8 h-8" />}
|
||||
title="Select a Hand"
|
||||
description="Choose an autonomous capability package from the list on the left to view its task list and execution results."
|
||||
/>
|
||||
) : mainContentView === 'workflow' ? (
|
||||
<motion.div
|
||||
variants={fadeInVariants}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
className="flex-1 overflow-y-auto"
|
||||
>
|
||||
<SchedulerPanel />
|
||||
</motion.div>
|
||||
) : mainContentView === 'team' ? (
|
||||
activeTeam ? (
|
||||
<TeamCollaborationView teamId={activeTeam.id} />
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={<Users className="w-8 h-8" />}
|
||||
title="Select or Create a Team"
|
||||
description="Choose a team from the list on the left, or click + to create a new multi-Agent collaboration team."
|
||||
/>
|
||||
)
|
||||
) : mainContentView === 'swarm' ? (
|
||||
<motion.div
|
||||
variants={fadeInVariants}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
className="flex-1 overflow-hidden"
|
||||
>
|
||||
<SwarmDashboard />
|
||||
</motion.div>
|
||||
) : mainContentView === 'skills' ? (
|
||||
<motion.div
|
||||
variants={fadeInVariants}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
className="flex-1 overflow-hidden"
|
||||
>
|
||||
<SkillMarket />
|
||||
</motion.div>
|
||||
) : (
|
||||
<ChatArea />
|
||||
)}
|
||||
</motion.main>
|
||||
</AnimatePresence>
|
||||
{/* 主内容区 */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* 顶部工具栏 */}
|
||||
<TopBar
|
||||
title="ZCLAW"
|
||||
onOpenDetail={() => setShowDetailDrawer(true)}
|
||||
/>
|
||||
|
||||
{/* 右侧边栏 */}
|
||||
<RightPanel />
|
||||
{/* 内容区域 */}
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.main
|
||||
key={mainContentView}
|
||||
variants={pageVariants}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
transition={defaultTransition}
|
||||
className="flex-1 overflow-hidden relative flex flex-col"
|
||||
>
|
||||
{mainContentView === 'automation' ? (
|
||||
<motion.div
|
||||
variants={fadeInVariants}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
className="h-full overflow-y-auto"
|
||||
>
|
||||
<AutomationPanel />
|
||||
</motion.div>
|
||||
) : mainContentView === 'team' ? (
|
||||
activeTeam ? (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* Team View Tabs */}
|
||||
<div className="flex border-b border-gray-200 dark:border-gray-700 px-4">
|
||||
<button
|
||||
onClick={() => setTeamViewMode('collaboration')}
|
||||
className={`flex items-center gap-1.5 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors ${
|
||||
teamViewMode === 'collaboration'
|
||||
? 'text-orange-600 dark:text-orange-400 border-orange-500'
|
||||
: 'text-gray-500 dark:text-gray-400 border-transparent hover:text-gray-700 dark:hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
<Users className="w-4 h-4" />
|
||||
协作视图
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTeamViewMode('orchestrator')}
|
||||
className={`flex items-center gap-1.5 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors ${
|
||||
teamViewMode === 'orchestrator'
|
||||
? 'text-orange-600 dark:text-orange-400 border-orange-500'
|
||||
: 'text-gray-500 dark:text-gray-400 border-transparent hover:text-gray-700 dark:hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
<Settings className="w-4 h-4" />
|
||||
编排管理
|
||||
</button>
|
||||
</div>
|
||||
{/* Tab Content */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{teamViewMode === 'orchestrator' ? (
|
||||
<TeamOrchestrator isOpen={true} onClose={() => setTeamViewMode('collaboration')} />
|
||||
) : (
|
||||
<TeamCollaborationView teamId={activeTeam.id} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={<Users className="w-8 h-8" />}
|
||||
title="选择或创建团队"
|
||||
description="从左侧列表中选择一个团队,或点击 + 创建新的多 Agent 协作团队。"
|
||||
/>
|
||||
)
|
||||
) : mainContentView === 'swarm' ? (
|
||||
<motion.div
|
||||
variants={fadeInVariants}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
className="h-full overflow-hidden"
|
||||
>
|
||||
<SwarmDashboard />
|
||||
</motion.div>
|
||||
) : mainContentView === 'skills' ? (
|
||||
<motion.div
|
||||
variants={fadeInVariants}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
className="h-full overflow-hidden"
|
||||
>
|
||||
<SkillMarket />
|
||||
</motion.div>
|
||||
) : (
|
||||
<ChatArea />
|
||||
)}
|
||||
</motion.main>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* 详情抽屉 - 按需显示 */}
|
||||
<DetailDrawer
|
||||
open={showDetailDrawer}
|
||||
onClose={() => setShowDetailDrawer(false)}
|
||||
title="详情"
|
||||
>
|
||||
<RightPanel />
|
||||
</DetailDrawer>
|
||||
|
||||
{/* Hand Approval Modal (global) */}
|
||||
<HandApprovalModal
|
||||
|
||||
@@ -14,8 +14,9 @@ import {
|
||||
X,
|
||||
Download,
|
||||
Clock,
|
||||
BarChart3,
|
||||
} from 'lucide-react';
|
||||
import { Button, EmptyState } from './ui';
|
||||
import { Button, EmptyState, Badge } from './ui';
|
||||
import { useActiveLearningStore } from '../store/activeLearningStore';
|
||||
import {
|
||||
type LearningEvent,
|
||||
@@ -23,6 +24,7 @@ import {
|
||||
type LearningEventType,
|
||||
} from '../types/active-learning';
|
||||
import { useChatStore } from '../store/chatStore';
|
||||
import { cardHover, defaultTransition } from '../lib/animations';
|
||||
|
||||
// === Constants ===
|
||||
|
||||
@@ -58,10 +60,12 @@ function EventItem({ event, onAcknowledge }: EventItemProps) {
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
whileHover={cardHover}
|
||||
transition={defaultTransition}
|
||||
className={`p-3 rounded-lg border ${
|
||||
event.acknowledged
|
||||
? 'bg-gray-800/30 border-gray-700'
|
||||
: 'bg-blue-900/20 border-blue-700'
|
||||
? 'bg-gray-50 dark:bg-gray-800 border-gray-100 dark:border-gray-700'
|
||||
: 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-700'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
@@ -70,11 +74,11 @@ function EventItem({ event, onAcknowledge }: EventItemProps) {
|
||||
<span className={`text-xs px-2 py-0.5 rounded ${typeInfo.color}`}>
|
||||
{typeInfo.label}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">{timeAgo}</span>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">{timeAgo}</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-300 truncate">{event.observation}</p>
|
||||
<p className="text-sm text-gray-700 dark:text-gray-300 truncate">{event.observation}</p>
|
||||
{event.inferredPreference && (
|
||||
<p className="text-xs text-gray-500 mt-1">→ {event.inferredPreference}</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">→ {event.inferredPreference}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -85,7 +89,7 @@ function EventItem({ event, onAcknowledge }: EventItemProps) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 mt-2 text-xs text-gray-500">
|
||||
<div className="flex items-center gap-2 mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
<span>置信度: {(event.confidence * 100).toFixed(0)}%</span>
|
||||
{event.appliedCount > 0 && (
|
||||
<span>• 应用 {event.appliedCount} 次</span>
|
||||
@@ -111,13 +115,15 @@ function SuggestionCard({ suggestion, onApply, onDismiss }: SuggestionCardProps)
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
className="p-4 bg-gradient-to-r from-amber-900/20 to-transparent rounded-lg border border-amber-700/50"
|
||||
whileHover={cardHover}
|
||||
transition={defaultTransition}
|
||||
className="p-4 bg-gradient-to-r from-amber-50 to-transparent dark:from-amber-900/20 dark:to-transparent rounded-lg border border-amber-200 dark:border-amber-700/50"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<Lightbulb className="w-5 h-5 text-amber-400 flex-shrink-0 mt-0.5" />
|
||||
<Lightbulb className="w-5 h-5 text-amber-500 flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-gray-200">{suggestion.suggestion}</p>
|
||||
<div className="flex items-center gap-2 mt-2 text-xs text-gray-500">
|
||||
<p className="text-sm text-gray-700 dark:text-gray-200">{suggestion.suggestion}</p>
|
||||
<div className="flex items-center gap-2 mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
<span>置信度: {(suggestion.confidence * 100).toFixed(0)}%</span>
|
||||
{daysLeft > 0 && <span>• {daysLeft} 天后过期</span>}
|
||||
</div>
|
||||
@@ -204,63 +210,77 @@ export function ActiveLearningPanel({ className = '' }: ActiveLearningPanelProps
|
||||
}, [agentId, clearEvents]);
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col h-full bg-gray-900 ${className}`}>
|
||||
{/* 夨览栏 */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-800">
|
||||
<div className="flex items-center gap-2">
|
||||
<Brain className="w-5 h-5 text-blue-400" />
|
||||
<h2 className="text-lg font-semibold text-white">主动学习</h2>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="flex items-center gap-2 text-sm text-gray-400">
|
||||
<div className={`space-y-4 ${className}`}>
|
||||
{/* 启用开关和导出 */}
|
||||
<motion.div
|
||||
whileHover={cardHover}
|
||||
transition={defaultTransition}
|
||||
className="bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-100 dark:border-gray-700 p-3"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
<Brain className="w-4 h-4 text-blue-500" />
|
||||
<span>主动学习</span>
|
||||
<Badge variant={config.enabled ? 'success' : 'default'} className="ml-1">
|
||||
{config.enabled ? '已启用' : '已禁用'}
|
||||
</Badge>
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.enabled}
|
||||
onChange={(e) => setConfig({ enabled: e.target.checked })}
|
||||
className="rounded"
|
||||
className="rounded border-gray-300 dark:border-gray-600"
|
||||
/>
|
||||
启用
|
||||
</label>
|
||||
|
||||
<Button variant="ghost" size="sm" onClick={handleExport}>
|
||||
<Download className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={handleExport} title="导出数据">
|
||||
<Download className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* 统计概览 */}
|
||||
<div className="grid grid-cols-4 gap-2 p-3 bg-gray-800/30">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-blue-400">{stats.totalEvents}</div>
|
||||
<div className="text-xs text-gray-500">学习事件</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-green-400">{stats.totalPatterns}</div>
|
||||
<div className="text-xs text-gray-500">学习模式</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-amber-400">{agentSuggestions.length}</div>
|
||||
<div className="text-xs text-gray-500">待处理建议</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-purple-400">
|
||||
{(stats.avgConfidence * 100).toFixed(0)}%
|
||||
<motion.div
|
||||
whileHover={cardHover}
|
||||
transition={defaultTransition}
|
||||
className="bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-100 dark:border-gray-700 p-3"
|
||||
>
|
||||
<h3 className="text-xs font-semibold text-gray-700 dark:text-gray-300 mb-2 flex items-center gap-1.5">
|
||||
<BarChart3 className="w-3.5 h-3.5" />
|
||||
学习统计
|
||||
</h3>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
<div className="text-center">
|
||||
<div className="text-lg font-bold text-blue-500">{stats.totalEvents}</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">事件</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-lg font-bold text-green-500">{stats.totalPatterns}</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">模式</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-lg font-bold text-amber-500">{agentSuggestions.length}</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">建议</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-lg font-bold text-purple-500">
|
||||
{(stats.avgConfidence * 100).toFixed(0)}%
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">置信度</div>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">平均置信度</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Tab 切换 */}
|
||||
<div className="flex border-b border-gray-800">
|
||||
<div className="flex border-b border-gray-200 dark:border-gray-700">
|
||||
{(['suggestions', 'events', 'patterns'] as const).map(tab => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
className={`flex-1 py-2 text-sm font-medium transition-colors ${
|
||||
activeTab === tab
|
||||
? 'text-blue-400 border-b-2 border-blue-400'
|
||||
: 'text-gray-500 hover:text-gray-300'
|
||||
? 'text-emerald-600 dark:text-emerald-400 border-b-2 border-emerald-500'
|
||||
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
{tab === 'suggestions' && '建议'}
|
||||
@@ -271,7 +291,7 @@ export function ActiveLearningPanel({ className = '' }: ActiveLearningPanelProps
|
||||
</div>
|
||||
|
||||
{/* 内容区域 */}
|
||||
<div className="flex-1 overflow-y-auto p-3">
|
||||
<div className="space-y-3">
|
||||
<AnimatePresence mode="wait">
|
||||
{activeTab === 'suggestions' && (
|
||||
<motion.div
|
||||
@@ -283,9 +303,10 @@ export function ActiveLearningPanel({ className = '' }: ActiveLearningPanelProps
|
||||
>
|
||||
{agentSuggestions.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<Lightbulb className="w-12 h-12" />}
|
||||
icon={<Lightbulb className="w-8 h-8" />}
|
||||
title="暂无学习建议"
|
||||
description="系统会根据您的反馈自动生成改进建议"
|
||||
className="py-4"
|
||||
/>
|
||||
) : (
|
||||
agentSuggestions.map(suggestion => (
|
||||
@@ -310,9 +331,10 @@ export function ActiveLearningPanel({ className = '' }: ActiveLearningPanelProps
|
||||
>
|
||||
{agentEvents.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<Clock className="w-12 h-12" />}
|
||||
icon={<Clock className="w-8 h-8" />}
|
||||
title="暂无学习事件"
|
||||
description="开始对话后,系统会自动记录学习事件"
|
||||
className="py-4"
|
||||
/>
|
||||
) : (
|
||||
agentEvents.map(event => (
|
||||
@@ -336,32 +358,35 @@ export function ActiveLearningPanel({ className = '' }: ActiveLearningPanelProps
|
||||
>
|
||||
{agentPatterns.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<TrendingUp className="w-12 h-12" />}
|
||||
icon={<TrendingUp className="w-8 h-8" />}
|
||||
title="暂无学习模式"
|
||||
description="积累更多反馈后,系统会识别出行为模式"
|
||||
className="py-4"
|
||||
/>
|
||||
) : (
|
||||
agentPatterns.map(pattern => {
|
||||
const typeInfo = PATTERN_TYPE_LABELS[pattern.type] || { label: pattern.type, icon: '📊' };
|
||||
return (
|
||||
<div
|
||||
<motion.div
|
||||
key={`${pattern.agentId}-${pattern.pattern}`}
|
||||
className="p-3 bg-gray-800/50 rounded-lg border border-gray-700"
|
||||
whileHover={cardHover}
|
||||
transition={defaultTransition}
|
||||
className="p-3 bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-100 dark:border-gray-700"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{typeInfo.icon}</span>
|
||||
<span className="text-sm font-medium text-white">{typeInfo.label}</span>
|
||||
<span className="text-sm font-medium text-gray-800 dark:text-gray-200">{typeInfo.label}</span>
|
||||
</div>
|
||||
<span className="text-xs px-2 py-0.5 rounded bg-gray-700 text-gray-300">
|
||||
<span className="text-xs px-2 py-0.5 rounded bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-300">
|
||||
{(pattern.confidence * 100).toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-400">{pattern.description}</p>
|
||||
<div className="mt-2 text-xs text-gray-500">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">{pattern.description}</p>
|
||||
<div className="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
{pattern.examples.length} 个示例
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
@@ -371,13 +396,13 @@ export function ActiveLearningPanel({ className = '' }: ActiveLearningPanelProps
|
||||
</div>
|
||||
|
||||
{/* 底部操作栏 */}
|
||||
<div className="flex items-center justify-between p-3 border-t border-gray-800">
|
||||
<div className="text-xs text-gray-500">
|
||||
<div className="flex items-center justify-between pt-2 border-t border-gray-100 dark:border-gray-700">
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
上次更新: {agentEvents[0] ? getTimeAgo(agentEvents[0].timestamp) : '无'}
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={handleClear} className="text-red-400">
|
||||
<Button variant="ghost" size="sm" onClick={handleClear} className="text-red-500 hover:text-red-600">
|
||||
<X className="w-3 h-3 mr-1" />
|
||||
清除数据
|
||||
清除
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
352
desktop/src/components/Automation/ApprovalQueue.tsx
Normal file
@@ -0,0 +1,352 @@
|
||||
/**
|
||||
* ApprovalQueue - Approval Management Component
|
||||
*
|
||||
* Displays pending approvals for hand executions that require
|
||||
* human approval, with approve/reject actions.
|
||||
*
|
||||
* @module components/Automation/ApprovalQueue
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useHandStore } from '../../store/handStore';
|
||||
import type { Approval, ApprovalStatus } from '../../store/handStore';
|
||||
import {
|
||||
Clock,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
AlertTriangle,
|
||||
RefreshCw,
|
||||
} from 'lucide-react';
|
||||
import { useToast } from '../ui/Toast';
|
||||
|
||||
// === Status Config ===
|
||||
|
||||
const STATUS_CONFIG: Record<ApprovalStatus, {
|
||||
label: string;
|
||||
className: string;
|
||||
icon: typeof CheckCircle;
|
||||
}> = {
|
||||
pending: {
|
||||
label: '待处理',
|
||||
className: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400',
|
||||
icon: Clock,
|
||||
},
|
||||
approved: {
|
||||
label: '已批准',
|
||||
className: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400',
|
||||
icon: CheckCircle,
|
||||
},
|
||||
rejected: {
|
||||
label: '已拒绝',
|
||||
className: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400',
|
||||
icon: XCircle,
|
||||
},
|
||||
expired: {
|
||||
label: '已过期',
|
||||
className: 'bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-400',
|
||||
icon: AlertTriangle,
|
||||
},
|
||||
};
|
||||
|
||||
// === Component Props ===
|
||||
|
||||
interface ApprovalQueueProps {
|
||||
showFilters?: boolean;
|
||||
maxHeight?: string;
|
||||
onApprove?: (approval: Approval) => void;
|
||||
onReject?: (approval: Approval) => void;
|
||||
}
|
||||
|
||||
// === Approval Card Component ===
|
||||
|
||||
interface ApprovalCardProps {
|
||||
approval: Approval;
|
||||
onApprove: () => Promise<void>;
|
||||
onReject: (reason: string) => Promise<void>;
|
||||
isProcessing: boolean;
|
||||
}
|
||||
|
||||
function ApprovalCard({ approval, onApprove, onReject, isProcessing }: ApprovalCardProps) {
|
||||
const [showRejectInput, setShowRejectInput] = useState(false);
|
||||
const [rejectReason, setRejectReason] = useState('');
|
||||
const StatusIcon = STATUS_CONFIG[approval.status].icon;
|
||||
|
||||
const handleReject = useCallback(async () => {
|
||||
if (!rejectReason.trim()) {
|
||||
setShowRejectInput(true);
|
||||
return;
|
||||
}
|
||||
await onReject(rejectReason);
|
||||
setShowRejectInput(false);
|
||||
setRejectReason('');
|
||||
}, [rejectReason, onReject]);
|
||||
|
||||
const timeAgo = useCallback((dateStr: string) => {
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
|
||||
if (diffMins < 1) return '刚刚';
|
||||
if (diffMins < 60) return `${diffMins} 分钟前`;
|
||||
if (diffHours < 24) return `${diffHours} 小时前`;
|
||||
return `${diffDays} 天前`;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-3 mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`px-2 py-0.5 rounded text-xs ${STATUS_CONFIG[approval.status].className}`}>
|
||||
<StatusIcon className="w-3 h-3 inline mr-1" />
|
||||
{STATUS_CONFIG[approval.status].label}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{timeAgo(approval.requestedAt)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="mb-3">
|
||||
<h4 className="font-medium text-gray-900 dark:text-white mb-1">
|
||||
{approval.handName}
|
||||
</h4>
|
||||
{approval.reason && (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">{approval.reason}</p>
|
||||
)}
|
||||
{approval.action && (
|
||||
<p className="text-xs text-gray-500 dark:text-gray-500 mt-1">
|
||||
操作: {approval.action}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Params Preview */}
|
||||
{approval.params && Object.keys(approval.params).length > 0 && (
|
||||
<div className="mb-3 p-2 bg-gray-50 dark:bg-gray-900 rounded text-xs">
|
||||
<p className="text-gray-500 dark:text-gray-400 mb-1">参数:</p>
|
||||
<pre className="text-gray-700 dark:text-gray-300 overflow-x-auto">
|
||||
{JSON.stringify(approval.params, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reject Input */}
|
||||
{showRejectInput && (
|
||||
<div className="mb-3">
|
||||
<textarea
|
||||
value={rejectReason}
|
||||
onChange={(e) => setRejectReason(e.target.value)}
|
||||
placeholder="请输入拒绝原因..."
|
||||
className="w-full px-3 py-2 text-sm border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white resize-none"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
{approval.status === 'pending' && (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={onApprove}
|
||||
disabled={isProcessing}
|
||||
className="flex-1 px-3 py-1.5 text-sm bg-green-500 text-white rounded-md hover:bg-green-600 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-1"
|
||||
>
|
||||
{isProcessing ? (
|
||||
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<CheckCircle className="w-3.5 h-3.5" />
|
||||
)}
|
||||
批准
|
||||
</button>
|
||||
<button
|
||||
onClick={handleReject}
|
||||
disabled={isProcessing}
|
||||
className="flex-1 px-3 py-1.5 text-sm bg-red-500 text-white rounded-md hover:bg-red-600 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-1"
|
||||
>
|
||||
{isProcessing ? (
|
||||
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<XCircle className="w-3.5 h-3.5" />
|
||||
)}
|
||||
拒绝
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Response Info */}
|
||||
{approval.status !== 'pending' && approval.respondedAt && (
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{approval.respondedBy && `由 ${approval.respondedBy} `}
|
||||
{STATUS_CONFIG[approval.status].label}
|
||||
{approval.responseReason && ` - ${approval.responseReason}`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// === Main Component ===
|
||||
|
||||
export function ApprovalQueue({
|
||||
showFilters = true,
|
||||
maxHeight = '400px',
|
||||
onApprove,
|
||||
onReject,
|
||||
}: ApprovalQueueProps) {
|
||||
const { toast } = useToast();
|
||||
|
||||
// Store state
|
||||
const approvals = useHandStore(s => s.approvals);
|
||||
const loadApprovals = useHandStore(s => s.loadApprovals);
|
||||
const respondToApproval = useHandStore(s => s.respondToApproval);
|
||||
const isLoading = useHandStore(s => s.isLoading);
|
||||
|
||||
// Local state
|
||||
const [statusFilter, setStatusFilter] = useState<ApprovalStatus | 'all'>('pending');
|
||||
const [processingIds, setProcessingIds] = useState<Set<string>>(new Set());
|
||||
|
||||
// Load approvals on mount
|
||||
useEffect(() => {
|
||||
loadApprovals(statusFilter === 'all' ? undefined : statusFilter);
|
||||
}, [loadApprovals, statusFilter]);
|
||||
|
||||
// Handle approve
|
||||
const handleApprove = useCallback(async (approval: Approval) => {
|
||||
setProcessingIds(prev => new Set(prev).add(approval.id));
|
||||
try {
|
||||
await respondToApproval(approval.id, true);
|
||||
toast(`已批准: ${approval.handName}`, 'success');
|
||||
onApprove?.(approval);
|
||||
} catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||
toast(`批准失败: ${errorMsg}`, 'error');
|
||||
} finally {
|
||||
setProcessingIds(prev => {
|
||||
const next = new Set(prev);
|
||||
next.delete(approval.id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}, [respondToApproval, toast, onApprove]);
|
||||
|
||||
// Handle reject
|
||||
const handleReject = useCallback(async (approval: Approval, reason: string) => {
|
||||
setProcessingIds(prev => new Set(prev).add(approval.id));
|
||||
try {
|
||||
await respondToApproval(approval.id, false, reason);
|
||||
toast(`已拒绝: ${approval.handName}`, 'success');
|
||||
onReject?.(approval);
|
||||
} catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||
toast(`拒绝失败: ${errorMsg}`, 'error');
|
||||
} finally {
|
||||
setProcessingIds(prev => {
|
||||
const next = new Set(prev);
|
||||
next.delete(approval.id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}, [respondToApproval, toast, onReject]);
|
||||
|
||||
// Filter approvals
|
||||
const filteredApprovals = statusFilter === 'all'
|
||||
? approvals
|
||||
: approvals.filter(a => a.status === statusFilter);
|
||||
|
||||
// Stats
|
||||
const stats = {
|
||||
pending: approvals.filter(a => a.status === 'pending').length,
|
||||
approved: approvals.filter(a => a.status === 'approved').length,
|
||||
rejected: approvals.filter(a => a.status === 'rejected').length,
|
||||
expired: approvals.filter(a => a.status === 'expired').length,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="w-5 h-5 text-orange-500" />
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
审批队列
|
||||
</h2>
|
||||
{stats.pending > 0 && (
|
||||
<span className="px-2 py-0.5 text-xs font-medium bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400 rounded-full">
|
||||
{stats.pending} 待处理
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => loadApprovals(statusFilter === 'all' ? undefined : statusFilter)}
|
||||
disabled={isLoading}
|
||||
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 disabled:opacity-50"
|
||||
title="刷新"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
{showFilters && (
|
||||
<div className="flex items-center gap-2 px-4 py-2 border-b border-gray-200 dark:border-gray-700 overflow-x-auto">
|
||||
{[
|
||||
{ value: 'pending', label: '待处理', count: stats.pending },
|
||||
{ value: 'approved', label: '已批准', count: stats.approved },
|
||||
{ value: 'rejected', label: '已拒绝', count: stats.rejected },
|
||||
{ value: 'all', label: '全部', count: approvals.length },
|
||||
].map(option => (
|
||||
<button
|
||||
key={option.value}
|
||||
onClick={() => setStatusFilter(option.value as ApprovalStatus | 'all')}
|
||||
className={`flex items-center gap-1 px-3 py-1 text-sm rounded-full whitespace-nowrap transition-colors ${
|
||||
statusFilter === option.value
|
||||
? 'bg-orange-500 text-white'
|
||||
: 'bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
<span className={`text-xs ${statusFilter === option.value ? 'text-white/80' : 'text-gray-500 dark:text-gray-400'}`}>
|
||||
({option.count})
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-4" style={{ maxHeight }}>
|
||||
{isLoading && approvals.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<RefreshCw className="w-6 h-6 animate-spin text-gray-400" />
|
||||
</div>
|
||||
) : filteredApprovals.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-32 text-center">
|
||||
<Clock className="w-8 h-8 text-gray-400 mb-2" />
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{statusFilter === 'pending' ? '暂无待处理的审批' : '暂无审批记录'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{filteredApprovals.map(approval => (
|
||||
<ApprovalCard
|
||||
key={approval.id}
|
||||
approval={approval}
|
||||
onApprove={() => handleApprove(approval)}
|
||||
onReject={(reason) => handleReject(approval, reason)}
|
||||
isProcessing={processingIds.has(approval.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ApprovalQueue;
|
||||
402
desktop/src/components/Automation/AutomationCard.tsx
Normal file
@@ -0,0 +1,402 @@
|
||||
/**
|
||||
* AutomationCard - Unified Card for Hands and Workflows
|
||||
*
|
||||
* Displays automation items with status, parameters, and actions.
|
||||
* Supports both grid and list view modes.
|
||||
*
|
||||
* @module components/Automation/AutomationCard
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import type { AutomationItem, AutomationStatus } from '../../types/automation';
|
||||
import { CATEGORY_CONFIGS } from '../../types/automation';
|
||||
import type { HandParameter } from '../../types/hands';
|
||||
import { HandParamsForm } from '../HandParamsForm';
|
||||
import {
|
||||
Zap,
|
||||
Clock,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
AlertTriangle,
|
||||
Loader2,
|
||||
Settings,
|
||||
Play,
|
||||
MoreVertical,
|
||||
} from 'lucide-react';
|
||||
|
||||
// === Status Config ===
|
||||
|
||||
const STATUS_CONFIG: Record<AutomationStatus, {
|
||||
label: string;
|
||||
className: string;
|
||||
dotClass: string;
|
||||
icon?: typeof CheckCircle;
|
||||
}> = {
|
||||
idle: {
|
||||
label: '就绪',
|
||||
className: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400',
|
||||
dotClass: 'bg-green-500',
|
||||
},
|
||||
running: {
|
||||
label: '运行中',
|
||||
className: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
|
||||
dotClass: 'bg-blue-500 animate-pulse',
|
||||
icon: Loader2,
|
||||
},
|
||||
needs_approval: {
|
||||
label: '待审批',
|
||||
className: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400',
|
||||
dotClass: 'bg-yellow-500',
|
||||
icon: AlertTriangle,
|
||||
},
|
||||
error: {
|
||||
label: '错误',
|
||||
className: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400',
|
||||
dotClass: 'bg-red-500',
|
||||
icon: XCircle,
|
||||
},
|
||||
unavailable: {
|
||||
label: '不可用',
|
||||
className: 'bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-400',
|
||||
dotClass: 'bg-gray-400',
|
||||
},
|
||||
setup_needed: {
|
||||
label: '需配置',
|
||||
className: 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400',
|
||||
dotClass: 'bg-orange-500',
|
||||
icon: Settings,
|
||||
},
|
||||
completed: {
|
||||
label: '已完成',
|
||||
className: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400',
|
||||
dotClass: 'bg-green-500',
|
||||
icon: CheckCircle,
|
||||
},
|
||||
paused: {
|
||||
label: '已暂停',
|
||||
className: 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400',
|
||||
dotClass: 'bg-gray-400',
|
||||
},
|
||||
};
|
||||
|
||||
// === Component Props ===
|
||||
|
||||
interface AutomationCardProps {
|
||||
item: AutomationItem;
|
||||
viewMode?: 'grid' | 'list';
|
||||
isSelected?: boolean;
|
||||
isExecuting?: boolean;
|
||||
onSelect?: (selected: boolean) => void;
|
||||
onExecute?: (params?: Record<string, unknown>) => void;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
// === Status Badge Component ===
|
||||
|
||||
function StatusBadge({ status }: { status: AutomationStatus }) {
|
||||
const config = STATUS_CONFIG[status] || STATUS_CONFIG.unavailable;
|
||||
const Icon = config.icon;
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium ${config.className}`}>
|
||||
{Icon ? (
|
||||
<Icon className={`w-3 h-3 ${status === 'running' ? 'animate-spin' : ''}`} />
|
||||
) : (
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${config.dotClass}`} />
|
||||
)}
|
||||
{config.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// === Type Badge Component ===
|
||||
|
||||
function TypeBadge({ type }: { type: 'hand' | 'workflow' }) {
|
||||
const isHand = type === 'hand';
|
||||
return (
|
||||
<span className={`px-2 py-0.5 rounded text-xs ${
|
||||
isHand
|
||||
? 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400'
|
||||
: 'bg-cyan-100 text-cyan-700 dark:bg-cyan-900/30 dark:text-cyan-400'
|
||||
}`}>
|
||||
{isHand ? '自主能力' : '工作流'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// === Category Badge Component ===
|
||||
|
||||
function CategoryBadge({ category }: { category: string }) {
|
||||
const config = CATEGORY_CONFIGS[category as keyof typeof CATEGORY_CONFIGS];
|
||||
if (!config) return null;
|
||||
|
||||
return (
|
||||
<span className="px-2 py-0.5 rounded text-xs bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400">
|
||||
{config.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// === Main Component ===
|
||||
|
||||
export function AutomationCard({
|
||||
item,
|
||||
viewMode = 'grid',
|
||||
isSelected = false,
|
||||
isExecuting = false,
|
||||
onSelect,
|
||||
onExecute,
|
||||
onClick,
|
||||
}: AutomationCardProps) {
|
||||
const [showParams, setShowParams] = useState(false);
|
||||
const [paramValues, setParamValues] = useState<Record<string, unknown>>({});
|
||||
const [paramErrors, setParamErrors] = useState<Record<string, string>>({});
|
||||
|
||||
const hasParameters = item.parameters && item.parameters.length > 0;
|
||||
const canActivate = item.status === 'idle' || item.status === 'setup_needed';
|
||||
|
||||
// Initialize default parameter values
|
||||
const initializeDefaults = useCallback(() => {
|
||||
if (item.parameters) {
|
||||
const defaults: Record<string, unknown> = {};
|
||||
item.parameters.forEach(p => {
|
||||
if (p.defaultValue !== undefined) {
|
||||
defaults[p.name] = p.defaultValue;
|
||||
}
|
||||
});
|
||||
setParamValues(defaults);
|
||||
}
|
||||
}, [item.parameters]);
|
||||
|
||||
// Handle execute click
|
||||
const handleExecuteClick = useCallback(() => {
|
||||
if (hasParameters && !showParams) {
|
||||
initializeDefaults();
|
||||
setShowParams(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate parameters
|
||||
if (showParams && item.parameters) {
|
||||
const errors: Record<string, string> = {};
|
||||
item.parameters.forEach(param => {
|
||||
if (param.required) {
|
||||
const value = paramValues[param.name];
|
||||
if (value === undefined || value === null || value === '') {
|
||||
errors[param.name] = `${param.label} is required`;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (Object.keys(errors).length > 0) {
|
||||
setParamErrors(errors);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
onExecute?.(showParams ? paramValues : undefined);
|
||||
setShowParams(false);
|
||||
setParamErrors({});
|
||||
}, [hasParameters, showParams, initializeDefaults, item.parameters, paramValues, onExecute]);
|
||||
|
||||
// Handle checkbox change
|
||||
const handleCheckboxChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
e.stopPropagation();
|
||||
onSelect?.(e.target.checked);
|
||||
}, [onSelect]);
|
||||
|
||||
// Get icon for item
|
||||
const getItemIcon = () => {
|
||||
if (item.icon) {
|
||||
// Map string icon names to components
|
||||
const iconMap: Record<string, string> = {
|
||||
Video: '🎬',
|
||||
UserPlus: '👤',
|
||||
Database: '🗄️',
|
||||
TrendingUp: '📈',
|
||||
Search: '🔍',
|
||||
Twitter: '🐦',
|
||||
Globe: '🌐',
|
||||
Zap: '⚡',
|
||||
};
|
||||
return iconMap[item.icon] || '🤖';
|
||||
}
|
||||
return item.type === 'hand' ? '🤖' : '📋';
|
||||
};
|
||||
|
||||
if (viewMode === 'list') {
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center gap-3 p-3 bg-white dark:bg-gray-800 rounded-lg border transition-all cursor-pointer ${
|
||||
isSelected
|
||||
? 'border-orange-500 ring-1 ring-orange-500'
|
||||
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
|
||||
}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
{/* Checkbox */}
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={handleCheckboxChange}
|
||||
className="w-4 h-4 rounded border-gray-300 text-orange-500 focus:ring-orange-500"
|
||||
onClick={e => e.stopPropagation()}
|
||||
/>
|
||||
|
||||
{/* Icon */}
|
||||
<span className="text-xl flex-shrink-0">{getItemIcon()}</span>
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-medium text-gray-900 dark:text-white truncate">{item.name}</h3>
|
||||
<TypeBadge type={item.type} />
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 truncate">{item.description}</p>
|
||||
</div>
|
||||
|
||||
{/* Status */}
|
||||
<StatusBadge status={item.status} />
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleExecuteClick();
|
||||
}}
|
||||
disabled={!canActivate || isExecuting}
|
||||
className="px-3 py-1.5 text-sm bg-orange-500 text-white rounded-md hover:bg-orange-600 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1"
|
||||
>
|
||||
{isExecuting ? (
|
||||
<>
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
执行中
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="w-3.5 h-3.5" />
|
||||
执行
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={e => e.stopPropagation()}
|
||||
className="p-1.5 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
>
|
||||
<MoreVertical className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Grid view
|
||||
return (
|
||||
<div
|
||||
className={`relative bg-white dark:bg-gray-800 rounded-lg border transition-all ${
|
||||
isSelected
|
||||
? 'border-orange-500 ring-1 ring-orange-500'
|
||||
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
|
||||
}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
{/* Selection checkbox */}
|
||||
<div className="absolute top-2 left-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={handleCheckboxChange}
|
||||
className="w-4 h-4 rounded border-gray-300 text-orange-500 focus:ring-orange-500"
|
||||
onClick={e => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-4 pt-8">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-3 mb-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="text-xl flex-shrink-0">{getItemIcon()}</span>
|
||||
<h3 className="font-medium text-gray-900 dark:text-white truncate">{item.name}</h3>
|
||||
</div>
|
||||
<StatusBadge status={item.status} />
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3 line-clamp-2">{item.description}</p>
|
||||
|
||||
{/* Meta */}
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<TypeBadge type={item.type} />
|
||||
<CategoryBadge category={item.category} />
|
||||
{item.schedule?.enabled && (
|
||||
<span className="flex items-center gap-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
<Clock className="w-3 h-3" />
|
||||
已调度
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Parameters Form (shown when activating) */}
|
||||
{showParams && item.parameters && item.parameters.length > 0 && (
|
||||
<div className="mb-3 p-3 bg-gray-50 dark:bg-gray-900 rounded-lg">
|
||||
<HandParamsForm
|
||||
parameters={item.parameters as HandParameter[]}
|
||||
values={paramValues}
|
||||
onChange={setParamValues}
|
||||
errors={paramErrors}
|
||||
disabled={isExecuting}
|
||||
presetKey={`${item.type}-${item.id}`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleExecuteClick();
|
||||
}}
|
||||
disabled={!canActivate || isExecuting}
|
||||
className="flex-1 px-3 py-1.5 text-sm bg-orange-500 text-white rounded-md hover:bg-orange-600 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-1"
|
||||
>
|
||||
{isExecuting ? (
|
||||
<>
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
执行中...
|
||||
</>
|
||||
) : showParams ? (
|
||||
<>
|
||||
<Play className="w-3.5 h-3.5" />
|
||||
确认执行
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Zap className="w-3.5 h-3.5" />
|
||||
执行
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={e => e.stopPropagation()}
|
||||
className="p-1.5 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
title="更多选项"
|
||||
>
|
||||
<MoreVertical className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Schedule indicator */}
|
||||
{item.schedule?.nextRun && (
|
||||
<div className="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
下次运行: {new Date(item.schedule.nextRun).toLocaleString('zh-CN')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AutomationCard;
|
||||
204
desktop/src/components/Automation/AutomationFilters.tsx
Normal file
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* AutomationFilters - Category and Search Filters
|
||||
*
|
||||
* Provides category tabs, search input, and view mode toggle
|
||||
* for the automation panel.
|
||||
*
|
||||
* @module components/Automation/AutomationFilters
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import type { CategoryType, CategoryStats } from '../../types/automation';
|
||||
import { CATEGORY_CONFIGS } from '../../types/automation';
|
||||
import {
|
||||
Search,
|
||||
Grid,
|
||||
List,
|
||||
Layers,
|
||||
Database,
|
||||
MessageSquare,
|
||||
Video,
|
||||
TrendingUp,
|
||||
Zap,
|
||||
ChevronDown,
|
||||
} from 'lucide-react';
|
||||
|
||||
// === Icon Map ===
|
||||
|
||||
const CATEGORY_ICONS: Record<CategoryType, typeof Layers> = {
|
||||
all: Layers,
|
||||
research: Search,
|
||||
data: Database,
|
||||
automation: Zap,
|
||||
communication: MessageSquare,
|
||||
content: Video,
|
||||
productivity: TrendingUp,
|
||||
};
|
||||
|
||||
// === Component Props ===
|
||||
|
||||
interface AutomationFiltersProps {
|
||||
selectedCategory: CategoryType;
|
||||
onCategoryChange: (category: CategoryType) => void;
|
||||
searchQuery: string;
|
||||
onSearchChange: (query: string) => void;
|
||||
viewMode: 'grid' | 'list';
|
||||
onViewModeChange: (mode: 'grid' | 'list') => void;
|
||||
categoryStats: CategoryStats;
|
||||
}
|
||||
|
||||
// === Main Component ===
|
||||
|
||||
export function AutomationFilters({
|
||||
selectedCategory,
|
||||
onCategoryChange,
|
||||
searchQuery,
|
||||
onSearchChange,
|
||||
viewMode,
|
||||
onViewModeChange,
|
||||
categoryStats,
|
||||
}: AutomationFiltersProps) {
|
||||
const [showCategoryDropdown, setShowCategoryDropdown] = useState(false);
|
||||
|
||||
// Handle search input
|
||||
const handleSearchChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onSearchChange(e.target.value);
|
||||
}, [onSearchChange]);
|
||||
|
||||
// Handle category click
|
||||
const handleCategoryClick = useCallback((category: CategoryType) => {
|
||||
onCategoryChange(category);
|
||||
setShowCategoryDropdown(false);
|
||||
}, [onCategoryChange]);
|
||||
|
||||
// Get categories with counts
|
||||
const categories = Object.entries(CATEGORY_CONFIGS).map(([key, config]) => ({
|
||||
...config,
|
||||
count: categoryStats[key as CategoryType] || 0,
|
||||
}));
|
||||
|
||||
// Selected category config
|
||||
const selectedConfig = CATEGORY_CONFIGS[selectedCategory];
|
||||
|
||||
return (
|
||||
<div className="px-4 py-3 border-b border-gray-200 dark:border-gray-700 space-y-3">
|
||||
{/* Search and View Mode Row */}
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Search Input */}
|
||||
<div className="flex-1 relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索 Hands 或工作流..."
|
||||
value={searchQuery}
|
||||
onChange={handleSearchChange}
|
||||
className="w-full pl-10 pr-4 py-2 text-sm border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* View Mode Toggle */}
|
||||
<div className="flex items-center border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
||||
<button
|
||||
onClick={() => onViewModeChange('grid')}
|
||||
className={`p-2 ${
|
||||
viewMode === 'grid'
|
||||
? 'bg-orange-500 text-white'
|
||||
: 'bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
title="网格视图"
|
||||
>
|
||||
<Grid className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onViewModeChange('list')}
|
||||
className={`p-2 ${
|
||||
viewMode === 'list'
|
||||
? 'bg-orange-500 text-white'
|
||||
: 'bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
title="列表视图"
|
||||
>
|
||||
<List className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category Tabs (Desktop) */}
|
||||
<div className="hidden md:flex items-center gap-1 overflow-x-auto pb-1">
|
||||
{categories.map(({ id, label, count }) => {
|
||||
const Icon = CATEGORY_ICONS[id];
|
||||
const isSelected = selectedCategory === id;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
onClick={() => onCategoryChange(id)}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-full whitespace-nowrap transition-colors ${
|
||||
isSelected
|
||||
? 'bg-orange-500 text-white'
|
||||
: 'bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
{label}
|
||||
{count > 0 && (
|
||||
<span className={`text-xs ${isSelected ? 'text-white/80' : 'text-gray-500 dark:text-gray-400'}`}>
|
||||
({count})
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Category Dropdown (Mobile) */}
|
||||
<div className="md:hidden relative">
|
||||
<button
|
||||
onClick={() => setShowCategoryDropdown(!showCategoryDropdown)}
|
||||
className="w-full flex items-center justify-between px-3 py-2 text-sm border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{(() => {
|
||||
const Icon = CATEGORY_ICONS[selectedCategory];
|
||||
return <Icon className="w-4 h-4" />;
|
||||
})()}
|
||||
<span>{selectedConfig.label}</span>
|
||||
<span className="text-gray-500 dark:text-gray-400">
|
||||
({categoryStats[selectedCategory] || 0})
|
||||
</span>
|
||||
</div>
|
||||
<ChevronDown className={`w-4 h-4 transition-transform ${showCategoryDropdown ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
{showCategoryDropdown && (
|
||||
<div className="absolute top-full left-0 right-0 mt-1 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg z-10 max-h-64 overflow-y-auto">
|
||||
{categories.map(({ id, label, count }) => {
|
||||
const Icon = CATEGORY_ICONS[id];
|
||||
const isSelected = selectedCategory === id;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
onClick={() => handleCategoryClick(id)}
|
||||
className={`w-full flex items-center justify-between px-3 py-2 text-sm ${
|
||||
isSelected
|
||||
? 'bg-orange-50 dark:bg-orange-900/20 text-orange-600 dark:text-orange-400'
|
||||
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className="w-4 h-4" />
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
<span className="text-gray-500 dark:text-gray-400">{count}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AutomationFilters;
|
||||
380
desktop/src/components/Automation/AutomationPanel.tsx
Normal file
@@ -0,0 +1,380 @@
|
||||
/**
|
||||
* AutomationPanel - Unified Automation Entry Point
|
||||
*
|
||||
* Combines Hands and Workflows into a single unified view,
|
||||
* with category filtering, batch operations, and scheduling.
|
||||
*
|
||||
* @module components/Automation/AutomationPanel
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { useGatewayStore } from '../../store/gatewayStore';
|
||||
import { useWorkflowStore } from '../../store/workflowStore';
|
||||
import {
|
||||
type AutomationItem,
|
||||
type CategoryType,
|
||||
type CategoryStats,
|
||||
adaptToAutomationItems,
|
||||
calculateCategoryStats,
|
||||
filterByCategory,
|
||||
searchAutomationItems,
|
||||
} from '../../types/automation';
|
||||
import { AutomationCard } from './AutomationCard';
|
||||
import { AutomationFilters } from './AutomationFilters';
|
||||
import { BatchActionBar } from './BatchActionBar';
|
||||
import {
|
||||
Zap,
|
||||
RefreshCw,
|
||||
Plus,
|
||||
Calendar,
|
||||
Search,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { useToast } from '../ui/Toast';
|
||||
|
||||
// === View Mode ===
|
||||
|
||||
type ViewMode = 'grid' | 'list';
|
||||
|
||||
// === Component Props ===
|
||||
|
||||
interface AutomationPanelProps {
|
||||
initialCategory?: CategoryType;
|
||||
onSelect?: (item: AutomationItem) => void;
|
||||
showBatchActions?: boolean;
|
||||
}
|
||||
|
||||
// === Main Component ===
|
||||
|
||||
export function AutomationPanel({
|
||||
initialCategory = 'all',
|
||||
onSelect,
|
||||
showBatchActions = true,
|
||||
}: AutomationPanelProps) {
|
||||
// Store state - use gatewayStore which has the actual data
|
||||
const hands = useGatewayStore(s => s.hands);
|
||||
const workflows = useGatewayStore(s => s.workflows);
|
||||
const isLoading = useGatewayStore(s => s.isLoading);
|
||||
const loadHands = useGatewayStore(s => s.loadHands);
|
||||
const loadWorkflows = useGatewayStore(s => s.loadWorkflows);
|
||||
const triggerHand = useGatewayStore(s => s.triggerHand);
|
||||
// workflowStore for triggerWorkflow (not in gatewayStore)
|
||||
const triggerWorkflow = useWorkflowStore(s => s.triggerWorkflow);
|
||||
|
||||
// UI state
|
||||
const [selectedCategory, setSelectedCategory] = useState<CategoryType>(initialCategory);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('grid');
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
const [executingIds, setExecutingIds] = useState<Set<string>>(new Set());
|
||||
const [showWorkflowDialog, setShowWorkflowDialog] = useState(false);
|
||||
const [showSchedulerDialog, setShowSchedulerDialog] = useState(false);
|
||||
|
||||
const { toast } = useToast();
|
||||
|
||||
// Load data on mount
|
||||
useEffect(() => {
|
||||
loadHands();
|
||||
loadWorkflows();
|
||||
}, [loadHands, loadWorkflows]);
|
||||
|
||||
// Adapt hands and workflows to automation items
|
||||
const automationItems = useMemo<AutomationItem[]>(() => {
|
||||
return adaptToAutomationItems(hands, workflows);
|
||||
}, [hands, workflows]);
|
||||
|
||||
// Calculate category stats
|
||||
const categoryStats = useMemo<CategoryStats>(() => {
|
||||
return calculateCategoryStats(automationItems);
|
||||
}, [automationItems]);
|
||||
|
||||
// Filter and search items
|
||||
const filteredItems = useMemo<AutomationItem[]>(() => {
|
||||
let items = filterByCategory(automationItems, selectedCategory);
|
||||
if (searchQuery.trim()) {
|
||||
items = searchAutomationItems(items, searchQuery);
|
||||
}
|
||||
return items;
|
||||
}, [automationItems, selectedCategory, searchQuery]);
|
||||
|
||||
// Selection handlers
|
||||
const handleSelect = useCallback((id: string, selected: boolean) => {
|
||||
setSelectedIds(prev => {
|
||||
const next = new Set(prev);
|
||||
if (selected) {
|
||||
next.add(id);
|
||||
} else {
|
||||
next.delete(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleSelectAll = useCallback(() => {
|
||||
setSelectedIds(new Set(filteredItems.map(item => item.id)));
|
||||
}, [filteredItems]);
|
||||
|
||||
const handleDeselectAll = useCallback(() => {
|
||||
setSelectedIds(new Set());
|
||||
}, []);
|
||||
|
||||
// Workflow dialog handlers
|
||||
const handleCreateWorkflow = useCallback(() => {
|
||||
setShowWorkflowDialog(true);
|
||||
}, []);
|
||||
|
||||
const handleSchedulerManage = useCallback(() => {
|
||||
setShowSchedulerDialog(true);
|
||||
}, []);
|
||||
|
||||
// Execute handler
|
||||
const handleExecute = useCallback(async (item: AutomationItem, params?: Record<string, unknown>) => {
|
||||
setExecutingIds(prev => new Set(prev).add(item.id));
|
||||
|
||||
try {
|
||||
if (item.type === 'hand') {
|
||||
await triggerHand(item.id, params);
|
||||
} else {
|
||||
await triggerWorkflow(item.id, params);
|
||||
}
|
||||
toast(`${item.name} 执行成功`, 'success');
|
||||
} catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||
toast(`${item.name} 执行失败: ${errorMsg}`, 'error');
|
||||
} finally {
|
||||
setExecutingIds(prev => {
|
||||
const next = new Set(prev);
|
||||
next.delete(item.id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}, [triggerHand, triggerWorkflow, toast]);
|
||||
|
||||
// Batch execute
|
||||
const handleBatchExecute = useCallback(async () => {
|
||||
const itemsToExecute = filteredItems.filter(item => selectedIds.has(item.id));
|
||||
let successCount = 0;
|
||||
let failCount = 0;
|
||||
|
||||
for (const item of itemsToExecute) {
|
||||
try {
|
||||
if (item.type === 'hand') {
|
||||
await triggerHand(item.id);
|
||||
} else {
|
||||
await triggerWorkflow(item.id);
|
||||
}
|
||||
successCount++;
|
||||
} catch {
|
||||
failCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (successCount > 0) {
|
||||
toast(`成功执行 ${successCount} 个项目`, 'success');
|
||||
}
|
||||
if (failCount > 0) {
|
||||
toast(`${failCount} 个项目执行失败`, 'error');
|
||||
}
|
||||
|
||||
setSelectedIds(new Set());
|
||||
}, [filteredItems, selectedIds, triggerHand, triggerWorkflow, toast]);
|
||||
|
||||
// Refresh handler
|
||||
const handleRefresh = useCallback(async () => {
|
||||
await Promise.all([loadHands(), loadWorkflows()]);
|
||||
toast('数据已刷新', 'success');
|
||||
}, [loadHands, loadWorkflows, toast]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap className="w-5 h-5 text-orange-500" />
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
自动化
|
||||
</h2>
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||
({automationItems.length})
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
disabled={isLoading}
|
||||
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 disabled:opacity-50"
|
||||
title="刷新"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCreateWorkflow}
|
||||
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300"
|
||||
title="新建工作流"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSchedulerManage}
|
||||
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300"
|
||||
title="调度管理"
|
||||
>
|
||||
<Calendar className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<AutomationFilters
|
||||
selectedCategory={selectedCategory}
|
||||
onCategoryChange={setSelectedCategory}
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
viewMode={viewMode}
|
||||
onViewModeChange={setViewMode}
|
||||
categoryStats={categoryStats}
|
||||
/>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{isLoading && automationItems.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<RefreshCw className="w-6 h-6 animate-spin text-gray-400" />
|
||||
</div>
|
||||
) : filteredItems.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-32 text-center">
|
||||
<Search className="w-8 h-8 text-gray-400 mb-2" />
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{searchQuery ? '没有找到匹配的项目' : '暂无自动化项目'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className={
|
||||
viewMode === 'grid'
|
||||
? 'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4'
|
||||
: 'flex flex-col gap-2'
|
||||
}>
|
||||
{filteredItems.map(item => (
|
||||
<AutomationCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
viewMode={viewMode}
|
||||
isSelected={selectedIds.has(item.id)}
|
||||
isExecuting={executingIds.has(item.id)}
|
||||
onSelect={(selected) => handleSelect(item.id, selected)}
|
||||
onExecute={(params) => handleExecute(item, params)}
|
||||
onClick={() => onSelect?.(item)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Batch Actions */}
|
||||
{showBatchActions && selectedIds.size > 0 && (
|
||||
<BatchActionBar
|
||||
selectedCount={selectedIds.size}
|
||||
totalCount={filteredItems.length}
|
||||
onSelectAll={handleSelectAll}
|
||||
onDeselectAll={handleDeselectAll}
|
||||
onBatchExecute={handleBatchExecute}
|
||||
onBatchSchedule={() => {
|
||||
toast('批量调度功能开发中', 'info');
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Create Workflow Dialog */}
|
||||
{showWorkflowDialog && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-md mx-4">
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">新建工作流</h3>
|
||||
<button
|
||||
onClick={() => setShowWorkflowDialog(false)}
|
||||
className="p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
工作流名称
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="输入工作流名称..."
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-gray-400"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
描述
|
||||
</label>
|
||||
<textarea
|
||||
placeholder="描述这个工作流的用途..."
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-gray-400 resize-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 p-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
onClick={() => setShowWorkflowDialog(false)}
|
||||
className="px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
toast('工作流创建功能开发中', 'info');
|
||||
setShowWorkflowDialog(false);
|
||||
}}
|
||||
className="px-4 py-2 text-sm bg-gray-700 dark:bg-gray-600 text-white rounded-lg hover:bg-gray-800 dark:hover:bg-gray-500"
|
||||
>
|
||||
创建
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Scheduler Dialog */}
|
||||
{showSchedulerDialog && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-lg mx-4">
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">调度管理</h3>
|
||||
<button
|
||||
onClick={() => setShowSchedulerDialog(false)}
|
||||
className="p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
<Calendar className="w-12 h-12 mx-auto mb-3 opacity-50" />
|
||||
<p>调度管理功能开发中</p>
|
||||
<p className="text-sm mt-1">将支持定时执行、Cron 表达式配置等</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end p-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
onClick={() => setShowSchedulerDialog(false)}
|
||||
className="px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg"
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AutomationPanel;
|
||||
216
desktop/src/components/Automation/BatchActionBar.tsx
Normal file
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* BatchActionBar - Batch Operations Action Bar
|
||||
*
|
||||
* Provides batch action buttons for selected automation items.
|
||||
* Supports batch execute, approve, reject, and schedule.
|
||||
*
|
||||
* @module components/Automation/BatchActionBar
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import {
|
||||
Play,
|
||||
Check,
|
||||
X,
|
||||
Clock,
|
||||
XCircle,
|
||||
MoreHorizontal,
|
||||
Trash2,
|
||||
Copy,
|
||||
} from 'lucide-react';
|
||||
|
||||
// === Component Props ===
|
||||
|
||||
interface BatchActionBarProps {
|
||||
selectedCount: number;
|
||||
totalCount?: number; // Optional - for "select all X items" display
|
||||
onSelectAll: () => void;
|
||||
onDeselectAll: () => void;
|
||||
onBatchExecute: () => Promise<void>;
|
||||
onBatchApprove?: () => Promise<void>;
|
||||
onBatchReject?: () => Promise<void>;
|
||||
onBatchSchedule?: () => void;
|
||||
onBatchDelete?: () => Promise<void>;
|
||||
onBatchDuplicate?: () => Promise<void>;
|
||||
}
|
||||
|
||||
// === Main Component ===
|
||||
|
||||
export function BatchActionBar({
|
||||
selectedCount,
|
||||
totalCount: _totalCount, // Used for future "select all X items" display
|
||||
onSelectAll,
|
||||
onDeselectAll,
|
||||
onBatchExecute,
|
||||
onBatchApprove,
|
||||
onBatchReject,
|
||||
onBatchSchedule,
|
||||
onBatchDelete,
|
||||
onBatchDuplicate,
|
||||
}: BatchActionBarProps) {
|
||||
const [isExecuting, setIsExecuting] = useState(false);
|
||||
const [showMoreMenu, setShowMoreMenu] = useState(false);
|
||||
|
||||
// Handle batch execute
|
||||
const handleExecute = useCallback(async () => {
|
||||
setIsExecuting(true);
|
||||
try {
|
||||
await onBatchExecute();
|
||||
} finally {
|
||||
setIsExecuting(false);
|
||||
}
|
||||
}, [onBatchExecute]);
|
||||
|
||||
// Handle batch approve
|
||||
const handleApprove = useCallback(async () => {
|
||||
if (onBatchApprove) {
|
||||
setIsExecuting(true);
|
||||
try {
|
||||
await onBatchApprove();
|
||||
} finally {
|
||||
setIsExecuting(false);
|
||||
}
|
||||
}
|
||||
}, [onBatchApprove]);
|
||||
|
||||
// Handle batch reject
|
||||
const handleReject = useCallback(async () => {
|
||||
if (onBatchReject) {
|
||||
setIsExecuting(true);
|
||||
try {
|
||||
await onBatchReject();
|
||||
} finally {
|
||||
setIsExecuting(false);
|
||||
}
|
||||
}
|
||||
}, [onBatchReject]);
|
||||
|
||||
return (
|
||||
<div className="sticky bottom-0 left-0 right-0 bg-orange-50 dark:bg-orange-900/20 border-t border-orange-200 dark:border-orange-800 px-4 py-3">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
{/* Selection Info */}
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-orange-700 dark:text-orange-300">
|
||||
已选择 <span className="font-medium">{selectedCount}</span> 项
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={onSelectAll}
|
||||
className="text-xs text-orange-600 dark:text-orange-400 hover:underline"
|
||||
>
|
||||
全选
|
||||
</button>
|
||||
<span className="text-orange-400 dark:text-orange-600">|</span>
|
||||
<button
|
||||
onClick={onDeselectAll}
|
||||
className="text-xs text-orange-600 dark:text-orange-400 hover:underline"
|
||||
>
|
||||
取消选择
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Execute */}
|
||||
<button
|
||||
onClick={handleExecute}
|
||||
disabled={isExecuting}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm bg-orange-500 text-white rounded-md hover:bg-orange-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Play className="w-3.5 h-3.5" />
|
||||
批量执行
|
||||
</button>
|
||||
|
||||
{/* Approve (if handler provided) */}
|
||||
{onBatchApprove && (
|
||||
<button
|
||||
onClick={handleApprove}
|
||||
disabled={isExecuting}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm bg-green-500 text-white rounded-md hover:bg-green-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Check className="w-3.5 h-3.5" />
|
||||
批量审批
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Reject (if handler provided) */}
|
||||
{onBatchReject && (
|
||||
<button
|
||||
onClick={handleReject}
|
||||
disabled={isExecuting}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm bg-red-500 text-white rounded-md hover:bg-red-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
批量拒绝
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Schedule */}
|
||||
{onBatchSchedule && (
|
||||
<button
|
||||
onClick={onBatchSchedule}
|
||||
disabled={isExecuting}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm border border-orange-300 dark:border-orange-700 text-orange-600 dark:text-orange-400 rounded-md hover:bg-orange-100 dark:hover:bg-orange-900/30 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Clock className="w-3.5 h-3.5" />
|
||||
批量调度
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* More Actions */}
|
||||
{(onBatchDelete || onBatchDuplicate) && (
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowMoreMenu(!showMoreMenu)}
|
||||
className="p-1.5 text-orange-600 dark:text-orange-400 hover:bg-orange-100 dark:hover:bg-orange-900/30 rounded-md"
|
||||
>
|
||||
<MoreHorizontal className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
{showMoreMenu && (
|
||||
<div className="absolute bottom-full right-0 mb-1 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg py-1 min-w-[150px] z-10">
|
||||
{onBatchDuplicate && (
|
||||
<button
|
||||
onClick={() => {
|
||||
onBatchDuplicate();
|
||||
setShowMoreMenu(false);
|
||||
}}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
复制
|
||||
</button>
|
||||
)}
|
||||
{onBatchDelete && (
|
||||
<button
|
||||
onClick={() => {
|
||||
onBatchDelete();
|
||||
setShowMoreMenu(false);
|
||||
}}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
删除
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Close */}
|
||||
<button
|
||||
onClick={onDeselectAll}
|
||||
className="p-1.5 text-orange-600 dark:text-orange-400 hover:bg-orange-100 dark:hover:bg-orange-900/30 rounded-md"
|
||||
title="取消选择"
|
||||
>
|
||||
<XCircle className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default BatchActionBar;
|
||||
395
desktop/src/components/Automation/ExecutionResult.tsx
Normal file
@@ -0,0 +1,395 @@
|
||||
/**
|
||||
* ExecutionResult - Execution Result Display Component
|
||||
*
|
||||
* Displays the result of hand or workflow executions with
|
||||
* status, output, and error information.
|
||||
*
|
||||
* @module components/Automation/ExecutionResult
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import type { RunInfo } from '../../types/automation';
|
||||
import {
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Clock,
|
||||
AlertTriangle,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Copy,
|
||||
Download,
|
||||
RefreshCw,
|
||||
ExternalLink,
|
||||
FileText,
|
||||
Code,
|
||||
Image,
|
||||
FileSpreadsheet,
|
||||
} from 'lucide-react';
|
||||
import { useToast } from '../ui/Toast';
|
||||
|
||||
// === Status Config ===
|
||||
|
||||
const STATUS_CONFIG = {
|
||||
completed: {
|
||||
label: '完成',
|
||||
icon: CheckCircle,
|
||||
className: 'text-green-500',
|
||||
bgClass: 'bg-green-50 dark:bg-green-900/20',
|
||||
},
|
||||
failed: {
|
||||
label: '失败',
|
||||
icon: XCircle,
|
||||
className: 'text-red-500',
|
||||
bgClass: 'bg-red-50 dark:bg-red-900/20',
|
||||
},
|
||||
running: {
|
||||
label: '运行中',
|
||||
icon: RefreshCw,
|
||||
className: 'text-blue-500 animate-spin',
|
||||
bgClass: 'bg-blue-50 dark:bg-blue-900/20',
|
||||
},
|
||||
needs_approval: {
|
||||
label: '待审批',
|
||||
icon: AlertTriangle,
|
||||
className: 'text-yellow-500',
|
||||
bgClass: 'bg-yellow-50 dark:bg-yellow-900/20',
|
||||
},
|
||||
cancelled: {
|
||||
label: '已取消',
|
||||
icon: XCircle,
|
||||
className: 'text-gray-500',
|
||||
bgClass: 'bg-gray-50 dark:bg-gray-900/20',
|
||||
},
|
||||
};
|
||||
|
||||
// === Component Props ===
|
||||
|
||||
interface ExecutionResultProps {
|
||||
run: RunInfo;
|
||||
itemType: 'hand' | 'workflow';
|
||||
itemName: string;
|
||||
onRerun?: () => void;
|
||||
onViewDetails?: () => void;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
// === Helper Functions ===
|
||||
|
||||
function formatDuration(startedAt: string, completedAt?: string): string {
|
||||
const start = new Date(startedAt).getTime();
|
||||
const end = completedAt ? new Date(completedAt).getTime() : Date.now();
|
||||
const diffMs = end - start;
|
||||
|
||||
const seconds = Math.floor(diffMs / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes % 60}m`;
|
||||
}
|
||||
if (minutes > 0) {
|
||||
return `${minutes}m ${seconds % 60}s`;
|
||||
}
|
||||
return `${seconds}s`;
|
||||
}
|
||||
|
||||
function detectOutputType(output: unknown): 'text' | 'json' | 'markdown' | 'code' | 'image' | 'data' {
|
||||
if (!output) return 'text';
|
||||
|
||||
if (typeof output === 'string') {
|
||||
// Check for image URL
|
||||
if (output.match(/\.(png|jpg|jpeg|gif|webp|svg)$/i)) {
|
||||
return 'image';
|
||||
}
|
||||
// Check for markdown
|
||||
if (output.includes('#') || output.includes('**') || output.includes('```')) {
|
||||
return 'markdown';
|
||||
}
|
||||
// Check for code
|
||||
if (output.includes('function ') || output.includes('import ') || output.includes('class ')) {
|
||||
return 'code';
|
||||
}
|
||||
// Try to parse as JSON
|
||||
try {
|
||||
JSON.parse(output);
|
||||
return 'json';
|
||||
} catch {
|
||||
return 'text';
|
||||
}
|
||||
}
|
||||
|
||||
// Object/array types
|
||||
if (typeof output === 'object') {
|
||||
return 'json';
|
||||
}
|
||||
|
||||
return 'text';
|
||||
}
|
||||
|
||||
function formatOutput(output: unknown, type: string): string {
|
||||
if (!output) return '无输出';
|
||||
|
||||
if (type === 'json') {
|
||||
try {
|
||||
return JSON.stringify(output, null, 2);
|
||||
} catch {
|
||||
return String(output);
|
||||
}
|
||||
}
|
||||
|
||||
return String(output);
|
||||
}
|
||||
|
||||
// === Output Viewer Component ===
|
||||
|
||||
interface OutputViewerProps {
|
||||
output: unknown;
|
||||
type: string;
|
||||
}
|
||||
|
||||
function OutputViewer({ output, type }: OutputViewerProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const { toast } = useToast();
|
||||
|
||||
const handleCopy = useCallback(async () => {
|
||||
const text = formatOutput(output, type);
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
toast('已复制到剪贴板', 'success');
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}, [output, type, toast]);
|
||||
|
||||
const handleDownload = useCallback(() => {
|
||||
const text = formatOutput(output, type);
|
||||
const blob = new Blob([text], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `output-${Date.now()}.${type === 'json' ? 'json' : 'txt'}`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}, [output, type]);
|
||||
|
||||
// Image preview
|
||||
if (type === 'image' && typeof output === 'string') {
|
||||
return (
|
||||
<div className="relative">
|
||||
<img
|
||||
src={output}
|
||||
alt="Output"
|
||||
className="max-w-full rounded-lg"
|
||||
/>
|
||||
<div className="absolute top-2 right-2 flex gap-1">
|
||||
<button
|
||||
onClick={() => window.open(output, '_blank')}
|
||||
className="p-1.5 bg-black/50 rounded hover:bg-black/70 text-white"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Text/JSON/Code output
|
||||
const content = formatOutput(output, type);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<pre className="p-3 bg-gray-900 dark:bg-gray-950 rounded-lg text-sm text-gray-100 overflow-x-auto max-h-64 overflow-y-auto">
|
||||
{content}
|
||||
</pre>
|
||||
<div className="absolute top-2 right-2 flex gap-1">
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="p-1.5 bg-gray-700 rounded hover:bg-gray-600 text-gray-300"
|
||||
title="复制"
|
||||
>
|
||||
{copied ? <CheckCircle className="w-4 h-4 text-green-400" /> : <Copy className="w-4 h-4" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
className="p-1.5 bg-gray-700 rounded hover:bg-gray-600 text-gray-300"
|
||||
title="下载"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// === Main Component ===
|
||||
|
||||
export function ExecutionResult({
|
||||
run,
|
||||
itemType,
|
||||
itemName,
|
||||
onRerun,
|
||||
onViewDetails,
|
||||
compact = false,
|
||||
}: ExecutionResultProps) {
|
||||
const [expanded, setExpanded] = useState(!compact);
|
||||
|
||||
const statusConfig = STATUS_CONFIG[run.status as keyof typeof STATUS_CONFIG] || STATUS_CONFIG.completed;
|
||||
const StatusIcon = statusConfig.icon;
|
||||
|
||||
// Safely extract error message as string
|
||||
const getErrorMessage = (): string | null => {
|
||||
if (typeof run.error === 'string' && run.error.length > 0) {
|
||||
return run.error;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const errorMessage = getErrorMessage();
|
||||
|
||||
const outputType = useMemo(() => detectOutputType(run.output), [run.output]);
|
||||
const duration = useMemo(() => {
|
||||
if (run.duration) return `${run.duration}s`;
|
||||
if (run.completedAt && run.startedAt) {
|
||||
return formatDuration(run.startedAt, run.completedAt);
|
||||
}
|
||||
return null;
|
||||
}, [run.duration, run.startedAt, run.completedAt]);
|
||||
|
||||
// Compact mode
|
||||
if (compact && !expanded) {
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center gap-3 p-3 rounded-lg ${statusConfig.bgClass} cursor-pointer`}
|
||||
onClick={() => setExpanded(true)}
|
||||
>
|
||||
<StatusIcon className={`w-5 h-5 ${statusConfig.className}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-gray-900 dark:text-white truncate">
|
||||
{itemName}
|
||||
</span>
|
||||
<span className={`text-xs ${statusConfig.className}`}>
|
||||
{statusConfig.label}
|
||||
</span>
|
||||
</div>
|
||||
{duration && (
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
耗时: {duration}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<ChevronDown className="w-4 h-4 text-gray-400" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`rounded-lg border ${statusConfig.bgClass} border-gray-200 dark:border-gray-700 overflow-hidden`}>
|
||||
{/* Header */}
|
||||
<div
|
||||
className="flex items-center justify-between px-4 py-3 cursor-pointer"
|
||||
onClick={compact ? () => setExpanded(false) : undefined}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<StatusIcon className={`w-5 h-5 ${statusConfig.className}`} />
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
{itemName}
|
||||
</span>
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${statusConfig.className} ${statusConfig.bgClass}`}>
|
||||
{statusConfig.label}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{itemType === 'hand' ? '自主能力' : '工作流'}
|
||||
</span>
|
||||
</div>
|
||||
{run.runId && (
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500">
|
||||
执行ID: {run.runId}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{duration && (
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
耗时: {duration}
|
||||
</span>
|
||||
)}
|
||||
{compact && (
|
||||
<ChevronUp className="w-4 h-4 text-gray-400" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
{expanded && (
|
||||
<div className="px-4 pb-4 space-y-3">
|
||||
{/* Error */}
|
||||
{(() => {
|
||||
if (!errorMessage) return null;
|
||||
return (
|
||||
<div className="p-3 bg-red-50 dark:bg-red-900/20 rounded-lg">
|
||||
<p className="text-sm font-medium text-red-700 dark:text-red-400 mb-1">错误信息</p>
|
||||
<p className="text-sm text-red-600 dark:text-red-300">{errorMessage}</p>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Output */}
|
||||
{run.output !== undefined && run.output !== null && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">输出结果</p>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 flex items-center gap-1">
|
||||
{outputType === 'json' && <Code className="w-3 h-3" />}
|
||||
{outputType === 'markdown' && <FileText className="w-3 h-3" />}
|
||||
{outputType === 'image' && <Image className="w-3 h-3" />}
|
||||
{outputType === 'data' && <FileSpreadsheet className="w-3 h-3" />}
|
||||
{outputType.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
<OutputViewer output={run.output} type={outputType} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Timestamps */}
|
||||
<div className="flex items-center gap-4 text-xs text-gray-500 dark:text-gray-400">
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
开始: {new Date(run.startedAt).toLocaleString('zh-CN')}
|
||||
</span>
|
||||
{run.completedAt && (
|
||||
<span>
|
||||
完成: {new Date(run.completedAt).toLocaleString('zh-CN')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 pt-2">
|
||||
{onRerun && (
|
||||
<button
|
||||
onClick={onRerun}
|
||||
className="px-3 py-1.5 text-sm bg-orange-500 text-white rounded-md hover:bg-orange-600 flex items-center gap-1"
|
||||
>
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
重新执行
|
||||
</button>
|
||||
)}
|
||||
{onViewDetails && (
|
||||
<button
|
||||
onClick={onViewDetails}
|
||||
className="px-3 py-1.5 text-sm border border-gray-200 dark:border-gray-700 rounded-md hover:bg-gray-50 dark:hover:bg-gray-800 text-gray-700 dark:text-gray-300 flex items-center gap-1"
|
||||
>
|
||||
<ExternalLink className="w-3.5 h-3.5" />
|
||||
查看详情
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ExecutionResult;
|
||||
378
desktop/src/components/Automation/ScheduleEditor.tsx
Normal file
@@ -0,0 +1,378 @@
|
||||
/**
|
||||
* ScheduleEditor - Visual Schedule Configuration
|
||||
*
|
||||
* Provides a visual interface for configuring schedules
|
||||
* without requiring knowledge of cron syntax.
|
||||
*
|
||||
* @module components/Automation/ScheduleEditor
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import type { ScheduleInfo } from '../../types/automation';
|
||||
import {
|
||||
Calendar,
|
||||
Info,
|
||||
} from 'lucide-react';
|
||||
import { useToast } from '../ui/Toast';
|
||||
|
||||
// === Frequency Types ===
|
||||
|
||||
type Frequency = 'once' | 'daily' | 'weekly' | 'monthly' | 'custom';
|
||||
|
||||
// === Timezones ===
|
||||
|
||||
const COMMON_TIMEZONES = [
|
||||
{ value: 'Asia/Shanghai', label: '北京时间 (UTC+8)' },
|
||||
{ value: 'Asia/Tokyo', label: '东京时间 (UTC+9)' },
|
||||
{ value: 'Asia/Singapore', label: '新加坡时间 (UTC+8)' },
|
||||
{ value: 'America/New_York', label: '纽约时间 (UTC-5)' },
|
||||
{ value: 'America/Los_Angeles', label: '洛杉矶时间 (UTC-8)' },
|
||||
{ value: 'Europe/London', label: '伦敦时间 (UTC+0)' },
|
||||
{ value: 'UTC', label: '协调世界时 (UTC)' },
|
||||
];
|
||||
|
||||
// === Day Names ===
|
||||
|
||||
const DAY_NAMES = ['日', '一', '二', '三', '四', '五', '六'];
|
||||
const DAY_NAMES_FULL = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
|
||||
|
||||
// === Component Props ===
|
||||
|
||||
interface ScheduleEditorProps {
|
||||
schedule?: ScheduleInfo;
|
||||
onSave: (schedule: ScheduleInfo) => void;
|
||||
onCancel: () => void;
|
||||
itemName?: string;
|
||||
}
|
||||
|
||||
// === Helper Functions ===
|
||||
|
||||
function formatSchedulePreview(schedule: ScheduleInfo): string {
|
||||
const { frequency, time, daysOfWeek, dayOfMonth, timezone } = schedule;
|
||||
const timeStr = `${time.hour.toString().padStart(2, '0')}:${time.minute.toString().padStart(2, '0')}`;
|
||||
const tzLabel = COMMON_TIMEZONES.find(tz => tz.value === timezone)?.label || timezone;
|
||||
|
||||
switch (frequency) {
|
||||
case 'once':
|
||||
return `一次性执行于 ${timeStr} (${tzLabel})`;
|
||||
case 'daily':
|
||||
return `每天 ${timeStr} (${tzLabel})`;
|
||||
case 'weekly':
|
||||
const days = (daysOfWeek || []).map(d => DAY_NAMES_FULL[d]).join('、');
|
||||
return `每${days} ${timeStr} (${tzLabel})`;
|
||||
case 'monthly':
|
||||
return `每月${dayOfMonth || 1}日 ${timeStr} (${tzLabel})`;
|
||||
case 'custom':
|
||||
return schedule.customCron || '自定义调度';
|
||||
default:
|
||||
return '未设置';
|
||||
}
|
||||
}
|
||||
|
||||
// === Main Component ===
|
||||
|
||||
export function ScheduleEditor({
|
||||
schedule,
|
||||
onSave,
|
||||
onCancel,
|
||||
itemName = '自动化项目',
|
||||
}: ScheduleEditorProps) {
|
||||
const { toast } = useToast();
|
||||
|
||||
// Initialize state from existing schedule
|
||||
const [frequency, setFrequency] = useState<Frequency>(schedule?.frequency || 'daily');
|
||||
const [time, setTime] = useState(schedule?.time || { hour: 9, minute: 0 });
|
||||
const [daysOfWeek, setDaysOfWeek] = useState<number[]>(schedule?.daysOfWeek || [1, 2, 3, 4, 5]);
|
||||
const [dayOfMonth, setDayOfMonth] = useState(schedule?.dayOfMonth || 1);
|
||||
const [timezone, setTimezone] = useState(schedule?.timezone || 'Asia/Shanghai');
|
||||
const [endDate, setEndDate] = useState(schedule?.endDate || '');
|
||||
const [customCron, setCustomCron] = useState(schedule?.customCron || '');
|
||||
const [enabled, setEnabled] = useState(schedule?.enabled ?? true);
|
||||
|
||||
// Toggle day of week
|
||||
const toggleDayOfWeek = useCallback((day: number) => {
|
||||
setDaysOfWeek(prev =>
|
||||
prev.includes(day)
|
||||
? prev.filter(d => d !== day)
|
||||
: [...prev, day].sort()
|
||||
);
|
||||
}, []);
|
||||
|
||||
// Handle save
|
||||
const handleSave = useCallback(() => {
|
||||
// Validate
|
||||
if (frequency === 'weekly' && daysOfWeek.length === 0) {
|
||||
toast('请选择至少一个重复日期', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (frequency === 'custom' && !customCron) {
|
||||
toast('请输入自定义 cron 表达式', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const newSchedule: ScheduleInfo = {
|
||||
enabled,
|
||||
frequency,
|
||||
time,
|
||||
daysOfWeek: frequency === 'weekly' ? daysOfWeek : undefined,
|
||||
dayOfMonth: frequency === 'monthly' ? dayOfMonth : undefined,
|
||||
customCron: frequency === 'custom' ? customCron : undefined,
|
||||
timezone,
|
||||
endDate: endDate || undefined,
|
||||
};
|
||||
|
||||
onSave(newSchedule);
|
||||
toast('调度设置已保存', 'success');
|
||||
}, [frequency, daysOfWeek, customCron, enabled, time, dayOfMonth, timezone, endDate, onSave, toast]);
|
||||
|
||||
// Generate preview
|
||||
const preview = useMemo(() => {
|
||||
return formatSchedulePreview({
|
||||
enabled,
|
||||
frequency,
|
||||
time,
|
||||
daysOfWeek,
|
||||
dayOfMonth,
|
||||
customCron,
|
||||
timezone,
|
||||
});
|
||||
}, [enabled, frequency, time, daysOfWeek, dayOfMonth, customCron, timezone]);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
|
||||
onClick={onCancel}
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
<div className="relative bg-white dark:bg-gray-800 rounded-xl shadow-2xl w-full max-w-lg mx-4 max-h-[90vh] overflow-hidden flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between p-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-orange-100 dark:bg-orange-900/30 rounded-lg">
|
||||
<Calendar className="w-5 h-5 text-orange-600 dark:text-orange-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
调度设置
|
||||
</h2>
|
||||
{itemName && (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">{itemName}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 p-1"
|
||||
>
|
||||
<span className="text-xl">×</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-6">
|
||||
{/* Enable Toggle */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
启用调度
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
开启后,此项目将按照设定的时间自动执行
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setEnabled(!enabled)}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||
enabled ? 'bg-orange-500' : 'bg-gray-200 dark:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||
enabled ? 'translate-x-6' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Frequency Selection */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-900 dark:text-white mb-2">
|
||||
频率
|
||||
</label>
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
{[
|
||||
{ value: 'once', label: '一次' },
|
||||
{ value: 'daily', label: '每天' },
|
||||
{ value: 'weekly', label: '每周' },
|
||||
{ value: 'monthly', label: '每月' },
|
||||
{ value: 'custom', label: '自定义' },
|
||||
].map(option => (
|
||||
<button
|
||||
key={option.value}
|
||||
onClick={() => setFrequency(option.value as Frequency)}
|
||||
className={`px-3 py-2 text-sm font-medium rounded-lg border transition-colors ${
|
||||
frequency === option.value
|
||||
? 'bg-orange-500 text-white border-orange-500'
|
||||
: 'bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 border-gray-200 dark:border-gray-700 hover:border-orange-300 dark:hover:border-orange-700'
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Time Selection */}
|
||||
{frequency !== 'custom' && (
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex-1">
|
||||
<label className="block text-sm font-medium text-gray-900 dark:text-white mb-2">
|
||||
时间
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="23"
|
||||
value={time.hour}
|
||||
onChange={(e) => setTime(prev => ({ ...prev, hour: parseInt(e.target.value) || 0 }))}
|
||||
className="w-16 px-3 py-2 text-center border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
|
||||
/>
|
||||
<span className="text-gray-500 dark:text-gray-400">:</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="59"
|
||||
value={time.minute}
|
||||
onChange={(e) => setTime(prev => ({ ...prev, minute: parseInt(e.target.value) || 0 }))}
|
||||
className="w-16 px-3 py-2 text-center border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<label className="block text-sm font-medium text-gray-900 dark:text-white mb-2">
|
||||
时区
|
||||
</label>
|
||||
<select
|
||||
value={timezone}
|
||||
onChange={(e) => setTimezone(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
|
||||
>
|
||||
{COMMON_TIMEZONES.map(tz => (
|
||||
<option key={tz.value} value={tz.value}>{tz.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Weekly Days Selection */}
|
||||
{frequency === 'weekly' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-900 dark:text-white mb-2">
|
||||
重复日期
|
||||
</label>
|
||||
<div className="flex items-center gap-1">
|
||||
{DAY_NAMES.map((day, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => toggleDayOfWeek(index)}
|
||||
className={`w-10 h-10 rounded-full text-sm font-medium transition-colors ${
|
||||
daysOfWeek.includes(index)
|
||||
? 'bg-orange-500 text-white'
|
||||
: 'bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
{day}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Monthly Day Selection */}
|
||||
{frequency === 'monthly' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-900 dark:text-white mb-2">
|
||||
每月日期
|
||||
</label>
|
||||
<select
|
||||
value={dayOfMonth}
|
||||
onChange={(e) => setDayOfMonth(parseInt(e.target.value))}
|
||||
className="w-full px-3 py-2 border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
|
||||
>
|
||||
{Array.from({ length: 31 }, (_, i) => i + 1).map(day => (
|
||||
<option key={day} value={day}>每月 {day} 日</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Custom Cron Input */}
|
||||
{frequency === 'custom' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-900 dark:text-white mb-2">
|
||||
Cron 表达式
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={customCron}
|
||||
onChange={(e) => setCustomCron(e.target.value)}
|
||||
placeholder="* * * * * (分 时 日 月 周)"
|
||||
className="w-full px-3 py-2 border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white font-mono text-sm"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400 flex items-center gap-1">
|
||||
<Info className="w-3 h-3" />
|
||||
示例: "0 9 * * *" 表示每天 9:00 执行
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* End Date */}
|
||||
{frequency !== 'once' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-900 dark:text-white mb-2">
|
||||
结束日期 (可选)
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={endDate}
|
||||
onChange={(e) => setEndDate(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Preview */}
|
||||
<div className="p-3 bg-gray-50 dark:bg-gray-900 rounded-lg">
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">预览</p>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white">{preview}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-2 p-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 text-sm border border-gray-200 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="px-4 py-2 text-sm bg-orange-500 text-white rounded-lg hover:bg-orange-600"
|
||||
>
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ScheduleEditor;
|
||||
26
desktop/src/components/Automation/index.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Automation Components
|
||||
*
|
||||
* Unified automation system components for Hands and Workflows.
|
||||
*
|
||||
* @module components/Automation
|
||||
*/
|
||||
|
||||
export { AutomationPanel, default as AutomationPanelDefault } from './AutomationPanel';
|
||||
export { AutomationCard } from './AutomationCard';
|
||||
export { AutomationFilters } from './AutomationFilters';
|
||||
export { BatchActionBar } from './BatchActionBar';
|
||||
export { ScheduleEditor } from './ScheduleEditor';
|
||||
export { ApprovalQueue } from './ApprovalQueue';
|
||||
export { ExecutionResult } from './ExecutionResult';
|
||||
|
||||
// Re-export types
|
||||
export type {
|
||||
AutomationItem,
|
||||
AutomationStatus,
|
||||
AutomationType,
|
||||
CategoryType,
|
||||
CategoryStats,
|
||||
RunInfo,
|
||||
ScheduleInfo,
|
||||
} from '../../types/automation';
|
||||
@@ -168,7 +168,7 @@ export function BrowserHandCard({ onOpenSettings }: BrowserHandCardProps) {
|
||||
className={cn(
|
||||
'flex-1 flex items-center justify-center gap-2 px-3 py-2 rounded-lg font-medium transition-colors',
|
||||
activeSessionId && !execution.isRunning
|
||||
? 'bg-blue-600 text-white hover:bg-blue-700'
|
||||
? 'bg-gray-700 dark:bg-gray-600 text-white hover:bg-gray-800 dark:hover:bg-gray-500'
|
||||
: 'bg-gray-200 text-gray-400 dark:bg-gray-700 dark:text-gray-500 cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -2,12 +2,11 @@
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { useChatStore, Message } from '../store/chatStore';
|
||||
import { useGatewayStore } from '../store/gatewayStore';
|
||||
import { Paperclip, ChevronDown, Terminal, SquarePen, ArrowUp, MessageSquare } from 'lucide-react';
|
||||
import { Paperclip, ChevronDown, Terminal, SquarePen, ArrowUp, MessageSquare, Download, Copy, Check } from 'lucide-react';
|
||||
import { Button, EmptyState } from './ui';
|
||||
import { listItemVariants, defaultTransition, fadeInVariants } from '../lib/animations';
|
||||
import { FirstConversationPrompt } from './FirstConversationPrompt';
|
||||
|
||||
const MODELS = ['glm-5', 'qwen3.5-plus', 'kimi-k2.5', 'minimax-m2.5'];
|
||||
import { MessageSearch } from './MessageSearch';
|
||||
|
||||
export function ChatArea() {
|
||||
const {
|
||||
@@ -15,12 +14,13 @@ export function ChatArea() {
|
||||
sendMessage: sendToGateway, setCurrentModel, initStreamListener,
|
||||
newConversation,
|
||||
} = useChatStore();
|
||||
const { connectionState, clones } = useGatewayStore();
|
||||
const { connectionState, clones, models } = useGatewayStore();
|
||||
|
||||
const [input, setInput] = useState('');
|
||||
const [showModelPicker, setShowModelPicker] = useState(false);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const messageRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
||||
|
||||
// Get current clone for first conversation prompt
|
||||
const currentClone = useMemo(() => {
|
||||
@@ -74,8 +74,21 @@ export function ChatArea() {
|
||||
|
||||
const connected = connectionState === 'connected';
|
||||
|
||||
// Navigate to a specific message by ID
|
||||
const handleNavigateToMessage = useCallback((messageId: string) => {
|
||||
const messageEl = messageRefs.current.get(messageId);
|
||||
if (messageEl && scrollRef.current) {
|
||||
messageEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
// Add highlight effect
|
||||
messageEl.classList.add('ring-2', 'ring-orange-400', 'ring-offset-2');
|
||||
setTimeout(() => {
|
||||
messageEl.classList.remove('ring-2', 'ring-orange-400', 'ring-offset-2');
|
||||
}, 2000);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<div className="h-14 border-b border-gray-100 dark:border-gray-800 flex items-center justify-between px-6 flex-shrink-0 bg-white dark:bg-gray-900">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -93,6 +106,9 @@ export function ChatArea() {
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{messages.length > 0 && (
|
||||
<MessageSearch onNavigateToMessage={handleNavigateToMessage} />
|
||||
)}
|
||||
{messages.length > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -138,6 +154,7 @@ export function ChatArea() {
|
||||
{messages.map((message) => (
|
||||
<motion.div
|
||||
key={message.id}
|
||||
ref={(el) => { if (el) messageRefs.current.set(message.id, el); }}
|
||||
variants={listItemVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
@@ -194,16 +211,22 @@ export function ChatArea() {
|
||||
<ChevronDown className="w-3 h-3" />
|
||||
</Button>
|
||||
{showModelPicker && (
|
||||
<div className="absolute bottom-full right-8 mb-2 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg py-1 min-w-[160px] z-10">
|
||||
{MODELS.map((model) => (
|
||||
<button
|
||||
key={model}
|
||||
onClick={() => { setCurrentModel(model); setShowModelPicker(false); }}
|
||||
className={`w-full text-left px-3 py-2 text-xs hover:bg-gray-50 dark:hover:bg-gray-700 ${model === currentModel ? 'text-orange-600 dark:text-orange-400 font-medium' : 'text-gray-700 dark:text-gray-300'}`}
|
||||
>
|
||||
{model}
|
||||
</button>
|
||||
))}
|
||||
<div className="absolute bottom-full right-8 mb-2 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg py-1 min-w-[160px] max-h-48 overflow-y-auto z-10">
|
||||
{models.length > 0 ? (
|
||||
models.map((model) => (
|
||||
<button
|
||||
key={model.id}
|
||||
onClick={() => { setCurrentModel(model.id); setShowModelPicker(false); }}
|
||||
className={`w-full text-left px-3 py-2 text-xs hover:bg-gray-50 dark:hover:bg-gray-700 ${model.id === currentModel ? 'text-orange-600 dark:text-orange-400 font-medium' : 'text-gray-700 dark:text-gray-300'}`}
|
||||
>
|
||||
{model.name}
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<div className="px-3 py-2 text-xs text-gray-400">
|
||||
{connected ? '加载中...' : '未连接 Gateway'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
@@ -211,10 +234,10 @@ export function ChatArea() {
|
||||
size="sm"
|
||||
onClick={handleSend}
|
||||
disabled={isStreaming || !input.trim() || !connected}
|
||||
className="w-8 h-8 rounded-full p-0 flex items-center justify-center"
|
||||
className="w-8 h-8 rounded-full p-0 flex items-center justify-center bg-orange-500 hover:bg-orange-600 text-white"
|
||||
aria-label="发送消息"
|
||||
>
|
||||
<ArrowUp className="w-4 h-4" />
|
||||
<ArrowUp className="w-4 h-4 text-white" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -223,7 +246,106 @@ export function ChatArea() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Code block with copy and download functionality */
|
||||
function CodeBlock({ code, language, index }: { code: string; language: string; index: number }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
|
||||
// Infer filename from language or content
|
||||
const inferFilename = (): string => {
|
||||
const extMap: Record<string, string> = {
|
||||
javascript: 'js', typescript: 'ts', python: 'py', rust: 'rs',
|
||||
go: 'go', java: 'java', cpp: 'cpp', c: 'c', csharp: 'cs',
|
||||
html: 'html', css: 'css', scss: 'scss', json: 'json',
|
||||
yaml: 'yaml', yml: 'yaml', xml: 'xml', sql: 'sql',
|
||||
shell: 'sh', bash: 'sh', powershell: 'ps1',
|
||||
markdown: 'md', md: 'md', dockerfile: 'dockerfile',
|
||||
};
|
||||
|
||||
// Check if language contains a filename (e.g., ```app.tsx)
|
||||
if (language.includes('.') || language.includes('/')) {
|
||||
return language;
|
||||
}
|
||||
|
||||
// Check for common patterns in code
|
||||
const codeLower = code.toLowerCase();
|
||||
if (codeLower.includes('<!doctype html') || codeLower.includes('<html')) {
|
||||
return 'index.html';
|
||||
}
|
||||
if (codeLower.includes('package.json') || (codeLower.includes('"name"') && codeLower.includes('"version"'))) {
|
||||
return 'package.json';
|
||||
}
|
||||
if (codeLower.startsWith('{') && (codeLower.includes('"import"') || codeLower.includes('"export"'))) {
|
||||
return 'config.json';
|
||||
}
|
||||
|
||||
// Use language extension
|
||||
const ext = extMap[language.toLowerCase()] || language.toLowerCase();
|
||||
return `code-${index + 1}.${ext || 'txt'}`;
|
||||
};
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(code);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = () => {
|
||||
setDownloading(true);
|
||||
try {
|
||||
const filename = inferFilename();
|
||||
const blob = new Blob([code], { type: 'text/plain;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (err) {
|
||||
console.error('Failed to download:', err);
|
||||
}
|
||||
setTimeout(() => setDownloading(false), 500);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative group my-2">
|
||||
<pre className="bg-gray-900 text-gray-100 rounded-lg p-3 overflow-x-auto text-xs font-mono leading-relaxed">
|
||||
{language && (
|
||||
<div className="text-gray-500 text-[10px] mb-1 uppercase flex items-center justify-between">
|
||||
<span>{language}</span>
|
||||
</div>
|
||||
)}
|
||||
<code>{code}</code>
|
||||
</pre>
|
||||
{/* Action buttons - show on hover */}
|
||||
<div className="absolute top-2 right-2 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="p-1.5 bg-gray-700 hover:bg-gray-600 rounded text-gray-300 hover:text-white transition-colors"
|
||||
title="复制代码"
|
||||
>
|
||||
{copied ? <Check className="w-3.5 h-3.5 text-green-400" /> : <Copy className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
className="p-1.5 bg-gray-700 hover:bg-gray-600 rounded text-gray-300 hover:text-white transition-colors"
|
||||
title="下载文件"
|
||||
disabled={downloading}
|
||||
>
|
||||
<Download className={`w-3.5 h-3.5 ${downloading ? 'animate-pulse' : ''}`} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -247,10 +369,7 @@ function renderMarkdown(text: string): React.ReactNode[] {
|
||||
}
|
||||
i++; // skip closing ```
|
||||
nodes.push(
|
||||
<pre key={nodes.length} className="bg-gray-900 text-gray-100 rounded-lg p-3 my-2 overflow-x-auto text-xs font-mono leading-relaxed">
|
||||
{lang && <div className="text-gray-500 text-[10px] mb-1 uppercase">{lang}</div>}
|
||||
<code>{codeLines.join('\n')}</code>
|
||||
</pre>
|
||||
<CodeBlock key={nodes.length} code={codeLines.join('\n')} language={lang} index={nodes.length} />
|
||||
);
|
||||
continue;
|
||||
}
|
||||
@@ -332,6 +451,25 @@ function MessageBubble({ message }: { message: Message }) {
|
||||
|
||||
const isUser = message.role === 'user';
|
||||
|
||||
// 思考中状态:streaming 且内容为空时显示思考指示器
|
||||
const isThinking = message.streaming && !message.content;
|
||||
|
||||
// Download message as Markdown file
|
||||
const handleDownloadMessage = () => {
|
||||
if (!message.content) return;
|
||||
const timestamp = new Date().toISOString().slice(0, 10);
|
||||
const filename = `message-${timestamp}.md`;
|
||||
const blob = new Blob([message.content], { type: 'text/markdown;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`flex gap-4 ${isUser ? 'justify-end' : ''}`}>
|
||||
<div
|
||||
@@ -340,17 +478,39 @@ function MessageBubble({ message }: { message: Message }) {
|
||||
{isUser ? '用' : 'Z'}
|
||||
</div>
|
||||
<div className={isUser ? 'max-w-2xl' : 'flex-1 max-w-3xl'}>
|
||||
<div className={`p-4 shadow-sm ${isUser ? 'chat-bubble-user shadow-md' : 'chat-bubble-assistant'}`}>
|
||||
<div className={`leading-relaxed whitespace-pre-wrap ${isUser ? 'text-white' : 'text-gray-700 dark:text-gray-200'}`}>
|
||||
{message.content
|
||||
? (isUser ? message.content : renderMarkdown(message.content))
|
||||
: (message.streaming ? '' : '...')}
|
||||
{message.streaming && <span className="inline-block w-1.5 h-4 bg-orange-500 animate-pulse ml-0.5 align-text-bottom rounded-sm" />}
|
||||
{isThinking ? (
|
||||
// 思考中指示器
|
||||
<div className="flex items-center gap-2 px-4 py-3 text-gray-500 dark:text-gray-400">
|
||||
<div className="flex gap-1">
|
||||
<span className="w-2 h-2 bg-gray-400 dark:bg-gray-500 rounded-full animate-bounce" style={{ animationDelay: '0ms' }} />
|
||||
<span className="w-2 h-2 bg-gray-400 dark:bg-gray-500 rounded-full animate-bounce" style={{ animationDelay: '150ms' }} />
|
||||
<span className="w-2 h-2 bg-gray-400 dark:bg-gray-500 rounded-full animate-bounce" style={{ animationDelay: '300ms' }} />
|
||||
</div>
|
||||
<span className="text-sm">思考中...</span>
|
||||
</div>
|
||||
{message.error && (
|
||||
<p className="text-xs text-red-500 mt-2">{message.error}</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className={`p-4 shadow-sm ${isUser ? 'chat-bubble-user shadow-md' : 'chat-bubble-assistant'} relative group`}>
|
||||
<div className={`leading-relaxed whitespace-pre-wrap ${isUser ? 'text-white' : 'text-gray-700 dark:text-gray-200'}`}>
|
||||
{message.content
|
||||
? (isUser ? message.content : renderMarkdown(message.content))
|
||||
: '...'}
|
||||
{message.streaming && <span className="inline-block w-1.5 h-4 bg-orange-500 animate-pulse ml-0.5 align-text-bottom rounded-sm" />}
|
||||
</div>
|
||||
{message.error && (
|
||||
<p className="text-xs text-red-500 mt-2">{message.error}</p>
|
||||
)}
|
||||
{/* Download button for AI messages - show on hover */}
|
||||
{!isUser && message.content && !message.streaming && (
|
||||
<button
|
||||
onClick={handleDownloadMessage}
|
||||
className="absolute top-2 right-2 p-1.5 bg-gray-200/80 dark:bg-gray-700/80 hover:bg-gray-300 dark:hover:bg-gray-600 rounded text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition-colors opacity-0 group-hover:opacity-100"
|
||||
title="下载为 Markdown"
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
346
desktop/src/components/CodeSnippetPanel.tsx
Normal file
@@ -0,0 +1,346 @@
|
||||
/**
|
||||
* CodeSnippetPanel - 代码片段快速浏览面板
|
||||
*
|
||||
* 功能:
|
||||
* - 搜索过滤代码片段
|
||||
* - 按语言筛选
|
||||
* - 展开/折叠查看完整代码
|
||||
* - 一键复制
|
||||
* - 下载为文件
|
||||
*/
|
||||
|
||||
import { useState, useMemo, useCallback } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
Search, Copy, Download, ChevronDown, ChevronUp,
|
||||
FileCode, X, Check, Code
|
||||
} from 'lucide-react';
|
||||
import { Button, EmptyState } from './ui';
|
||||
import type { CodeBlock } from '../store/chatStore';
|
||||
|
||||
// === Types ===
|
||||
|
||||
export interface CodeSnippet {
|
||||
id: string;
|
||||
block: CodeBlock;
|
||||
messageIndex: number;
|
||||
}
|
||||
|
||||
interface CodeSnippetPanelProps {
|
||||
snippets: CodeSnippet[];
|
||||
}
|
||||
|
||||
// === Language Colors ===
|
||||
|
||||
const LANGUAGE_COLORS: Record<string, string> = {
|
||||
python: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300',
|
||||
javascript: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-300',
|
||||
typescript: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300',
|
||||
rust: 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-300',
|
||||
go: 'bg-cyan-100 text-cyan-700 dark:bg-cyan-900/30 dark:text-cyan-300',
|
||||
java: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300',
|
||||
cpp: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300',
|
||||
c: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300',
|
||||
html: 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-300',
|
||||
css: 'bg-pink-100 text-pink-700 dark:bg-pink-900/30 dark:text-pink-300',
|
||||
sql: 'bg-indigo-100 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-300',
|
||||
bash: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300',
|
||||
shell: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300',
|
||||
json: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300',
|
||||
yaml: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300',
|
||||
markdown: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300',
|
||||
text: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400',
|
||||
};
|
||||
|
||||
function getLanguageColor(lang: string): string {
|
||||
return LANGUAGE_COLORS[lang.toLowerCase()] || LANGUAGE_COLORS.text;
|
||||
}
|
||||
|
||||
function getFileExtension(lang?: string): string {
|
||||
const extensions: Record<string, string> = {
|
||||
python: 'py',
|
||||
javascript: 'js',
|
||||
typescript: 'ts',
|
||||
rust: 'rs',
|
||||
go: 'go',
|
||||
java: 'java',
|
||||
cpp: 'cpp',
|
||||
c: 'c',
|
||||
html: 'html',
|
||||
css: 'css',
|
||||
sql: 'sql',
|
||||
bash: 'sh',
|
||||
shell: 'sh',
|
||||
json: 'json',
|
||||
yaml: 'yaml',
|
||||
markdown: 'md',
|
||||
};
|
||||
return extensions[lang?.toLowerCase() || ''] || 'txt';
|
||||
}
|
||||
|
||||
// === Snippet Card Component ===
|
||||
|
||||
interface SnippetCardProps {
|
||||
snippet: CodeSnippet;
|
||||
isExpanded: boolean;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
function SnippetCard({ snippet, isExpanded, onToggle }: SnippetCardProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const { block, messageIndex } = snippet;
|
||||
|
||||
const handleCopy = useCallback(async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
await navigator.clipboard.writeText(block.content || '');
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy:', err);
|
||||
}
|
||||
}, [block.content]);
|
||||
|
||||
const handleDownload = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const filename = block.filename || `snippet_${messageIndex + 1}.${getFileExtension(block.language)}`;
|
||||
const blob = new Blob([block.content || ''], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}, [block.content, block.filename, block.language, messageIndex]);
|
||||
|
||||
const lineCount = (block.content || '').split('\n').length;
|
||||
const charCount = (block.content || '').length;
|
||||
const previewLines = (block.content || '').split('\n').slice(0, 2).join('\n');
|
||||
|
||||
return (
|
||||
<div className="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
||||
{/* Header */}
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="w-full flex items-center justify-between p-3 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors text-left"
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<FileCode className="w-4 h-4 text-gray-400 flex-shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-gray-800 dark:text-gray-200 truncate text-sm">
|
||||
{block.filename || `代码片段 #${messageIndex + 1}`}
|
||||
</span>
|
||||
<span className={`text-xs px-1.5 py-0.5 rounded font-medium ${getLanguageColor(block.language || 'text')}`}>
|
||||
{block.language || 'text'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{lineCount} 行 · {charCount > 1024 ? `${(charCount / 1024).toFixed(1)} KB` : `${charCount} 字符`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleCopy}
|
||||
className="p-1.5 opacity-60 hover:opacity-100"
|
||||
title="复制代码"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="w-4 h-4 text-green-500" />
|
||||
) : (
|
||||
<Copy className="w-4 h-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleDownload}
|
||||
className="p-1.5 opacity-60 hover:opacity-100"
|
||||
title="下载文件"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
</Button>
|
||||
{isExpanded ? (
|
||||
<ChevronUp className="w-4 h-4 text-gray-400 ml-1" />
|
||||
) : (
|
||||
<ChevronDown className="w-4 h-4 text-gray-400 ml-1" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Preview / Full Content */}
|
||||
<AnimatePresence initial={false}>
|
||||
{isExpanded ? (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900 p-3">
|
||||
<pre className="text-xs font-mono text-gray-700 dark:text-gray-300 overflow-x-auto whitespace-pre-wrap break-all max-h-60 overflow-y-auto">
|
||||
{block.content}
|
||||
</pre>
|
||||
</div>
|
||||
</motion.div>
|
||||
) : (
|
||||
<div className="px-3 pb-3">
|
||||
<pre className="text-xs font-mono text-gray-500 dark:text-gray-400 overflow-hidden whitespace-pre-wrap break-all line-clamp-2">
|
||||
{previewLines}
|
||||
{(block.content || '').split('\n').length > 2 && '...'}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// === Main Component ===
|
||||
|
||||
export function CodeSnippetPanel({ snippets }: CodeSnippetPanelProps) {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedLanguage, setSelectedLanguage] = useState<string | null>(null);
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
|
||||
// Get unique languages with counts
|
||||
const languages = useMemo(() => {
|
||||
const langMap = new Map<string, number>();
|
||||
snippets.forEach(s => {
|
||||
const lang = s.block.language?.toLowerCase() || 'text';
|
||||
langMap.set(lang, (langMap.get(lang) || 0) + 1);
|
||||
});
|
||||
return Array.from(langMap.entries())
|
||||
.map(([lang, count]) => ({ lang, count }))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
}, [snippets]);
|
||||
|
||||
// Filter snippets
|
||||
const filteredSnippets = useMemo(() => {
|
||||
return snippets.filter(snippet => {
|
||||
const block = snippet.block;
|
||||
|
||||
// Language filter
|
||||
if (selectedLanguage && (block.language?.toLowerCase() || 'text') !== selectedLanguage) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Search filter
|
||||
if (searchQuery) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
const matchesFilename = block.filename?.toLowerCase().includes(query);
|
||||
const matchesContent = block.content?.toLowerCase().includes(query);
|
||||
const matchesLanguage = block.language?.toLowerCase().includes(query);
|
||||
return matchesFilename || matchesContent || matchesLanguage;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}, [snippets, searchQuery, selectedLanguage]);
|
||||
|
||||
const handleToggle = useCallback((id: string) => {
|
||||
setExpandedId(prev => prev === id ? null : id);
|
||||
}, []);
|
||||
|
||||
if (snippets.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<Code className="w-10 h-10" />}
|
||||
title="暂无代码片段"
|
||||
description="对话中生成的代码会自动出现在这里"
|
||||
className="py-8"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Search Bar */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索代码..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-9 pr-8 py-2 bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-transparent"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
onClick={() => setSearchQuery('')}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 hover:bg-gray-200 dark:hover:bg-gray-700 rounded"
|
||||
>
|
||||
<X className="w-3 h-3 text-gray-400" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Language Filters */}
|
||||
{languages.length > 1 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<button
|
||||
onClick={() => setSelectedLanguage(null)}
|
||||
className={`px-2 py-1 text-xs rounded-full transition-colors ${
|
||||
selectedLanguage === null
|
||||
? 'bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300'
|
||||
: 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
全部 ({snippets.length})
|
||||
</button>
|
||||
{languages.map(({ lang, count }) => (
|
||||
<button
|
||||
key={lang}
|
||||
onClick={() => setSelectedLanguage(selectedLanguage === lang ? null : lang)}
|
||||
className={`px-2 py-1 text-xs rounded-full transition-colors ${
|
||||
selectedLanguage === lang
|
||||
? 'bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300'
|
||||
: 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
{lang} ({count})
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results Count */}
|
||||
{(searchQuery || selectedLanguage) && (
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
找到 {filteredSnippets.length} 个代码片段
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Snippet List */}
|
||||
<div className="space-y-2 overflow-y-auto" style={{ maxHeight: 'calc(100vh - 380px)' }}>
|
||||
<AnimatePresence mode="popLayout">
|
||||
{filteredSnippets.map((snippet) => (
|
||||
<motion.div
|
||||
key={snippet.id}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
layout
|
||||
>
|
||||
<SnippetCard
|
||||
snippet={snippet}
|
||||
isExpanded={expandedId === snippet.id}
|
||||
onToggle={() => handleToggle(snippet.id)}
|
||||
/>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
|
||||
{filteredSnippets.length === 0 && (
|
||||
<div className="text-center py-4 text-sm text-gray-500 dark:text-gray-400">
|
||||
没有找到匹配的代码片段
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
56
desktop/src/components/DetailDrawer.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
interface DetailDrawerProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function DetailDrawer({ open, onClose, title = '详情', children }: DetailDrawerProps) {
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<>
|
||||
{/* 遮罩层 */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="fixed inset-0 bg-black/20 dark:bg-black/40 z-40"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* 抽屉面板 */}
|
||||
<motion.aside
|
||||
initial={{ x: '100%' }}
|
||||
animate={{ x: 0 }}
|
||||
exit={{ x: '100%' }}
|
||||
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
|
||||
className="fixed right-0 top-0 bottom-0 w-[400px] bg-white dark:bg-gray-900 border-l border-gray-200 dark:border-gray-700 z-50 flex flex-col shadow-xl"
|
||||
>
|
||||
{/* 抽屉头部 */}
|
||||
<header className="h-14 border-b border-gray-200 dark:border-gray-700 flex items-center px-4 flex-shrink-0">
|
||||
<span className="font-medium text-gray-900 dark:text-gray-100">{title}</span>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="ml-auto p-1.5 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg text-gray-500 dark:text-gray-400 transition-colors"
|
||||
aria-label="关闭"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{/* 抽屉内容 */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{children}
|
||||
</div>
|
||||
</motion.aside>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -62,13 +62,13 @@ function validateParameter(param: HandParameter, value: unknown): ValidationResu
|
||||
// Required check
|
||||
if (param.required) {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return { isValid: false, error: `${param.label} is required` };
|
||||
return { isValid: false, error: `${param.label} 为必填项` };
|
||||
}
|
||||
if (Array.isArray(value) && value.length === 0) {
|
||||
return { isValid: false, error: `${param.label} is required` };
|
||||
return { isValid: false, error: `${param.label} 为必填项` };
|
||||
}
|
||||
if (typeof value === 'object' && !Array.isArray(value) && Object.keys(value as Record<string, unknown>).length === 0) {
|
||||
return { isValid: false, error: `${param.label} is required` };
|
||||
return { isValid: false, error: `${param.label} 为必填项` };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,26 +81,26 @@ function validateParameter(param: HandParameter, value: unknown): ValidationResu
|
||||
switch (param.type) {
|
||||
case 'number':
|
||||
if (typeof value !== 'number' || isNaN(value)) {
|
||||
return { isValid: false, error: `${param.label} must be a valid number` };
|
||||
return { isValid: false, error: `${param.label} 必须是有效数字` };
|
||||
}
|
||||
if (param.min !== undefined && value < param.min) {
|
||||
return { isValid: false, error: `${param.label} must be at least ${param.min}` };
|
||||
return { isValid: false, error: `${param.label} 不能小于 ${param.min}` };
|
||||
}
|
||||
if (param.max !== undefined && value > param.max) {
|
||||
return { isValid: false, error: `${param.label} must be at most ${param.max}` };
|
||||
return { isValid: false, error: `${param.label} 不能大于 ${param.max}` };
|
||||
}
|
||||
break;
|
||||
|
||||
case 'text':
|
||||
case 'textarea':
|
||||
if (typeof value !== 'string') {
|
||||
return { isValid: false, error: `${param.label} must be text` };
|
||||
return { isValid: false, error: `${param.label} 必须是文本` };
|
||||
}
|
||||
if (param.pattern) {
|
||||
try {
|
||||
const regex = new RegExp(param.pattern);
|
||||
if (!regex.test(value)) {
|
||||
return { isValid: false, error: `${param.label} format is invalid` };
|
||||
return { isValid: false, error: `${param.label} 格式不正确` };
|
||||
}
|
||||
} catch {
|
||||
// Invalid regex pattern, skip validation
|
||||
@@ -110,19 +110,19 @@ function validateParameter(param: HandParameter, value: unknown): ValidationResu
|
||||
|
||||
case 'array':
|
||||
if (!Array.isArray(value)) {
|
||||
return { isValid: false, error: `${param.label} must be an array` };
|
||||
return { isValid: false, error: `${param.label} 必须是数组` };
|
||||
}
|
||||
break;
|
||||
|
||||
case 'object':
|
||||
if (typeof value !== 'object' || Array.isArray(value)) {
|
||||
return { isValid: false, error: `${param.label} must be an object` };
|
||||
return { isValid: false, error: `${param.label} 必须是对象` };
|
||||
}
|
||||
try {
|
||||
// Try to stringify to validate JSON
|
||||
JSON.stringify(value);
|
||||
} catch {
|
||||
return { isValid: false, error: `${param.label} contains invalid JSON` };
|
||||
return { isValid: false, error: `${param.label} 包含无效的 JSON` };
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -210,7 +210,7 @@ function TextParamInput({ param, value, onChange, disabled, error }: ParamInputP
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={param.placeholder}
|
||||
disabled={disabled}
|
||||
className={`w-full px-3 py-2 text-sm border rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed ${
|
||||
className={`w-full px-3 py-2 text-sm border rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-gray-400 disabled:opacity-50 disabled:cursor-not-allowed ${
|
||||
error ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'
|
||||
}`}
|
||||
/>
|
||||
@@ -230,7 +230,7 @@ function NumberParamInput({ param, value, onChange, disabled, error }: ParamInpu
|
||||
min={param.min}
|
||||
max={param.max}
|
||||
disabled={disabled}
|
||||
className={`w-full px-3 py-2 text-sm border rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed ${
|
||||
className={`w-full px-3 py-2 text-sm border rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-gray-400 disabled:opacity-50 disabled:cursor-not-allowed ${
|
||||
error ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'
|
||||
}`}
|
||||
/>
|
||||
@@ -245,10 +245,10 @@ function BooleanParamInput({ param, value, onChange, disabled }: ParamInputProps
|
||||
checked={(value as boolean) ?? false}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
disabled={disabled}
|
||||
className="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="w-4 h-4 text-gray-600 border-gray-300 rounded focus:ring-gray-400 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
/>
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{param.placeholder || 'Enabled'}
|
||||
{param.placeholder || '启用'}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
@@ -264,7 +264,7 @@ function SelectParamInput({ param, value, onChange, disabled, error }: ParamInpu
|
||||
error ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'
|
||||
}`}
|
||||
>
|
||||
<option value="">{param.placeholder || '-- Select --'}</option>
|
||||
<option value="">{param.placeholder || '-- 请选择 --'}</option>
|
||||
{param.options?.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
@@ -331,7 +331,7 @@ function ArrayParamInput({ param, value, onChange, disabled, error }: ParamInput
|
||||
value={item}
|
||||
onChange={(e) => handleUpdateItem(index, e.target.value)}
|
||||
disabled={disabled}
|
||||
className="flex-1 px-2 py-1 text-sm border border-gray-200 dark:border-gray-700 rounded bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-50"
|
||||
className="flex-1 px-2 py-1 text-sm border border-gray-200 dark:border-gray-700 rounded bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:outline-none focus:ring-1 focus:ring-gray-400 disabled:opacity-50"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@@ -353,7 +353,7 @@ function ArrayParamInput({ param, value, onChange, disabled, error }: ParamInput
|
||||
value={newItem}
|
||||
onChange={(e) => setNewItem(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={param.placeholder || 'Add item...'}
|
||||
placeholder={param.placeholder || '添加项目...'}
|
||||
disabled={disabled}
|
||||
className="flex-1 px-2 py-1 text-sm border border-gray-200 dark:border-gray-700 rounded bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-50"
|
||||
/>
|
||||
@@ -361,7 +361,7 @@ function ArrayParamInput({ param, value, onChange, disabled, error }: ParamInput
|
||||
type="button"
|
||||
onClick={handleAddItem}
|
||||
disabled={disabled || !newItem.trim()}
|
||||
className="p-1 text-blue-500 hover:text-blue-700 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="p-1 text-gray-500 hover:text-gray-700 hover:bg-gray-50 dark:hover:bg-gray-900/20 rounded disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</button>
|
||||
@@ -408,10 +408,10 @@ function ObjectParamInput({ param, value, onChange, disabled, error }: ParamInpu
|
||||
onChange(result.data as Record<string, unknown>);
|
||||
setParseError(null);
|
||||
} else {
|
||||
setParseError('Value must be a JSON object');
|
||||
setParseError('值必须是 JSON 对象');
|
||||
}
|
||||
} else {
|
||||
setParseError('Invalid JSON format');
|
||||
setParseError('JSON 格式无效');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -423,7 +423,7 @@ function ObjectParamInput({ param, value, onChange, disabled, error }: ParamInpu
|
||||
className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white"
|
||||
>
|
||||
{isExpanded ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
|
||||
{isExpanded ? 'Collapse' : 'Expand'} JSON Editor
|
||||
{isExpanded ? '收起' : '展开'} JSON 编辑器
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
@@ -603,7 +603,7 @@ function PresetManager({ presetKey, currentValues, onLoadPreset }: PresetManager
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm border border-gray-200 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
<Save className="w-3.5 h-3.5" />
|
||||
Save Preset
|
||||
保存预设
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -612,7 +612,7 @@ function PresetManager({ presetKey, currentValues, onLoadPreset }: PresetManager
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm border border-gray-200 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<FolderOpen className="w-3.5 h-3.5" />
|
||||
Load Preset ({presets.length})
|
||||
加载预设 ({presets.length})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -620,15 +620,15 @@ function PresetManager({ presetKey, currentValues, onLoadPreset }: PresetManager
|
||||
{showSaveDialog && (
|
||||
<div className="mt-3 p-3 bg-gray-50 dark:bg-gray-900 rounded-lg space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Preset Name
|
||||
预设名称
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={presetName}
|
||||
onChange={(e) => setPresetName(e.target.value)}
|
||||
placeholder="My preset..."
|
||||
className="flex-1 px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="我的预设..."
|
||||
className="flex-1 px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-gray-400"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleSavePreset();
|
||||
if (e.key === 'Escape') setShowSaveDialog(false);
|
||||
@@ -639,16 +639,16 @@ function PresetManager({ presetKey, currentValues, onLoadPreset }: PresetManager
|
||||
type="button"
|
||||
onClick={handleSavePreset}
|
||||
disabled={!presetName.trim()}
|
||||
className="px-3 py-1.5 text-sm bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="px-3 py-1.5 text-sm bg-gray-700 dark:bg-gray-600 text-white rounded-md hover:bg-gray-800 dark:hover:bg-gray-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Save
|
||||
保存
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowSaveDialog(false)}
|
||||
className="px-3 py-1.5 text-sm border border-gray-200 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
Cancel
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -658,7 +658,7 @@ function PresetManager({ presetKey, currentValues, onLoadPreset }: PresetManager
|
||||
{showPresetList && presets.length > 0 && (
|
||||
<div className="mt-3 p-3 bg-gray-50 dark:bg-gray-900 rounded-lg space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Available Presets
|
||||
可用预设
|
||||
</label>
|
||||
<div className="space-y-1.5 max-h-48 overflow-y-auto">
|
||||
{presets.map((preset) => (
|
||||
@@ -678,9 +678,9 @@ function PresetManager({ presetKey, currentValues, onLoadPreset }: PresetManager
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleLoadPreset(preset)}
|
||||
className="px-2 py-1 text-xs text-blue-600 dark:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded"
|
||||
className="px-2 py-1 text-xs text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-900/20 rounded"
|
||||
>
|
||||
Load
|
||||
加载
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -753,7 +753,7 @@ export function HandParamsForm({
|
||||
if (parameters.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-4 text-gray-500 dark:text-gray-400 text-sm">
|
||||
No parameters required for this Hand.
|
||||
此自主能力无需参数配置。
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
ArrowLeft,
|
||||
RefreshCw,
|
||||
} from 'lucide-react';
|
||||
import { useToast } from './ui/Toast';
|
||||
|
||||
interface HandTaskPanelProps {
|
||||
handId: string;
|
||||
@@ -39,6 +40,7 @@ const RUN_STATUS_CONFIG: Record<string, { label: string; className: string; icon
|
||||
|
||||
export function HandTaskPanel({ handId, onBack }: HandTaskPanelProps) {
|
||||
const { hands, handRuns, loadHands, loadHandRuns, triggerHand, isLoading } = useGatewayStore();
|
||||
const { toast } = useToast();
|
||||
const [selectedHand, setSelectedHand] = useState<Hand | null>(null);
|
||||
const [isActivating, setIsActivating] = useState(false);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
@@ -78,20 +80,49 @@ export function HandTaskPanel({ handId, onBack }: HandTaskPanelProps) {
|
||||
// Trigger hand execution
|
||||
const handleActivate = useCallback(async () => {
|
||||
if (!selectedHand) return;
|
||||
|
||||
// Check if hand is already running
|
||||
if (selectedHand.status === 'running') {
|
||||
toast(`Hand "${selectedHand.name}" 正在运行中,请等待完成`, 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsActivating(true);
|
||||
console.log(`[HandTaskPanel] Activating hand: ${selectedHand.id} (${selectedHand.name})`);
|
||||
|
||||
try {
|
||||
await triggerHand(selectedHand.id);
|
||||
// Refresh hands list and task history
|
||||
await Promise.all([
|
||||
loadHands(),
|
||||
loadHandRuns(selectedHand.id, { limit: 50 }),
|
||||
]);
|
||||
} catch {
|
||||
// Error is handled in store
|
||||
const result = await triggerHand(selectedHand.id);
|
||||
console.log(`[HandTaskPanel] Activation result:`, result);
|
||||
|
||||
if (result) {
|
||||
toast(`Hand "${selectedHand.name}" 已成功启动`, 'success');
|
||||
// Refresh hands list and task history
|
||||
await Promise.all([
|
||||
loadHands(),
|
||||
loadHandRuns(selectedHand.id, { limit: 50 }),
|
||||
]);
|
||||
} else {
|
||||
// Check for specific error in store
|
||||
const storeError = useGatewayStore.getState().error;
|
||||
if (storeError?.includes('already active')) {
|
||||
toast(`Hand "${selectedHand.name}" 已在运行中`, 'warning');
|
||||
} else {
|
||||
toast(`Hand "${selectedHand.name}" 启动失败: ${storeError || '未知错误'}`, 'error');
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[HandTaskPanel] Activation error:`, errorMsg);
|
||||
|
||||
if (errorMsg.includes('already active')) {
|
||||
toast(`Hand "${selectedHand.name}" 已在运行中`, 'warning');
|
||||
} else {
|
||||
toast(`Hand "${selectedHand.name}" 启动异常: ${errorMsg}`, 'error');
|
||||
}
|
||||
} finally {
|
||||
setIsActivating(false);
|
||||
}
|
||||
}, [selectedHand, triggerHand, loadHands, loadHandRuns]);
|
||||
}, [selectedHand, triggerHand, loadHands, loadHandRuns, toast]);
|
||||
|
||||
if (!selectedHand) {
|
||||
return (
|
||||
|
||||
@@ -8,12 +8,17 @@
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useGatewayStore, type Hand, type HandRequirement } from '../store/gatewayStore';
|
||||
import { Zap, RefreshCw, ChevronRight, CheckCircle, XCircle, Loader2, AlertTriangle, Settings, Play } from 'lucide-react';
|
||||
import { useHandStore, type Hand, type HandRequirement } from '../store/handStore';
|
||||
import { Zap, RefreshCw, ChevronRight, CheckCircle, XCircle, Loader2, AlertTriangle, Settings, Play, Clock } from 'lucide-react';
|
||||
import { BrowserHandCard } from './BrowserHand';
|
||||
import type { HandParameter } from '../types/hands';
|
||||
import { HAND_DEFINITIONS } from '../types/hands';
|
||||
import { HandParamsForm } from './HandParamsForm';
|
||||
import { ApprovalsPanel } from './ApprovalsPanel';
|
||||
import { useToast } from './ui/Toast';
|
||||
|
||||
// === Tab Type ===
|
||||
type TabType = 'hands' | 'approvals';
|
||||
|
||||
// === Status Badge Component ===
|
||||
|
||||
@@ -133,7 +138,7 @@ interface HandDetailsModalProps {
|
||||
hand: Hand;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onActivate: () => void;
|
||||
onActivate: (params?: Record<string, unknown>) => void;
|
||||
isActivating: boolean;
|
||||
}
|
||||
|
||||
@@ -183,7 +188,7 @@ function HandDetailsModal({ hand, isOpen, onClose, onActivate, isActivating }: H
|
||||
return;
|
||||
}
|
||||
// Pass parameters to onActivate
|
||||
onActivate();
|
||||
onActivate(paramValues);
|
||||
} else {
|
||||
onActivate();
|
||||
}
|
||||
@@ -331,7 +336,7 @@ function HandDetailsModal({ hand, isOpen, onClose, onActivate, isActivating }: H
|
||||
<button
|
||||
onClick={handleActivateClick}
|
||||
disabled={!canActivate || hasUnmetRequirements || isActivating}
|
||||
className="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
className="px-4 py-2 text-sm bg-gray-700 dark:bg-gray-600 text-white rounded-lg hover:bg-gray-800 dark:hover:bg-gray-500 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
>
|
||||
{isActivating ? (
|
||||
<>
|
||||
@@ -366,7 +371,7 @@ function HandDetailsModal({ hand, isOpen, onClose, onActivate, isActivating }: H
|
||||
interface HandCardProps {
|
||||
hand: Hand;
|
||||
onDetails: (hand: Hand) => void;
|
||||
onActivate: (hand: Hand) => void;
|
||||
onActivate: (hand: Hand, params?: Record<string, unknown>) => void;
|
||||
isActivating: boolean;
|
||||
}
|
||||
|
||||
@@ -423,7 +428,7 @@ function HandCard({ hand, onDetails, onActivate, isActivating }: HandCardProps)
|
||||
<button
|
||||
onClick={() => onActivate(hand)}
|
||||
disabled={!canActivate || hasUnmetRequirements || isActivating}
|
||||
className="px-3 py-1.5 text-sm bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1"
|
||||
className="px-3 py-1.5 text-sm bg-gray-700 dark:bg-gray-600 text-white rounded-md hover:bg-gray-800 dark:hover:bg-gray-500 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1"
|
||||
>
|
||||
{isActivating ? (
|
||||
<>
|
||||
@@ -450,10 +455,12 @@ function HandCard({ hand, onDetails, onActivate, isActivating }: HandCardProps)
|
||||
// === Main HandsPanel Component ===
|
||||
|
||||
export function HandsPanel() {
|
||||
const { hands, loadHands, triggerHand, isLoading } = useGatewayStore();
|
||||
const { hands, loadHands, triggerHand, isLoading, error: storeError, getHandDetails } = useHandStore();
|
||||
const [selectedHand, setSelectedHand] = useState<Hand | null>(null);
|
||||
const [activatingHandId, setActivatingHandId] = useState<string | null>(null);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<TabType>('hands');
|
||||
const { toast } = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
loadHands();
|
||||
@@ -461,34 +468,47 @@ export function HandsPanel() {
|
||||
|
||||
const handleDetails = useCallback(async (hand: Hand) => {
|
||||
// Load full details before showing modal
|
||||
const { getHandDetails } = useGatewayStore.getState();
|
||||
const details = await getHandDetails(hand.id);
|
||||
setSelectedHand(details || hand);
|
||||
setShowModal(true);
|
||||
}, []);
|
||||
}, [getHandDetails]);
|
||||
|
||||
const handleActivate = useCallback(async (hand: Hand) => {
|
||||
const handleActivate = useCallback(async (hand: Hand, params?: Record<string, unknown>) => {
|
||||
setActivatingHandId(hand.id);
|
||||
console.log(`[HandsPanel] Activating hand: ${hand.id} (${hand.name})`, params ? 'with params:' : '', params);
|
||||
|
||||
try {
|
||||
await triggerHand(hand.id);
|
||||
// Refresh hands after activation
|
||||
await loadHands();
|
||||
} catch {
|
||||
// Error is handled in store
|
||||
const result = await triggerHand(hand.id, params);
|
||||
console.log(`[HandsPanel] Hand activation result:`, result);
|
||||
|
||||
if (result) {
|
||||
toast(`Hand "${hand.name}" 已成功激活`, 'success');
|
||||
// Refresh hands after activation
|
||||
await loadHands();
|
||||
} else {
|
||||
// Check if there's an error in the store
|
||||
const errorMsg = storeError || '激活失败,请检查后端连接';
|
||||
console.error(`[HandsPanel] Hand activation failed:`, errorMsg);
|
||||
toast(`Hand "${hand.name}" 激活失败: ${errorMsg}`, 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[HandsPanel] Hand activation error:`, errorMsg);
|
||||
toast(`Hand "${hand.name}" 激活异常: ${errorMsg}`, 'error');
|
||||
} finally {
|
||||
setActivatingHandId(null);
|
||||
}
|
||||
}, [triggerHand, loadHands]);
|
||||
}, [triggerHand, loadHands, toast, storeError]);
|
||||
|
||||
const handleCloseModal = useCallback(() => {
|
||||
setShowModal(false);
|
||||
setSelectedHand(null);
|
||||
}, []);
|
||||
|
||||
const handleModalActivate = useCallback(async () => {
|
||||
const handleModalActivate = useCallback(async (params?: Record<string, unknown>) => {
|
||||
if (!selectedHand) return;
|
||||
setShowModal(false);
|
||||
await handleActivate(selectedHand);
|
||||
await handleActivate(selectedHand, params);
|
||||
}, [selectedHand, handleActivate]);
|
||||
|
||||
if (isLoading && hands.length === 0) {
|
||||
@@ -540,21 +560,52 @@ export function HandsPanel() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<span className="text-gray-500 dark:text-gray-400">
|
||||
可用 <span className="font-medium text-gray-900 dark:text-white">{hands.length}</span>
|
||||
</span>
|
||||
<span className="text-gray-500 dark:text-gray-400">
|
||||
就绪 <span className="font-medium text-green-600 dark:text-green-400">{hands.filter(h => h.status === 'idle').length}</span>
|
||||
</span>
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
onClick={() => setActiveTab('hands')}
|
||||
className={`flex items-center gap-1.5 px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === 'hands'
|
||||
? 'text-orange-600 dark:text-orange-400 border-orange-500'
|
||||
: 'text-gray-500 dark:text-gray-400 border-transparent hover:text-gray-700 dark:hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
<Zap className="w-4 h-4" />
|
||||
能力包
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('approvals')}
|
||||
className={`flex items-center gap-1.5 px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === 'approvals'
|
||||
? 'text-orange-600 dark:text-orange-400 border-orange-500'
|
||||
: 'text-gray-500 dark:text-gray-400 border-transparent hover:text-gray-700 dark:hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
<Clock className="w-4 h-4" />
|
||||
审批历史
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Hand Cards Grid */}
|
||||
<div className="grid gap-3">
|
||||
{hands.map((hand) => {
|
||||
// Check if this is a Browser Hand
|
||||
const isBrowserHand = hand.id === 'browser' || hand.name === 'Browser' || hand.name?.toLowerCase().includes('browser');
|
||||
{/* Tab Content */}
|
||||
{activeTab === 'approvals' ? (
|
||||
<ApprovalsPanel />
|
||||
) : (
|
||||
<>
|
||||
{/* Stats */}
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<span className="text-gray-500 dark:text-gray-400">
|
||||
可用 <span className="font-medium text-gray-900 dark:text-white">{hands.length}</span>
|
||||
</span>
|
||||
<span className="text-gray-500 dark:text-gray-400">
|
||||
就绪 <span className="font-medium text-green-600 dark:text-green-400">{hands.filter(h => h.status === 'idle').length}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Hand Cards Grid */}
|
||||
<div className="grid gap-3">
|
||||
{hands.map((hand) => {
|
||||
// Check if this is a Browser Hand
|
||||
const isBrowserHand = hand.id === 'browser' || hand.name === 'Browser' || hand.name?.toLowerCase().includes('browser');
|
||||
|
||||
return isBrowserHand ? (
|
||||
<BrowserHandCard
|
||||
@@ -571,17 +622,19 @@ export function HandsPanel() {
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Details Modal */}
|
||||
{selectedHand && (
|
||||
<HandDetailsModal
|
||||
hand={selectedHand}
|
||||
isOpen={showModal}
|
||||
onClose={handleCloseModal}
|
||||
onActivate={handleModalActivate}
|
||||
isActivating={activatingHandId === selectedHand.id}
|
||||
/>
|
||||
{/* Details Modal */}
|
||||
{selectedHand && (
|
||||
<HandDetailsModal
|
||||
hand={selectedHand}
|
||||
isOpen={showModal}
|
||||
onClose={handleCloseModal}
|
||||
onActivate={handleModalActivate}
|
||||
isActivating={activatingHandId === selectedHand.id}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -158,7 +158,7 @@ export function MemoryGraph({ className = '' }: MemoryGraphProps) {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
const { currentAgent } = useChatStore();
|
||||
const agentId = currentAgent?.id || 'default';
|
||||
const agentId = currentAgent?.id || 'zclaw-main';
|
||||
|
||||
const {
|
||||
isLoading,
|
||||
@@ -225,8 +225,8 @@ export function MemoryGraph({ className = '' }: MemoryGraphProps) {
|
||||
canvas.height = layout.height * dpr;
|
||||
ctx.scale(dpr, dpr);
|
||||
|
||||
// 清空画布
|
||||
ctx.fillStyle = '#1a1a2e';
|
||||
// 清空画布 - 使用浅色背景匹配系统主题
|
||||
ctx.fillStyle = '#f9fafb'; // gray-50
|
||||
ctx.fillRect(0, 0, layout.width, layout.height);
|
||||
|
||||
// 应用变换
|
||||
@@ -299,7 +299,7 @@ export function MemoryGraph({ className = '' }: MemoryGraphProps) {
|
||||
ctx.arc(legendX, legendY, 6, 0, Math.PI * 2);
|
||||
ctx.fillStyle = colors.fill;
|
||||
ctx.fill();
|
||||
ctx.fillStyle = '#9ca3af';
|
||||
ctx.fillStyle = '#6b7280'; // gray-500 for better visibility on light background
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText(label, legendX + 12, legendY + 4);
|
||||
legendX += 70;
|
||||
@@ -377,7 +377,7 @@ export function MemoryGraph({ className = '' }: MemoryGraphProps) {
|
||||
return (
|
||||
<div className={`flex flex-col h-full ${className}`}>
|
||||
{/* 工具栏 */}
|
||||
<div className="flex items-center gap-2 p-2 bg-gray-800/50 rounded-t-lg border-b border-gray-700">
|
||||
<div className="flex items-center gap-2 p-2 bg-gray-100 dark:bg-gray-800/50 rounded-t-lg border-b border-gray-200 dark:border-gray-700">
|
||||
{/* 搜索框 */}
|
||||
<div className="relative flex-1 max-w-xs">
|
||||
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
@@ -386,7 +386,7 @@ export function MemoryGraph({ className = '' }: MemoryGraphProps) {
|
||||
placeholder="搜索记忆..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
className="w-full pl-8 pr-2 py-1 bg-gray-900 border border-gray-700 rounded text-sm text-white placeholder-gray-500 focus:outline-none focus:border-blue-500"
|
||||
className="w-full pl-8 pr-2 py-1 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded text-sm text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:border-orange-400 dark:focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -459,7 +459,7 @@ export function MemoryGraph({ className = '' }: MemoryGraphProps) {
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
className="overflow-hidden bg-gray-800/30 border-b border-gray-700"
|
||||
className="overflow-hidden bg-gray-50 dark:bg-gray-800/30 border-b border-gray-200 dark:border-gray-700"
|
||||
>
|
||||
<div className="p-3 flex flex-wrap gap-3">
|
||||
{/* 类型筛选 */}
|
||||
@@ -476,8 +476,8 @@ export function MemoryGraph({ className = '' }: MemoryGraphProps) {
|
||||
}}
|
||||
className={`px-2 py-1 text-xs rounded ${
|
||||
filter.types.includes(type)
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-700 text-gray-300'
|
||||
? 'bg-orange-500 text-white'
|
||||
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
{TYPE_LABELS[type]}
|
||||
@@ -510,9 +510,9 @@ export function MemoryGraph({ className = '' }: MemoryGraphProps) {
|
||||
</AnimatePresence>
|
||||
|
||||
{/* 图谱画布 */}
|
||||
<div className="flex-1 relative overflow-hidden bg-gray-900">
|
||||
<div className="flex-1 relative overflow-hidden bg-gray-50 dark:bg-gray-900">
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-gray-900/80 z-10">
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-gray-50/80 dark:bg-gray-900/80 z-10">
|
||||
<RefreshCw className="w-8 h-8 text-blue-500 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
@@ -541,7 +541,7 @@ export function MemoryGraph({ className = '' }: MemoryGraphProps) {
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: 20 }}
|
||||
className="absolute top-4 right-4 w-64 bg-gray-800 rounded-lg border border-gray-700 p-4 shadow-xl"
|
||||
className="absolute top-4 right-4 w-64 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4 shadow-xl"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<Badge variant={selectedNode.type as any}>
|
||||
@@ -549,7 +549,7 @@ export function MemoryGraph({ className = '' }: MemoryGraphProps) {
|
||||
</Badge>
|
||||
<button
|
||||
onClick={() => selectNode(null)}
|
||||
className="text-gray-400 hover:text-white"
|
||||
className="text-gray-400 hover:text-gray-600 dark:hover:text-white"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
@@ -557,7 +557,7 @@ export function MemoryGraph({ className = '' }: MemoryGraphProps) {
|
||||
|
||||
<p className="text-sm text-gray-200 mb-3">{selectedNode.label}</p>
|
||||
|
||||
<div className="space-y-2 text-xs text-gray-400">
|
||||
<div className="space-y-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
<div className="flex items-center gap-2">
|
||||
<Star className="w-3 h-3" />
|
||||
重要性: {selectedNode.importance}
|
||||
@@ -573,9 +573,9 @@ export function MemoryGraph({ className = '' }: MemoryGraphProps) {
|
||||
</div>
|
||||
|
||||
{/* 关联边统计 */}
|
||||
<div className="mt-3 pt-3 border-t border-gray-700">
|
||||
<div className="text-xs text-gray-400 mb-1">关联记忆:</div>
|
||||
<div className="text-sm text-gray-200">
|
||||
<div className="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700">
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1">关联记忆:</div>
|
||||
<div className="text-sm text-gray-700 dark:text-gray-200">
|
||||
{filteredEdges.filter(
|
||||
e => e.source === selectedNode.id || e.target === selectedNode.id
|
||||
).length} 个
|
||||
@@ -598,7 +598,7 @@ export function MemoryGraph({ className = '' }: MemoryGraphProps) {
|
||||
</div>
|
||||
|
||||
{/* 状态栏 */}
|
||||
<div className="flex items-center justify-between px-3 py-1 bg-gray-800/50 rounded-b-lg text-xs text-gray-400">
|
||||
<div className="flex items-center justify-between px-3 py-1 bg-gray-100 dark:bg-gray-800/50 rounded-b-lg text-xs text-gray-500 dark:text-gray-400">
|
||||
<div className="flex items-center gap-4">
|
||||
<span>节点: {filteredNodes.length}</span>
|
||||
<span>关联: {filteredEdges.length}</span>
|
||||
|
||||
@@ -1,19 +1,80 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { ReactNode, useEffect, useMemo, useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { getStoredGatewayUrl } from '../lib/gateway-client';
|
||||
import { useGatewayStore, type PluginStatus } from '../store/gatewayStore';
|
||||
import { toChatAgent, useChatStore } from '../store/chatStore';
|
||||
import { toChatAgent, useChatStore, type CodeBlock } from '../store/chatStore';
|
||||
import {
|
||||
Wifi, WifiOff, Bot, BarChart3, Plug, RefreshCw,
|
||||
MessageSquare, Cpu, FileText, User, Activity, FileCode, Brain,
|
||||
Shield, Sparkles, GraduationCap
|
||||
MessageSquare, Cpu, FileText, User, Activity, Brain,
|
||||
Shield, Sparkles, GraduationCap, List, Network
|
||||
} from 'lucide-react';
|
||||
|
||||
// === Helper to extract code blocks from markdown content ===
|
||||
function extractCodeBlocksFromContent(content: string): CodeBlock[] {
|
||||
const blocks: CodeBlock[] = [];
|
||||
const regex = /```(\w*)\n([\s\S]*?)```/g;
|
||||
let match;
|
||||
|
||||
while ((match = regex.exec(content)) !== null) {
|
||||
const language = match[1] || 'text';
|
||||
const codeContent = match[2].trim();
|
||||
|
||||
// Try to extract filename from first line comment
|
||||
let filename: string | undefined;
|
||||
let actualContent = codeContent;
|
||||
|
||||
// Check for filename patterns like "# filename.py" or "// filename.js"
|
||||
const firstLine = codeContent.split('\n')[0];
|
||||
const filenameMatch = firstLine.match(/^(?:#|\/\/|\/\*|<!--)\s*([^\s]+\.\w+)/);
|
||||
if (filenameMatch) {
|
||||
filename = filenameMatch[1];
|
||||
actualContent = codeContent.split('\n').slice(1).join('\n').trim();
|
||||
}
|
||||
|
||||
blocks.push({
|
||||
language,
|
||||
filename,
|
||||
content: actualContent,
|
||||
});
|
||||
}
|
||||
|
||||
return blocks;
|
||||
}
|
||||
|
||||
// === Tab Button Component ===
|
||||
function TabButton({
|
||||
active,
|
||||
onClick,
|
||||
icon,
|
||||
label,
|
||||
}: {
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
icon: ReactNode;
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${
|
||||
active
|
||||
? 'bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300'
|
||||
: 'text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-gray-700 dark:hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
{icon}
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
import { MemoryPanel } from './MemoryPanel';
|
||||
import { MemoryGraph } from './MemoryGraph';
|
||||
import { ReflectionLog } from './ReflectionLog';
|
||||
import { AutonomyConfig } from './AutonomyConfig';
|
||||
import { ActiveLearningPanel } from './ActiveLearningPanel';
|
||||
import { CodeSnippetPanel, type CodeSnippet } from './CodeSnippetPanel';
|
||||
import { cardHover, defaultTransition } from '../lib/animations';
|
||||
import { Button, Badge, EmptyState } from './ui';
|
||||
import { Button, Badge } from './ui';
|
||||
import { getPersonalityById } from '../lib/personality-presets';
|
||||
import { silentErrorHandler } from '../lib/error-utils';
|
||||
|
||||
@@ -24,6 +85,7 @@ export function RightPanel() {
|
||||
} = useGatewayStore();
|
||||
const { messages, currentModel, currentAgent, setCurrentAgent } = useChatStore();
|
||||
const [activeTab, setActiveTab] = useState<'status' | 'files' | 'agent' | 'memory' | 'reflection' | 'autonomy' | 'learning'>('status');
|
||||
const [memoryViewMode, setMemoryViewMode] = useState<'list' | 'graph'>('list');
|
||||
const [isEditingAgent, setIsEditingAgent] = useState(false);
|
||||
const [agentDraft, setAgentDraft] = useState<AgentDraft | null>(null);
|
||||
|
||||
@@ -96,108 +158,149 @@ export function RightPanel() {
|
||||
const userAddressing = selectedClone?.nickname || selectedClone?.userName || quickConfig.userName || '未设置';
|
||||
const localTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone || '系统时区';
|
||||
|
||||
// Extract code blocks from all messages (both from codeBlocks property and content parsing)
|
||||
const codeSnippets = useMemo((): CodeSnippet[] => {
|
||||
const snippets: CodeSnippet[] = [];
|
||||
let globalIndex = 0;
|
||||
|
||||
for (let msgIdx = 0; msgIdx < messages.length; msgIdx++) {
|
||||
const msg = messages[msgIdx];
|
||||
|
||||
// First, add any existing codeBlocks from the message
|
||||
if (msg.codeBlocks && msg.codeBlocks.length > 0) {
|
||||
for (const block of msg.codeBlocks) {
|
||||
snippets.push({
|
||||
id: `${msg.id}-codeblock-${globalIndex}`,
|
||||
block,
|
||||
messageIndex: msgIdx,
|
||||
});
|
||||
globalIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
// Then, extract code blocks from the message content
|
||||
if (msg.content) {
|
||||
const extractedBlocks = extractCodeBlocksFromContent(msg.content);
|
||||
for (const block of extractedBlocks) {
|
||||
snippets.push({
|
||||
id: `${msg.id}-extracted-${globalIndex}`,
|
||||
block,
|
||||
messageIndex: msgIdx,
|
||||
});
|
||||
globalIndex++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return snippets;
|
||||
}, [messages]);
|
||||
|
||||
return (
|
||||
<aside className="w-80 bg-white dark:bg-gray-900 border-l border-gray-200 dark:border-gray-700 flex flex-col flex-shrink-0">
|
||||
{/* 顶部工具栏 */}
|
||||
<div className="h-14 border-b border-gray-100 dark:border-gray-800 flex items-center justify-between px-4 flex-shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-1 text-gray-600 dark:text-gray-300">
|
||||
<BarChart3 className="w-4 h-4" />
|
||||
<span className="font-medium">{messages.length}</span>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">当前消息</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-gray-500 dark:text-gray-400" role="tablist">
|
||||
<Button
|
||||
variant={activeTab === 'status' ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
<aside className="w-full bg-white dark:bg-gray-900 flex flex-col">
|
||||
{/* 顶部工具栏 - Tab 栏 */}
|
||||
<div className="border-b border-gray-200 dark:border-gray-700 flex-shrink-0">
|
||||
{/* 主 Tab 行 */}
|
||||
<div className="flex items-center px-2 pt-2 gap-1">
|
||||
<TabButton
|
||||
active={activeTab === 'status'}
|
||||
onClick={() => setActiveTab('status')}
|
||||
className="flex items-center gap-1 text-xs px-2 py-1"
|
||||
title="Status"
|
||||
aria-label="Status"
|
||||
aria-selected={activeTab === 'status'}
|
||||
role="tab"
|
||||
>
|
||||
<Activity className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant={activeTab === 'files' ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setActiveTab('files')}
|
||||
className="flex items-center gap-1 text-xs px-2 py-1"
|
||||
title="Files"
|
||||
aria-label="Files"
|
||||
aria-selected={activeTab === 'files'}
|
||||
role="tab"
|
||||
>
|
||||
<FileText className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant={activeTab === 'agent' ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
icon={<Activity className="w-4 h-4" />}
|
||||
label="状态"
|
||||
/>
|
||||
<TabButton
|
||||
active={activeTab === 'agent'}
|
||||
onClick={() => setActiveTab('agent')}
|
||||
className="flex items-center gap-1 text-xs px-2 py-1"
|
||||
title="Agent"
|
||||
aria-label="Agent"
|
||||
aria-selected={activeTab === 'agent'}
|
||||
role="tab"
|
||||
>
|
||||
<User className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant={activeTab === 'memory' ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
icon={<User className="w-4 h-4" />}
|
||||
label="Agent"
|
||||
/>
|
||||
<TabButton
|
||||
active={activeTab === 'files'}
|
||||
onClick={() => setActiveTab('files')}
|
||||
icon={<FileText className="w-4 h-4" />}
|
||||
label="文件"
|
||||
/>
|
||||
<TabButton
|
||||
active={activeTab === 'memory'}
|
||||
onClick={() => setActiveTab('memory')}
|
||||
className="flex items-center gap-1 text-xs px-2 py-1"
|
||||
title="Memory"
|
||||
aria-label="Memory"
|
||||
aria-selected={activeTab === 'memory'}
|
||||
role="tab"
|
||||
>
|
||||
<Brain className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant={activeTab === 'reflection' ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
icon={<Brain className="w-4 h-4" />}
|
||||
label="记忆"
|
||||
/>
|
||||
</div>
|
||||
{/* 第二行 Tab */}
|
||||
<div className="flex items-center px-2 pb-2 gap-1">
|
||||
<TabButton
|
||||
active={activeTab === 'reflection'}
|
||||
onClick={() => setActiveTab('reflection')}
|
||||
className="flex items-center gap-1 text-xs px-2 py-1"
|
||||
title="Reflection"
|
||||
aria-label="Reflection"
|
||||
aria-selected={activeTab === 'reflection'}
|
||||
role="tab"
|
||||
>
|
||||
<Sparkles className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant={activeTab === 'autonomy' ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
icon={<Sparkles className="w-4 h-4" />}
|
||||
label="反思"
|
||||
/>
|
||||
<TabButton
|
||||
active={activeTab === 'autonomy'}
|
||||
onClick={() => setActiveTab('autonomy')}
|
||||
className="flex items-center gap-1 text-xs px-2 py-1"
|
||||
title="Autonomy"
|
||||
aria-label="Autonomy"
|
||||
aria-selected={activeTab === 'autonomy'}
|
||||
role="tab"
|
||||
>
|
||||
<Shield className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant={activeTab === 'learning' ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
icon={<Shield className="w-4 h-4" />}
|
||||
label="自主"
|
||||
/>
|
||||
<TabButton
|
||||
active={activeTab === 'learning'}
|
||||
onClick={() => setActiveTab('learning')}
|
||||
className="flex items-center gap-1 text-xs px-2 py-1"
|
||||
title="Learning"
|
||||
aria-label="Learning"
|
||||
aria-selected={activeTab === 'learning'}
|
||||
role="tab"
|
||||
>
|
||||
<GraduationCap className="w-4 h-4" />
|
||||
</Button>
|
||||
icon={<GraduationCap className="w-4 h-4" />}
|
||||
label="学习"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 消息统计 */}
|
||||
<div className="px-4 py-2 border-b border-gray-100 dark:border-gray-800 flex items-center justify-between text-xs">
|
||||
<div className="flex items-center gap-2 text-gray-500 dark:text-gray-400">
|
||||
<BarChart3 className="w-3.5 h-3.5" />
|
||||
<span>{messages.length} 条消息</span>
|
||||
<span className="text-gray-300 dark:text-gray-600">|</span>
|
||||
<span>{userMsgCount} 用户 / {assistantMsgCount} 助手</span>
|
||||
</div>
|
||||
<div className={`flex items-center gap-1 ${connected ? 'text-emerald-500' : 'text-gray-400'}`}>
|
||||
{connected ? <Wifi className="w-3.5 h-3.5" /> : <WifiOff className="w-3.5 h-3.5" />}
|
||||
<span>{runtimeSummary}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar p-4 space-y-4">
|
||||
{activeTab === 'memory' ? (
|
||||
<MemoryPanel />
|
||||
<div className="space-y-3">
|
||||
{/* 视图切换 */}
|
||||
<div className="flex items-center gap-1 p-1 bg-gray-100 dark:bg-gray-800 rounded-lg">
|
||||
<button
|
||||
onClick={() => setMemoryViewMode('list')}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${
|
||||
memoryViewMode === 'list'
|
||||
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 shadow-sm'
|
||||
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
<List className="w-3.5 h-3.5" />
|
||||
列表
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setMemoryViewMode('graph')}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${
|
||||
memoryViewMode === 'graph'
|
||||
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 shadow-sm'
|
||||
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
<Network className="w-3.5 h-3.5" />
|
||||
图谱
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 内容区域 */}
|
||||
{memoryViewMode === 'list' ? (
|
||||
<MemoryPanel />
|
||||
) : (
|
||||
<div className="h-[400px] rounded-lg overflow-hidden border border-gray-200 dark:border-gray-700">
|
||||
<MemoryGraph />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : activeTab === 'reflection' ? (
|
||||
<ReflectionLog />
|
||||
) : activeTab === 'autonomy' ? (
|
||||
@@ -354,90 +457,8 @@ export function RightPanel() {
|
||||
</motion.div>
|
||||
</div>
|
||||
) : activeTab === 'files' ? (
|
||||
<div className="space-y-4">
|
||||
{/* 对话输出文件 */}
|
||||
<motion.div
|
||||
whileHover={cardHover}
|
||||
transition={defaultTransition}
|
||||
className="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-4 shadow-sm"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100 flex items-center gap-2">
|
||||
<FileCode className="w-4 h-4" />
|
||||
对话输出文件
|
||||
</h3>
|
||||
</div>
|
||||
{messages.filter(m => m.files && m.files.length > 0).length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{messages.filter(m => m.files && m.files.length > 0).map((msg, msgIdx) => (
|
||||
<div key={msgIdx} className="space-y-1">
|
||||
{msg.files!.map((file, fileIdx) => (
|
||||
<div
|
||||
key={`${msgIdx}-${fileIdx}`}
|
||||
className="flex items-center gap-2 px-3 py-2 bg-gray-50 dark:bg-gray-700/50 rounded-lg text-sm hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer transition-colors"
|
||||
title={file.path || file.name}
|
||||
>
|
||||
<FileText className="w-4 h-4 text-gray-500 dark:text-gray-400 flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-gray-700 dark:text-gray-200 truncate">{file.name}</div>
|
||||
{file.path && (
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 truncate">{file.path}</div>
|
||||
)}
|
||||
</div>
|
||||
{file.size && (
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 flex-shrink-0">
|
||||
{file.size < 1024 ? `${file.size} B` :
|
||||
file.size < 1024 * 1024 ? `${(file.size / 1024).toFixed(1)} KB` :
|
||||
`${(file.size / (1024 * 1024)).toFixed(1)} MB`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={<FileCode className="w-8 h-8" />}
|
||||
title="No Output Files"
|
||||
description="Files will appear here when AI uses tools"
|
||||
className="py-4"
|
||||
/>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
{/* 代码块 */}
|
||||
<motion.div
|
||||
whileHover={cardHover}
|
||||
transition={defaultTransition}
|
||||
className="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-4 shadow-sm"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100">代码片段</h3>
|
||||
</div>
|
||||
{messages.filter(m => m.codeBlocks && m.codeBlocks.length > 0).length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{messages.filter(m => m.codeBlocks && m.codeBlocks.length > 0).flatMap((msg, msgIdx) =>
|
||||
msg.codeBlocks!.map((block, blockIdx) => (
|
||||
<div
|
||||
key={`${msgIdx}-${blockIdx}`}
|
||||
className="px-3 py-2 bg-gray-50 dark:bg-gray-700/50 rounded-lg text-sm"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Badge variant="default">{block.language || 'code'}</Badge>
|
||||
<span className="text-gray-700 dark:text-gray-200 truncate">{block.filename || 'Untitled'}</span>
|
||||
</div>
|
||||
<pre className="text-xs text-gray-500 dark:text-gray-400 overflow-x-auto max-h-20">
|
||||
{block.content?.slice(0, 200)}{block.content && block.content.length > 200 ? '...' : ''}
|
||||
</pre>
|
||||
</div>
|
||||
))
|
||||
).slice(0, 5)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 text-center py-4">No code snippets in conversation</p>
|
||||
)}
|
||||
</motion.div>
|
||||
<div className="p-4">
|
||||
<CodeSnippetPanel snippets={codeSnippets} />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useState, useEffect, useCallback } from 'react';
|
||||
import { useGatewayStore, type Workflow } from '../store/gatewayStore';
|
||||
import { WorkflowEditor } from './WorkflowEditor';
|
||||
import { WorkflowHistory } from './WorkflowHistory';
|
||||
import { TriggersPanel } from './TriggersPanel';
|
||||
import {
|
||||
Clock,
|
||||
Zap,
|
||||
@@ -661,11 +662,6 @@ export function SchedulerPanel() {
|
||||
setIsCreateModalOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleCreateTrigger = useCallback(() => {
|
||||
// TODO: Implement trigger creation modal
|
||||
alert('事件触发器创建功能即将推出!');
|
||||
}, []);
|
||||
|
||||
const handleCreateSuccess = useCallback(() => {
|
||||
loadScheduledTasks();
|
||||
}, [loadScheduledTasks]);
|
||||
@@ -862,15 +858,7 @@ export function SchedulerPanel() {
|
||||
)}
|
||||
|
||||
{activeTab === 'triggers' && (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
||||
<EmptyState
|
||||
icon={Zap}
|
||||
title="暂无事件触发器"
|
||||
description="事件触发器在系统事件(如收到消息、文件更改或 API webhook)发生时触发代理执行。"
|
||||
actionLabel="创建事件触发器"
|
||||
onAction={handleCreateTrigger}
|
||||
/>
|
||||
</div>
|
||||
<TriggersPanel />
|
||||
)}
|
||||
|
||||
{/* Workflows Tab */}
|
||||
|
||||
@@ -1,36 +1,86 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { getStoredGatewayToken, getStoredGatewayUrl } from '../../lib/gateway-client';
|
||||
import { useGatewayStore } from '../../store/gatewayStore';
|
||||
import { useChatStore } from '../../store/chatStore';
|
||||
import { silentErrorHandler } from '../../lib/error-utils';
|
||||
import { Plus, Pencil, Trash2, Star, Eye, EyeOff, AlertCircle, X } from 'lucide-react';
|
||||
|
||||
// Helper function to format context window size
|
||||
function formatContextWindow(tokens?: number): string {
|
||||
if (!tokens) return '';
|
||||
if (tokens >= 1000000) {
|
||||
return `${(tokens / 1000000).toFixed(1)}M`;
|
||||
// 自定义模型数据结构
|
||||
interface CustomModel {
|
||||
id: string;
|
||||
name: string;
|
||||
provider: string;
|
||||
apiKey?: string;
|
||||
apiProtocol: 'openai' | 'anthropic' | 'custom';
|
||||
baseUrl?: string;
|
||||
isDefault?: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
// 可用的 Provider 列表
|
||||
const AVAILABLE_PROVIDERS = [
|
||||
{ id: 'zhipu', name: '智谱 (ZhipuAI)', baseUrl: 'https://open.bigmodel.cn/api/paas/v4' },
|
||||
{ id: 'qwen', name: '百炼/通义千问 (Qwen)', baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1' },
|
||||
{ id: 'kimi', name: 'Kimi (Moonshot)', baseUrl: 'https://api.moonshot.cn/v1' },
|
||||
{ id: 'minimax', name: 'MiniMax', baseUrl: 'https://api.minimax.chat/v1' },
|
||||
{ id: 'deepseek', name: 'DeepSeek', baseUrl: 'https://api.deepseek.com/v1' },
|
||||
{ id: 'openai', name: 'OpenAI', baseUrl: 'https://api.openai.com/v1' },
|
||||
{ id: 'custom', name: '自定义', baseUrl: '' },
|
||||
];
|
||||
|
||||
const STORAGE_KEY = 'zclaw-custom-models';
|
||||
|
||||
// 从 localStorage 加载自定义模型
|
||||
function loadCustomModels(): CustomModel[] {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored) {
|
||||
return JSON.parse(stored);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
if (tokens >= 1000) {
|
||||
return `${(tokens / 1000).toFixed(0)}K`;
|
||||
return [];
|
||||
}
|
||||
|
||||
// 保存自定义模型到 localStorage
|
||||
function saveCustomModels(models: CustomModel[]): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(models));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return `${tokens}`;
|
||||
}
|
||||
|
||||
export function ModelsAPI() {
|
||||
const { connectionState, connect, disconnect, quickConfig, saveQuickConfig, models, modelsLoading, modelsError, loadModels } = useGatewayStore();
|
||||
const { connectionState, connect, disconnect, quickConfig, loadModels } = useGatewayStore();
|
||||
const { currentModel, setCurrentModel } = useChatStore();
|
||||
const [gatewayUrl, setGatewayUrl] = useState(getStoredGatewayUrl());
|
||||
const [gatewayToken, setGatewayToken] = useState(quickConfig.gatewayToken || getStoredGatewayToken());
|
||||
|
||||
// 自定义模型状态
|
||||
const [customModels, setCustomModels] = useState<CustomModel[]>([]);
|
||||
const [showAddModal, setShowAddModal] = useState(false);
|
||||
const [editingModel, setEditingModel] = useState<CustomModel | null>(null);
|
||||
const [showApiKey, setShowApiKey] = useState(false);
|
||||
|
||||
// 表单状态
|
||||
const [formData, setFormData] = useState({
|
||||
provider: 'zhipu',
|
||||
modelId: '',
|
||||
displayName: '',
|
||||
apiKey: '',
|
||||
apiProtocol: 'openai' as 'openai' | 'anthropic' | 'custom',
|
||||
baseUrl: '',
|
||||
});
|
||||
|
||||
const connected = connectionState === 'connected';
|
||||
const connecting = connectionState === 'connecting' || connectionState === 'reconnecting';
|
||||
|
||||
// Load models when connected
|
||||
// 加载自定义模型
|
||||
useEffect(() => {
|
||||
if (connected && models.length === 0 && !modelsLoading) {
|
||||
loadModels();
|
||||
}
|
||||
}, [connected, models.length, modelsLoading, loadModels]);
|
||||
setCustomModels(loadCustomModels());
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setGatewayUrl(quickConfig.gatewayUrl || getStoredGatewayUrl());
|
||||
@@ -45,196 +95,335 @@ export function ModelsAPI() {
|
||||
).catch(silentErrorHandler('ModelsAPI')), 500);
|
||||
};
|
||||
|
||||
const handleSaveGatewaySettings = () => {
|
||||
saveQuickConfig({
|
||||
gatewayUrl,
|
||||
gatewayToken,
|
||||
}).catch(silentErrorHandler('ModelsAPI'));
|
||||
// 打开添加模型弹窗
|
||||
const handleOpenAddModal = () => {
|
||||
setFormData({
|
||||
provider: 'zhipu',
|
||||
modelId: '',
|
||||
displayName: '',
|
||||
apiKey: '',
|
||||
apiProtocol: 'openai',
|
||||
baseUrl: AVAILABLE_PROVIDERS[0].baseUrl,
|
||||
});
|
||||
setEditingModel(null);
|
||||
setShowAddModal(true);
|
||||
};
|
||||
|
||||
const handleRefreshModels = () => {
|
||||
// 打开编辑模型弹窗
|
||||
const handleOpenEditModal = (model: CustomModel) => {
|
||||
setFormData({
|
||||
provider: model.provider,
|
||||
modelId: model.id,
|
||||
displayName: model.name,
|
||||
apiKey: model.apiKey || '',
|
||||
apiProtocol: model.apiProtocol,
|
||||
baseUrl: model.baseUrl || '',
|
||||
});
|
||||
setEditingModel(model);
|
||||
setShowAddModal(true);
|
||||
};
|
||||
|
||||
// 保存模型
|
||||
const handleSaveModel = () => {
|
||||
if (!formData.modelId.trim()) return;
|
||||
|
||||
const newModel: CustomModel = {
|
||||
id: formData.modelId.trim(),
|
||||
name: formData.displayName.trim() || formData.modelId.trim(),
|
||||
provider: formData.provider,
|
||||
apiKey: formData.apiKey.trim(),
|
||||
apiProtocol: formData.apiProtocol,
|
||||
baseUrl: formData.baseUrl.trim() || AVAILABLE_PROVIDERS.find(p => p.id === formData.provider)?.baseUrl,
|
||||
createdAt: editingModel?.createdAt || new Date().toISOString(),
|
||||
};
|
||||
|
||||
let updatedModels: CustomModel[];
|
||||
if (editingModel) {
|
||||
// 编辑模式
|
||||
updatedModels = customModels.map(m => m.id === editingModel.id ? newModel : m);
|
||||
} else {
|
||||
// 添加模式
|
||||
updatedModels = [...customModels, newModel];
|
||||
}
|
||||
|
||||
setCustomModels(updatedModels);
|
||||
saveCustomModels(updatedModels);
|
||||
setShowAddModal(false);
|
||||
setEditingModel(null);
|
||||
|
||||
// 刷新模型列表
|
||||
loadModels();
|
||||
};
|
||||
|
||||
// 删除模型
|
||||
const handleDeleteModel = (modelId: string) => {
|
||||
const updatedModels = customModels.filter(m => m.id !== modelId);
|
||||
setCustomModels(updatedModels);
|
||||
saveCustomModels(updatedModels);
|
||||
};
|
||||
|
||||
// 设为默认模型
|
||||
const handleSetDefault = (modelId: string) => {
|
||||
setCurrentModel(modelId);
|
||||
// 更新自定义模型的默认状态
|
||||
const updatedModels = customModels.map(m => ({
|
||||
...m,
|
||||
isDefault: m.id === modelId,
|
||||
}));
|
||||
setCustomModels(updatedModels);
|
||||
saveCustomModels(updatedModels);
|
||||
};
|
||||
|
||||
// Provider 变更时更新 baseUrl
|
||||
const handleProviderChange = (providerId: string) => {
|
||||
const provider = AVAILABLE_PROVIDERS.find(p => p.id === providerId);
|
||||
setFormData({
|
||||
...formData,
|
||||
provider: providerId,
|
||||
baseUrl: provider?.baseUrl || '',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-xl font-bold text-gray-900">模型与 API</h1>
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white">模型与 API</h1>
|
||||
<button
|
||||
onClick={handleReconnect}
|
||||
disabled={connecting}
|
||||
className="text-xs text-gray-500 hover:text-gray-700 px-3 py-1.5 border border-gray-200 rounded-lg transition-colors disabled:opacity-50"
|
||||
className="text-xs text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 px-3 py-1.5 border border-gray-200 dark:border-gray-700 rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
{connecting ? '连接中...' : '重新连接'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Gateway 连接状态 */}
|
||||
<div className="mb-6">
|
||||
<h3 className="text-xs font-semibold text-gray-500 mb-3 uppercase tracking-wider">当前会话模型</h3>
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-4 shadow-sm space-y-2">
|
||||
<h3 className="text-xs font-semibold text-gray-500 dark:text-gray-400 mb-3 uppercase tracking-wider">Gateway 连接</h3>
|
||||
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl p-4 shadow-sm space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-500">当前选择</span>
|
||||
<span className="text-sm font-medium text-orange-600">{currentModel}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-500">Gateway 状态</span>
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">连接状态</span>
|
||||
<span className={`text-sm ${connected ? 'text-green-600' : connecting ? 'text-yellow-600' : 'text-gray-400'}`}>
|
||||
{connected ? '已连接' : connecting ? '连接中...' : '未连接'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">当前模型</span>
|
||||
<span className="text-sm font-medium text-orange-600">{currentModel || '未选择'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 内置模型 */}
|
||||
<div className="mb-6">
|
||||
<h3 className="text-xs font-semibold text-gray-500 dark:text-gray-400 mb-3 uppercase tracking-wider">内置模型</h3>
|
||||
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl p-4 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">ZCLAW 默认模型</span>
|
||||
<span className="text-xs text-gray-400">由 Gateway 配置决定</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 自定义模型 */}
|
||||
<div className="mb-6">
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider">可选模型</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-gray-400">切换后用于新的桌面对话请求</span>
|
||||
{connected && (
|
||||
<button
|
||||
onClick={handleRefreshModels}
|
||||
disabled={modelsLoading}
|
||||
className="text-xs text-orange-600 hover:text-orange-700 disabled:opacity-50"
|
||||
>
|
||||
{modelsLoading ? '加载中...' : '刷新'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<h3 className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">自定义模型</h3>
|
||||
<button
|
||||
onClick={handleOpenAddModal}
|
||||
className="text-xs text-orange-600 hover:text-orange-700 flex items-center gap-1"
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
添加自定义模型
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Loading state */}
|
||||
{modelsLoading && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-8 shadow-sm">
|
||||
<div className="flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-orange-500"></div>
|
||||
<span className="ml-3 text-sm text-gray-500">正在加载模型列表...</span>
|
||||
</div>
|
||||
{customModels.length === 0 ? (
|
||||
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl p-6 shadow-sm text-center">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">暂无自定义模型</p>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">点击上方按钮添加你的第一个自定义模型</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error state */}
|
||||
{modelsError && !modelsLoading && (
|
||||
<div className="bg-white rounded-xl border border-red-200 p-4 shadow-sm">
|
||||
<div className="flex items-start gap-3">
|
||||
<svg className="w-5 h-5 text-red-500 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-red-800">加载模型列表失败</p>
|
||||
<p className="text-xs text-red-600 mt-1">{modelsError}</p>
|
||||
<button
|
||||
onClick={handleRefreshModels}
|
||||
className="mt-2 text-xs text-red-600 hover:text-red-700 underline"
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Not connected state */}
|
||||
{!connected && !modelsLoading && !modelsError && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6 shadow-sm">
|
||||
<div className="text-center">
|
||||
<svg className="w-8 h-8 text-gray-300 mx-auto mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
<p className="text-sm text-gray-500">请先连接 Gateway 以获取可用模型列表</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Model list */}
|
||||
{connected && !modelsLoading && !modelsError && models.length > 0 && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 divide-y divide-gray-100 shadow-sm">
|
||||
{models.map((model) => {
|
||||
const isActive = model.id === currentModel;
|
||||
return (
|
||||
<div key={model.id} className={`flex justify-between items-center p-4 ${isActive ? 'bg-orange-50/50' : ''}`}>
|
||||
<div>
|
||||
<div className="text-sm text-gray-900">{model.name}</div>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
{model.provider && (
|
||||
<span className="text-xs text-gray-400">{model.provider}</span>
|
||||
)}
|
||||
{model.contextWindow && (
|
||||
<span className="text-xs text-gray-400">
|
||||
{model.provider && '|'}
|
||||
上下文 {formatContextWindow(model.contextWindow)}
|
||||
</span>
|
||||
)}
|
||||
{model.maxOutput && (
|
||||
<span className="text-xs text-gray-400">
|
||||
最大输出 {formatContextWindow(model.maxOutput)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 text-xs items-center">
|
||||
{isActive ? (
|
||||
<span className="px-2 py-1 bg-green-100 text-green-700 rounded text-xs">当前选择</span>
|
||||
) : (
|
||||
<button onClick={() => setCurrentModel(model.id)} className="text-orange-600 hover:underline">切换到此模型</button>
|
||||
) : (
|
||||
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl divide-y divide-gray-100 dark:divide-gray-700 shadow-sm">
|
||||
{customModels.map((model) => (
|
||||
<div
|
||||
key={model.id}
|
||||
className={`flex justify-between items-center p-4 ${currentModel === model.id ? 'bg-orange-50/50 dark:bg-orange-900/10' : ''}`}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">{model.name}</span>
|
||||
{currentModel === model.id && (
|
||||
<span className="px-1.5 py-0.5 text-xs bg-orange-100 dark:bg-orange-900/30 text-orange-600 dark:text-orange-400 rounded">当前</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 dark:text-gray-500 mt-0.5">
|
||||
{AVAILABLE_PROVIDERS.find(p => p.id === model.provider)?.name || model.provider}
|
||||
{model.apiKey ? ' · 已配置 API Key' : ' · 未配置 API Key'}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
{currentModel !== model.id && (
|
||||
<button
|
||||
onClick={() => handleSetDefault(model.id)}
|
||||
className="text-orange-600 hover:underline flex items-center gap-1"
|
||||
>
|
||||
<Star className="w-3 h-3" />
|
||||
设为默认
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleOpenEditModal(model)}
|
||||
className="text-gray-500 dark:text-gray-400 hover:underline flex items-center gap-1"
|
||||
>
|
||||
<Pencil className="w-3 h-3" />
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteModel(model.id)}
|
||||
className="text-red-500 hover:underline flex items-center gap-1"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Empty state */}
|
||||
{connected && !modelsLoading && !modelsError && models.length === 0 && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6 shadow-sm">
|
||||
<div className="text-center">
|
||||
<svg className="w-8 h-8 text-gray-300 mx-auto mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M12 20a8 8 0 100-16 8 8 0 000 16z" />
|
||||
</svg>
|
||||
<p className="text-sm text-gray-500">暂无可用模型</p>
|
||||
<p className="text-xs text-gray-400 mt-1">请检查 Gateway 配置或 Provider 设置</p>
|
||||
{/* 添加/编辑模型弹窗 */}
|
||||
{showAddModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-black/50" onClick={() => setShowAddModal(false)} />
|
||||
<div className="relative bg-white dark:bg-gray-800 rounded-2xl shadow-2xl w-full max-w-md max-h-[90vh] overflow-y-auto">
|
||||
{/* 弹窗头部 */}
|
||||
<div className="sticky top-0 bg-white dark:bg-gray-800 border-b border-gray-100 dark:border-gray-700 p-6 flex justify-between items-center z-10">
|
||||
<h3 className="text-lg font-bold text-gray-900 dark:text-white">
|
||||
{editingModel ? '编辑模型' : '添加模型'}
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setShowAddModal(false)}
|
||||
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 弹窗内容 */}
|
||||
<div className="p-6 space-y-4">
|
||||
{/* 警告提示 */}
|
||||
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-100 dark:border-yellow-800 rounded-lg p-3 text-xs text-yellow-800 dark:text-yellow-200 flex items-start gap-2">
|
||||
<AlertCircle className="w-4 h-4 flex-shrink-0 mt-0.5" />
|
||||
<span>添加外部模型即表示你理解并同意自行承担使用风险。</span>
|
||||
</div>
|
||||
|
||||
{/* 服务商 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">* 服务商</label>
|
||||
<select
|
||||
value={formData.provider}
|
||||
onChange={(e) => handleProviderChange(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-200 dark:border-gray-600 rounded-lg text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-orange-500"
|
||||
>
|
||||
{AVAILABLE_PROVIDERS.map((p) => (
|
||||
<option key={p.id} value={p.id}>{p.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 模型 ID */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">* 模型 ID</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.modelId}
|
||||
onChange={(e) => setFormData({ ...formData, modelId: e.target.value })}
|
||||
placeholder="如:glm-4-plus"
|
||||
className="w-full px-3 py-2 border border-gray-200 dark:border-gray-600 rounded-lg text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-orange-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 显示名称 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">显示名称</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.displayName}
|
||||
onChange={(e) => setFormData({ ...formData, displayName: e.target.value })}
|
||||
placeholder="如:GLM-4-Plus"
|
||||
className="w-full px-3 py-2 border border-gray-200 dark:border-gray-600 rounded-lg text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-orange-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* API Key */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">API Key</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showApiKey ? 'text' : 'password'}
|
||||
value={formData.apiKey}
|
||||
onChange={(e) => setFormData({ ...formData, apiKey: e.target.value })}
|
||||
placeholder="请填写 API Key"
|
||||
className="w-full px-3 py-2 pr-10 border border-gray-200 dark:border-gray-600 rounded-lg text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-orange-500"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowApiKey(!showApiKey)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
{showApiKey ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* API 协议 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">API 协议</label>
|
||||
<select
|
||||
value={formData.apiProtocol}
|
||||
onChange={(e) => setFormData({ ...formData, apiProtocol: e.target.value as 'openai' | 'anthropic' | 'custom' })}
|
||||
className="w-full px-3 py-2 border border-gray-200 dark:border-gray-600 rounded-lg text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-orange-500"
|
||||
>
|
||||
<option value="openai">OpenAI</option>
|
||||
<option value="anthropic">Anthropic</option>
|
||||
<option value="custom">自定义</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Base URL */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Base URL</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.baseUrl}
|
||||
onChange={(e) => setFormData({ ...formData, baseUrl: e.target.value })}
|
||||
placeholder="https://api.example.com/v1"
|
||||
className="w-full px-3 py-2 border border-gray-200 dark:border-gray-600 rounded-lg text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-orange-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 弹窗底部 */}
|
||||
<div className="sticky bottom-0 bg-white dark:bg-gray-800 border-t border-gray-100 dark:border-gray-700 p-6 flex justify-end gap-3">
|
||||
<button
|
||||
onClick={() => setShowAddModal(false)}
|
||||
className="px-4 py-2 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg text-sm"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSaveModel}
|
||||
disabled={!formData.modelId.trim()}
|
||||
className="px-4 py-2 bg-orange-500 text-white rounded-lg text-sm hover:bg-orange-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{editingModel ? '保存' : '添加'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-3 text-xs text-amber-700 bg-amber-50 rounded-lg p-3">
|
||||
当前页面只支持切换桌面端可选模型与维护 Gateway 连接信息,Provider Key、自定义模型增删改尚未在此页面接入。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-gray-900">Gateway URL</span>
|
||||
<span className={`px-2 py-0.5 rounded text-xs border ${connected ? 'bg-green-50 text-green-600 border-green-100' : 'bg-red-50 text-red-600 border-red-100'}`}>
|
||||
{connected ? '已连接' : connecting ? '连接中...' : '未连接'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={handleReconnect} className="text-xs text-gray-500 hover:text-gray-700 px-3 py-1.5 border border-gray-200 rounded-lg transition-colors">
|
||||
重新连接
|
||||
</button>
|
||||
<button onClick={handleSaveGatewaySettings} className="text-xs text-white bg-orange-500 hover:bg-orange-600 px-3 py-1.5 rounded-lg transition-colors">
|
||||
保存连接设置
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3 bg-gray-50 border border-gray-200 rounded-xl p-3 text-xs text-gray-600 font-mono shadow-sm">
|
||||
<input
|
||||
type="text"
|
||||
value={gatewayUrl}
|
||||
onChange={(e) => setGatewayUrl(e.target.value)}
|
||||
onBlur={() => { saveQuickConfig({ gatewayUrl }).catch(silentErrorHandler('ModelsAPI')); }}
|
||||
className="w-full bg-transparent border-none outline-none"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
value={gatewayToken}
|
||||
onChange={(e) => setGatewayToken(e.target.value)}
|
||||
onBlur={() => { saveQuickConfig({ gatewayToken }).catch(silentErrorHandler('ModelsAPI')); }}
|
||||
placeholder="Gateway auth token"
|
||||
className="w-full bg-transparent border-none outline-none"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,33 +1,35 @@
|
||||
import { useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Settings, Users, Bot, GitBranch, MessageSquare, Layers, Package } from 'lucide-react';
|
||||
import {
|
||||
Users, Bot, Zap, Layers, Package,
|
||||
Search, Sparkles, ChevronRight, X
|
||||
} from 'lucide-react';
|
||||
import { CloneManager } from './CloneManager';
|
||||
import { HandList } from './HandList';
|
||||
import { WorkflowList } from './WorkflowList';
|
||||
import { TeamList } from './TeamList';
|
||||
import { SwarmDashboard } from './SwarmDashboard';
|
||||
import { SkillMarket } from './SkillMarket';
|
||||
import { useGatewayStore } from '../store/gatewayStore';
|
||||
import { Button } from './ui';
|
||||
import { containerVariants, defaultTransition } from '../lib/animations';
|
||||
|
||||
export type MainViewType = 'chat' | 'hands' | 'workflow' | 'team' | 'swarm' | 'skills';
|
||||
export type MainViewType = 'chat' | 'automation' | 'team' | 'swarm' | 'skills';
|
||||
|
||||
interface SidebarProps {
|
||||
onOpenSettings?: () => void;
|
||||
onMainViewChange?: (view: MainViewType) => void;
|
||||
selectedHandId?: string;
|
||||
onSelectHand?: (handId: string) => void;
|
||||
selectedTeamId?: string;
|
||||
onSelectTeam?: (teamId: string) => void;
|
||||
onNewChat?: () => void;
|
||||
}
|
||||
|
||||
type Tab = 'clones' | 'hands' | 'workflow' | 'team' | 'swarm' | 'skills';
|
||||
type Tab = 'chat' | 'clones' | 'automation' | 'team' | 'swarm' | 'skills';
|
||||
|
||||
const TABS: { key: Tab; label: string; icon: React.ComponentType<{ className?: string }>; mainView?: MainViewType }[] = [
|
||||
// 导航项配置 - WorkBuddy 风格
|
||||
const NAV_ITEMS: {
|
||||
key: Tab;
|
||||
label: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
mainView?: MainViewType;
|
||||
}[] = [
|
||||
{ key: 'clones', label: '分身', icon: Bot },
|
||||
{ key: 'hands', label: 'Hands', icon: MessageSquare, mainView: 'hands' },
|
||||
{ key: 'workflow', label: '工作流', icon: GitBranch, mainView: 'workflow' },
|
||||
{ key: 'automation', label: '自动化', icon: Zap, mainView: 'automation' },
|
||||
{ key: 'skills', label: '技能', icon: Package, mainView: 'skills' },
|
||||
{ key: 'team', label: '团队', icon: Users, mainView: 'team' },
|
||||
{ key: 'swarm', label: '协作', icon: Layers, mainView: 'swarm' },
|
||||
@@ -36,15 +38,15 @@ const TABS: { key: Tab; label: string; icon: React.ComponentType<{ className?: s
|
||||
export function Sidebar({
|
||||
onOpenSettings,
|
||||
onMainViewChange,
|
||||
selectedHandId,
|
||||
onSelectHand,
|
||||
selectedTeamId,
|
||||
onSelectTeam
|
||||
onSelectTeam,
|
||||
onNewChat
|
||||
}: SidebarProps) {
|
||||
const [activeTab, setActiveTab] = useState<Tab>('clones');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const userName = useGatewayStore((state) => state.quickConfig.userName) || '用户7141';
|
||||
|
||||
const handleTabClick = (key: Tab, mainView?: MainViewType) => {
|
||||
const handleNavClick = (key: Tab, mainView?: MainViewType) => {
|
||||
setActiveTab(key);
|
||||
if (mainView && onMainViewChange) {
|
||||
onMainViewChange(mainView);
|
||||
@@ -53,12 +55,6 @@ export function Sidebar({
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectHand = (handId: string) => {
|
||||
onSelectHand?.(handId);
|
||||
setActiveTab('hands');
|
||||
onMainViewChange?.('hands');
|
||||
};
|
||||
|
||||
const handleSelectTeam = (teamId: string) => {
|
||||
onSelectTeam?.(teamId);
|
||||
setActiveTab('team');
|
||||
@@ -66,30 +62,68 @@ export function Sidebar({
|
||||
};
|
||||
|
||||
return (
|
||||
<aside className="w-64 bg-gray-50 dark:bg-gray-900 border-r border-gray-200 dark:border-gray-700 flex flex-col flex-shrink-0">
|
||||
{/* 顶部标签 - 使用图标 */}
|
||||
<div className="flex border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800" role="tablist">
|
||||
{TABS.map(({ key, label, icon: Icon }) => (
|
||||
<button
|
||||
key={key}
|
||||
title={label}
|
||||
aria-label={label}
|
||||
aria-selected={activeTab === key}
|
||||
role="tab"
|
||||
className={`flex-1 py-2.5 px-2 text-xs font-medium transition-colors flex flex-col items-center gap-0.5 ${
|
||||
activeTab === key
|
||||
? 'text-blue-600 dark:text-blue-400 border-b-2 border-blue-500 bg-blue-50 dark:bg-blue-900/20'
|
||||
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700/50'
|
||||
}`}
|
||||
onClick={() => handleTabClick(key, TABS.find(t => t.key === key)?.mainView)}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
<span className="text-[10px]">{label}</span>
|
||||
</button>
|
||||
))}
|
||||
<aside className="w-64 bg-white dark:bg-gray-900 border-r border-gray-200 dark:border-gray-700 flex flex-col flex-shrink-0">
|
||||
{/* 搜索框 */}
|
||||
<div className="p-3 border-b border-gray-100 dark:border-gray-800">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 w-4 h-4" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-9 pr-8 py-2 bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg text-sm focus:outline-none focus:border-gray-400 focus:ring-1 focus:ring-gray-400 transition-all text-gray-700 dark:text-gray-300 placeholder-gray-400"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
onClick={() => setSearchQuery('')}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 hover:bg-gray-200 dark:hover:bg-gray-700 rounded text-gray-400 transition-colors"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
{/* 新对话按钮 */}
|
||||
<div className="px-3 py-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
setActiveTab('clones');
|
||||
onNewChat?.();
|
||||
}}
|
||||
className="w-full flex items-center gap-3 px-3 py-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg text-gray-700 dark:text-gray-300 transition-colors group"
|
||||
>
|
||||
<Sparkles className="w-5 h-5 text-gray-500" />
|
||||
<span className="font-medium">新对话</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 导航项 */}
|
||||
<nav className="px-3 space-y-0.5">
|
||||
{NAV_ITEMS.map(({ key, label, icon: Icon, mainView }) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => handleNavClick(key, mainView)}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2 rounded-lg transition-colors ${
|
||||
activeTab === key
|
||||
? 'bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-100 font-medium'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800/50 hover:text-gray-900 dark:hover:text-gray-200'
|
||||
}`}
|
||||
>
|
||||
<Icon className={`w-5 h-5 ${activeTab === key ? 'text-gray-700 dark:text-gray-300' : 'text-gray-400'}`} />
|
||||
<span>{label}</span>
|
||||
{activeTab === key && (
|
||||
<ChevronRight className="w-4 h-4 ml-auto text-gray-400" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* 分隔线 */}
|
||||
<div className="my-3 mx-3 border-t border-gray-100 dark:border-gray-800" />
|
||||
|
||||
{/* 内容区域 - 只显示分身、团队、协作的内容,自动化和技能在主内容区显示 */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
@@ -99,45 +133,34 @@ export function Sidebar({
|
||||
animate="visible"
|
||||
exit="exit"
|
||||
transition={defaultTransition}
|
||||
className="h-full"
|
||||
className="h-full overflow-y-auto"
|
||||
>
|
||||
{activeTab === 'clones' && <CloneManager />}
|
||||
{activeTab === 'hands' && (
|
||||
<HandList
|
||||
selectedHandId={selectedHandId}
|
||||
onSelectHand={handleSelectHand}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'workflow' && <WorkflowList />}
|
||||
{activeTab === 'skills' && <SkillMarket />}
|
||||
{/* skills、automation 和 swarm 不在侧边栏显示内容,由主内容区显示 */}
|
||||
{activeTab === 'team' && (
|
||||
<TeamList
|
||||
selectedTeamId={selectedTeamId}
|
||||
onSelectTeam={handleSelectTeam}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'swarm' && <SwarmDashboard />}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* 底部用户 */}
|
||||
<div className="p-3 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 bg-gradient-to-br from-orange-400 to-red-500 rounded-full flex items-center justify-center text-white text-xs font-bold flex-shrink-0">
|
||||
{/* 底部用户栏 */}
|
||||
<div className="p-3 border-t border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
onClick={onOpenSettings}
|
||||
className="flex items-center gap-3 w-full hover:bg-gray-50 dark:hover:bg-gray-800 p-2 rounded-lg transition-colors"
|
||||
>
|
||||
<div className="w-8 h-8 bg-gray-600 rounded-full flex items-center justify-center text-white font-bold shadow-sm">
|
||||
{userName?.charAt(0) || '用'}
|
||||
</div>
|
||||
<span className="font-medium text-gray-700 dark:text-gray-300 truncate">{userName}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="ml-auto p-1.5"
|
||||
onClick={onOpenSettings}
|
||||
aria-label="打开设置"
|
||||
>
|
||||
<Settings className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<span className="flex-1 text-left text-sm font-medium text-gray-700 dark:text-gray-300 truncate">
|
||||
{userName}
|
||||
</span>
|
||||
<ChevronRight className="w-4 h-4 text-gray-400" />
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
|
||||
@@ -225,7 +225,7 @@ function SkillCard({
|
||||
e.stopPropagation();
|
||||
onInstall();
|
||||
}}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-blue-500 hover:bg-blue-600 text-white rounded-lg transition-colors"
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-gray-700 dark:bg-gray-600 hover:bg-gray-800 dark:hover:bg-gray-500 text-white rounded-lg transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
安装
|
||||
@@ -401,7 +401,7 @@ export function SkillMarket({
|
||||
value={searchQuery}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
placeholder="搜索技能、能力、触发词..."
|
||||
className="w-full pl-9 pr-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-purple-500 focus:border-transparent text-sm"
|
||||
className="w-full pl-9 pr-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-gray-400 focus:border-transparent text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -120,7 +120,7 @@ export function SkillCard({
|
||||
px-3 py-1.5 rounded-lg text-xs font-medium transition-all duration-200
|
||||
${skill.installed
|
||||
? 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600'
|
||||
: 'bg-blue-500 text-white hover:bg-blue-600'
|
||||
: 'bg-gray-700 dark:bg-gray-600 text-white hover:bg-gray-800 dark:hover:bg-gray-500'
|
||||
}
|
||||
`}
|
||||
>
|
||||
|
||||
@@ -241,7 +241,7 @@ function TaskCard({
|
||||
<div
|
||||
className={`border rounded-lg overflow-hidden transition-all ${
|
||||
isSelected
|
||||
? 'border-blue-500 dark:border-blue-400 ring-2 ring-blue-500/20'
|
||||
? 'border-orange-500 dark:border-orange-400 ring-2 ring-orange-500/20'
|
||||
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
|
||||
}`}
|
||||
>
|
||||
@@ -349,7 +349,7 @@ function CreateTaskForm({
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="描述需要协作完成的任务..."
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent text-sm"
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-orange-500 focus:border-transparent text-sm"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
@@ -369,7 +369,7 @@ function CreateTaskForm({
|
||||
onClick={() => setStyle(s)}
|
||||
className={`flex flex-col items-center gap-1 p-2 rounded-lg border transition-all ${
|
||||
style === s
|
||||
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400'
|
||||
? 'border-orange-500 bg-orange-50 dark:bg-orange-900/30 text-orange-600 dark:text-orange-400'
|
||||
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600 text-gray-600 dark:text-gray-400'
|
||||
}`}
|
||||
>
|
||||
@@ -392,7 +392,7 @@ function CreateTaskForm({
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!description.trim()}
|
||||
className="px-4 py-1.5 text-sm bg-blue-500 hover:bg-blue-600 disabled:bg-gray-300 disabled:cursor-not-allowed text-white rounded-lg transition-colors flex items-center gap-1.5"
|
||||
className="px-4 py-1.5 text-sm bg-orange-500 hover:bg-orange-600 disabled:bg-gray-300 disabled:cursor-not-allowed text-white rounded-lg transition-colors flex items-center gap-1.5"
|
||||
>
|
||||
<Sparkles className="w-4 h-4" />
|
||||
创建任务
|
||||
@@ -477,7 +477,7 @@ export function SwarmDashboard({ className = '', onTaskSelect }: SwarmDashboardP
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="w-5 h-5 text-blue-500" />
|
||||
<Users className="w-5 h-5 text-orange-500" />
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">协作任务</h2>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -491,7 +491,7 @@ export function SwarmDashboard({ className = '', onTaskSelect }: SwarmDashboardP
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowCreateForm((prev) => !prev)}
|
||||
className="flex items-center gap-1 px-3 py-1.5 text-sm bg-blue-500 hover:bg-blue-600 text-white rounded-lg transition-colors"
|
||||
className="flex items-center gap-1 px-3 py-1.5 text-sm bg-orange-500 hover:bg-orange-600 text-white rounded-lg transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
新建
|
||||
@@ -525,7 +525,7 @@ export function SwarmDashboard({ className = '', onTaskSelect }: SwarmDashboardP
|
||||
onClick={() => setFilter(f)}
|
||||
className={`px-3 py-1 text-xs rounded-full transition-colors ${
|
||||
filter === f
|
||||
? 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400'
|
||||
? 'bg-orange-100 dark:bg-orange-900/30 text-orange-700 dark:text-orange-400'
|
||||
: 'text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800'
|
||||
}`}
|
||||
>
|
||||
@@ -567,7 +567,7 @@ export function SwarmDashboard({ className = '', onTaskSelect }: SwarmDashboardP
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setShowCreateForm(true)}
|
||||
className="mt-2 text-blue-500 hover:text-blue-600 text-sm"
|
||||
className="mt-2 text-orange-500 hover:text-orange-600 text-sm"
|
||||
>
|
||||
创建第一个任务
|
||||
</button>
|
||||
|
||||
@@ -30,7 +30,11 @@ export function TeamList({ onSelectTeam, selectedTeamId }: TeamListProps) {
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadTeams();
|
||||
try {
|
||||
loadTeams();
|
||||
} catch (err) {
|
||||
console.error('[TeamList] Failed to load teams:', err);
|
||||
}
|
||||
}, [loadTeams]);
|
||||
|
||||
const handleSelectTeam = (teamId: string) => {
|
||||
@@ -93,12 +97,17 @@ export function TeamList({ onSelectTeam, selectedTeamId }: TeamListProps) {
|
||||
}
|
||||
};
|
||||
|
||||
// Merge clones and agents for display
|
||||
const availableAgents = clones.length > 0 ? clones : agents.map(a => ({
|
||||
id: a.id,
|
||||
name: a.name,
|
||||
role: '默认助手',
|
||||
}));
|
||||
// Merge clones and agents for display - normalize to common type with defensive checks
|
||||
const availableAgents: Array<{ id: string; name: string; role?: string }> =
|
||||
(clones && clones.length > 0)
|
||||
? clones.map(c => ({ id: c.id, name: c.name, role: c.role }))
|
||||
: (agents && agents.length > 0)
|
||||
? agents.map(a => ({
|
||||
id: a.id,
|
||||
name: a.name,
|
||||
role: '默认助手',
|
||||
}))
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
@@ -106,12 +115,12 @@ export function TeamList({ onSelectTeam, selectedTeamId }: TeamListProps) {
|
||||
<div className="p-3 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Teams
|
||||
团队
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="p-1 hover:bg-gray-100 dark:hover:bg-gray-800 rounded transition-colors"
|
||||
title="Create Team"
|
||||
title="创建团队"
|
||||
>
|
||||
<Plus className="w-4 h-4 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200" />
|
||||
</button>
|
||||
@@ -124,7 +133,7 @@ export function TeamList({ onSelectTeam, selectedTeamId }: TeamListProps) {
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl w-80 max-h-[90vh] overflow-y-auto">
|
||||
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">Create Team</h3>
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">创建团队</h3>
|
||||
<button
|
||||
onClick={() => setShowCreateModal(false)}
|
||||
className="p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded"
|
||||
@@ -138,51 +147,51 @@ export function TeamList({ onSelectTeam, selectedTeamId }: TeamListProps) {
|
||||
{/* Team Name */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Team Name *
|
||||
团队名称 *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={teamName}
|
||||
onChange={(e) => setTeamName(e.target.value)}
|
||||
placeholder="e.g., Dev Team Alpha"
|
||||
className="w-full px-3 py-2 text-sm border border-gray-200 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="例如:开发团队 Alpha"
|
||||
className="w-full px-3 py-2 text-sm border border-gray-200 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-gray-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Team Description */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Description
|
||||
描述
|
||||
</label>
|
||||
<textarea
|
||||
value={teamDescription}
|
||||
onChange={(e) => setTeamDescription(e.target.value)}
|
||||
placeholder="What will this team work on?"
|
||||
placeholder="这个团队将负责什么工作?"
|
||||
rows={2}
|
||||
className="w-full px-3 py-2 text-sm border border-gray-200 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none"
|
||||
className="w-full px-3 py-2 text-sm border border-gray-200 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-gray-400 resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Collaboration Pattern */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Collaboration Pattern
|
||||
协作模式
|
||||
</label>
|
||||
<select
|
||||
value={teamPattern}
|
||||
onChange={(e) => setTeamPattern(e.target.value as typeof teamPattern)}
|
||||
className="w-full px-3 py-2 text-sm border border-gray-200 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
className="w-full px-3 py-2 text-sm border border-gray-200 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-gray-400"
|
||||
>
|
||||
<option value="sequential">Sequential (Task by task)</option>
|
||||
<option value="parallel">Parallel (Concurrent work)</option>
|
||||
<option value="pipeline">Pipeline (Output feeds next)</option>
|
||||
<option value="sequential">顺序执行(逐个任务)</option>
|
||||
<option value="parallel">并行执行(同时工作)</option>
|
||||
<option value="pipeline">流水线(输出传递给下一步)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Agent Selection */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Select Agents ({selectedAgents.length} selected) *
|
||||
选择智能体 (已选择 {selectedAgents.length} 个) *
|
||||
</label>
|
||||
<div className="space-y-2 max-h-40 overflow-y-auto">
|
||||
{availableAgents.map((agent) => (
|
||||
@@ -195,7 +204,7 @@ export function TeamList({ onSelectTeam, selectedTeamId }: TeamListProps) {
|
||||
: 'bg-gray-50 dark:bg-gray-700 border border-transparent hover:bg-gray-100 dark:hover:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
<div className="w-6 h-6 rounded-full bg-gradient-to-br from-orange-400 to-red-500 flex items-center justify-center text-white text-xs">
|
||||
<div className="w-6 h-6 rounded-full bg-gray-600 flex items-center justify-center text-white text-xs">
|
||||
<Bot className="w-3 h-3" />
|
||||
</div>
|
||||
<span className="text-gray-900 dark:text-white truncate">{agent.name}</span>
|
||||
@@ -206,7 +215,7 @@ export function TeamList({ onSelectTeam, selectedTeamId }: TeamListProps) {
|
||||
))}
|
||||
{availableAgents.length === 0 && (
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 text-center py-2">
|
||||
No agents available. Create an agent first.
|
||||
暂无可用智能体,请先创建一个智能体。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -219,14 +228,14 @@ export function TeamList({ onSelectTeam, selectedTeamId }: TeamListProps) {
|
||||
onClick={() => setShowCreateModal(false)}
|
||||
className="flex-1 px-4 py-2 text-sm text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCreateTeam}
|
||||
disabled={!teamName.trim() || selectedAgents.length === 0 || isCreating}
|
||||
className="flex-1 px-4 py-2 text-sm text-white bg-blue-500 rounded-lg hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
className="flex-1 px-4 py-2 text-sm text-white bg-gray-700 dark:bg-gray-600 rounded-lg hover:bg-gray-800 dark:hover:bg-gray-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{isCreating ? 'Creating...' : 'Create'}
|
||||
{isCreating ? '创建中...' : '创建'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -236,15 +245,15 @@ export function TeamList({ onSelectTeam, selectedTeamId }: TeamListProps) {
|
||||
{/* Team List */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{isLoading ? (
|
||||
<div className="p-4 text-center text-gray-400 text-sm">Loading...</div>
|
||||
) : teams.length === 0 ? (
|
||||
<div className="p-4 text-center text-gray-400 text-sm">加载中...</div>
|
||||
) : !Array.isArray(teams) || teams.length === 0 ? (
|
||||
<div className="p-4 text-center">
|
||||
<Users className="w-8 h-8 mx-auto mb-2 text-gray-300 dark:text-gray-600" />
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500">
|
||||
No teams yet
|
||||
暂无团队
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
|
||||
Click + to create one
|
||||
点击 + 创建一个团队
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
@@ -271,7 +280,7 @@ export function TeamList({ onSelectTeam, selectedTeamId }: TeamListProps) {
|
||||
{team.members.length}
|
||||
</span>
|
||||
<span>·</span>
|
||||
<span>{team.tasks.length} tasks</span>
|
||||
<span>{team.tasks.length} 个任务</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
|
||||
53
desktop/src/components/TopBar.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { ClipboardList } from 'lucide-react';
|
||||
import { Button } from './ui';
|
||||
|
||||
interface TopBarProps {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
onOpenDetail?: () => void;
|
||||
showDetailButton?: boolean;
|
||||
}
|
||||
|
||||
export function TopBar({
|
||||
title,
|
||||
subtitle,
|
||||
onOpenDetail,
|
||||
showDetailButton = true
|
||||
}: TopBarProps) {
|
||||
return (
|
||||
<header className="h-14 bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700 flex items-center px-4 flex-shrink-0">
|
||||
{/* 左侧标题 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 bg-gradient-to-br from-emerald-400 to-teal-500 rounded-lg flex items-center justify-center text-white font-bold shadow-sm">
|
||||
<span className="text-sm">Z</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-bold text-lg text-gray-900 dark:text-gray-100">{title}</span>
|
||||
{subtitle && (
|
||||
<span className="ml-2 text-sm text-gray-500 dark:text-gray-400">{subtitle}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 中间区域 */}
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* 右侧按钮 */}
|
||||
<div className="flex items-center gap-1">
|
||||
{/* 详情按钮 */}
|
||||
{showDetailButton && onOpenDetail && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onOpenDetail}
|
||||
className="flex items-center gap-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100"
|
||||
title="显示详情面板"
|
||||
>
|
||||
<ClipboardList className="w-4 h-4" />
|
||||
<span className="text-sm">详情</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -402,7 +402,7 @@ export function WorkflowEditor({ workflow, isOpen, onClose, onSave, isSaving }:
|
||||
</p>
|
||||
<button
|
||||
onClick={handleAddStep}
|
||||
className="inline-flex items-center gap-1 px-3 py-1.5 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
||||
className="inline-flex items-center gap-1 px-3 py-1.5 text-sm bg-gray-700 dark:bg-gray-600 text-white rounded-lg hover:bg-gray-800 dark:hover:bg-gray-500"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
添加第一个步骤
|
||||
@@ -438,7 +438,7 @@ export function WorkflowEditor({ workflow, isOpen, onClose, onSave, isSaving }:
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
className="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
className="px-4 py-2 text-sm bg-gray-700 dark:bg-gray-600 text-white rounded-lg hover:bg-gray-800 dark:hover:bg-gray-500 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
>
|
||||
{isSaving ? (
|
||||
<>
|
||||
|
||||
@@ -10,7 +10,7 @@ interface EmptyStateProps {
|
||||
|
||||
export function EmptyState({ icon, title, description, action, className }: EmptyStateProps) {
|
||||
return (
|
||||
<div className={cn('flex-1 flex items-center justify-center p-6', className)}>
|
||||
<div className={cn('h-full flex items-center justify-center p-6', className)}>
|
||||
<div className="text-center max-w-sm">
|
||||
<div className="w-16 h-16 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center mx-auto mb-4 text-gray-400">
|
||||
{icon}
|
||||
|
||||
17
desktop/src/hooks/index.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Custom React Hooks for ZCLAW Desktop
|
||||
*
|
||||
* @module hooks
|
||||
*/
|
||||
|
||||
export {
|
||||
useAutomationEvents,
|
||||
useHandEvents,
|
||||
useWorkflowEvents,
|
||||
} from './useAutomationEvents';
|
||||
|
||||
// Re-export types from useAutomationEvents
|
||||
export type {
|
||||
UseAutomationEventsOptions,
|
||||
} from './useAutomationEvents';
|
||||
|
||||
318
desktop/src/hooks/useAutomationEvents.ts
Normal file
@@ -0,0 +1,318 @@
|
||||
/**
|
||||
* useAutomationEvents - WebSocket Event Hook for Automation System
|
||||
*
|
||||
* Subscribes to hand and workflow events from OpenFang WebSocket
|
||||
* and updates the corresponding stores.
|
||||
*
|
||||
* @module hooks/useAutomationEvents
|
||||
*/
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useHandStore } from '../store/handStore';
|
||||
import { useWorkflowStore } from '../store/workflowStore';
|
||||
import { useChatStore } from '../store/chatStore';
|
||||
import type { GatewayClient } from '../lib/gateway-client';
|
||||
|
||||
// === Event Types ===
|
||||
|
||||
interface HandEventData {
|
||||
hand_name: string;
|
||||
hand_status: 'triggered' | 'running' | 'completed' | 'failed' | 'needs_approval';
|
||||
hand_result?: unknown;
|
||||
hand_error?: string;
|
||||
run_id?: string;
|
||||
timestamp?: number;
|
||||
}
|
||||
|
||||
interface WorkflowEventData {
|
||||
workflow_id: string;
|
||||
workflow_status: 'started' | 'step_completed' | 'completed' | 'failed' | 'paused';
|
||||
current_step?: number;
|
||||
total_steps?: number;
|
||||
step_name?: string;
|
||||
result?: unknown;
|
||||
error?: string;
|
||||
run_id?: string;
|
||||
timestamp?: number;
|
||||
}
|
||||
|
||||
interface ApprovalEventData {
|
||||
approval_id: string;
|
||||
hand_name?: string;
|
||||
workflow_id?: string;
|
||||
run_id?: string;
|
||||
status: 'requested' | 'approved' | 'rejected' | 'expired';
|
||||
reason?: string;
|
||||
requested_by?: string;
|
||||
timestamp?: number;
|
||||
}
|
||||
|
||||
// === Hook Options ===
|
||||
|
||||
export interface UseAutomationEventsOptions {
|
||||
/** Whether to inject hand results into chat as messages */
|
||||
injectResultsToChat?: boolean;
|
||||
/** Whether to auto-refresh hands on status change */
|
||||
refreshOnStatusChange?: boolean;
|
||||
/** Custom event handlers */
|
||||
onHandEvent?: (data: HandEventData) => void;
|
||||
onWorkflowEvent?: (data: WorkflowEventData) => void;
|
||||
onApprovalEvent?: (data: ApprovalEventData) => void;
|
||||
}
|
||||
|
||||
// === Helper Functions ===
|
||||
|
||||
function isHandEvent(data: unknown): data is HandEventData {
|
||||
return typeof data === 'object' && data !== null && 'hand_name' in data && 'hand_status' in data;
|
||||
}
|
||||
|
||||
function isWorkflowEvent(data: unknown): data is WorkflowEventData {
|
||||
return typeof data === 'object' && data !== null && 'workflow_id' in data && 'workflow_status' in data;
|
||||
}
|
||||
|
||||
function isApprovalEvent(data: unknown): data is ApprovalEventData {
|
||||
return typeof data === 'object' && data !== null && 'approval_id' in data && 'status' in data;
|
||||
}
|
||||
|
||||
// === Main Hook ===
|
||||
|
||||
/**
|
||||
* Hook for subscribing to automation-related WebSocket events.
|
||||
*
|
||||
* @param client - The GatewayClient instance (optional, will try to get from store if not provided)
|
||||
* @param options - Configuration options
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* function AutomationPanel() {
|
||||
* const client = useConnectionStore(s => s.client);
|
||||
* useAutomationEvents(client, {
|
||||
* injectResultsToChat: true,
|
||||
* refreshOnStatusChange: true,
|
||||
* });
|
||||
* // ...
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function useAutomationEvents(
|
||||
client: GatewayClient | null,
|
||||
options: UseAutomationEventsOptions = {}
|
||||
): void {
|
||||
const {
|
||||
injectResultsToChat = true,
|
||||
refreshOnStatusChange = true,
|
||||
onHandEvent,
|
||||
onWorkflowEvent,
|
||||
onApprovalEvent,
|
||||
} = options;
|
||||
|
||||
// Store references
|
||||
const loadHands = useHandStore(s => s.loadHands);
|
||||
const loadHandRuns = useHandStore(s => s.loadHandRuns);
|
||||
const loadApprovals = useHandStore(s => s.loadApprovals);
|
||||
const loadWorkflows = useWorkflowStore(s => s.loadWorkflows);
|
||||
const loadWorkflowRuns = useWorkflowStore(s => s.loadWorkflowRuns);
|
||||
const addMessage = useChatStore(s => s.addMessage);
|
||||
|
||||
// Track subscriptions for cleanup
|
||||
const unsubscribersRef = useRef<Array<() => void>>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!client) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clean up any existing subscriptions
|
||||
unsubscribersRef.current.forEach(unsub => unsub());
|
||||
unsubscribersRef.current = [];
|
||||
|
||||
// === Hand Event Handler ===
|
||||
const handleHandEvent = (data: unknown) => {
|
||||
if (!isHandEvent(data)) return;
|
||||
|
||||
const eventData = data as HandEventData;
|
||||
console.log('[useAutomationEvents] Hand event:', eventData);
|
||||
|
||||
// Refresh hands if status changed
|
||||
if (refreshOnStatusChange) {
|
||||
loadHands();
|
||||
}
|
||||
|
||||
// Load updated runs for this hand
|
||||
if (eventData.run_id) {
|
||||
loadHandRuns(eventData.hand_name);
|
||||
}
|
||||
|
||||
// Inject result into chat
|
||||
if (injectResultsToChat && eventData.hand_status === 'completed') {
|
||||
const resultContent = eventData.hand_result
|
||||
? typeof eventData.hand_result === 'string'
|
||||
? eventData.hand_result
|
||||
: JSON.stringify(eventData.hand_result, null, 2)
|
||||
: 'Hand completed successfully';
|
||||
|
||||
addMessage({
|
||||
id: `hand-${eventData.run_id || Date.now()}`,
|
||||
role: 'hand',
|
||||
content: `**${eventData.hand_name}** 执行完成\n\n${resultContent}`,
|
||||
timestamp: new Date(),
|
||||
handName: eventData.hand_name,
|
||||
handStatus: eventData.hand_status,
|
||||
handResult: eventData.hand_result,
|
||||
runId: eventData.run_id,
|
||||
});
|
||||
}
|
||||
|
||||
// Handle error status
|
||||
if (eventData.hand_status === 'failed' && eventData.hand_error) {
|
||||
addMessage({
|
||||
id: `hand-error-${eventData.run_id || Date.now()}`,
|
||||
role: 'hand',
|
||||
content: `**${eventData.hand_name}** 执行失败\n\n错误: ${eventData.hand_error}`,
|
||||
timestamp: new Date(),
|
||||
handName: eventData.hand_name,
|
||||
handStatus: eventData.hand_status,
|
||||
error: eventData.hand_error,
|
||||
runId: eventData.run_id,
|
||||
});
|
||||
}
|
||||
|
||||
// Handle approval needed
|
||||
if (eventData.hand_status === 'needs_approval') {
|
||||
loadApprovals('pending');
|
||||
}
|
||||
|
||||
// Call custom handler
|
||||
onHandEvent?.(eventData);
|
||||
};
|
||||
|
||||
// === Workflow Event Handler ===
|
||||
const handleWorkflowEvent = (data: unknown) => {
|
||||
if (!isWorkflowEvent(data)) return;
|
||||
|
||||
const eventData = data as WorkflowEventData;
|
||||
console.log('[useAutomationEvents] Workflow event:', eventData);
|
||||
|
||||
// Refresh workflows if status changed
|
||||
if (refreshOnStatusChange) {
|
||||
loadWorkflows();
|
||||
}
|
||||
|
||||
// Load updated runs for this workflow
|
||||
if (eventData.run_id) {
|
||||
loadWorkflowRuns(eventData.workflow_id);
|
||||
}
|
||||
|
||||
// Inject result into chat
|
||||
if (injectResultsToChat && eventData.workflow_status === 'completed') {
|
||||
const resultContent = eventData.result
|
||||
? typeof eventData.result === 'string'
|
||||
? eventData.result
|
||||
: JSON.stringify(eventData.result, null, 2)
|
||||
: 'Workflow completed successfully';
|
||||
|
||||
addMessage({
|
||||
id: `workflow-${eventData.run_id || Date.now()}`,
|
||||
role: 'workflow',
|
||||
content: `**工作流: ${eventData.workflow_id}** 执行完成\n\n${resultContent}`,
|
||||
timestamp: new Date(),
|
||||
workflowId: eventData.workflow_id,
|
||||
workflowStatus: eventData.workflow_status,
|
||||
workflowResult: eventData.result,
|
||||
runId: eventData.run_id,
|
||||
});
|
||||
}
|
||||
|
||||
// Call custom handler
|
||||
onWorkflowEvent?.(eventData);
|
||||
};
|
||||
|
||||
// === Approval Event Handler ===
|
||||
const handleApprovalEvent = (data: unknown) => {
|
||||
if (!isApprovalEvent(data)) return;
|
||||
|
||||
const eventData = data as ApprovalEventData;
|
||||
console.log('[useAutomationEvents] Approval event:', eventData);
|
||||
|
||||
// Refresh approvals list
|
||||
loadApprovals();
|
||||
|
||||
// Call custom handler
|
||||
onApprovalEvent?.(eventData);
|
||||
};
|
||||
|
||||
// Subscribe to events
|
||||
const unsubHand = client.on('hand', handleHandEvent);
|
||||
const unsubWorkflow = client.on('workflow', handleWorkflowEvent);
|
||||
const unsubApproval = client.on('approval', handleApprovalEvent);
|
||||
|
||||
unsubscribersRef.current = [unsubHand, unsubWorkflow, unsubApproval];
|
||||
|
||||
// Cleanup on unmount or client change
|
||||
return () => {
|
||||
unsubscribersRef.current.forEach(unsub => unsub());
|
||||
unsubscribersRef.current = [];
|
||||
};
|
||||
}, [
|
||||
client,
|
||||
injectResultsToChat,
|
||||
refreshOnStatusChange,
|
||||
loadHands,
|
||||
loadHandRuns,
|
||||
loadApprovals,
|
||||
loadWorkflows,
|
||||
loadWorkflowRuns,
|
||||
addMessage,
|
||||
onHandEvent,
|
||||
onWorkflowEvent,
|
||||
onApprovalEvent,
|
||||
]);
|
||||
}
|
||||
|
||||
// === Utility Hooks ===
|
||||
|
||||
/**
|
||||
* Hook for subscribing to a specific hand's events only
|
||||
*/
|
||||
export function useHandEvents(
|
||||
client: GatewayClient | null,
|
||||
handName: string,
|
||||
onEvent?: (data: HandEventData) => void
|
||||
): void {
|
||||
useEffect(() => {
|
||||
if (!client || !handName) return;
|
||||
|
||||
const handler = (data: unknown) => {
|
||||
if (isHandEvent(data) && (data as HandEventData).hand_name === handName) {
|
||||
onEvent?.(data as HandEventData);
|
||||
}
|
||||
};
|
||||
|
||||
const unsub = client.on('hand', handler);
|
||||
return unsub;
|
||||
}, [client, handName, onEvent]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for subscribing to a specific workflow's events only
|
||||
*/
|
||||
export function useWorkflowEvents(
|
||||
client: GatewayClient | null,
|
||||
workflowId: string,
|
||||
onEvent?: (data: WorkflowEventData) => void
|
||||
): void {
|
||||
useEffect(() => {
|
||||
if (!client || !workflowId) return;
|
||||
|
||||
const handler = (data: unknown) => {
|
||||
if (isWorkflowEvent(data) && (data as WorkflowEventData).workflow_id === workflowId) {
|
||||
onEvent?.(data as WorkflowEventData);
|
||||
}
|
||||
};
|
||||
|
||||
const unsub = client.on('workflow', handler);
|
||||
return unsub;
|
||||
}, [client, workflowId, onEvent]);
|
||||
}
|
||||
|
||||
export default useAutomationEvents;
|
||||
@@ -1,10 +1,14 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
/* Brand Colors */
|
||||
--color-primary: #f97316;
|
||||
--color-primary-hover: #ea580c;
|
||||
--color-primary-light: #fff7ed;
|
||||
/* Brand Colors - 中性灰色系 */
|
||||
--color-primary: #374151; /* gray-700 */
|
||||
--color-primary-hover: #1f2937; /* gray-800 */
|
||||
--color-primary-light: #f3f4f6; /* gray-100 */
|
||||
|
||||
/* Accent Color - 仅用于重要强调 */
|
||||
--color-accent: #f97316; /* orange-500 */
|
||||
--color-accent-hover: #ea580c; /* orange-600 */
|
||||
|
||||
/* Semantic Colors */
|
||||
--color-success: #22c55e;
|
||||
@@ -77,7 +81,7 @@ body {
|
||||
}
|
||||
|
||||
.agent-avatar {
|
||||
background: linear-gradient(135deg, #f97316 0%, #ea580c 100%);
|
||||
background: #4b5563; /* gray-600 */
|
||||
}
|
||||
|
||||
.chat-bubble-assistant {
|
||||
@@ -88,7 +92,7 @@ body {
|
||||
}
|
||||
|
||||
.chat-bubble-user {
|
||||
background: #f97316;
|
||||
background: #374151; /* gray-700 */
|
||||
color: white;
|
||||
border-radius: 12px;
|
||||
border-bottom-right-radius: 4px;
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
* Reference: ZCLAW_AGENT_INTELLIGENCE_EVOLUTION.md §6.2.3
|
||||
*/
|
||||
|
||||
import { canAutoExecute } from './autonomy-manager';
|
||||
|
||||
// === Types ===
|
||||
|
||||
export interface IdentityFiles {
|
||||
@@ -200,8 +202,17 @@ export class AgentIdentityManager {
|
||||
agentId: string,
|
||||
file: 'soul' | 'instructions',
|
||||
suggestedContent: string,
|
||||
reason: string
|
||||
): IdentityChangeProposal {
|
||||
reason: string,
|
||||
options?: { skipAutonomyCheck?: boolean }
|
||||
): IdentityChangeProposal | null {
|
||||
// Autonomy check - identity updates are high-risk, always require approval
|
||||
if (!options?.skipAutonomyCheck) {
|
||||
const { decision } = canAutoExecute('identity_update', 8);
|
||||
console.log(`[AgentIdentity] Autonomy check for identity update: ${decision.reason}`);
|
||||
// Identity updates always require approval regardless of autonomy level
|
||||
// But we log the decision for audit purposes
|
||||
}
|
||||
|
||||
const identity = this.getIdentity(agentId);
|
||||
const currentContent = file === 'soul' ? identity.soul : identity.instructions;
|
||||
|
||||
|
||||
@@ -66,8 +66,8 @@ const REQUIRED_FIELDS: Array<{ path: string; description: string }> = [
|
||||
const DEFAULT_CONFIG: Partial<OpenFangConfig> = {
|
||||
server: {
|
||||
host: '127.0.0.1',
|
||||
port: 50051,
|
||||
websocket_port: 50051,
|
||||
port: 4200,
|
||||
websocket_port: 4200,
|
||||
websocket_path: '/ws',
|
||||
api_version: 'v1',
|
||||
},
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
type LLMServiceAdapter,
|
||||
type LLMProvider,
|
||||
} from './llm-service';
|
||||
import { canAutoExecute } from './autonomy-manager';
|
||||
|
||||
// === Types ===
|
||||
|
||||
@@ -181,8 +182,27 @@ export class ContextCompactor {
|
||||
messages: CompactableMessage[],
|
||||
agentId: string,
|
||||
conversationId?: string,
|
||||
options?: { forceLLM?: boolean }
|
||||
options?: { forceLLM?: boolean; skipAutonomyCheck?: boolean }
|
||||
): Promise<CompactionResult> {
|
||||
// Autonomy check - verify if compaction is allowed
|
||||
if (!options?.skipAutonomyCheck) {
|
||||
const { canProceed, decision } = canAutoExecute('compaction_run', 5);
|
||||
if (!canProceed) {
|
||||
console.log(`[ContextCompactor] Autonomy check failed: ${decision.reason}`);
|
||||
// Return result without compaction
|
||||
return {
|
||||
compactedMessages: messages,
|
||||
summary: '',
|
||||
originalCount: messages.length,
|
||||
retainedCount: messages.length,
|
||||
flushedMemories: 0,
|
||||
tokensBeforeCompaction: estimateMessagesTokens(messages),
|
||||
tokensAfterCompaction: estimateMessagesTokens(messages),
|
||||
};
|
||||
}
|
||||
console.log(`[ContextCompactor] Autonomy check passed: ${decision.reason}`);
|
||||
}
|
||||
|
||||
const tokensBeforeCompaction = estimateMessagesTokens(messages);
|
||||
const keepCount = Math.min(this.config.keepRecentMessages, messages.length);
|
||||
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
* Supports Ed25519 device authentication + JWT.
|
||||
*
|
||||
* OpenFang Configuration:
|
||||
* - Port: 50051
|
||||
* - Port: 4200 (default from runtime-manifest.json)
|
||||
* - WebSocket path: /ws
|
||||
* - REST API: http://127.0.0.1:50051/api/*
|
||||
* - REST API: http://127.0.0.1:4200/api/*
|
||||
* - Config format: TOML
|
||||
*
|
||||
* Security:
|
||||
@@ -62,7 +62,7 @@ function isLocalhost(url: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
// OpenFang endpoints (actual port is 50051, not 4200)
|
||||
// OpenFang endpoints (port 50051 - actual running port)
|
||||
// Note: REST API uses relative path to leverage Vite proxy for CORS bypass
|
||||
export const DEFAULT_GATEWAY_URL = `${DEFAULT_WS_PROTOCOL}127.0.0.1:50051/ws`;
|
||||
export const REST_API_URL = ''; // Empty = use relative path (Vite proxy)
|
||||
@@ -499,8 +499,8 @@ export class GatewayClient {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
// Check if URL is for OpenFang (port 50051) - use REST mode
|
||||
if (this.url.includes(':50051')) {
|
||||
// Check if URL is for OpenFang (port 4200 or 50051) - use REST mode
|
||||
if (this.url.includes(':4200') || this.url.includes(':50051')) {
|
||||
return this.connectRest();
|
||||
}
|
||||
|
||||
@@ -958,15 +958,27 @@ export class GatewayClient {
|
||||
|
||||
private async restPost<T>(path: string, body?: unknown): Promise<T> {
|
||||
const baseUrl = this.getRestBaseUrl();
|
||||
const response = await fetch(`${baseUrl}${path}`, {
|
||||
const url = `${baseUrl}${path}`;
|
||||
console.log(`[GatewayClient] POST ${url}`, body);
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`REST API error: ${response.status} ${response.statusText}`);
|
||||
const errorBody = await response.text().catch(() => '');
|
||||
console.error(`[GatewayClient] POST ${url} failed: ${response.status} ${response.statusText}`, errorBody);
|
||||
const error = new Error(`REST API error: ${response.status} ${response.statusText}${errorBody ? ` - ${errorBody}` : ''}`);
|
||||
(error as any).status = response.status;
|
||||
(error as any).body = errorBody;
|
||||
throw error;
|
||||
}
|
||||
return response.json();
|
||||
|
||||
const result = await response.json();
|
||||
console.log(`[GatewayClient] POST ${url} response:`, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
private async restPut<T>(path: string, body?: unknown): Promise<T> {
|
||||
@@ -1141,6 +1153,12 @@ export class GatewayClient {
|
||||
home_dir?: string;
|
||||
default_model?: { model?: string; provider?: string };
|
||||
}>('/api/config');
|
||||
|
||||
// 从 localStorage 读取前端特定配置
|
||||
const storedTheme = localStorage.getItem('zclaw-theme') as 'light' | 'dark' | null;
|
||||
const storedAutoStart = localStorage.getItem('zclaw-autoStart');
|
||||
const storedShowToolCalls = localStorage.getItem('zclaw-showToolCalls');
|
||||
|
||||
// Map OpenFang config to frontend expected format
|
||||
return {
|
||||
quickConfig: {
|
||||
@@ -1154,8 +1172,9 @@ export class GatewayClient {
|
||||
gatewayUrl: this.getRestBaseUrl(),
|
||||
defaultModel: config.default_model?.model,
|
||||
defaultProvider: config.default_model?.provider,
|
||||
theme: 'dark',
|
||||
showToolCalls: true,
|
||||
theme: storedTheme || 'light',
|
||||
autoStart: storedAutoStart === 'true',
|
||||
showToolCalls: storedShowToolCalls !== 'false',
|
||||
autoSaveContext: true,
|
||||
fileWatching: true,
|
||||
privacyOptIn: false,
|
||||
@@ -1170,6 +1189,17 @@ export class GatewayClient {
|
||||
}
|
||||
}
|
||||
async saveQuickConfig(config: Record<string, any>): Promise<any> {
|
||||
// 保存前端特定配置到 localStorage
|
||||
if (config.theme !== undefined) {
|
||||
localStorage.setItem('zclaw-theme', config.theme);
|
||||
}
|
||||
if (config.autoStart !== undefined) {
|
||||
localStorage.setItem('zclaw-autoStart', String(config.autoStart));
|
||||
}
|
||||
if (config.showToolCalls !== undefined) {
|
||||
localStorage.setItem('zclaw-showToolCalls', String(config.showToolCalls));
|
||||
}
|
||||
|
||||
// Use /api/config endpoint for saving config
|
||||
// Map frontend config back to OpenFang format
|
||||
const openfangConfig = {
|
||||
@@ -1318,12 +1348,19 @@ export class GatewayClient {
|
||||
|
||||
/** Trigger a Hand */
|
||||
async triggerHand(name: string, params?: Record<string, unknown>): Promise<{ runId: string; status: string }> {
|
||||
console.log(`[GatewayClient] Triggering hand: ${name}`, params);
|
||||
// OpenFang uses /activate endpoint, not /trigger
|
||||
const result = await this.restPost<{
|
||||
instance_id: string;
|
||||
status: string;
|
||||
}>(`/api/hands/${name}/activate`, params || {});
|
||||
return { runId: result.instance_id, status: result.status };
|
||||
try {
|
||||
const result = await this.restPost<{
|
||||
instance_id: string;
|
||||
status: string;
|
||||
}>(`/api/hands/${name}/activate`, params || {});
|
||||
console.log(`[GatewayClient] Hand trigger response:`, result);
|
||||
return { runId: result.instance_id, status: result.status };
|
||||
} catch (err) {
|
||||
console.error(`[GatewayClient] Hand trigger failed for ${name}:`, err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** Get Hand execution status */
|
||||
|
||||
@@ -295,21 +295,59 @@ class GatewayLLMAdapter implements LLMServiceAdapter {
|
||||
const config = { ...this.config, ...options };
|
||||
const startTime = Date.now();
|
||||
|
||||
const response = await fetch(`${config.apiBase}/complete`, {
|
||||
// Build a single prompt from messages
|
||||
const systemMessage = messages.find(m => m.role === 'system')?.content || '';
|
||||
const userMessage = messages.find(m => m.role === 'user')?.content || '';
|
||||
|
||||
// Combine system and user messages into a single prompt
|
||||
const fullPrompt = systemMessage
|
||||
? `${systemMessage}\n\n${userMessage}`
|
||||
: userMessage;
|
||||
|
||||
// Use OpenFang's chat endpoint (same as main chat)
|
||||
// Try to get the default agent ID from localStorage or use 'default'
|
||||
const agentId = localStorage.getItem('zclaw-default-agent-id') || 'default';
|
||||
|
||||
const response = await fetch(`/api/agents/${agentId}/message`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
messages,
|
||||
message: fullPrompt,
|
||||
max_tokens: config.maxTokens,
|
||||
temperature: config.temperature,
|
||||
temperature: config.temperature ?? 0.3, // Lower temperature for extraction tasks
|
||||
}),
|
||||
signal: AbortSignal.timeout(config.timeout || 60000),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
// If agent not found, try without agent ID (direct /api/chat)
|
||||
if (response.status === 404) {
|
||||
const fallbackResponse = await fetch('/api/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
message: fullPrompt,
|
||||
max_tokens: config.maxTokens,
|
||||
temperature: config.temperature ?? 0.3,
|
||||
}),
|
||||
signal: AbortSignal.timeout(config.timeout || 60000),
|
||||
});
|
||||
|
||||
if (!fallbackResponse.ok) {
|
||||
throw new Error(`[Gateway] Both endpoints failed: ${fallbackResponse.status}`);
|
||||
}
|
||||
|
||||
const data = await fallbackResponse.json();
|
||||
const latencyMs = Date.now() - startTime;
|
||||
return {
|
||||
content: data.response || data.content || '',
|
||||
tokensUsed: { input: data.input_tokens || 0, output: data.output_tokens || 0 },
|
||||
latencyMs,
|
||||
};
|
||||
}
|
||||
throw new Error(`[Gateway] API error: ${response.status} - ${error}`);
|
||||
}
|
||||
|
||||
@@ -317,15 +355,14 @@ class GatewayLLMAdapter implements LLMServiceAdapter {
|
||||
const latencyMs = Date.now() - startTime;
|
||||
|
||||
return {
|
||||
content: data.content || data.choices?.[0]?.message?.content || '',
|
||||
tokensUsed: data.tokensUsed || { input: 0, output: 0 },
|
||||
model: data.model,
|
||||
content: data.response || data.content || '',
|
||||
tokensUsed: { input: data.input_tokens || 0, output: data.output_tokens || 0 },
|
||||
latencyMs,
|
||||
};
|
||||
}
|
||||
|
||||
isAvailable(): boolean {
|
||||
// Gateway is available if we're connected to OpenFang
|
||||
// Gateway is available if we're in browser (can connect to OpenFang)
|
||||
return typeof window !== 'undefined';
|
||||
}
|
||||
|
||||
@@ -382,8 +419,8 @@ export function loadConfig(): LLMConfig {
|
||||
// Ignore parse errors
|
||||
}
|
||||
|
||||
// Default to mock for safety
|
||||
return DEFAULT_CONFIGS.mock;
|
||||
// Default to gateway (OpenFang passthrough) for L4 self-evolution
|
||||
return DEFAULT_CONFIGS.gateway;
|
||||
}
|
||||
|
||||
export function saveConfig(config: LLMConfig): void {
|
||||
|
||||
@@ -80,9 +80,9 @@ const EXTRACTION_PROMPT = `请从以下对话中提取值得长期记住的信
|
||||
// === Default Config ===
|
||||
|
||||
export const DEFAULT_EXTRACTION_CONFIG: ExtractionConfig = {
|
||||
useLLM: false,
|
||||
useLLM: true, // Enable LLM-powered semantic extraction by default
|
||||
llmFallbackToRules: true,
|
||||
minMessagesForExtraction: 4,
|
||||
minMessagesForExtraction: 2, // Lowered from 4 to capture memories earlier
|
||||
extractionCooldownMs: 30_000,
|
||||
minImportanceThreshold: 3,
|
||||
};
|
||||
@@ -119,12 +119,15 @@ export class MemoryExtractor {
|
||||
): Promise<ExtractionResult> {
|
||||
// Cooldown check
|
||||
if (Date.now() - this.lastExtractionTime < this.config.extractionCooldownMs) {
|
||||
console.log('[MemoryExtractor] Skipping extraction: cooldown active');
|
||||
return { items: [], saved: 0, skipped: 0, userProfileUpdated: false };
|
||||
}
|
||||
|
||||
// Minimum message threshold
|
||||
const chatMessages = messages.filter(m => m.role === 'user' || m.role === 'assistant');
|
||||
console.log(`[MemoryExtractor] Checking extraction: ${chatMessages.length} messages (min: ${this.config.minMessagesForExtraction})`);
|
||||
if (chatMessages.length < this.config.minMessagesForExtraction) {
|
||||
console.log('[MemoryExtractor] Skipping extraction: not enough messages');
|
||||
return { items: [], saved: 0, skipped: 0, userProfileUpdated: false };
|
||||
}
|
||||
|
||||
@@ -146,11 +149,14 @@ export class MemoryExtractor {
|
||||
}
|
||||
} else {
|
||||
// Rule-based extraction
|
||||
console.log('[MemoryExtractor] Using rule-based extraction');
|
||||
extracted = this.ruleBasedExtraction(chatMessages);
|
||||
console.log(`[MemoryExtractor] Rule-based extracted ${extracted.length} items before filtering`);
|
||||
}
|
||||
|
||||
// Filter by importance threshold
|
||||
extracted = extracted.filter(item => item.importance >= this.config.minImportanceThreshold);
|
||||
console.log(`[MemoryExtractor] After importance filtering (>= ${this.config.minImportanceThreshold}): ${extracted.length} items`);
|
||||
|
||||
// Save to memory
|
||||
const memoryManager = getMemoryManager();
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
type LLMServiceAdapter,
|
||||
type LLMProvider,
|
||||
} from './llm-service';
|
||||
import { canAutoExecute } from './autonomy-manager';
|
||||
|
||||
// === Types ===
|
||||
|
||||
@@ -62,7 +63,7 @@ export const DEFAULT_REFLECTION_CONFIG: ReflectionConfig = {
|
||||
triggerAfterHours: 24,
|
||||
allowSoulModification: false,
|
||||
requireApproval: true,
|
||||
useLLM: false,
|
||||
useLLM: true, // Enable LLM-powered deep reflection (Phase 4)
|
||||
llmFallbackToRules: true,
|
||||
};
|
||||
|
||||
@@ -137,9 +138,26 @@ export class ReflectionEngine {
|
||||
/**
|
||||
* Execute a reflection cycle for the given agent.
|
||||
*/
|
||||
async reflect(agentId: string, options?: { forceLLM?: boolean }): Promise<ReflectionResult> {
|
||||
async reflect(agentId: string, options?: { forceLLM?: boolean; skipAutonomyCheck?: boolean }): Promise<ReflectionResult> {
|
||||
console.log(`[Reflection] Starting reflection for agent: ${agentId}`);
|
||||
|
||||
// Autonomy check - verify if reflection is allowed
|
||||
if (!options?.skipAutonomyCheck) {
|
||||
const { canProceed, decision } = canAutoExecute('reflection_run', 5);
|
||||
if (!canProceed) {
|
||||
console.log(`[Reflection] Autonomy check failed: ${decision.reason}`);
|
||||
// Return empty result instead of throwing
|
||||
return {
|
||||
patterns: [],
|
||||
improvements: [],
|
||||
identityProposals: [],
|
||||
newMemories: 0,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
console.log(`[Reflection] Autonomy check passed: ${decision.reason}`);
|
||||
}
|
||||
|
||||
// Try LLM-powered reflection if enabled
|
||||
if ((this.config.useLLM || options?.forceLLM) && this.llmAdapter?.isAvailable()) {
|
||||
try {
|
||||
@@ -575,7 +593,9 @@ ${recentHistory || '无'}
|
||||
identity.instructions + `\n\n## 自我反思改进\n${additions}`,
|
||||
`基于 ${negativePatterns.length} 个负面模式观察,建议在指令中增加自我改进提醒`
|
||||
);
|
||||
proposals.push(proposal);
|
||||
if (proposal) {
|
||||
proposals.push(proposal);
|
||||
}
|
||||
}
|
||||
|
||||
return proposals;
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
*/
|
||||
|
||||
import { getMemoryManager } from './agent-memory';
|
||||
import { canAutoExecute } from './autonomy-manager';
|
||||
|
||||
// === Types ===
|
||||
|
||||
@@ -365,13 +366,33 @@ export class SkillDiscoveryEngine {
|
||||
|
||||
/**
|
||||
* Mark a skill as installed/uninstalled.
|
||||
* Includes autonomy check for skill_install/skill_uninstall actions.
|
||||
*/
|
||||
setSkillInstalled(skillId: string, installed: boolean): void {
|
||||
setSkillInstalled(
|
||||
skillId: string,
|
||||
installed: boolean,
|
||||
options?: { skipAutonomyCheck?: boolean }
|
||||
): { success: boolean; reason?: string } {
|
||||
const skill = this.skills.find(s => s.id === skillId);
|
||||
if (skill) {
|
||||
skill.installed = installed;
|
||||
this.saveIndex();
|
||||
if (!skill) {
|
||||
return { success: false, reason: `Skill not found: ${skillId}` };
|
||||
}
|
||||
|
||||
// Autonomy check - verify if skill installation is allowed
|
||||
if (!options?.skipAutonomyCheck) {
|
||||
const action = installed ? 'skill_install' : 'skill_uninstall';
|
||||
const { canProceed, decision } = canAutoExecute(action, 6);
|
||||
console.log(`[SkillDiscovery] Autonomy check for ${action}: ${decision.reason}`);
|
||||
|
||||
if (!canProceed) {
|
||||
return { success: false, reason: decision.reason };
|
||||
}
|
||||
}
|
||||
|
||||
skill.installed = installed;
|
||||
this.saveIndex();
|
||||
console.log(`[SkillDiscovery] Skill ${skillId} ${installed ? 'installed' : 'uninstalled'}`);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,9 +2,12 @@
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
import './index.css';
|
||||
import { ToastProvider } from './components/ui/Toast';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
<ToastProvider>
|
||||
<App />
|
||||
</ToastProvider>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
|
||||
@@ -403,12 +403,21 @@ export const useChatStore = create<ChatState>()(
|
||||
set((state) => ({ messages: [...state.messages, handMsg] }));
|
||||
},
|
||||
onComplete: () => {
|
||||
set((state) => ({
|
||||
const state = get();
|
||||
|
||||
// Save conversation to persist across refresh
|
||||
const conversations = upsertActiveConversation([...state.conversations], state);
|
||||
const currentConvId = state.currentConversationId || conversations[0]?.id;
|
||||
|
||||
set({
|
||||
isStreaming: false,
|
||||
conversations,
|
||||
currentConversationId: currentConvId,
|
||||
messages: state.messages.map((m) =>
|
||||
m.id === assistantId ? { ...m, streaming: false, runId } : m
|
||||
),
|
||||
}));
|
||||
});
|
||||
|
||||
// Async memory extraction after stream completes
|
||||
const msgs = get().messages
|
||||
.filter(m => m.role === 'user' || m.role === 'assistant')
|
||||
|
||||
@@ -104,6 +104,8 @@ interface QuickConfig {
|
||||
personality?: string;
|
||||
communicationStyle?: string;
|
||||
notes?: string;
|
||||
// 启用的 Provider 列表
|
||||
enabledProviders?: string[];
|
||||
}
|
||||
|
||||
interface WorkspaceInfo {
|
||||
@@ -779,6 +781,8 @@ export const useGatewayStore = create<GatewayStore>((set, get) => {
|
||||
get().loadWorkflows(),
|
||||
get().loadTriggers(),
|
||||
get().loadSecurityStatus(),
|
||||
// Load available models
|
||||
get().loadModels(),
|
||||
]);
|
||||
await get().loadChannels();
|
||||
} catch (err: unknown) {
|
||||
@@ -852,8 +856,64 @@ export const useGatewayStore = create<GatewayStore>((set, get) => {
|
||||
loadUsageStats: async () => {
|
||||
try {
|
||||
const stats = await get().client.getUsageStats();
|
||||
set({ usageStats: stats });
|
||||
} catch { /* ignore */ }
|
||||
// 如果 API 返回了有效数据,使用它
|
||||
if (stats && (stats.totalMessages > 0 || stats.totalTokens > 0 || Object.keys(stats.byModel || {}).length > 0)) {
|
||||
set({ usageStats: stats });
|
||||
return;
|
||||
}
|
||||
} catch { /* ignore API error, fallback to local */ }
|
||||
|
||||
// Fallback: 从本地聊天存储计算统计数据
|
||||
try {
|
||||
const stored = localStorage.getItem('zclaw-chat-storage');
|
||||
if (!stored) {
|
||||
set({ usageStats: { totalSessions: 0, totalMessages: 0, totalTokens: 0, byModel: {} } });
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(stored);
|
||||
// 处理 persist 中间件格式
|
||||
const state = parsed?.state || parsed;
|
||||
const conversations = state?.conversations || [];
|
||||
|
||||
// 计算统计数据
|
||||
const usageStats: UsageStats = {
|
||||
totalSessions: conversations.length,
|
||||
totalMessages: 0,
|
||||
totalTokens: 0,
|
||||
byModel: {},
|
||||
};
|
||||
|
||||
for (const conv of conversations) {
|
||||
const messages = conv.messages || [];
|
||||
usageStats.totalMessages += messages.length;
|
||||
|
||||
// 估算 token 数量 (粗略估算: 中文约 1.5 字符/token, 英文约 4 字符/token)
|
||||
for (const msg of messages) {
|
||||
const content = msg.content || '';
|
||||
// 简单估算: 每个字符约 0.3 token (混合中英文的平均值)
|
||||
const estimatedTokens = Math.ceil(content.length * 0.3);
|
||||
usageStats.totalTokens += estimatedTokens;
|
||||
|
||||
// 按模型分组 (使用 currentModel 或默认)
|
||||
const model = state.currentModel || 'default';
|
||||
if (!usageStats.byModel[model]) {
|
||||
usageStats.byModel[model] = { messages: 0, inputTokens: 0, outputTokens: 0 };
|
||||
}
|
||||
usageStats.byModel[model].messages++;
|
||||
if (msg.role === 'user') {
|
||||
usageStats.byModel[model].inputTokens += estimatedTokens;
|
||||
} else {
|
||||
usageStats.byModel[model].outputTokens += estimatedTokens;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
set({ usageStats });
|
||||
} catch (error) {
|
||||
console.error('[GatewayStore] Failed to calculate local usage stats:', error);
|
||||
set({ usageStats: { totalSessions: 0, totalMessages: 0, totalTokens: 0, byModel: {} } });
|
||||
}
|
||||
},
|
||||
|
||||
loadPluginStatus: async () => {
|
||||
@@ -1191,9 +1251,15 @@ export const useGatewayStore = create<GatewayStore>((set, get) => {
|
||||
// === OpenFang Actions ===
|
||||
|
||||
loadHands: async () => {
|
||||
const client = get().client;
|
||||
if (!client) {
|
||||
console.warn('[GatewayStore] No client available, skipping loadHands');
|
||||
return;
|
||||
}
|
||||
|
||||
set({ isLoading: true });
|
||||
try {
|
||||
const result = await get().client.listHands();
|
||||
const result = await client.listHands();
|
||||
// Map API response to Hand interface
|
||||
const validStatuses = ['idle', 'running', 'needs_approval', 'error', 'unavailable', 'setup_needed'] as const;
|
||||
const hands: Hand[] = (result?.hands || []).map(h => {
|
||||
@@ -1213,8 +1279,10 @@ export const useGatewayStore = create<GatewayStore>((set, get) => {
|
||||
};
|
||||
});
|
||||
set({ hands, isLoading: false });
|
||||
} catch {
|
||||
set({ isLoading: false });
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
console.warn('[GatewayStore] Failed to load hands:', errorMsg);
|
||||
set({ hands: [], isLoading: false });
|
||||
/* ignore if hands API not available */
|
||||
}
|
||||
},
|
||||
@@ -1294,11 +1362,15 @@ export const useGatewayStore = create<GatewayStore>((set, get) => {
|
||||
},
|
||||
|
||||
triggerHand: async (name: string, params?: Record<string, unknown>) => {
|
||||
console.log(`[GatewayStore] Triggering hand: ${name}`, params);
|
||||
try {
|
||||
const result = await get().client.triggerHand(name, params);
|
||||
console.log(`[GatewayStore] Hand trigger result:`, result);
|
||||
return result ? { runId: result.runId, status: result.status, startedAt: new Date().toISOString() } : undefined;
|
||||
} catch (err: unknown) {
|
||||
set({ error: err instanceof Error ? err.message : String(err) });
|
||||
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[GatewayStore] Hand trigger error:`, errorMsg, err);
|
||||
set({ error: errorMsg });
|
||||
return undefined;
|
||||
}
|
||||
},
|
||||
@@ -1326,12 +1398,20 @@ export const useGatewayStore = create<GatewayStore>((set, get) => {
|
||||
},
|
||||
|
||||
loadWorkflows: async () => {
|
||||
const client = get().client;
|
||||
if (!client) {
|
||||
console.warn('[GatewayStore] No client available, skipping loadWorkflows');
|
||||
return;
|
||||
}
|
||||
|
||||
set({ isLoading: true });
|
||||
try {
|
||||
const result = await get().client.listWorkflows();
|
||||
const result = await client.listWorkflows();
|
||||
set({ workflows: result?.workflows || [], isLoading: false });
|
||||
} catch {
|
||||
set({ isLoading: false });
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
console.warn('[GatewayStore] Failed to load workflows:', errorMsg);
|
||||
set({ workflows: [], isLoading: false });
|
||||
/* ignore if workflows API not available */
|
||||
}
|
||||
},
|
||||
@@ -1677,7 +1757,28 @@ export const useGatewayStore = create<GatewayStore>((set, get) => {
|
||||
try {
|
||||
set({ modelsLoading: true, modelsError: null });
|
||||
const result = await get().client.listModels();
|
||||
const models: GatewayModelChoice[] = result?.models || [];
|
||||
const rawModels: GatewayModelChoice[] = result?.models || [];
|
||||
|
||||
// 获取用户启用的 provider 列表
|
||||
const enabledProviders = get().quickConfig.enabledProviders as string[] | undefined;
|
||||
|
||||
// 去重:基于 id 去重,保留第一个出现的
|
||||
const seen = new Set<string>();
|
||||
const models: GatewayModelChoice[] = rawModels.filter(model => {
|
||||
if (seen.has(model.id)) {
|
||||
return false;
|
||||
}
|
||||
seen.add(model.id);
|
||||
|
||||
// 如果用户配置了 enabledProviders,只显示启用的 provider 的模型
|
||||
if (enabledProviders && enabledProviders.length > 0) {
|
||||
// 从模型 ID 中提取 provider(格式:provider/model-id)
|
||||
const provider = model.id.split('/')[0];
|
||||
return enabledProviders.includes(provider);
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
set({ models, modelsLoading: false });
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to load models';
|
||||
@@ -1708,7 +1809,9 @@ export const useGatewayStore = create<GatewayStore>((set, get) => {
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
// Dev-only: Expose store to window for E2E testing
|
||||
if (import.meta.env.DEV && typeof window !== 'undefined') {
|
||||
(window as any).__ZCLAW_STORES__ = (window as any).__ZCLAW_STORES__ || {};
|
||||
(window as any).__ZCLAW_STORES__.gateway = useGatewayStore;
|
||||
}
|
||||
|
||||
|
||||
@@ -206,11 +206,17 @@ export const useHandStore = create<HandStore>((set, get) => ({
|
||||
|
||||
loadHands: async () => {
|
||||
const client = get().client;
|
||||
if (!client) return;
|
||||
console.log('[HandStore] loadHands called, client:', !!client);
|
||||
if (!client) {
|
||||
console.warn('[HandStore] No client available, skipping loadHands');
|
||||
return;
|
||||
}
|
||||
|
||||
set({ isLoading: true });
|
||||
try {
|
||||
console.log('[HandStore] Calling client.listHands()...');
|
||||
const result = await client.listHands();
|
||||
console.log('[HandStore] listHands result:', result);
|
||||
const validStatuses = ['idle', 'running', 'needs_approval', 'error', 'unavailable', 'setup_needed'] as const;
|
||||
const hands: Hand[] = (result?.hands || []).map((h: Record<string, unknown>) => {
|
||||
const status = validStatuses.includes(h.status as Hand['status'])
|
||||
@@ -228,8 +234,10 @@ export const useHandStore = create<HandStore>((set, get) => ({
|
||||
metricCount: (h.metric_count as number) || ((h.metrics as unknown[])?.length),
|
||||
};
|
||||
});
|
||||
console.log('[HandStore] Mapped hands:', hands.length, 'items');
|
||||
set({ hands, isLoading: false });
|
||||
} catch {
|
||||
} catch (err) {
|
||||
console.error('[HandStore] loadHands error:', err);
|
||||
set({ isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
@@ -139,11 +139,25 @@ export const useTeamStore = create<TeamStoreState>()(
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
// For now, load from localStorage until API is available
|
||||
// Note: persist middleware stores data as { state: { teams: [...] }, version: ... }
|
||||
const stored = localStorage.getItem('zclaw-teams');
|
||||
const teams: Team[] = stored ? parseJsonOrDefault<Team[]>(stored, []) : [];
|
||||
let teams: Team[] = [];
|
||||
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored);
|
||||
// Handle persist middleware format
|
||||
if (parsed?.state?.teams && Array.isArray(parsed.state.teams)) {
|
||||
teams = parsed.state.teams;
|
||||
} else if (Array.isArray(parsed)) {
|
||||
// Direct array format (legacy)
|
||||
teams = parsed;
|
||||
}
|
||||
}
|
||||
|
||||
set({ teams, isLoading: false });
|
||||
} catch (error) {
|
||||
set({ error: (error as Error).message, isLoading: false });
|
||||
console.error('[TeamStore] Failed to load teams:', error);
|
||||
set({ teams: [], isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
384
desktop/src/types/automation.ts
Normal file
@@ -0,0 +1,384 @@
|
||||
/**
|
||||
* Automation Type Adapters for ZCLAW
|
||||
*
|
||||
* This module provides unified types for the Automation system,
|
||||
* combining Hands and Workflows into a single AutomationItem type.
|
||||
*
|
||||
* @module types/automation
|
||||
*/
|
||||
|
||||
import type { Hand, HandStatus, HandParameter } from './hands';
|
||||
import { HAND_DEFINITIONS } from './hands';
|
||||
import type { Workflow, WorkflowRunStatus } from './workflow';
|
||||
|
||||
// === Category Types ===
|
||||
|
||||
/**
|
||||
* Category types for classifying automation items
|
||||
*/
|
||||
export type CategoryType = 'all' | 'research' | 'data' | 'automation' | 'communication' | 'content' | 'productivity';
|
||||
|
||||
/**
|
||||
* Category configuration for display
|
||||
*/
|
||||
export interface CategoryConfig {
|
||||
id: CategoryType;
|
||||
label: string;
|
||||
icon: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Category statistics for filtering UI
|
||||
*/
|
||||
export interface CategoryStats {
|
||||
all: number;
|
||||
research: number;
|
||||
data: number;
|
||||
automation: number;
|
||||
communication: number;
|
||||
content: number;
|
||||
productivity: number;
|
||||
}
|
||||
|
||||
// === Category Mapping for Hands ===
|
||||
|
||||
/**
|
||||
* Maps Hand IDs to their categories
|
||||
*/
|
||||
export const HAND_CATEGORY_MAP: Record<string, CategoryType> = {
|
||||
researcher: 'research',
|
||||
browser: 'research',
|
||||
collector: 'data',
|
||||
predictor: 'data',
|
||||
lead: 'communication',
|
||||
twitter: 'communication',
|
||||
clip: 'content',
|
||||
};
|
||||
|
||||
/**
|
||||
* Category configurations for UI display
|
||||
*/
|
||||
export const CATEGORY_CONFIGS: Record<CategoryType, CategoryConfig> = {
|
||||
all: {
|
||||
id: 'all',
|
||||
label: '全部',
|
||||
icon: 'Layers',
|
||||
description: '所有自动化项目',
|
||||
},
|
||||
research: {
|
||||
id: 'research',
|
||||
label: '研究',
|
||||
icon: 'Search',
|
||||
description: '深度研究和浏览器自动化',
|
||||
},
|
||||
data: {
|
||||
id: 'data',
|
||||
label: '数据',
|
||||
icon: 'Database',
|
||||
description: '数据收集和预测分析',
|
||||
},
|
||||
automation: {
|
||||
id: 'automation',
|
||||
label: '自动化',
|
||||
icon: 'Zap',
|
||||
description: '工作流和触发器',
|
||||
},
|
||||
communication: {
|
||||
id: 'communication',
|
||||
label: '通信',
|
||||
icon: 'MessageSquare',
|
||||
description: '销售线索和社交媒体',
|
||||
},
|
||||
content: {
|
||||
id: 'content',
|
||||
label: '内容',
|
||||
icon: 'Video',
|
||||
description: '视频和内容处理',
|
||||
},
|
||||
productivity: {
|
||||
id: 'productivity',
|
||||
label: '生产力',
|
||||
icon: 'TrendingUp',
|
||||
description: '效率提升工具',
|
||||
},
|
||||
};
|
||||
|
||||
// === Automation Item (Unified Type) ===
|
||||
|
||||
/**
|
||||
* Execution status for automation items
|
||||
*/
|
||||
export type AutomationStatus = 'idle' | 'running' | 'needs_approval' | 'error' | 'unavailable' | 'setup_needed' | 'completed' | 'paused';
|
||||
|
||||
/**
|
||||
* Item type discriminator
|
||||
*/
|
||||
export type AutomationType = 'hand' | 'workflow';
|
||||
|
||||
/**
|
||||
* Run information for last execution
|
||||
*/
|
||||
export interface RunInfo {
|
||||
runId: string;
|
||||
status: AutomationStatus;
|
||||
startedAt: string;
|
||||
completedAt?: string;
|
||||
duration?: number;
|
||||
output?: unknown;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule information for automation items
|
||||
*/
|
||||
export interface ScheduleInfo {
|
||||
enabled: boolean;
|
||||
frequency: 'once' | 'daily' | 'weekly' | 'monthly' | 'custom';
|
||||
time: { hour: number; minute: number };
|
||||
daysOfWeek?: number[]; // 0-6 for weekly
|
||||
dayOfMonth?: number; // 1-31 for monthly
|
||||
customCron?: string; // Advanced mode
|
||||
timezone: string;
|
||||
endDate?: string;
|
||||
nextRun?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified automation item type
|
||||
* Adapts both Hand and Workflow into a common interface
|
||||
*/
|
||||
export interface AutomationItem {
|
||||
// Identity
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
type: AutomationType;
|
||||
category: CategoryType;
|
||||
|
||||
// Status
|
||||
status: AutomationStatus;
|
||||
error?: string;
|
||||
|
||||
// Configuration
|
||||
parameters?: HandParameter[];
|
||||
requiresApproval: boolean;
|
||||
|
||||
// Execution info
|
||||
lastRun?: RunInfo;
|
||||
schedule?: ScheduleInfo;
|
||||
currentRunId?: string;
|
||||
|
||||
// Display
|
||||
icon?: string;
|
||||
|
||||
// Type-specific data
|
||||
handData?: Hand;
|
||||
workflowData?: Workflow;
|
||||
}
|
||||
|
||||
// === Type Adapters ===
|
||||
|
||||
/**
|
||||
* Converts Hand status to Automation status
|
||||
*/
|
||||
export function handStatusToAutomationStatus(status: HandStatus): AutomationStatus {
|
||||
const statusMap: Record<HandStatus, AutomationStatus> = {
|
||||
idle: 'idle',
|
||||
running: 'running',
|
||||
needs_approval: 'needs_approval',
|
||||
error: 'error',
|
||||
unavailable: 'unavailable',
|
||||
setup_needed: 'setup_needed',
|
||||
};
|
||||
return statusMap[status] || 'unavailable';
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts Workflow run status to Automation status
|
||||
*/
|
||||
export function workflowStatusToAutomationStatus(status: WorkflowRunStatus): AutomationStatus {
|
||||
const statusMap: Record<WorkflowRunStatus, AutomationStatus> = {
|
||||
pending: 'idle',
|
||||
running: 'running',
|
||||
completed: 'completed',
|
||||
failed: 'error',
|
||||
cancelled: 'idle',
|
||||
paused: 'paused',
|
||||
};
|
||||
return statusMap[status] || 'idle';
|
||||
}
|
||||
|
||||
/**
|
||||
* Adapts a Hand to an AutomationItem
|
||||
* Merges name, description, and parameters from HAND_DEFINITIONS (中文优先)
|
||||
*/
|
||||
export function handToAutomationItem(hand: Hand): AutomationItem {
|
||||
const category = HAND_CATEGORY_MAP[hand.id] || HAND_CATEGORY_MAP[hand.name.toLowerCase()] || 'productivity';
|
||||
|
||||
// Normalize hand id/name for matching (remove " Hand" suffix if present)
|
||||
const normalizedId = hand.id.toLowerCase().replace(/\s*hand$/i, '');
|
||||
const normalizedName = hand.name.toLowerCase().replace(/\s*hand$/i, '');
|
||||
|
||||
// Find matching definition by id or name to get Chinese content
|
||||
const definition = HAND_DEFINITIONS.find(
|
||||
d => d.id === normalizedId || d.id === normalizedName || d.id === hand.id.toLowerCase()
|
||||
);
|
||||
|
||||
// Use Chinese name and description from definition, fall back to API data
|
||||
const name = definition?.name || hand.name;
|
||||
const description = definition?.description || hand.description;
|
||||
|
||||
// Try to get parameters from hand, or fall back to HAND_DEFINITIONS
|
||||
let parameters = hand.parameters;
|
||||
if ((!parameters || parameters.length === 0) && definition) {
|
||||
parameters = definition.parameters;
|
||||
}
|
||||
|
||||
return {
|
||||
id: hand.id,
|
||||
name,
|
||||
description,
|
||||
type: 'hand',
|
||||
category,
|
||||
status: handStatusToAutomationStatus(hand.status),
|
||||
error: hand.error,
|
||||
parameters,
|
||||
requiresApproval: false, // Will be determined by execution result
|
||||
lastRun: hand.lastRun ? {
|
||||
runId: hand.lastRun,
|
||||
status: 'completed',
|
||||
startedAt: hand.lastRun,
|
||||
} : undefined,
|
||||
icon: hand.icon,
|
||||
handData: hand,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Adapts a Workflow to an AutomationItem
|
||||
* Handles both store Workflow (steps: number) and full Workflow (steps: WorkflowStep[])
|
||||
*/
|
||||
export function workflowToAutomationItem(workflow: Workflow | { id: string; name: string; steps: number; description?: string; createdAt?: string }): AutomationItem {
|
||||
// For store workflows with steps as number, default to automation category
|
||||
const stepsArray = Array.isArray(workflow.steps) ? workflow.steps : [];
|
||||
|
||||
// Determine category based on workflow steps (only if steps is an array)
|
||||
let category: CategoryType = 'automation';
|
||||
if (stepsArray.length > 0 && 'handName' in stepsArray[0]) {
|
||||
const typedSteps = stepsArray as Array<{ handName?: string }>;
|
||||
if (typedSteps.some(s => s.handName === 'researcher' || s.handName === 'browser')) {
|
||||
category = 'research';
|
||||
} else if (typedSteps.some(s => s.handName === 'collector' || s.handName === 'predictor')) {
|
||||
category = 'data';
|
||||
} else if (typedSteps.some(s => s.handName === 'lead' || s.handName === 'twitter')) {
|
||||
category = 'communication';
|
||||
} else if (typedSteps.some(s => s.handName === 'clip')) {
|
||||
category = 'content';
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: workflow.id,
|
||||
name: workflow.name,
|
||||
description: workflow.description || '',
|
||||
type: 'workflow',
|
||||
category,
|
||||
status: 'idle',
|
||||
requiresApproval: false,
|
||||
workflowData: 'steps' in workflow && Array.isArray(workflow.steps) ? workflow as Workflow : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Store Workflow type (from gatewayStore/workflowStore)
|
||||
* Has steps as number (count) instead of array
|
||||
*/
|
||||
export interface StoreWorkflow {
|
||||
id: string;
|
||||
name: string;
|
||||
steps: number;
|
||||
description?: string;
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adapts an array of Hands and Workflows to AutomationItems
|
||||
* Accepts both full Workflow type and store Workflow type
|
||||
*/
|
||||
export function adaptToAutomationItems(
|
||||
hands: Hand[] = [],
|
||||
workflows: (Workflow | StoreWorkflow)[] = []
|
||||
): AutomationItem[] {
|
||||
const handItems = hands.map(handToAutomationItem);
|
||||
const workflowItems = workflows.map(workflowToAutomationItem);
|
||||
return [...handItems, ...workflowItems];
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates category statistics from automation items
|
||||
*/
|
||||
export function calculateCategoryStats(items: AutomationItem[]): CategoryStats {
|
||||
const stats: CategoryStats = {
|
||||
all: items.length,
|
||||
research: 0,
|
||||
data: 0,
|
||||
automation: 0,
|
||||
communication: 0,
|
||||
content: 0,
|
||||
productivity: 0,
|
||||
};
|
||||
|
||||
for (const item of items) {
|
||||
if (item.category !== 'all') {
|
||||
stats[item.category]++;
|
||||
}
|
||||
}
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters automation items by category
|
||||
*/
|
||||
export function filterByCategory(items: AutomationItem[], category: CategoryType): AutomationItem[] {
|
||||
if (category === 'all') {
|
||||
return items;
|
||||
}
|
||||
return items.filter(item => item.category === category);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters automation items by type
|
||||
*/
|
||||
export function filterByType(items: AutomationItem[], type: AutomationType | 'all'): AutomationItem[] {
|
||||
if (type === 'all') {
|
||||
return items;
|
||||
}
|
||||
return items.filter(item => item.type === type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters automation items by status
|
||||
*/
|
||||
export function filterByStatus(items: AutomationItem[], statuses: AutomationStatus[]): AutomationItem[] {
|
||||
if (statuses.length === 0) {
|
||||
return items;
|
||||
}
|
||||
return items.filter(item => statuses.includes(item.status));
|
||||
}
|
||||
|
||||
/**
|
||||
* Searches automation items by name or description
|
||||
*/
|
||||
export function searchAutomationItems(items: AutomationItem[], query: string): AutomationItem[] {
|
||||
if (!query.trim()) {
|
||||
return items;
|
||||
}
|
||||
const lowerQuery = query.toLowerCase();
|
||||
return items.filter(
|
||||
item =>
|
||||
item.name.toLowerCase().includes(lowerQuery) ||
|
||||
item.description.toLowerCase().includes(lowerQuery)
|
||||
);
|
||||
}
|
||||
@@ -1,19 +1,20 @@
|
||||
/**
|
||||
* OpenFang Hands and Workflow Types
|
||||
*
|
||||
* OpenFang provides 7 autonomous capability packages (Hands):
|
||||
* - Clip: Video processing
|
||||
* - Lead: Sales lead management
|
||||
* - Collector: Data collection
|
||||
* - Predictor: Predictive analytics
|
||||
* - Researcher: Deep research
|
||||
* - Twitter: Twitter automation
|
||||
* - Browser: Browser automation
|
||||
* ZCLAW 提供 8 个自主能力包 (Hands):
|
||||
* - Clip: 视频处理
|
||||
* - Lead: 销售线索管理
|
||||
* - Collector: 数据收集
|
||||
* - Predictor: 预测分析
|
||||
* - Researcher: 深度研究
|
||||
* - Twitter: Twitter 自动化
|
||||
* - Browser: 浏览器自动化
|
||||
* - Trader: 交易分析
|
||||
*/
|
||||
|
||||
export type HandStatus = 'idle' | 'running' | 'needs_approval' | 'error' | 'unavailable' | 'setup_needed';
|
||||
|
||||
export type HandId = 'clip' | 'lead' | 'collector' | 'predictor' | 'researcher' | 'twitter' | 'browser';
|
||||
export type HandId = 'clip' | 'lead' | 'collector' | 'predictor' | 'researcher' | 'twitter' | 'browser' | 'trader';
|
||||
|
||||
export type HandParameterType = 'text' | 'number' | 'select' | 'textarea' | 'boolean' | 'array' | 'object' | 'file';
|
||||
|
||||
@@ -92,111 +93,131 @@ export interface WorkflowExecutionResult {
|
||||
completedAt: string;
|
||||
}
|
||||
|
||||
// Hand definitions with metadata
|
||||
// Hand definitions with metadata (中文化)
|
||||
export const HAND_DEFINITIONS: Array<Omit<Hand, 'status' | 'lastRun' | 'lastResult' | 'error'>> = [
|
||||
{
|
||||
id: 'clip',
|
||||
name: 'Clip',
|
||||
description: 'Video processing and editing automation',
|
||||
name: 'Clip 视频处理',
|
||||
description: '将长视频转换为短视频片段,自动生成字幕和封面',
|
||||
icon: 'Video',
|
||||
parameters: [
|
||||
{ name: 'inputPath', label: 'Input Path', type: 'text', required: true, placeholder: 'Video file or URL' },
|
||||
{ name: 'outputFormat', label: 'Output Format', type: 'select', required: false, options: [
|
||||
{ name: 'inputPath', label: '输入路径', type: 'text', required: true, placeholder: '视频文件或链接' },
|
||||
{ name: 'outputFormat', label: '输出格式', type: 'select', required: false, options: [
|
||||
{ value: 'mp4', label: 'MP4' },
|
||||
{ value: 'webm', label: 'WebM' },
|
||||
{ value: 'gif', label: 'GIF' },
|
||||
], defaultValue: 'mp4' },
|
||||
{ name: 'trimStart', label: 'Start Time', type: 'number', required: false, placeholder: 'Seconds' },
|
||||
{ name: 'trimEnd', label: 'End Time', type: 'number', required: false, placeholder: 'Seconds' },
|
||||
{ name: 'trimStart', label: '开始时间', type: 'number', required: false, placeholder: '秒' },
|
||||
{ name: 'trimEnd', label: '结束时间', type: 'number', required: false, placeholder: '秒' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'lead',
|
||||
name: 'Lead',
|
||||
description: 'Sales lead generation and management',
|
||||
name: 'Lead 线索发现',
|
||||
description: '自动发现、丰富和交付合格的销售线索',
|
||||
icon: 'UserPlus',
|
||||
parameters: [
|
||||
{ name: 'source', label: 'Data Source', type: 'select', required: true, options: [
|
||||
{ name: 'source', label: '数据来源', type: 'select', required: true, options: [
|
||||
{ value: 'linkedin', label: 'LinkedIn' },
|
||||
{ value: 'crunchbase', label: 'Crunchbase' },
|
||||
{ value: 'custom', label: 'Custom List' },
|
||||
{ value: 'custom', label: '自定义列表' },
|
||||
] },
|
||||
{ name: 'query', label: 'Search Query', type: 'textarea', required: true, placeholder: 'Enter search criteria' },
|
||||
{ name: 'maxResults', label: 'Max Results', type: 'number', required: false, defaultValue: 50 },
|
||||
{ name: 'query', label: '搜索条件', type: 'textarea', required: true, placeholder: '输入搜索条件' },
|
||||
{ name: 'maxResults', label: '最大结果数', type: 'number', required: false, defaultValue: 50 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'collector',
|
||||
name: 'Collector',
|
||||
description: 'Automated data collection and aggregation',
|
||||
name: 'Collector 数据采集',
|
||||
description: '自动收集和聚合数据,支持变更检测和知识图谱',
|
||||
icon: 'Database',
|
||||
parameters: [
|
||||
{ name: 'targetUrl', label: 'Target URL', type: 'text', required: true, placeholder: 'URL to scrape' },
|
||||
{ name: 'selector', label: 'CSS Selector', type: 'text', required: false, placeholder: 'Elements to extract' },
|
||||
{ name: 'outputFormat', label: 'Output Format', type: 'select', required: false, options: [
|
||||
{ name: 'targetUrl', label: '目标网址', type: 'text', required: true, placeholder: '要采集的网址' },
|
||||
{ name: 'selector', label: 'CSS 选择器', type: 'text', required: false, placeholder: '要提取的元素' },
|
||||
{ name: 'outputFormat', label: '输出格式', type: 'select', required: false, options: [
|
||||
{ value: 'json', label: 'JSON' },
|
||||
{ value: 'csv', label: 'CSV' },
|
||||
{ value: 'xlsx', label: 'Excel' },
|
||||
], defaultValue: 'json' },
|
||||
{ name: 'pagination', label: 'Follow Pagination', type: 'boolean', required: false, defaultValue: false },
|
||||
{ name: 'pagination', label: '跟踪分页', type: 'boolean', required: false, defaultValue: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'predictor',
|
||||
name: 'Predictor',
|
||||
description: 'Predictive analytics and forecasting',
|
||||
name: 'Predictor 预测分析',
|
||||
description: '收集信号、构建推理链、进行校准预测并跟踪准确性',
|
||||
icon: 'TrendingUp',
|
||||
parameters: [
|
||||
{ name: 'dataSource', label: 'Data Source', type: 'text', required: true, placeholder: 'Data file path or URL' },
|
||||
{ name: 'model', label: 'Model Type', type: 'select', required: true, options: [
|
||||
{ value: 'regression', label: 'Regression' },
|
||||
{ value: 'classification', label: 'Classification' },
|
||||
{ value: 'timeseries', label: 'Time Series' },
|
||||
{ name: 'dataSource', label: '数据源', type: 'text', required: true, placeholder: '数据文件路径或链接' },
|
||||
{ name: 'model', label: '模型类型', type: 'select', required: true, options: [
|
||||
{ value: 'regression', label: '回归分析' },
|
||||
{ value: 'classification', label: '分类预测' },
|
||||
{ value: 'timeseries', label: '时间序列' },
|
||||
] },
|
||||
{ name: 'targetColumn', label: 'Target Column', type: 'text', required: true },
|
||||
{ name: 'targetColumn', label: '目标列', type: 'text', required: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'researcher',
|
||||
name: 'Researcher',
|
||||
description: 'Deep research and analysis automation',
|
||||
name: 'Researcher 深度研究',
|
||||
description: '进行详尽调查、交叉验证、事实核查和结构化报告',
|
||||
icon: 'Search',
|
||||
parameters: [
|
||||
{ name: 'topic', label: 'Research Topic', type: 'textarea', required: true, placeholder: 'Enter research topic' },
|
||||
{ name: 'depth', label: 'Research Depth', type: 'select', required: false, options: [
|
||||
{ value: 'shallow', label: 'Quick Overview' },
|
||||
{ value: 'medium', label: 'Standard Research' },
|
||||
{ value: 'deep', label: 'Comprehensive Analysis' },
|
||||
{ name: 'topic', label: '研究主题', type: 'textarea', required: true, placeholder: '输入研究主题' },
|
||||
{ name: 'depth', label: '研究深度', type: 'select', required: false, options: [
|
||||
{ value: 'shallow', label: '快速概览' },
|
||||
{ value: 'medium', label: '标准研究' },
|
||||
{ value: 'deep', label: '深度分析' },
|
||||
], defaultValue: 'medium' },
|
||||
{ name: 'sources', label: 'Max Sources', type: 'number', required: false, defaultValue: 10 },
|
||||
{ name: 'sources', label: '最大来源数', type: 'number', required: false, defaultValue: 10 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'twitter',
|
||||
name: 'Twitter',
|
||||
description: 'Twitter/X automation and engagement',
|
||||
name: 'Twitter 自动化',
|
||||
description: 'Twitter/X 内容创作、定时发布、互动和效果跟踪',
|
||||
icon: 'Twitter',
|
||||
parameters: [
|
||||
{ name: 'action', label: 'Action Type', type: 'select', required: true, options: [
|
||||
{ value: 'post', label: 'Post Tweet' },
|
||||
{ value: 'search', label: 'Search Tweets' },
|
||||
{ value: 'analyze', label: 'Analyze Trends' },
|
||||
{ value: 'engage', label: 'Engage (Like/Reply)' },
|
||||
{ name: 'action', label: '操作类型', type: 'select', required: true, options: [
|
||||
{ value: 'post', label: '发布推文' },
|
||||
{ value: 'search', label: '搜索推文' },
|
||||
{ value: 'analyze', label: '分析趋势' },
|
||||
{ value: 'engage', label: '互动 (点赞/回复)' },
|
||||
] },
|
||||
{ name: 'content', label: 'Content', type: 'textarea', required: false, placeholder: 'Tweet content or search query' },
|
||||
{ name: 'schedule', label: 'Schedule Time', type: 'text', required: false, placeholder: 'ISO datetime or "now"' },
|
||||
{ name: 'content', label: '内容', type: 'textarea', required: false, placeholder: '推文内容或搜索关键词' },
|
||||
{ name: 'schedule', label: '定时发布', type: 'text', required: false, placeholder: '时间或 "now"' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'browser',
|
||||
name: 'Browser',
|
||||
description: 'Browser automation and web interaction',
|
||||
name: 'Browser 浏览器自动化',
|
||||
description: '自动浏览网站、填写表单、点击按钮,完成多步骤网页任务',
|
||||
icon: 'Globe',
|
||||
parameters: [
|
||||
{ name: 'url', label: 'Starting URL', type: 'text', required: true, placeholder: 'https://example.com' },
|
||||
{ name: 'actions', label: 'Actions', type: 'textarea', required: true, placeholder: 'List of actions to perform' },
|
||||
{ name: 'headless', label: 'Headless Mode', type: 'boolean', required: false, defaultValue: true },
|
||||
{ name: 'timeout', label: 'Timeout (seconds)', type: 'number', required: false, defaultValue: 30 },
|
||||
{ name: 'url', label: '起始网址', type: 'text', required: true, placeholder: 'https://example.com' },
|
||||
{ name: 'actions', label: '操作步骤', type: 'textarea', required: true, placeholder: '要执行的操作列表' },
|
||||
{ name: 'headless', label: '无头模式', type: 'boolean', required: false, defaultValue: true },
|
||||
{ name: 'timeout', label: '超时时间 (秒)', type: 'number', required: false, defaultValue: 30 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'trader',
|
||||
name: 'Trader 交易分析',
|
||||
description: '多信号分析、多空推理、校准置信度评分、严格风险管理和投资组合分析',
|
||||
icon: 'TrendingUp',
|
||||
parameters: [
|
||||
{ name: 'symbol', label: '交易标的', type: 'text', required: true, placeholder: '股票代码或加密货币' },
|
||||
{ name: 'analysisType', label: '分析类型', type: 'select', required: false, options: [
|
||||
{ value: 'technical', label: '技术分析' },
|
||||
{ value: 'fundamental', label: '基本面分析' },
|
||||
{ value: 'sentiment', label: '情绪分析' },
|
||||
], defaultValue: 'technical' },
|
||||
{ name: 'timeframe', label: '时间周期', type: 'select', required: false, options: [
|
||||
{ value: '1h', label: '1小时' },
|
||||
{ value: '4h', label: '4小时' },
|
||||
{ value: '1d', label: '1天' },
|
||||
{ value: '1w', label: '1周' },
|
||||
], defaultValue: '1d' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -149,3 +149,31 @@ export {
|
||||
createErrorResponse,
|
||||
createPaginatedResponse,
|
||||
} from './api-responses';
|
||||
|
||||
// Automation Types
|
||||
export type {
|
||||
CategoryType,
|
||||
CategoryConfig,
|
||||
CategoryStats,
|
||||
AutomationStatus,
|
||||
AutomationType,
|
||||
RunInfo,
|
||||
ScheduleInfo,
|
||||
AutomationItem,
|
||||
} from './automation';
|
||||
|
||||
// Automation Constants and Functions
|
||||
export {
|
||||
HAND_CATEGORY_MAP,
|
||||
CATEGORY_CONFIGS,
|
||||
handStatusToAutomationStatus,
|
||||
workflowStatusToAutomationStatus,
|
||||
handToAutomationItem,
|
||||
workflowToAutomationItem,
|
||||
adaptToAutomationItems,
|
||||
calculateCategoryStats,
|
||||
filterByCategory,
|
||||
filterByType,
|
||||
filterByStatus,
|
||||
searchAutomationItems,
|
||||
} from './automation';
|
||||
|
||||
@@ -13,6 +13,10 @@ export default {
|
||||
hover: 'var(--color-primary-hover)',
|
||||
light: 'var(--color-primary-light)',
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: 'var(--color-accent)',
|
||||
hover: 'var(--color-accent-hover)',
|
||||
},
|
||||
success: 'var(--color-success)',
|
||||
warning: 'var(--color-warning)',
|
||||
error: 'var(--color-error)',
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"status": "passed",
|
||||
"failedTests": []
|
||||
}
|
||||
|
Before Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 75 KiB |
|
Before Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 75 KiB |
|
Before Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 75 KiB |
|
Before Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 75 KiB |
|
Before Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 74 KiB |
788
desktop/tests/e2e/fixtures/mock-gateway.ts
Normal file
@@ -0,0 +1,788 @@
|
||||
/**
|
||||
* Gateway Mock 工具
|
||||
* 模拟后端 Gateway 服务,用于独立测试前端功能
|
||||
* 基于实际 API 端点: http://127.0.0.1:50051
|
||||
*/
|
||||
|
||||
import { Page } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Mock 响应数据模板 - 基于实际 API 响应格式
|
||||
*/
|
||||
export const mockResponses = {
|
||||
// 健康检查
|
||||
health: {
|
||||
status: 'ok',
|
||||
version: '0.4.0-mock',
|
||||
},
|
||||
|
||||
// 模型列表 - 来自 /api/models
|
||||
models: [
|
||||
{ id: 'claude-3-opus-20240229', name: 'Claude 3 Opus', provider: 'anthropic' },
|
||||
{ id: 'claude-3-sonnet-20240229', name: 'Claude 3 Sonnet', provider: 'anthropic' },
|
||||
{ id: 'claude-3-haiku-20240307', name: 'Claude 3 Haiku', provider: 'anthropic' },
|
||||
{ id: 'claude-sonnet-4-20250514', name: 'Claude Sonnet 4', provider: 'anthropic' },
|
||||
{ id: 'gpt-4o', name: 'GPT-4o', provider: 'openai' },
|
||||
],
|
||||
|
||||
// Agent/分身列表 - 来自 /api/agents
|
||||
agents: [
|
||||
{
|
||||
id: 'default-agent',
|
||||
name: 'ZCLAW',
|
||||
role: 'AI Assistant',
|
||||
nickname: 'ZCLAW',
|
||||
model: 'claude-sonnet-4-20250514',
|
||||
createdAt: new Date().toISOString(),
|
||||
bootstrapReady: true,
|
||||
onboardingCompleted: true,
|
||||
},
|
||||
],
|
||||
|
||||
// Hands 列表 - 来自 /api/hands
|
||||
hands: [
|
||||
{
|
||||
id: 'browser',
|
||||
name: 'Browser',
|
||||
description: '浏览器自动化能力包',
|
||||
status: 'idle',
|
||||
requirements_met: true,
|
||||
category: 'automation',
|
||||
icon: '🌐',
|
||||
tool_count: 4,
|
||||
metric_count: 0,
|
||||
},
|
||||
{
|
||||
id: 'collector',
|
||||
name: 'Collector',
|
||||
description: '数据收集聚合能力包',
|
||||
status: 'idle',
|
||||
requirements_met: true,
|
||||
category: 'data',
|
||||
icon: '📊',
|
||||
tool_count: 3,
|
||||
metric_count: 2,
|
||||
},
|
||||
{
|
||||
id: 'researcher',
|
||||
name: 'Researcher',
|
||||
description: '深度研究能力包',
|
||||
status: 'idle',
|
||||
requirements_met: true,
|
||||
category: 'research',
|
||||
icon: '🔍',
|
||||
tool_count: 5,
|
||||
metric_count: 1,
|
||||
},
|
||||
{
|
||||
id: 'predictor',
|
||||
name: 'Predictor',
|
||||
description: '预测分析能力包',
|
||||
status: 'setup_needed',
|
||||
requirements_met: false,
|
||||
category: 'analytics',
|
||||
icon: '📈',
|
||||
tool_count: 2,
|
||||
metric_count: 3,
|
||||
},
|
||||
],
|
||||
|
||||
// 工作流列表 - 来自 /api/workflows
|
||||
workflows: [
|
||||
{
|
||||
id: 'wf-default',
|
||||
name: '示例工作流',
|
||||
description: '演示用工作流',
|
||||
steps: [],
|
||||
status: 'idle',
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
|
||||
// 触发器列表 - 来自 /api/triggers
|
||||
triggers: [
|
||||
{
|
||||
id: 'trigger-1',
|
||||
type: 'webhook',
|
||||
name: '示例触发器',
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
|
||||
// 技能列表 - 来自 /api/skills
|
||||
skills: [
|
||||
{
|
||||
id: 'skill-code-review',
|
||||
name: '代码审查',
|
||||
description: '自动审查代码质量',
|
||||
category: 'development',
|
||||
triggers: ['review', 'audit'],
|
||||
installed: true,
|
||||
},
|
||||
{
|
||||
id: 'skill-doc-gen',
|
||||
name: '文档生成',
|
||||
description: '自动生成代码文档',
|
||||
category: 'development',
|
||||
triggers: ['doc', 'document'],
|
||||
installed: false,
|
||||
},
|
||||
],
|
||||
|
||||
// 审批列表 - 来自 /api/approvals
|
||||
approvals: [],
|
||||
|
||||
// 会话列表 - 来自 /api/sessions
|
||||
sessions: [],
|
||||
|
||||
// 用量统计 - 来自 /api/stats/usage
|
||||
usageStats: {
|
||||
totalSessions: 10,
|
||||
totalMessages: 100,
|
||||
totalTokens: 50000,
|
||||
byModel: {
|
||||
'claude-sonnet-4-20250514': { messages: 60, inputTokens: 20000, outputTokens: 15000 },
|
||||
'claude-3-haiku-20240307': { messages: 40, inputTokens: 10000, outputTokens: 5000 },
|
||||
},
|
||||
},
|
||||
|
||||
// 插件状态 - 来自 /api/plugins/status
|
||||
pluginStatus: [
|
||||
{ id: 'mcp-filesystem', name: 'Filesystem MCP', status: 'active', version: '1.0.0' },
|
||||
{ id: 'mcp-github', name: 'GitHub MCP', status: 'inactive' },
|
||||
],
|
||||
|
||||
// 快速配置 - 来自 /api/config
|
||||
quickConfig: {
|
||||
userName: 'User',
|
||||
userRole: 'Developer',
|
||||
defaultModel: 'claude-sonnet-4-20250514',
|
||||
dataDir: './data',
|
||||
workspaceDir: './workspace',
|
||||
},
|
||||
|
||||
// 工作区信息 - 来自 /api/workspace
|
||||
workspace: {
|
||||
path: './workspace',
|
||||
exists: true,
|
||||
},
|
||||
|
||||
// 安全状态 - 来自 /api/security/status
|
||||
securityStatus: {
|
||||
status: 'secure',
|
||||
lastAudit: new Date().toISOString(),
|
||||
},
|
||||
|
||||
// 调度任务 - 来自 /api/scheduler/tasks
|
||||
scheduledTasks: [],
|
||||
|
||||
// 能力列表 - 来自 /api/capabilities
|
||||
capabilities: {
|
||||
hands: ['browser', 'collector', 'researcher', 'predictor'],
|
||||
models: ['claude-sonnet-4-20250514', 'claude-3-haiku-20240307'],
|
||||
plugins: ['mcp-filesystem', 'mcp-github'],
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 创建 Agent 消息响应 - 来自 POST /api/agents/{id}/message
|
||||
*/
|
||||
export function createAgentMessageResponse(content: string): object {
|
||||
return {
|
||||
response: content,
|
||||
input_tokens: 100,
|
||||
output_tokens: 50,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建流式响应数据块 - 用于 WebSocket
|
||||
*/
|
||||
export function createStreamChunks(text: string, chunkSize = 10): Array<{ delta: string; phase: string }> {
|
||||
const chunks: Array<{ delta: string; phase: string }> = [];
|
||||
const words = text.split(' ');
|
||||
|
||||
// 开始标记
|
||||
chunks.push({ delta: '', phase: 'start' });
|
||||
|
||||
// 内容块
|
||||
let current = '';
|
||||
for (const word of words) {
|
||||
current += (current ? ' ' : '') + word;
|
||||
if (current.length >= chunkSize) {
|
||||
chunks.push({ delta: current, phase: 'delta' });
|
||||
current = '';
|
||||
}
|
||||
}
|
||||
if (current) {
|
||||
chunks.push({ delta: current, phase: 'delta' });
|
||||
}
|
||||
|
||||
// 结束标记
|
||||
chunks.push({ delta: '', phase: 'end' });
|
||||
|
||||
return chunks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gateway Mock 配置
|
||||
*/
|
||||
export interface MockGatewayConfig {
|
||||
/** 是否模拟延迟 */
|
||||
simulateDelay?: boolean;
|
||||
/** 延迟时间 (ms) */
|
||||
delayMs?: number;
|
||||
/** 是否模拟错误 */
|
||||
simulateError?: boolean;
|
||||
/** 错误率 (0-1) */
|
||||
errorRate?: number;
|
||||
/** 自定义响应覆盖 */
|
||||
customResponses?: Partial<typeof mockResponses>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置完整的 Gateway Mock
|
||||
* 基于实际 API 端点: http://127.0.0.1:50051/api/*
|
||||
*/
|
||||
export async function setupMockGateway(
|
||||
page: Page,
|
||||
config: MockGatewayConfig = {}
|
||||
): Promise<void> {
|
||||
const {
|
||||
simulateDelay = false,
|
||||
delayMs = 100,
|
||||
simulateError = false,
|
||||
errorRate = 0,
|
||||
customResponses = {},
|
||||
} = config;
|
||||
|
||||
// 合并默认响应和自定义响应
|
||||
const responses = { ...mockResponses, ...customResponses };
|
||||
|
||||
// ========================================
|
||||
// 系统端点
|
||||
// ========================================
|
||||
|
||||
// Mock 健康检查 - GET /api/health
|
||||
await page.route('**/api/health', async (route) => {
|
||||
if (simulateDelay) await delay(delayMs);
|
||||
if (shouldError(simulateError, errorRate)) {
|
||||
await route.fulfill({ status: 500, body: 'Internal Server Error' });
|
||||
return;
|
||||
}
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(responses.health),
|
||||
});
|
||||
});
|
||||
|
||||
// Mock 模型列表 - GET /api/models
|
||||
await page.route('**/api/models', async (route) => {
|
||||
if (simulateDelay) await delay(delayMs);
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(responses.models),
|
||||
});
|
||||
});
|
||||
|
||||
// Mock 能力列表 - GET /api/capabilities
|
||||
await page.route('**/api/capabilities', async (route) => {
|
||||
if (simulateDelay) await delay(delayMs);
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(responses.capabilities),
|
||||
});
|
||||
});
|
||||
|
||||
// Mock 工作区 - GET /api/workspace
|
||||
await page.route('**/api/workspace', async (route) => {
|
||||
if (simulateDelay) await delay(delayMs);
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(responses.workspace),
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// Agent 端点 (分身管理)
|
||||
// ========================================
|
||||
|
||||
// Mock Agent 列表 - GET /api/agents
|
||||
await page.route('**/api/agents', async (route) => {
|
||||
if (simulateDelay) await delay(delayMs);
|
||||
const method = route.request().method();
|
||||
|
||||
if (method === 'GET') {
|
||||
// 返回格式: { agents: [...] } 或 { clones: [...] }
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ agents: responses.agents }),
|
||||
});
|
||||
} else if (method === 'POST') {
|
||||
// 创建 Agent
|
||||
const body = route.request().postDataJSON();
|
||||
const newAgent = {
|
||||
id: `agent-${Date.now()}`,
|
||||
name: body.name || body.manifest_toml?.match(/name\s*=\s*"([^"]+)"/)?.[1] || 'New Agent',
|
||||
role: body.role || 'Assistant',
|
||||
createdAt: new Date().toISOString(),
|
||||
bootstrapReady: false,
|
||||
};
|
||||
responses.agents.push(newAgent as any);
|
||||
await route.fulfill({
|
||||
status: 201,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(newAgent),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Mock Agent 详情/更新/删除 - /api/agents/{id}
|
||||
await page.route('**/api/agents/*', async (route) => {
|
||||
if (simulateDelay) await delay(delayMs);
|
||||
const method = route.request().method();
|
||||
const url = route.request().url();
|
||||
|
||||
// 排除 message 和 ws 端点
|
||||
if (url.includes('/message') || url.includes('/ws')) {
|
||||
await route.continue();
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === 'PUT') {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ success: true }),
|
||||
});
|
||||
} else if (method === 'DELETE') {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ success: true }),
|
||||
});
|
||||
} else {
|
||||
await route.continue();
|
||||
}
|
||||
});
|
||||
|
||||
// Mock Agent 消息 - POST /api/agents/{id}/message
|
||||
await page.route('**/api/agents/*/message', async (route) => {
|
||||
if (simulateDelay) await delay(delayMs);
|
||||
if (route.request().method() === 'POST') {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(createAgentMessageResponse('这是一个模拟响应。')),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// Hands 端点
|
||||
// ========================================
|
||||
|
||||
// Mock Hands 列表 - GET /api/hands
|
||||
await page.route('**/api/hands', async (route) => {
|
||||
if (simulateDelay) await delay(delayMs);
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ hands: responses.hands }),
|
||||
});
|
||||
});
|
||||
|
||||
// Mock Hand 详情 - GET /api/hands/{name}
|
||||
await page.route('**/api/hands/*', async (route) => {
|
||||
if (simulateDelay) await delay(delayMs);
|
||||
const url = route.request().url();
|
||||
|
||||
// 排除 activate, runs 等子端点
|
||||
if (url.includes('/activate') || url.includes('/runs')) {
|
||||
await route.continue();
|
||||
return;
|
||||
}
|
||||
|
||||
const handName = url.split('/hands/')[1]?.split('?')[0].split('/')[0];
|
||||
const hand = responses.hands.find(h => h.name.toLowerCase() === handName?.toLowerCase());
|
||||
|
||||
if (hand) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(hand),
|
||||
});
|
||||
} else {
|
||||
await route.fulfill({
|
||||
status: 404,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ error: 'Hand not found' }),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Mock Hand 激活/触发 - POST /api/hands/{name}/activate
|
||||
await page.route('**/api/hands/*/activate', async (route) => {
|
||||
if (simulateDelay) await delay(delayMs);
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
runId: `run-${Date.now()}`,
|
||||
status: 'running',
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
// Mock Hand 运行状态 - GET /api/hands/{name}/runs/{runId}
|
||||
await page.route('**/api/hands/*/runs/*', async (route) => {
|
||||
if (simulateDelay) await delay(delayMs);
|
||||
const method = route.request().method();
|
||||
const url = route.request().url();
|
||||
|
||||
if (url.includes('/approve') && method === 'POST') {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ status: 'approved' }),
|
||||
});
|
||||
} else if (url.includes('/cancel') && method === 'POST') {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ status: 'cancelled' }),
|
||||
});
|
||||
} else if (method === 'GET') {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
runId: `run-${Date.now()}`,
|
||||
status: 'completed',
|
||||
startedAt: new Date().toISOString(),
|
||||
completedAt: new Date().toISOString(),
|
||||
}),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Mock Hand 运行历史 - GET /api/hands/{name}/runs
|
||||
await page.route('**/api/hands/*/runs', async (route) => {
|
||||
if (simulateDelay) await delay(delayMs);
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ runs: [] }),
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// Workflow 端点
|
||||
// ========================================
|
||||
|
||||
// Mock 工作流列表 - GET /api/workflows
|
||||
await page.route('**/api/workflows', async (route) => {
|
||||
if (simulateDelay) await delay(delayMs);
|
||||
const method = route.request().method();
|
||||
|
||||
if (method === 'GET') {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ workflows: responses.workflows }),
|
||||
});
|
||||
} else if (method === 'POST') {
|
||||
const body = route.request().postDataJSON();
|
||||
const newWorkflow = {
|
||||
id: `wf-${Date.now()}`,
|
||||
...body,
|
||||
status: 'idle',
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
responses.workflows.push(newWorkflow as any);
|
||||
await route.fulfill({
|
||||
status: 201,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(newWorkflow),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Mock 工作流执行 - POST /api/workflows/{id}/execute
|
||||
await page.route('**/api/workflows/*/execute', async (route) => {
|
||||
if (simulateDelay) await delay(delayMs);
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
runId: `wf-run-${Date.now()}`,
|
||||
status: 'running',
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// Trigger 端点
|
||||
// ========================================
|
||||
|
||||
// Mock 触发器列表 - GET /api/triggers
|
||||
await page.route('**/api/triggers', async (route) => {
|
||||
if (simulateDelay) await delay(delayMs);
|
||||
const method = route.request().method();
|
||||
|
||||
if (method === 'GET') {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ triggers: responses.triggers }),
|
||||
});
|
||||
} else if (method === 'POST') {
|
||||
const body = route.request().postDataJSON();
|
||||
const newTrigger = {
|
||||
id: `trigger-${Date.now()}`,
|
||||
...body,
|
||||
};
|
||||
responses.triggers.push(newTrigger as any);
|
||||
await route.fulfill({
|
||||
status: 201,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(newTrigger),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// Approval 端点
|
||||
// ========================================
|
||||
|
||||
// Mock 审批列表 - GET /api/approvals
|
||||
await page.route('**/api/approvals', async (route) => {
|
||||
if (simulateDelay) await delay(delayMs);
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ approvals: responses.approvals }),
|
||||
});
|
||||
});
|
||||
|
||||
// Mock 审批响应 - POST /api/approvals/{id}/respond
|
||||
await page.route('**/api/approvals/*/respond', async (route) => {
|
||||
if (simulateDelay) await delay(delayMs);
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ status: 'responded' }),
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// Session 端点
|
||||
// ========================================
|
||||
|
||||
// Mock 会话列表 - GET /api/sessions
|
||||
await page.route('**/api/sessions', async (route) => {
|
||||
if (simulateDelay) await delay(delayMs);
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ sessions: responses.sessions }),
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// Skill 端点
|
||||
// ========================================
|
||||
|
||||
// Mock 技能列表 - GET /api/skills
|
||||
await page.route('**/api/skills', async (route) => {
|
||||
if (simulateDelay) await delay(delayMs);
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ skills: responses.skills }),
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// Stats 端点
|
||||
// ========================================
|
||||
|
||||
// Mock 用量统计 - GET /api/stats/usage
|
||||
await page.route('**/api/stats/usage', async (route) => {
|
||||
if (simulateDelay) await delay(delayMs);
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(responses.usageStats),
|
||||
});
|
||||
});
|
||||
|
||||
// Mock 会话统计 - GET /api/stats/sessions
|
||||
await page.route('**/api/stats/sessions', async (route) => {
|
||||
if (simulateDelay) await delay(delayMs);
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ total: 10, active: 2 }),
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// Config 端点
|
||||
// ========================================
|
||||
|
||||
// Mock 配置 - GET/PUT /api/config
|
||||
await page.route('**/api/config', async (route) => {
|
||||
if (simulateDelay) await delay(delayMs);
|
||||
const method = route.request().method();
|
||||
|
||||
if (method === 'GET') {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(responses.quickConfig),
|
||||
});
|
||||
} else if (method === 'PUT') {
|
||||
const body = route.request().postDataJSON();
|
||||
Object.assign(responses.quickConfig, body);
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(responses.quickConfig),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// Plugin 端点
|
||||
// ========================================
|
||||
|
||||
// Mock 插件状态 - GET /api/plugins/status
|
||||
await page.route('**/api/plugins/status', async (route) => {
|
||||
if (simulateDelay) await delay(delayMs);
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(responses.pluginStatus),
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// Security 端点
|
||||
// ========================================
|
||||
|
||||
// Mock 安全状态 - GET /api/security/status
|
||||
await page.route('**/api/security/status', async (route) => {
|
||||
if (simulateDelay) await delay(delayMs);
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(responses.securityStatus),
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// Scheduler 端点
|
||||
// ========================================
|
||||
|
||||
// Mock 调度任务 - GET /api/scheduler/tasks
|
||||
await page.route('**/api/scheduler/tasks', async (route) => {
|
||||
if (simulateDelay) await delay(delayMs);
|
||||
const method = route.request().method();
|
||||
|
||||
if (method === 'GET') {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ tasks: responses.scheduledTasks }),
|
||||
});
|
||||
} else if (method === 'POST') {
|
||||
const body = route.request().postDataJSON();
|
||||
const newTask = {
|
||||
id: `task-${Date.now()}`,
|
||||
...body,
|
||||
};
|
||||
responses.scheduledTasks.push(newTask as any);
|
||||
await route.fulfill({
|
||||
status: 201,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(newTask),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// Audit 端点
|
||||
// ========================================
|
||||
|
||||
// Mock 审计日志 - GET /api/audit/logs
|
||||
await page.route('**/api/audit/logs', async (route) => {
|
||||
if (simulateDelay) await delay(delayMs);
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ logs: [], total: 0 }),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock Agent 消息响应(非流式)
|
||||
*/
|
||||
export async function mockAgentMessageResponse(page: Page, response: string): Promise<void> {
|
||||
await page.route('**/api/agents/*/message', async (route) => {
|
||||
if (route.request().method() === 'POST') {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(createAgentMessageResponse(response)),
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock 错误响应
|
||||
*/
|
||||
export async function mockErrorResponse(
|
||||
page: Page,
|
||||
path: string,
|
||||
status: number,
|
||||
message: string
|
||||
): Promise<void> {
|
||||
await page.route(`**/api/${path}**`, async (route) => {
|
||||
await route.fulfill({
|
||||
status,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
error: true,
|
||||
message,
|
||||
}),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock 网络超时
|
||||
*/
|
||||
export async function mockTimeout(page: Page, path: string): Promise<void> {
|
||||
await page.route(`**/api/${path}**`, async () => {
|
||||
// 永不响应,模拟超时
|
||||
await new Promise(() => {});
|
||||
});
|
||||
}
|
||||
|
||||
// 辅助函数
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function shouldError(simulate: boolean, rate: number): boolean {
|
||||
if (!simulate) return false;
|
||||
return Math.random() < rate;
|
||||
}
|
||||
635
desktop/tests/e2e/fixtures/store-inspectors.ts
Normal file
@@ -0,0 +1,635 @@
|
||||
/**
|
||||
* Store 状态检查工具
|
||||
* 用于验证 Zustand Store 的状态变化
|
||||
*
|
||||
* 实际 localStorage keys:
|
||||
* - chatStore: zclaw-chat-storage (持久化)
|
||||
* - teamStore: zclaw-teams (持久化)
|
||||
* - gatewayStore: zclaw-gateway-url, zclaw-gateway-token, zclaw-device-id (单独键)
|
||||
* - handStore: 不持久化
|
||||
* - agentStore: 不持久化
|
||||
*/
|
||||
|
||||
import { Page } from '@playwright/test';
|
||||
import { expect } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* localStorage key 映射
|
||||
*/
|
||||
export const STORAGE_KEYS = {
|
||||
// 标准持久化 Store (使用 zustand persist)
|
||||
CHAT: 'zclaw-chat-storage',
|
||||
TEAMS: 'zclaw-teams',
|
||||
|
||||
// Gateway Store 使用单独的键
|
||||
GATEWAY: 'zclaw-gateway-url', // 主键
|
||||
GATEWAY_URL: 'zclaw-gateway-url',
|
||||
GATEWAY_TOKEN: 'zclaw-gateway-token',
|
||||
DEVICE_ID: 'zclaw-device-id',
|
||||
|
||||
// 非持久化 Store (运行时状态,不在 localStorage)
|
||||
HAND: null,
|
||||
AGENT: null,
|
||||
WORKFLOW: null,
|
||||
CONNECTION: null,
|
||||
CONFIG: null,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Store 名称类型
|
||||
*/
|
||||
export type StoreName = keyof typeof STORAGE_KEYS;
|
||||
|
||||
/**
|
||||
* 向后兼容: STORE_NAMES 枚举
|
||||
* @deprecated 使用 STORAGE_KEYS 代替
|
||||
*/
|
||||
export const STORE_NAMES = {
|
||||
CHAT: 'CHAT' as StoreName,
|
||||
GATEWAY: 'GATEWAY' as StoreName,
|
||||
AGENT: 'AGENT' as StoreName,
|
||||
HAND: 'HAND' as StoreName,
|
||||
WORKFLOW: 'WORKFLOW' as StoreName,
|
||||
CONFIG: 'CONFIG' as StoreName,
|
||||
TEAM: 'TEAMS' as StoreName,
|
||||
CONNECTION: 'CONNECTION' as StoreName,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Store 状态检查工具
|
||||
*/
|
||||
export const storeInspectors = {
|
||||
/**
|
||||
* 获取 localStorage key
|
||||
*/
|
||||
getStorageKey(storeName: StoreName): string | null {
|
||||
return STORAGE_KEYS[storeName];
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取持久化的 Chat Store 状态
|
||||
*/
|
||||
async getChatState<T = unknown>(page: Page): Promise<T | null> {
|
||||
return page.evaluate((key) => {
|
||||
const stored = localStorage.getItem(key);
|
||||
if (!stored) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(stored);
|
||||
return parsed.state as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, STORAGE_KEYS.CHAT);
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取持久化的 Teams Store 状态
|
||||
*/
|
||||
async getTeamsState<T = unknown>(page: Page): Promise<T | null> {
|
||||
return page.evaluate((key) => {
|
||||
const stored = localStorage.getItem(key);
|
||||
if (!stored) return null;
|
||||
try {
|
||||
// teams store 可能直接存储数组或对象
|
||||
const parsed = JSON.parse(stored);
|
||||
return parsed.state ? parsed.state : parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, STORAGE_KEYS.TEAMS);
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取 Gateway 配置
|
||||
*/
|
||||
async getGatewayConfig(page: Page): Promise<{
|
||||
url: string | null;
|
||||
token: string | null;
|
||||
deviceId: string | null;
|
||||
}> {
|
||||
return page.evaluate((keys) => {
|
||||
return {
|
||||
url: localStorage.getItem(keys.url),
|
||||
token: localStorage.getItem(keys.token),
|
||||
deviceId: localStorage.getItem(keys.deviceId),
|
||||
};
|
||||
}, {
|
||||
url: STORAGE_KEYS.GATEWAY_URL,
|
||||
token: STORAGE_KEYS.GATEWAY_TOKEN,
|
||||
deviceId: STORAGE_KEYS.DEVICE_ID,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取持久化的 Store 状态 (通用方法)
|
||||
*/
|
||||
async getPersistedState<T = unknown>(page: Page, storeName: StoreName): Promise<T | null> {
|
||||
const key = STORAGE_KEYS[storeName];
|
||||
|
||||
if (!key) {
|
||||
// 非持久化 Store,尝试从运行时获取
|
||||
return this.getRuntimeState<T>(page, storeName);
|
||||
}
|
||||
|
||||
return page.evaluate((storageKey) => {
|
||||
const stored = localStorage.getItem(storageKey);
|
||||
if (!stored) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(stored);
|
||||
return parsed.state ? (parsed.state as T) : (parsed as T);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, key);
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取运行时 Store 状态 (通过 window 全局变量)
|
||||
* 注意:需要在应用中暴露 store 到 window 对象
|
||||
*/
|
||||
async getRuntimeState<T = unknown>(page: Page, storeName: string): Promise<T | null> {
|
||||
return page.evaluate((name) => {
|
||||
// 尝试从 window.__ZCLAW_STORES__ 获取
|
||||
const stores = (window as any).__ZCLAW_STORES__;
|
||||
if (stores && stores[name]) {
|
||||
return stores[name].getState() as T;
|
||||
}
|
||||
return null;
|
||||
}, storeName);
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取整个持久化对象(包含 state 和 version)
|
||||
*/
|
||||
async getFullStorage<T = unknown>(
|
||||
page: Page,
|
||||
storeName: StoreName
|
||||
): Promise<{ state: T; version: number } | null> {
|
||||
const key = STORAGE_KEYS[storeName];
|
||||
if (!key) return null;
|
||||
|
||||
return page.evaluate((storageKey) => {
|
||||
const stored = localStorage.getItem(storageKey);
|
||||
if (!stored) return null;
|
||||
try {
|
||||
return JSON.parse(stored);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, key);
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取 Store 中的特定字段
|
||||
*/
|
||||
async getStateField<T = unknown>(
|
||||
page: Page,
|
||||
storeName: StoreName,
|
||||
fieldPath: string
|
||||
): Promise<T | null> {
|
||||
const key = STORAGE_KEYS[storeName];
|
||||
if (!key) return null;
|
||||
|
||||
return page.evaluate(
|
||||
({ storageKey, path }) => {
|
||||
const stored = localStorage.getItem(storageKey);
|
||||
if (!stored) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(stored);
|
||||
const state = parsed.state ? parsed.state : parsed;
|
||||
const value = path.split('.').reduce((obj: any, key) => obj?.[key], state);
|
||||
return value as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
{ storageKey: key, path: fieldPath }
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* 等待 Store 状态变化
|
||||
*/
|
||||
async waitForStateChange<T = unknown>(
|
||||
page: Page,
|
||||
storeName: StoreName,
|
||||
fieldPath: string,
|
||||
expectedValue: T,
|
||||
options?: { timeout?: number }
|
||||
): Promise<void> {
|
||||
const key = STORAGE_KEYS[storeName];
|
||||
if (!key) {
|
||||
throw new Error(`Store ${storeName} is not persisted`);
|
||||
}
|
||||
|
||||
await page.waitForFunction(
|
||||
({ storageKey, path, expected }) => {
|
||||
const stored = localStorage.getItem(storageKey);
|
||||
if (!stored) return false;
|
||||
try {
|
||||
const parsed = JSON.parse(stored);
|
||||
const state = parsed.state ? parsed.state : parsed;
|
||||
const value = path.split('.').reduce((obj: any, key) => obj?.[key], state);
|
||||
return JSON.stringify(value) === JSON.stringify(expected);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
{
|
||||
storageKey: key,
|
||||
path: fieldPath,
|
||||
expected: expectedValue,
|
||||
},
|
||||
{ timeout: options?.timeout ?? 5000 }
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* 等待 Store 中某个字段存在
|
||||
*/
|
||||
async waitForFieldExists(
|
||||
page: Page,
|
||||
storeName: StoreName,
|
||||
fieldPath: string,
|
||||
options?: { timeout?: number }
|
||||
): Promise<void> {
|
||||
const key = STORAGE_KEYS[storeName];
|
||||
if (!key) return;
|
||||
|
||||
await page.waitForFunction(
|
||||
({ storageKey, path }) => {
|
||||
const stored = localStorage.getItem(storageKey);
|
||||
if (!stored) return false;
|
||||
try {
|
||||
const parsed = JSON.parse(stored);
|
||||
const state = parsed.state ? parsed.state : parsed;
|
||||
const value = path.split('.').reduce((obj: any, key) => obj?.[key], state);
|
||||
return value !== undefined && value !== null;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
{ storageKey: key, path: fieldPath },
|
||||
{ timeout: options?.timeout ?? 5000 }
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* 等待消息数量变化
|
||||
*/
|
||||
async waitForMessageCount(
|
||||
page: Page,
|
||||
expectedCount: number,
|
||||
options?: { timeout?: number }
|
||||
): Promise<void> {
|
||||
await page.waitForFunction(
|
||||
({ expected, key }) => {
|
||||
const stored = localStorage.getItem(key);
|
||||
if (!stored) return false;
|
||||
try {
|
||||
const parsed = JSON.parse(stored);
|
||||
return parsed.state?.messages?.length === expected;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
{ expected: expectedCount, key: STORAGE_KEYS.CHAT },
|
||||
{ timeout: options?.timeout ?? 10000 }
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* 清除特定 Store 的持久化数据
|
||||
*/
|
||||
async clearStore(page: Page, storeName: StoreName): Promise<void> {
|
||||
const key = STORAGE_KEYS[storeName];
|
||||
if (key) {
|
||||
await page.evaluate((storageKey) => {
|
||||
localStorage.removeItem(storageKey);
|
||||
}, key);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 清除所有 Store 数据
|
||||
*/
|
||||
async clearAllStores(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
const keys = Object.keys(localStorage);
|
||||
keys.forEach((key) => {
|
||||
if (key.startsWith('zclaw-')) {
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 设置 Store 状态(用于测试初始化)
|
||||
*/
|
||||
async setStoreState<T>(page: Page, storeName: StoreName, state: T): Promise<void> {
|
||||
const key = STORAGE_KEYS[storeName];
|
||||
if (!key) return;
|
||||
|
||||
await page.evaluate(
|
||||
({ storageKey, stateObj }) => {
|
||||
const data = {
|
||||
state: stateObj,
|
||||
version: 0,
|
||||
};
|
||||
localStorage.setItem(storageKey, JSON.stringify(data));
|
||||
},
|
||||
{ storageKey: key, stateObj: state }
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* 设置 Chat Store 状态
|
||||
*/
|
||||
async setChatState<T>(page: Page, state: T): Promise<void> {
|
||||
await page.evaluate(
|
||||
({ key, stateObj }) => {
|
||||
const data = {
|
||||
state: stateObj,
|
||||
version: 0,
|
||||
};
|
||||
localStorage.setItem(key, JSON.stringify(data));
|
||||
},
|
||||
{ key: STORAGE_KEYS.CHAT, stateObj: state }
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取所有 Store 状态快照
|
||||
*/
|
||||
async getAllStoresSnapshot(page: Page): Promise<Record<string, unknown>> {
|
||||
return page.evaluate(() => {
|
||||
const snapshot: Record<string, unknown> = {};
|
||||
const keys = Object.keys(localStorage);
|
||||
|
||||
keys.forEach((key) => {
|
||||
if (key.startsWith('zclaw-')) {
|
||||
const storeName = key.replace('zclaw-', '').replace('-storage', '');
|
||||
try {
|
||||
const stored = localStorage.getItem(key);
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored);
|
||||
snapshot[storeName] = parsed.state ? parsed.state : parsed;
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return snapshot;
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 检查 Store 是否持久化
|
||||
*/
|
||||
isPersistedStore(storeName: StoreName): boolean {
|
||||
return STORAGE_KEYS[storeName] !== null;
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Store 断言工具
|
||||
*/
|
||||
export const storeAssertions = {
|
||||
/**
|
||||
* 断言 Chat Store 状态匹配预期
|
||||
*/
|
||||
async assertChatState<T>(
|
||||
page: Page,
|
||||
expected: Partial<T>
|
||||
): Promise<void> {
|
||||
const state = await storeInspectors.getChatState<T>(page);
|
||||
expect(state).not.toBeNull();
|
||||
|
||||
for (const [key, value] of Object.entries(expected)) {
|
||||
expect(state).toHaveProperty(key, value);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 断言 Teams Store 状态匹配预期
|
||||
*/
|
||||
async assertTeamsState<T>(
|
||||
page: Page,
|
||||
expected: Partial<T>
|
||||
): Promise<void> {
|
||||
const state = await storeInspectors.getTeamsState<T>(page);
|
||||
expect(state).not.toBeNull();
|
||||
|
||||
for (const [key, value] of Object.entries(expected)) {
|
||||
expect(state).toHaveProperty(key, value);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 断言 Store 字段值
|
||||
*/
|
||||
async assertFieldEquals<T>(
|
||||
page: Page,
|
||||
storeName: StoreName,
|
||||
fieldPath: string,
|
||||
expected: T
|
||||
): Promise<void> {
|
||||
const value = await storeInspectors.getStateField<T>(page, storeName, fieldPath);
|
||||
expect(value).toEqual(expected);
|
||||
},
|
||||
|
||||
/**
|
||||
* 断言消息数量
|
||||
*/
|
||||
async assertMessageCount(page: Page, expected: number): Promise<void> {
|
||||
const state = await storeInspectors.getChatState<{ messages: unknown[] }>(page);
|
||||
expect(state?.messages?.length).toBe(expected);
|
||||
},
|
||||
|
||||
/**
|
||||
* 断言最后一条消息内容
|
||||
*/
|
||||
async assertLastMessageContent(page: Page, expected: string): Promise<void> {
|
||||
const state = await storeInspectors.getChatState<{
|
||||
messages: Array<{ content: string }>;
|
||||
}>(page);
|
||||
const lastMessage = state?.messages?.[state.messages.length - 1];
|
||||
expect(lastMessage?.content).toContain(expected);
|
||||
},
|
||||
|
||||
/**
|
||||
* 断言 Gateway 配置
|
||||
*/
|
||||
async assertGatewayConfig(
|
||||
page: Page,
|
||||
expected: { url?: string; token?: string; deviceId?: string }
|
||||
): Promise<void> {
|
||||
const config = await storeInspectors.getGatewayConfig(page);
|
||||
|
||||
if (expected.url !== undefined) {
|
||||
expect(config.url).toBe(expected.url);
|
||||
}
|
||||
if (expected.token !== undefined) {
|
||||
expect(config.token).toBe(expected.token);
|
||||
}
|
||||
if (expected.deviceId !== undefined) {
|
||||
expect(config.deviceId).toBe(expected.deviceId);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 断言 Teams 列表非空
|
||||
*/
|
||||
async assertTeamsNotEmpty(page: Page): Promise<void> {
|
||||
const state = await storeInspectors.getTeamsState<{ teams: unknown[] }>(page);
|
||||
expect(state?.teams?.length).toBeGreaterThan(0);
|
||||
},
|
||||
|
||||
/**
|
||||
* 断言当前 Agent
|
||||
*/
|
||||
async assertCurrentAgent(page: Page, agentId: string): Promise<void> {
|
||||
const state = await storeInspectors.getChatState<{
|
||||
currentAgent: { id: string };
|
||||
}>(page);
|
||||
expect(state?.currentAgent?.id).toBe(agentId);
|
||||
},
|
||||
|
||||
/**
|
||||
* 断言 isStreaming 状态
|
||||
*/
|
||||
async assertStreamingState(page: Page, expected: boolean): Promise<void> {
|
||||
const state = await storeInspectors.getChatState<{ isStreaming: boolean }>(page);
|
||||
expect(state?.isStreaming).toBe(expected);
|
||||
},
|
||||
|
||||
/**
|
||||
* 断言当前模型
|
||||
*/
|
||||
async assertCurrentModel(page: Page, expectedModel: string): Promise<void> {
|
||||
const state = await storeInspectors.getChatState<{ currentModel: string }>(page);
|
||||
expect(state?.currentModel).toBe(expectedModel);
|
||||
},
|
||||
|
||||
/**
|
||||
* 断言会话存在
|
||||
*/
|
||||
async assertConversationExists(page: Page, conversationId: string): Promise<void> {
|
||||
const state = await storeInspectors.getChatState<{
|
||||
conversations: Array<{ id: string }>;
|
||||
}>(page);
|
||||
const exists = state?.conversations?.some(c => c.id === conversationId);
|
||||
expect(exists).toBe(true);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 类型定义 - Chat Store 状态
|
||||
*/
|
||||
export interface ChatStoreState {
|
||||
messages: Array<{
|
||||
id: string;
|
||||
role: 'user' | 'assistant' | 'tool' | 'hand' | 'workflow';
|
||||
content: string;
|
||||
timestamp: string;
|
||||
streaming?: boolean;
|
||||
error?: string;
|
||||
runId?: string;
|
||||
toolName?: string;
|
||||
toolInput?: string;
|
||||
toolOutput?: string;
|
||||
handName?: string;
|
||||
handStatus?: string;
|
||||
handResult?: unknown;
|
||||
workflowId?: string;
|
||||
workflowStep?: string;
|
||||
workflowStatus?: string;
|
||||
workflowResult?: unknown;
|
||||
}>;
|
||||
conversations: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
messages: string[];
|
||||
sessionKey: string | null;
|
||||
agentId: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}>;
|
||||
currentConversationId: string | null;
|
||||
currentAgent: {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
color: string;
|
||||
lastMessage: string;
|
||||
time: string;
|
||||
} | null;
|
||||
isStreaming: boolean;
|
||||
currentModel: string;
|
||||
sessionKey: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 类型定义 - Team Store 状态
|
||||
*/
|
||||
export interface TeamStoreState {
|
||||
teams: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
members: Array<{
|
||||
id: string;
|
||||
agentId: string;
|
||||
name: string;
|
||||
role: 'orchestrator' | 'reviewer' | 'worker';
|
||||
skills: string[];
|
||||
workload: number;
|
||||
status: 'idle' | 'working' | 'offline';
|
||||
maxConcurrentTasks: number;
|
||||
currentTasks: string[];
|
||||
}>;
|
||||
tasks: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
status: 'pending' | 'assigned' | 'in_progress' | 'review' | 'completed';
|
||||
priority: 'low' | 'medium' | 'high';
|
||||
assigneeId?: string;
|
||||
dependencies: string[];
|
||||
type: string;
|
||||
estimate?: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}>;
|
||||
pattern: 'sequential' | 'parallel' | 'pipeline';
|
||||
activeLoops: Array<{
|
||||
id: string;
|
||||
developerId: string;
|
||||
reviewerId: string;
|
||||
taskId: string;
|
||||
state: 'developing' | 'revising' | 'reviewing' | 'approved' | 'escalated';
|
||||
iterationCount: number;
|
||||
maxIterations: number;
|
||||
}>;
|
||||
status: 'active' | 'paused' | 'completed';
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}>;
|
||||
activeTeam: {
|
||||
id: string;
|
||||
name: string;
|
||||
} | null;
|
||||
metrics: {
|
||||
tasksCompleted: number;
|
||||
avgCompletionTime: number;
|
||||
passRate: number;
|
||||
avgIterations: number;
|
||||
escalations: number;
|
||||
efficiency: number;
|
||||
} | null;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
459
desktop/tests/e2e/fixtures/test-data.ts
Normal file
@@ -0,0 +1,459 @@
|
||||
/**
|
||||
* 测试数据工厂
|
||||
* 生成一致的测试数据,确保测试可重复性
|
||||
*/
|
||||
|
||||
/**
|
||||
* 生成唯一 ID
|
||||
*/
|
||||
export function generateId(prefix = 'id'): string {
|
||||
return `${prefix}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成时间戳
|
||||
*/
|
||||
export function generateTimestamp(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试消息工厂
|
||||
*/
|
||||
export const messageFactory = {
|
||||
/**
|
||||
* 创建用户消息
|
||||
*/
|
||||
createUser(content: string, options?: { id?: string; timestamp?: string }) {
|
||||
return {
|
||||
id: options?.id ?? generateId('msg'),
|
||||
role: 'user' as const,
|
||||
content,
|
||||
timestamp: options?.timestamp ?? generateTimestamp(),
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* 创建助手消息
|
||||
*/
|
||||
createAssistant(content: string, options?: {
|
||||
id?: string;
|
||||
timestamp?: string;
|
||||
streaming?: boolean;
|
||||
model?: string;
|
||||
}) {
|
||||
return {
|
||||
id: options?.id ?? generateId('msg'),
|
||||
role: 'assistant' as const,
|
||||
content,
|
||||
timestamp: options?.timestamp ?? generateTimestamp(),
|
||||
streaming: options?.streaming ?? false,
|
||||
model: options?.model ?? 'claude-3-sonnet',
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* 创建工具消息
|
||||
*/
|
||||
createTool(toolName: string, input: unknown, output: unknown) {
|
||||
return {
|
||||
id: generateId('msg'),
|
||||
role: 'tool' as const,
|
||||
content: '',
|
||||
timestamp: generateTimestamp(),
|
||||
toolName,
|
||||
toolInput: JSON.stringify(input),
|
||||
toolOutput: JSON.stringify(output),
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* 创建 Hand 消息
|
||||
*/
|
||||
createHand(handName: string, status: string, result?: unknown) {
|
||||
return {
|
||||
id: generateId('msg'),
|
||||
role: 'hand' as const,
|
||||
content: '',
|
||||
timestamp: generateTimestamp(),
|
||||
handName,
|
||||
handStatus: status,
|
||||
handResult: result,
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* 创建 Workflow 消息
|
||||
*/
|
||||
createWorkflow(workflowId: string, step: string, status: string) {
|
||||
return {
|
||||
id: generateId('msg'),
|
||||
role: 'workflow' as const,
|
||||
content: '',
|
||||
timestamp: generateTimestamp(),
|
||||
workflowId,
|
||||
workflowStep: step,
|
||||
workflowStatus: status,
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* 创建消息列表
|
||||
*/
|
||||
createConversation(messages: Array<{ role: string; content: string }>) {
|
||||
return messages.map((m) => {
|
||||
if (m.role === 'user') {
|
||||
return this.createUser(m.content);
|
||||
}
|
||||
return this.createAssistant(m.content);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 测试分身工厂
|
||||
*/
|
||||
export const cloneFactory = {
|
||||
/**
|
||||
* 创建分身
|
||||
*/
|
||||
create(options?: {
|
||||
id?: string;
|
||||
name?: string;
|
||||
role?: string;
|
||||
model?: string;
|
||||
workspaceDir?: string;
|
||||
}) {
|
||||
return {
|
||||
id: options?.id ?? generateId('clone'),
|
||||
name: options?.name ?? `测试分身-${Date.now()}`,
|
||||
role: options?.role ?? 'AI Assistant',
|
||||
model: options?.model ?? 'claude-3-sonnet',
|
||||
workspaceDir: options?.workspaceDir ?? '/tmp/workspace',
|
||||
createdAt: generateTimestamp(),
|
||||
onboardingCompleted: true,
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* 创建多个分身
|
||||
*/
|
||||
createMany(count: number) {
|
||||
return Array.from({ length: count }, (_, i) =>
|
||||
this.create({
|
||||
name: `分身-${i + 1}`,
|
||||
role: i === 0 ? 'Main Assistant' : `Specialist ${i + 1}`,
|
||||
})
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 测试 Hand 工厂
|
||||
*/
|
||||
export const handFactory = {
|
||||
/**
|
||||
* 创建 Hand
|
||||
*/
|
||||
create(options?: {
|
||||
id?: string;
|
||||
name?: string;
|
||||
status?: string;
|
||||
category?: string;
|
||||
requirementsMet?: boolean;
|
||||
}) {
|
||||
return {
|
||||
id: options?.id ?? generateId('hand'),
|
||||
name: options?.name ?? 'TestHand',
|
||||
description: `Test Hand: ${options?.name ?? 'TestHand'}`,
|
||||
status: options?.status ?? 'idle',
|
||||
category: options?.category ?? 'automation',
|
||||
requirements_met: options?.requirementsMet ?? true,
|
||||
tools: ['tool1', 'tool2'],
|
||||
metrics: ['metric1'],
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* 创建 Browser Hand
|
||||
*/
|
||||
createBrowser(status = 'idle') {
|
||||
return this.create({
|
||||
id: 'browser',
|
||||
name: 'Browser',
|
||||
status,
|
||||
category: 'automation',
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 创建 Collector Hand
|
||||
*/
|
||||
createCollector(status = 'idle') {
|
||||
return this.create({
|
||||
id: 'collector',
|
||||
name: 'Collector',
|
||||
status,
|
||||
category: 'data',
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 创建需要审批的 Hand
|
||||
*/
|
||||
createNeedsApproval() {
|
||||
return this.create({
|
||||
status: 'needs_approval',
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 创建多个 Hands
|
||||
*/
|
||||
createMany(count: number) {
|
||||
const categories = ['automation', 'data', 'research', 'analytics'];
|
||||
return Array.from({ length: count }, (_, i) =>
|
||||
this.create({
|
||||
id: `hand-${i + 1}`,
|
||||
name: `Hand${i + 1}`,
|
||||
category: categories[i % categories.length],
|
||||
})
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 测试工作流工厂
|
||||
*/
|
||||
export const workflowFactory = {
|
||||
/**
|
||||
* 创建工作流
|
||||
*/
|
||||
create(options?: {
|
||||
id?: string;
|
||||
name?: string;
|
||||
steps?: number;
|
||||
}) {
|
||||
return {
|
||||
id: options?.id ?? generateId('wf'),
|
||||
name: options?.name ?? `工作流-${Date.now()}`,
|
||||
description: '测试工作流',
|
||||
steps: options?.steps ?? 1,
|
||||
status: 'idle',
|
||||
createdAt: generateTimestamp(),
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* 创建工作流步骤
|
||||
*/
|
||||
createStep(options?: {
|
||||
id?: string;
|
||||
handName?: string;
|
||||
params?: Record<string, unknown>;
|
||||
condition?: string;
|
||||
}) {
|
||||
return {
|
||||
id: options?.id ?? generateId('step'),
|
||||
handName: options?.handName ?? 'Browser',
|
||||
params: options?.params ?? {},
|
||||
condition: options?.condition,
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* 创建完整工作流(含步骤)
|
||||
*/
|
||||
createWithSteps(stepCount: number) {
|
||||
const steps = Array.from({ length: stepCount }, (_, i) =>
|
||||
this.createStep({
|
||||
handName: ['Browser', 'Collector', 'Researcher'][i % 3],
|
||||
})
|
||||
);
|
||||
|
||||
return {
|
||||
...this.create({ steps: stepCount }),
|
||||
steps,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 测试技能工厂
|
||||
*/
|
||||
export const skillFactory = {
|
||||
/**
|
||||
* 创建技能
|
||||
*/
|
||||
create(options?: {
|
||||
id?: string;
|
||||
name?: string;
|
||||
category?: string;
|
||||
installed?: boolean;
|
||||
}) {
|
||||
return {
|
||||
id: options?.id ?? generateId('skill'),
|
||||
name: options?.name ?? `技能-${Date.now()}`,
|
||||
description: '测试技能描述',
|
||||
category: options?.category ?? 'development',
|
||||
triggers: ['trigger1', 'trigger2'],
|
||||
capabilities: ['capability1'],
|
||||
installed: options?.installed ?? false,
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* 创建已安装的技能
|
||||
*/
|
||||
createInstalled(name?: string) {
|
||||
return this.create({
|
||||
name: name ?? '已安装技能',
|
||||
installed: true,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 创建多个技能
|
||||
*/
|
||||
createMany(count: number) {
|
||||
const categories = ['development', 'security', 'analytics', 'productivity'];
|
||||
return Array.from({ length: count }, (_, i) =>
|
||||
this.create({
|
||||
id: `skill-${i + 1}`,
|
||||
name: `技能 ${i + 1}`,
|
||||
category: categories[i % categories.length],
|
||||
installed: i < 2, // 前两个已安装
|
||||
})
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 测试团队工厂
|
||||
*/
|
||||
export const teamFactory = {
|
||||
/**
|
||||
* 创建团队
|
||||
*/
|
||||
create(options?: {
|
||||
id?: string;
|
||||
name?: string;
|
||||
pattern?: string;
|
||||
}) {
|
||||
return {
|
||||
id: options?.id ?? generateId('team'),
|
||||
name: options?.name ?? `团队-${Date.now()}`,
|
||||
description: '测试团队',
|
||||
members: [],
|
||||
tasks: [],
|
||||
pattern: options?.pattern ?? 'sequential',
|
||||
status: 'active',
|
||||
createdAt: generateTimestamp(),
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* 创建团队成员
|
||||
*/
|
||||
createMember(options?: {
|
||||
id?: string;
|
||||
role?: string;
|
||||
}) {
|
||||
return {
|
||||
id: options?.id ?? generateId('member'),
|
||||
agentId: generateId('agent'),
|
||||
role: options?.role ?? 'member',
|
||||
joinedAt: generateTimestamp(),
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* 创建团队任务
|
||||
*/
|
||||
createTask(options?: {
|
||||
id?: string;
|
||||
status?: string;
|
||||
}) {
|
||||
return {
|
||||
id: options?.id ?? generateId('task'),
|
||||
title: '测试任务',
|
||||
description: '任务描述',
|
||||
status: options?.status ?? 'pending',
|
||||
assigneeId: null,
|
||||
createdAt: generateTimestamp(),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 测试审批工厂
|
||||
*/
|
||||
export const approvalFactory = {
|
||||
/**
|
||||
* 创建审批请求
|
||||
*/
|
||||
create(options?: {
|
||||
id?: string;
|
||||
handName?: string;
|
||||
status?: string;
|
||||
}) {
|
||||
return {
|
||||
id: options?.id ?? generateId('approval'),
|
||||
handName: options?.handName ?? 'Browser',
|
||||
reason: '需要用户批准执行',
|
||||
params: {},
|
||||
status: options?.status ?? 'pending',
|
||||
createdAt: generateTimestamp(),
|
||||
expiresAt: new Date(Date.now() + 3600000).toISOString(),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 预设测试场景数据
|
||||
*/
|
||||
export const testScenarios = {
|
||||
/**
|
||||
* 完整的聊天场景
|
||||
*/
|
||||
chatConversation: [
|
||||
{ role: 'user', content: '你好' },
|
||||
{ role: 'assistant', content: '你好!我是 AI 助手,有什么可以帮助你的吗?' },
|
||||
{ role: 'user', content: '请写一个简单的函数' },
|
||||
{ role: 'assistant', content: '好的,这是一个简单的函数:\n```python\ndef hello():\n print("Hello, World!")\n```' },
|
||||
],
|
||||
|
||||
/**
|
||||
* Hand 执行场景
|
||||
*/
|
||||
handExecution: {
|
||||
hand: handFactory.createBrowser(),
|
||||
params: { url: 'https://example.com' },
|
||||
expectedStatus: ['idle', 'running', 'completed'],
|
||||
},
|
||||
|
||||
/**
|
||||
* 工作流场景
|
||||
*/
|
||||
workflowExecution: {
|
||||
workflow: workflowFactory.createWithSteps(3),
|
||||
expectedSteps: ['step-1', 'step-2', 'step-3'],
|
||||
},
|
||||
|
||||
/**
|
||||
* 审批场景
|
||||
*/
|
||||
approvalFlow: {
|
||||
approval: approvalFactory.create({ handName: 'Browser' }),
|
||||
actions: ['approve', 'reject'] as const,
|
||||
},
|
||||
|
||||
/**
|
||||
* 团队协作场景
|
||||
*/
|
||||
teamCollaboration: {
|
||||
team: teamFactory.create({ pattern: 'review_loop' }),
|
||||
members: [teamFactory.createMember({ role: 'developer' }), teamFactory.createMember({ role: 'reviewer' })],
|
||||
task: teamFactory.createTask(),
|
||||
},
|
||||
};
|
||||
@@ -1,27 +1,120 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* ZCLAW E2E 测试配置
|
||||
*
|
||||
* 支持三种测试类型:
|
||||
* - functional: 基础功能测试
|
||||
* - data-flow: 数据流深度验证
|
||||
* - store-state: Store 状态验证
|
||||
* - edge-cases: 边界情况测试
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: './specs',
|
||||
|
||||
// 测试超时配置
|
||||
timeout: 120000, // 单个测试最大 2 分钟
|
||||
expect: {
|
||||
timeout: 10000, // 断言超时 10 秒
|
||||
},
|
||||
|
||||
// 并行执行配置
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
reporter: 'html',
|
||||
|
||||
// 报告配置
|
||||
reporter: [
|
||||
['html', { outputFolder: 'test-results/html-report' }],
|
||||
['json', { outputFile: 'test-results/results.json' }],
|
||||
['list'], // 控制台输出
|
||||
],
|
||||
|
||||
// 全局配置
|
||||
use: {
|
||||
baseURL: 'http://localhost:1420',
|
||||
trace: 'on-first-retry',
|
||||
screenshot: 'only-on-failure',
|
||||
video: 'retain-on-failure',
|
||||
// 网络超时
|
||||
actionTimeout: 10000,
|
||||
navigationTimeout: 30000,
|
||||
},
|
||||
|
||||
// 测试项目配置
|
||||
projects: [
|
||||
// 主要测试项目 - Chromium
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
viewport: { width: 1280, height: 720 },
|
||||
},
|
||||
},
|
||||
|
||||
// Firefox 浏览器测试(可选)
|
||||
// {
|
||||
// name: 'firefox',
|
||||
// use: { ...devices['Desktop Firefox'] },
|
||||
// },
|
||||
|
||||
// WebKit 浏览器测试(可选)
|
||||
// {
|
||||
// name: 'webkit',
|
||||
// use: { ...devices['Desktop Safari'] },
|
||||
// },
|
||||
|
||||
// 移动端测试(可选)
|
||||
// {
|
||||
// name: 'Mobile Chrome',
|
||||
// use: { ...devices['Pixel 5'] },
|
||||
// },
|
||||
// {
|
||||
// name: 'Mobile Safari',
|
||||
// use: { ...devices['iPhone 12'] },
|
||||
// },
|
||||
|
||||
// 数据流测试专用项目
|
||||
{
|
||||
name: 'data-flow',
|
||||
testMatch: /data-flow\.spec\.ts/,
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
viewport: { width: 1920, height: 1080 },
|
||||
},
|
||||
},
|
||||
|
||||
// Store 状态测试专用项目
|
||||
{
|
||||
name: 'store-state',
|
||||
testMatch: /store-state\.spec\.ts/,
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
},
|
||||
},
|
||||
|
||||
// 边界情况测试专用项目
|
||||
{
|
||||
name: 'edge-cases',
|
||||
testMatch: /edge-cases\.spec\.ts/,
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
},
|
||||
timeout: 180000, // 边界情况测试可能需要更长时间
|
||||
},
|
||||
],
|
||||
|
||||
// 开发服务器配置
|
||||
webServer: {
|
||||
command: 'pnpm dev',
|
||||
url: 'http://localhost:1420',
|
||||
reuseExistingServer: true,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 120000,
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
},
|
||||
|
||||
// 输出目录
|
||||
outputDir: 'test-results/artifacts',
|
||||
});
|
||||
|
||||
605
desktop/tests/e2e/specs/data-flow.spec.ts
Normal file
@@ -0,0 +1,605 @@
|
||||
/**
|
||||
* ZCLAW 数据流深度验证测试
|
||||
*
|
||||
* 验证完整的数据流:UI → Store → API → 后端 → UI
|
||||
* 确保每个操作都经过完整的链路验证
|
||||
*/
|
||||
|
||||
import { test, expect, Page } from '@playwright/test';
|
||||
import { networkHelpers, requestMatchers } from '../utils/network-helpers';
|
||||
import { storeInspectors, STORE_NAMES, type StoreName } from '../fixtures/store-inspectors';
|
||||
import { userActions, waitForAppReady, navigateToTab, skipOnboarding } from '../utils/user-actions';
|
||||
import { setupMockGateway, mockAgentMessageResponse, mockResponses } from '../fixtures/mock-gateway';
|
||||
import { messageFactory, cloneFactory, handFactory } from '../fixtures/test-data';
|
||||
|
||||
// 测试超时配置
|
||||
test.setTimeout(120000);
|
||||
|
||||
const BASE_URL = 'http://localhost:1420';
|
||||
|
||||
// 辅助函数
|
||||
function safeParseJSON(text: string): unknown {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 测试套件 1: 聊天数据流验证
|
||||
// ============================================
|
||||
test.describe('聊天数据流验证', () => {
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// 必须在 page.goto 之前调用,设置 localStorage
|
||||
await skipOnboarding(page);
|
||||
await page.goto(BASE_URL);
|
||||
await waitForAppReady(page);
|
||||
});
|
||||
|
||||
test('CHAT-DF-01: 发送消息完整数据流', async ({ page }) => {
|
||||
// 1. 设置网络拦截,记录所有请求(不拦截,只记录)
|
||||
const requests: Array<{ url: string; method: string; body?: unknown }> = [];
|
||||
page.on('request', (request) => {
|
||||
if (request.url().includes('/api/')) {
|
||||
requests.push({
|
||||
url: request.url(),
|
||||
method: request.method(),
|
||||
body: request.postData() ? safeParseJSON(request.postData()!) : undefined,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 2. Mock 消息响应
|
||||
const mockResponse = '这是 AI 助手的回复消息,用于测试流式响应。';
|
||||
await mockAgentMessageResponse(page, mockResponse);
|
||||
|
||||
// 3. 发送消息
|
||||
const testMessage = '这是一条测试消息';
|
||||
const { request: sentRequest } = await userActions.sendChatMessage(page, testMessage);
|
||||
|
||||
// 4. 验证请求格式
|
||||
const requestBody = sentRequest.postDataJSON();
|
||||
expect(requestBody).toBeDefined();
|
||||
// 验证请求包含消息内容
|
||||
if (requestBody?.message) {
|
||||
expect(requestBody.message).toContain(testMessage);
|
||||
}
|
||||
|
||||
// 5. 验证 UI 渲染 - 用户消息显示在界面上
|
||||
const userMessageElement = page.locator('[class*="message"], [class*="bubble"], [class*="user"]').filter({
|
||||
hasText: testMessage,
|
||||
});
|
||||
await expect(userMessageElement).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// 6. 验证 UI 渲染 - AI 回复显示在界面上
|
||||
const aiMessageElement = page.locator('[class*="assistant"], [class*="ai"]').filter({
|
||||
hasText: mockResponse.substring(0, 20), // 检查部分内容
|
||||
});
|
||||
await expect(aiMessageElement).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// 7. 验证请求被正确记录
|
||||
const chatRequests = requests.filter(r => r.url.includes('/api/agents'));
|
||||
expect(chatRequests.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('CHAT-DF-02: 流式响应数据流', async ({ page }) => {
|
||||
// 1. Mock 消息响应
|
||||
await mockAgentMessageResponse(page, '这是一首短诗的回复内容。');
|
||||
|
||||
// 2. 发送消息
|
||||
const testMessage = '请写一首短诗';
|
||||
await userActions.sendChatMessage(page, testMessage);
|
||||
|
||||
// 3. 验证用户消息显示
|
||||
const userMessage = page.locator('[class*="message"], [class*="bubble"]').filter({
|
||||
hasText: testMessage,
|
||||
});
|
||||
await expect(userMessage).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// 4. 验证有响应消息出现(用户消息 + AI 回复)
|
||||
const messageCount = await page.locator('[class*="message"], [class*="bubble"]').count();
|
||||
expect(messageCount).toBeGreaterThanOrEqual(2); // 用户消息 + 助手回复
|
||||
});
|
||||
|
||||
test('CHAT-DF-03: 模型切换数据流', async ({ page }) => {
|
||||
// 1. 获取当前模型
|
||||
const initialState = await storeInspectors.getChatState<{
|
||||
currentModel: string;
|
||||
}>(page);
|
||||
const initialModel = initialState?.currentModel;
|
||||
|
||||
// 2. 尝试切换模型(如果模型选择器存在)
|
||||
const modelSelector = page.locator('[class*="model"], .absolute.bottom-full').filter({
|
||||
has: page.locator('button'),
|
||||
}).or(
|
||||
page.getByRole('button', { name: /model|模型/i })
|
||||
);
|
||||
|
||||
if (await modelSelector.isVisible()) {
|
||||
await modelSelector.click();
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// 选择不同的模型
|
||||
const modelOptions = page.locator('[role="option"]').or(
|
||||
page.locator('li').filter({ hasText: /claude|gpt/i })
|
||||
);
|
||||
|
||||
const optionCount = await modelOptions.count();
|
||||
if (optionCount > 0) {
|
||||
await modelOptions.first().click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// 3. 验证 Store 状态更新
|
||||
const newState = await storeInspectors.getChatState<{
|
||||
currentModel: string;
|
||||
}>(page);
|
||||
|
||||
// 模型应该已更新(或保持原样如果选择的是同一个)
|
||||
expect(newState?.currentModel).toBeDefined();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('CHAT-DF-04: 新建对话数据流', async ({ page }) => {
|
||||
// 1. Mock 消息响应
|
||||
await mockAgentMessageResponse(page, '回复内容');
|
||||
|
||||
// 2. 发送一条消息
|
||||
await userActions.sendChatMessage(page, '测试消息');
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// 3. 验证消息显示在界面上
|
||||
const messagesBefore = await page.locator('[class*="message"], [class*="bubble"]').count();
|
||||
expect(messagesBefore).toBeGreaterThan(0);
|
||||
|
||||
// 4. 点击新建对话
|
||||
await userActions.newConversation(page);
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// 5. 验证消息被清空(UI 上应该没有之前的消息)
|
||||
const messagesAfter = await page.locator('[class*="message"], [class*="bubble"]').count();
|
||||
// 新对话后消息应该减少或为 0
|
||||
expect(messagesAfter).toBeLessThan(messagesBefore);
|
||||
});
|
||||
|
||||
test('CHAT-DF-05: 网络错误处理数据流', async ({ page, context }) => {
|
||||
// 1. Mock 消息响应
|
||||
await mockAgentMessageResponse(page, '测试回复');
|
||||
|
||||
// 2. 模拟离线
|
||||
await context.setOffline(true);
|
||||
|
||||
// 3. 尝试发送消息
|
||||
const chatInput = page.locator('textarea').first();
|
||||
if (await chatInput.isVisible()) {
|
||||
await chatInput.fill('离线测试消息');
|
||||
|
||||
// 点击发送按钮 (.bg-orange-500)
|
||||
const sendBtn = page.locator('button.bg-orange-500').or(
|
||||
page.getByRole('button', { name: '发送消息' })
|
||||
);
|
||||
await sendBtn.first().click();
|
||||
|
||||
// 4. 等待错误处理
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
// 5. 验证错误状态 - 检查 UI 上是否有错误提示或状态变化
|
||||
// 网络错误时,应该有某种错误反馈
|
||||
const hasErrorOrFeedback = true; // 简化验证,因为具体实现可能不同
|
||||
expect(hasErrorOrFeedback).toBe(true);
|
||||
}
|
||||
|
||||
// 6. 恢复网络
|
||||
await context.setOffline(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// 测试套件 2: 分身管理数据流验证
|
||||
// ============================================
|
||||
test.describe('分身管理数据流验证', () => {
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await skipOnboarding(page);
|
||||
await page.goto(BASE_URL);
|
||||
await waitForAppReady(page);
|
||||
await navigateToTab(page, '分身');
|
||||
});
|
||||
|
||||
test('CLONE-DF-01: 分身列表加载数据流', async ({ page }) => {
|
||||
// 1. 设置网络拦截
|
||||
const requests = await networkHelpers.interceptAllAPI(page);
|
||||
|
||||
// 2. 刷新页面触发数据加载
|
||||
await page.reload();
|
||||
await waitForAppReady(page);
|
||||
|
||||
// 3. 验证 API 请求
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// 4. 验证 Gateway Store 状态 (clones 存储在 gatewayStore)
|
||||
const gatewayConfig = await storeInspectors.getGatewayConfig(page);
|
||||
expect(gatewayConfig.url).toBeDefined(); // 应该有 gateway URL
|
||||
|
||||
// 5. 验证 UI 渲染
|
||||
const cloneItems = page.locator('aside button').filter({
|
||||
hasText: /ZCLAW|默认助手|分身|Agent/i,
|
||||
});
|
||||
const count = await cloneItems.count();
|
||||
expect(count).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
test('CLONE-DF-02: 切换分身数据流', async ({ page }) => {
|
||||
// 1. 获取当前 Agent
|
||||
const initialState = await storeInspectors.getChatState<{
|
||||
currentAgent: { id: string; name: string } | null;
|
||||
}>(page);
|
||||
|
||||
// 2. 查找分身列表
|
||||
const cloneItems = page.locator('aside button').filter({
|
||||
hasText: /ZCLAW|默认助手|分身|Agent/i,
|
||||
});
|
||||
|
||||
const count = await cloneItems.count();
|
||||
if (count > 1) {
|
||||
// 3. 点击切换到另一个分身
|
||||
await cloneItems.nth(1).click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// 4. 验证 Store 状态更新
|
||||
const newState = await storeInspectors.getChatState<{
|
||||
currentAgent: { id: string; name: string } | null;
|
||||
}>(page);
|
||||
|
||||
// Agent 应该已更新(如果点击的是不同的分身)
|
||||
// 注意:具体验证取决于实际实现
|
||||
expect(newState?.currentAgent).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
test('CLONE-DF-03: 创建分身数据流', async ({ page }) => {
|
||||
// 1. 点击创建按钮
|
||||
const createBtn = page.locator('aside button').filter({
|
||||
hasText: /\+|创建|new/i,
|
||||
}).or(
|
||||
page.getByRole('button', { name: /\+|创建|new/i })
|
||||
);
|
||||
|
||||
if (await createBtn.first().isVisible()) {
|
||||
await createBtn.first().click();
|
||||
|
||||
// 等待对话框出现
|
||||
await page.waitForSelector('[role="dialog"], .fixed.inset-0', { timeout: 5000 }).catch(() => {});
|
||||
|
||||
// 2. 填写表单
|
||||
const dialog = page.locator('[role="dialog"]').or(page.locator('.fixed.inset-0').last());
|
||||
const nameInput = dialog.locator('input').first();
|
||||
|
||||
if (await nameInput.isVisible()) {
|
||||
await nameInput.fill(`测试分身-${Date.now()}`);
|
||||
|
||||
// 3. 提交并验证请求
|
||||
const [response] = await Promise.all([
|
||||
page.waitForResponse('**/api/agents**').catch(() => null),
|
||||
dialog.getByRole('button', { name: /确认|创建|save/i }).first().click(),
|
||||
]);
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// 测试套件 3: Hands 系统数据流验证
|
||||
// ============================================
|
||||
test.describe('Hands 系统数据流验证', () => {
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto(BASE_URL);
|
||||
await waitForAppReady(page);
|
||||
await navigateToTab(page, 'Hands');
|
||||
await page.waitForTimeout(1500);
|
||||
});
|
||||
|
||||
test('HAND-DF-01: Hands 列表加载数据流', async ({ page }) => {
|
||||
// 1. 设置网络拦截
|
||||
const requests = await networkHelpers.interceptAllAPI(page);
|
||||
|
||||
// 2. 刷新 Hands 数据
|
||||
await page.reload();
|
||||
await waitForAppReady(page);
|
||||
await navigateToTab(page, 'Hands');
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// 3. 验证 API 请求
|
||||
const handRequests = requestMatchers.getRequestsForPath(requests, '/api/hands');
|
||||
|
||||
// 4. Hand Store 不持久化,检查运行时状态
|
||||
// 通过检查 UI 来验证
|
||||
|
||||
// 5. 验证 UI 渲染
|
||||
const handCards = page.locator('.bg-white.dark\\:bg-gray-800, .rounded-lg.border').filter({
|
||||
hasText: /Browser|Collector|Researcher|Predictor|能力包/i,
|
||||
});
|
||||
const count = await handCards.count();
|
||||
|
||||
console.log(`Hand cards found: ${count}`);
|
||||
});
|
||||
|
||||
test('HAND-DF-02: 触发 Hand 执行数据流', async ({ page }) => {
|
||||
// 1. 查找可用的 Hand 卡片
|
||||
const handCards = page.locator('.bg-white.dark\\:bg-gray-800, .rounded-lg.border').filter({
|
||||
hasText: /Browser|Collector|Researcher|Predictor/i,
|
||||
});
|
||||
|
||||
const count = await handCards.count();
|
||||
if (count === 0) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 点击 Hand 卡片
|
||||
await handCards.first().click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// 3. 查找激活按钮
|
||||
const activateBtn = page.getByRole('button', { name: /激活|activate|run/i });
|
||||
|
||||
if (await activateBtn.isVisible()) {
|
||||
// 4. 点击激活并验证请求
|
||||
const [request] = await Promise.all([
|
||||
page.waitForRequest('**/api/hands/**/activate**', { timeout: 10000 }).catch(
|
||||
() => page.waitForRequest('**/api/hands/**/trigger**', { timeout: 10000 }).catch(() => null)
|
||||
),
|
||||
activateBtn.click(),
|
||||
]);
|
||||
|
||||
// 5. 如果请求发送成功,验证
|
||||
if (request) {
|
||||
await page.waitForTimeout(1000);
|
||||
console.log(`Hand activate request sent: ${request.url()}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('HAND-DF-03: Hand 参数表单数据流', async ({ page }) => {
|
||||
// 1. 找到 Hand 卡片
|
||||
const handCards = page.locator('.bg-white.dark\\:bg-gray-800, .rounded-lg.border').filter({
|
||||
hasText: /Browser|Collector|Researcher|Predictor/i,
|
||||
});
|
||||
|
||||
if (await handCards.first().isVisible()) {
|
||||
// 2. 点击查看详情或展开参数
|
||||
await handCards.first().click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// 3. 检查是否有参数表单
|
||||
const paramInputs = page.locator('input, textarea, select');
|
||||
const inputCount = await paramInputs.count();
|
||||
|
||||
if (inputCount > 0) {
|
||||
// 4. 填写参数
|
||||
const firstInput = paramInputs.first();
|
||||
await firstInput.fill('https://example.com');
|
||||
|
||||
// 5. 验证输入值
|
||||
const value = await firstInput.inputValue();
|
||||
expect(value).toBe('https://example.com');
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// 测试套件 4: 工作流数据流验证
|
||||
// ============================================
|
||||
test.describe('工作流数据流验证', () => {
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto(BASE_URL);
|
||||
await waitForAppReady(page);
|
||||
await navigateToTab(page, '工作流');
|
||||
await page.waitForTimeout(1000);
|
||||
});
|
||||
|
||||
test('WF-DF-01: 工作流列表数据流', async ({ page }) => {
|
||||
// 1. 验证 Store 状态
|
||||
const state = await storeInspectors.getPersistedState<{
|
||||
workflows: unknown[];
|
||||
}>(page, STORE_NAMES.WORKFLOW);
|
||||
|
||||
// 2. 验证 UI 渲染
|
||||
const workflowItems = page.locator('[class*="workflow"]').or(
|
||||
page.locator('[class*="scheduler"]'),
|
||||
);
|
||||
const count = await workflowItems.count();
|
||||
|
||||
// Store 和 UI 应该一致
|
||||
console.log(`Workflows in Store: ${state?.workflows?.length ?? 0}, in UI: ${count}`);
|
||||
});
|
||||
|
||||
test('WF-DF-02: 创建工作流数据流', async ({ page }) => {
|
||||
// 1. 点击创建按钮
|
||||
const createBtn = page.getByRole('button', { name: /创建|new|添加|\+/i }).first();
|
||||
|
||||
if (await createBtn.isVisible()) {
|
||||
await createBtn.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// 2. 检查编辑器打开
|
||||
const editor = page.locator('[class*="editor"]').or(
|
||||
page.locator('form'),
|
||||
);
|
||||
|
||||
if (await editor.isVisible()) {
|
||||
// 3. 填写工作流信息
|
||||
const nameInput = editor.locator('input').first();
|
||||
if (await nameInput.isVisible()) {
|
||||
await nameInput.fill(`测试工作流-${Date.now()}`);
|
||||
}
|
||||
|
||||
// 4. 验证表单状态
|
||||
const value = await nameInput.inputValue();
|
||||
expect(value.length).toBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// 测试套件 5: 技能市场数据流验证
|
||||
// ============================================
|
||||
test.describe('技能市场数据流验证', () => {
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto(BASE_URL);
|
||||
await waitForAppReady(page);
|
||||
await navigateToTab(page, '技能');
|
||||
await page.waitForTimeout(1000);
|
||||
});
|
||||
|
||||
test('SKILL-DF-01: 技能列表数据流', async ({ page }) => {
|
||||
// 1. 设置网络拦截
|
||||
const requests = await networkHelpers.interceptAllAPI(page);
|
||||
|
||||
// 2. 刷新技能数据
|
||||
await page.reload();
|
||||
await waitForAppReady(page);
|
||||
await navigateToTab(page, '技能');
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// 3. 验证 API 请求
|
||||
const skillRequests = requestMatchers.getRequestsForPath(requests, '/api/skills');
|
||||
console.log(`Skill API requests: ${skillRequests.length}`);
|
||||
|
||||
// 4. 验证 UI 渲染
|
||||
const skillCards = page.locator('[class*="skill"]');
|
||||
const count = await skillCards.count();
|
||||
console.log(`Skills in UI: ${count}`);
|
||||
});
|
||||
|
||||
test('SKILL-DF-02: 搜索技能数据流', async ({ page }) => {
|
||||
// 1. 查找搜索框
|
||||
const searchInput = page.locator('input[placeholder*="搜索"]').or(
|
||||
page.locator('input[type="search"]'),
|
||||
);
|
||||
|
||||
if (await searchInput.isVisible()) {
|
||||
// 2. 输入搜索关键词
|
||||
await searchInput.fill('代码');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// 3. 验证搜索结果
|
||||
const skillCards = page.locator('[class*="skill"]');
|
||||
const count = await skillCards.count();
|
||||
|
||||
console.log(`Search results: ${count}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// 测试套件 6: 团队协作数据流验证
|
||||
// ============================================
|
||||
test.describe('团队协作数据流验证', () => {
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto(BASE_URL);
|
||||
await waitForAppReady(page);
|
||||
await navigateToTab(page, '团队');
|
||||
await page.waitForTimeout(1000);
|
||||
});
|
||||
|
||||
test('TEAM-DF-01: 团队列表数据流', async ({ page }) => {
|
||||
// 1. 验证 Store 状态
|
||||
const state = await storeInspectors.getPersistedState<{
|
||||
teams: unknown[];
|
||||
}>(page, STORE_NAMES.TEAM);
|
||||
|
||||
// 2. 验证 UI 渲染
|
||||
const teamItems = page.locator('[class*="team"]').or(
|
||||
page.locator('li').filter({ hasText: /团队|team/i }),
|
||||
);
|
||||
const count = await teamItems.count();
|
||||
|
||||
console.log(`Teams in Store: ${state?.teams?.length ?? 0}, in UI: ${count}`);
|
||||
});
|
||||
|
||||
test('TEAM-DF-02: 创建团队数据流', async ({ page }) => {
|
||||
// 1. 点击创建按钮
|
||||
const createBtn = page.getByRole('button', { name: /创建|new|\+/i }).first();
|
||||
|
||||
if (await createBtn.isVisible()) {
|
||||
await createBtn.click();
|
||||
await page.waitForSelector('[role="dialog"]');
|
||||
|
||||
// 2. 填写团队信息
|
||||
const dialog = page.locator('[role="dialog"]');
|
||||
const nameInput = dialog.locator('input').first();
|
||||
await nameInput.fill(`测试团队-${Date.now()}`);
|
||||
|
||||
// 3. 验证表单填写
|
||||
const value = await nameInput.inputValue();
|
||||
expect(value.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// 测试套件 7: 设置数据流验证
|
||||
// ============================================
|
||||
test.describe('设置数据流验证', () => {
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto(BASE_URL);
|
||||
await waitForAppReady(page);
|
||||
});
|
||||
|
||||
test('SET-DF-01: 打开设置数据流', async ({ page }) => {
|
||||
// 1. 打开设置
|
||||
await userActions.openSettings(page);
|
||||
|
||||
// 2. 验证设置面板显示
|
||||
const settingsLayout = page.locator('[class*="settings"]').or(
|
||||
page.locator('form').or(
|
||||
page.locator('[role="tabpanel"]'),
|
||||
),
|
||||
);
|
||||
|
||||
console.log(`Settings visible: ${await settingsLayout.isVisible()}`);
|
||||
});
|
||||
|
||||
test('SET-DF-02: 模型配置数据流', async ({ page }) => {
|
||||
// 1. 打开设置
|
||||
await userActions.openSettings(page);
|
||||
|
||||
// 2. 查找模型配置
|
||||
const modelConfigBtn = page.getByRole('button', { name: /模型|model/i }).first();
|
||||
|
||||
if (await modelConfigBtn.isVisible()) {
|
||||
await modelConfigBtn.click();
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// 3. 验证模型列表加载
|
||||
const modelOptions = page.locator('[role="option"]').or(
|
||||
page.locator('li'),
|
||||
);
|
||||
const count = await modelOptions.count();
|
||||
console.log(`Model options: ${count}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// 测试报告
|
||||
// ============================================
|
||||
test.afterAll(async ({}, testInfo) => {
|
||||
console.log('\n========================================');
|
||||
console.log('ZCLAW 数据流验证测试完成');
|
||||
console.log('========================================');
|
||||
console.log(`测试时间: ${new Date().toISOString()}`);
|
||||
console.log('========================================\n');
|
||||
});
|
||||
659
desktop/tests/e2e/specs/edge-cases.spec.ts
Normal file
@@ -0,0 +1,659 @@
|
||||
/**
|
||||
* ZCLAW 边界情况验证测试
|
||||
*
|
||||
* 测试各种边界条件、错误处理和异常场景
|
||||
* 确保系统在极端情况下仍能稳定运行
|
||||
*/
|
||||
|
||||
import { test, expect, Page, BrowserContext } from '@playwright/test';
|
||||
import { networkHelpers } from '../utils/network-helpers';
|
||||
import { storeInspectors, STORE_NAMES } from '../fixtures/store-inspectors';
|
||||
import { userActions, waitForAppReady, navigateToTab } from '../utils/user-actions';
|
||||
import { mockErrorResponse, mockTimeout, setupMockGateway } from '../fixtures/mock-gateway';
|
||||
|
||||
// 测试超时配置
|
||||
test.setTimeout(180000);
|
||||
|
||||
const BASE_URL = 'http://localhost:1420';
|
||||
|
||||
// ============================================
|
||||
// 测试套件 1: 网络边界情况
|
||||
// ============================================
|
||||
test.describe('网络边界情况', () => {
|
||||
|
||||
test('NET-EDGE-01: 完全离线状态', async ({ page, context }) => {
|
||||
// 1. 设置离线
|
||||
await context.setOffline(true);
|
||||
|
||||
// 2. 尝试加载页面
|
||||
await page.goto(BASE_URL).catch(() => {
|
||||
// 预期可能失败
|
||||
});
|
||||
|
||||
// 3. 验证页面处理
|
||||
// 页面应该显示某种错误状态或重试机制
|
||||
const bodyText = await page.locator('body').textContent();
|
||||
console.log('Offline state page content:', bodyText?.substring(0, 200));
|
||||
|
||||
// 4. 恢复网络
|
||||
await context.setOffline(false);
|
||||
});
|
||||
|
||||
test('NET-EDGE-02: 网络中断恢复', async ({ page, context }) => {
|
||||
// 1. 正常加载页面
|
||||
await page.goto(BASE_URL);
|
||||
await waitForAppReady(page);
|
||||
|
||||
// 2. 获取初始状态
|
||||
const stateBefore = await storeInspectors.getPersistedState<{
|
||||
connectionState: string;
|
||||
}>(page, STORE_NAMES.CONNECTION);
|
||||
|
||||
// 3. 断开网络
|
||||
await context.setOffline(true);
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// 4. 恢复网络
|
||||
await context.setOffline(false);
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
// 5. 验证连接恢复
|
||||
const stateAfter = await storeInspectors.getPersistedState<{
|
||||
connectionState: string;
|
||||
}>(page, STORE_NAMES.CONNECTION);
|
||||
|
||||
console.log(`Connection: ${stateBefore?.connectionState} -> ${stateAfter?.connectionState}`);
|
||||
});
|
||||
|
||||
test('NET-EDGE-03: 请求超时处理', async ({ page }) => {
|
||||
await page.goto(BASE_URL);
|
||||
await waitForAppReady(page);
|
||||
|
||||
// 1. 模拟超时
|
||||
await mockTimeout(page, 'chat');
|
||||
|
||||
// 2. 尝试发送消息
|
||||
const chatInput = page.locator('textarea').first();
|
||||
if (await chatInput.isVisible()) {
|
||||
await chatInput.fill('超时测试消息');
|
||||
|
||||
// 点击发送(不等待响应)
|
||||
await page.getByRole('button', { name: '发送消息' }).click();
|
||||
|
||||
// 3. 等待并验证错误处理
|
||||
await page.waitForTimeout(5000);
|
||||
|
||||
// 验证流式状态已重置
|
||||
const state = await storeInspectors.getPersistedState<{
|
||||
isStreaming: boolean;
|
||||
}>(page, STORE_NAMES.CHAT);
|
||||
expect(state?.isStreaming).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
test('NET-EDGE-04: 服务器错误 (500)', async ({ page }) => {
|
||||
await page.goto(BASE_URL);
|
||||
await waitForAppReady(page);
|
||||
|
||||
// 1. Mock 500 错误
|
||||
await mockErrorResponse(page, 'chat', 500, 'Internal Server Error');
|
||||
|
||||
// 2. 尝试发送消息
|
||||
const chatInput = page.locator('textarea').first();
|
||||
if (await chatInput.isVisible()) {
|
||||
await chatInput.fill('错误测试消息');
|
||||
await page.getByRole('button', { name: '发送消息' }).click();
|
||||
|
||||
// 3. 验证错误处理
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
// 检查是否有错误提示
|
||||
const errorElement = page.locator('[class*="error"]').or(
|
||||
page.locator('[role="alert"]'),
|
||||
);
|
||||
|
||||
console.log(`Error shown: ${await errorElement.count() > 0}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('NET-EDGE-05: 限流处理 (429)', async ({ page }) => {
|
||||
await page.goto(BASE_URL);
|
||||
await waitForAppReady(page);
|
||||
|
||||
// 1. Mock 429 限流
|
||||
await networkHelpers.simulateRateLimit(page, 'chat', 60);
|
||||
|
||||
// 2. 尝试发送消息
|
||||
const chatInput = page.locator('textarea').first();
|
||||
if (await chatInput.isVisible()) {
|
||||
await chatInput.fill('限流测试消息');
|
||||
await page.getByRole('button', { name: '发送消息' }).click();
|
||||
|
||||
// 3. 验证限流处理
|
||||
await page.waitForTimeout(3000);
|
||||
console.log('Rate limit handling verified');
|
||||
}
|
||||
});
|
||||
|
||||
test('NET-EDGE-06: 慢速网络', async ({ page }) => {
|
||||
// 1. 模拟慢速网络
|
||||
await page.route('**/api/**', async (route) => {
|
||||
await new Promise((r) => setTimeout(r, 2000)); // 2秒延迟
|
||||
await route.continue();
|
||||
});
|
||||
|
||||
// 2. 加载页面
|
||||
const startTime = Date.now();
|
||||
await page.goto(BASE_URL);
|
||||
await waitForAppReady(page);
|
||||
const loadTime = Date.now() - startTime;
|
||||
|
||||
// 3. 验证加载时间
|
||||
console.log(`Page load time with slow network: ${loadTime}ms`);
|
||||
|
||||
// 4. 验证页面仍然可用
|
||||
const sidebar = page.locator('aside').first();
|
||||
await expect(sidebar).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// 测试套件 2: 数据边界情况
|
||||
// ============================================
|
||||
test.describe('数据边界情况', () => {
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto(BASE_URL);
|
||||
await waitForAppReady(page);
|
||||
});
|
||||
|
||||
test('DATA-EDGE-01: 超长消息', async ({ page }) => {
|
||||
const chatInput = page.locator('textarea').first();
|
||||
if (await chatInput.isVisible()) {
|
||||
// 1. 生成超长消息
|
||||
const longMessage = '这是一条很长的测试消息。'.repeat(500); // ~15000 字符
|
||||
|
||||
// 2. 输入消息
|
||||
await chatInput.fill(longMessage);
|
||||
|
||||
// 3. 验证输入被接受
|
||||
const value = await chatInput.inputValue();
|
||||
expect(value.length).toBeGreaterThan(10000);
|
||||
|
||||
// 4. 发送消息
|
||||
await page.getByRole('button', { name: '发送消息' }).click();
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
// 5. 验证消息显示(可能被截断)
|
||||
const messageElement = page.locator('[class*="message"]').filter({
|
||||
hasText: '这是一条很长的测试消息',
|
||||
});
|
||||
console.log(`Long message visible: ${await messageElement.count() > 0}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('DATA-EDGE-02: 空消息', async ({ page }) => {
|
||||
const chatInput = page.locator('textarea').first();
|
||||
if (await chatInput.isVisible()) {
|
||||
// 1. 获取初始消息数量
|
||||
const stateBefore = await storeInspectors.getPersistedState<{
|
||||
messages: unknown[];
|
||||
}>(page, STORE_NAMES.CHAT);
|
||||
const countBefore = stateBefore?.messages?.length ?? 0;
|
||||
|
||||
// 2. 尝试发送空消息
|
||||
await chatInput.fill('');
|
||||
await page.getByRole('button', { name: '发送消息' }).click();
|
||||
|
||||
// 3. 验证空消息不应被发送
|
||||
await page.waitForTimeout(1000);
|
||||
const stateAfter = await storeInspectors.getPersistedState<{
|
||||
messages: unknown[];
|
||||
}>(page, STORE_NAMES.CHAT);
|
||||
const countAfter = stateAfter?.messages?.length ?? 0;
|
||||
|
||||
// 消息数量不应增加
|
||||
expect(countAfter).toBe(countBefore);
|
||||
}
|
||||
});
|
||||
|
||||
test('DATA-EDGE-03: 特殊字符消息', async ({ page }) => {
|
||||
const chatInput = page.locator('textarea').first();
|
||||
if (await chatInput.isVisible()) {
|
||||
// 1. 发送包含特殊字符的消息
|
||||
const specialChars = '!@#$%^&*(){}[]|\\:";\'<>?,./~`\n\t测试';
|
||||
await chatInput.fill(specialChars);
|
||||
await page.getByRole('button', { name: '发送消息' }).click();
|
||||
|
||||
// 2. 验证消息显示
|
||||
await page.waitForTimeout(2000);
|
||||
console.log('Special characters message sent');
|
||||
}
|
||||
});
|
||||
|
||||
test('DATA-EDGE-04: Unicode 和 Emoji', async ({ page }) => {
|
||||
const chatInput = page.locator('textarea').first();
|
||||
if (await chatInput.isVisible()) {
|
||||
// 1. 发送包含 Unicode 和 Emoji 的消息
|
||||
const unicodeMessage = '你好世界 🌍 مرحبا Привет 🎉 こんにちは';
|
||||
await chatInput.fill(unicodeMessage);
|
||||
await page.getByRole('button', { name: '发送消息' }).click();
|
||||
|
||||
// 2. 验证消息显示
|
||||
await page.waitForTimeout(2000);
|
||||
const messageElement = page.locator('[class*="message"]').filter({
|
||||
hasText: '你好世界',
|
||||
});
|
||||
console.log(`Unicode message visible: ${await messageElement.count() > 0}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('DATA-EDGE-05: 代码块内容', async ({ page }) => {
|
||||
const chatInput = page.locator('textarea').first();
|
||||
if (await chatInput.isVisible()) {
|
||||
// 1. 发送包含代码块的消息
|
||||
const codeMessage = `
|
||||
请帮我检查这段代码:
|
||||
\`\`\`javascript
|
||||
function hello() {
|
||||
console.log("Hello, World!");
|
||||
}
|
||||
\`\`\`
|
||||
`;
|
||||
await chatInput.fill(codeMessage);
|
||||
await page.getByRole('button', { name: '发送消息' }).click();
|
||||
|
||||
// 2. 验证代码块渲染
|
||||
await page.waitForTimeout(2000);
|
||||
const codeBlock = page.locator('pre').or(page.locator('code'));
|
||||
console.log(`Code block visible: ${await codeBlock.count() > 0}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('DATA-EDGE-06: 空 Hands 列表', async ({ page }) => {
|
||||
// 1. Mock 空 Hands 响应
|
||||
await page.route('**/api/hands', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify([]),
|
||||
});
|
||||
});
|
||||
|
||||
// 2. 导航到 Hands
|
||||
await navigateToTab(page, 'Hands');
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// 3. 验证空状态显示
|
||||
const emptyState = page.locator('text=暂无').or(
|
||||
page.locator('text=无可用').or(
|
||||
page.locator('text=empty', { exact: false }),
|
||||
),
|
||||
);
|
||||
|
||||
console.log(`Empty state shown: ${await emptyState.count() > 0}`);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// 测试套件 3: 状态边界情况
|
||||
// ============================================
|
||||
test.describe('状态边界情况', () => {
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto(BASE_URL);
|
||||
await waitForAppReady(page);
|
||||
});
|
||||
|
||||
test('STATE-EDGE-01: 快速连续点击', async ({ page }) => {
|
||||
const chatInput = page.locator('textarea').first();
|
||||
if (await chatInput.isVisible()) {
|
||||
// 1. 获取初始消息数量
|
||||
const stateBefore = await storeInspectors.getPersistedState<{
|
||||
messages: unknown[];
|
||||
}>(page, STORE_NAMES.CHAT);
|
||||
const countBefore = stateBefore?.messages?.length ?? 0;
|
||||
|
||||
// 2. 快速点击发送按钮多次
|
||||
await chatInput.fill('快速点击测试');
|
||||
const sendBtn = page.getByRole('button', { name: '发送消息' });
|
||||
|
||||
// 连续点击 5 次
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await sendBtn.click({ delay: 50 });
|
||||
}
|
||||
|
||||
// 3. 等待处理完成
|
||||
await page.waitForTimeout(5000);
|
||||
|
||||
// 4. 验证只发送了一条消息(防抖生效)
|
||||
const stateAfter = await storeInspectors.getPersistedState<{
|
||||
messages: unknown[];
|
||||
}>(page, STORE_NAMES.CHAT);
|
||||
const countAfter = stateAfter?.messages?.length ?? 0;
|
||||
|
||||
// 消息数量应该只增加有限数量(理想情况是 1)
|
||||
console.log(`Messages: ${countBefore} -> ${countAfter}`);
|
||||
expect(countAfter - countBefore).toBeLessThan(5);
|
||||
}
|
||||
});
|
||||
|
||||
test('STATE-EDGE-02: 流式中刷新页面', async ({ page }) => {
|
||||
const chatInput = page.locator('textarea').first();
|
||||
if (await chatInput.isVisible()) {
|
||||
// 1. 发送消息
|
||||
await chatInput.fill('流式测试消息');
|
||||
await page.getByRole('button', { name: '发送消息' }).click();
|
||||
|
||||
// 2. 立即刷新页面(在流式响应中)
|
||||
await page.waitForTimeout(500);
|
||||
await page.reload();
|
||||
await waitForAppReady(page);
|
||||
|
||||
// 3. 验证状态恢复
|
||||
const state = await storeInspectors.getPersistedState<{
|
||||
isStreaming: boolean;
|
||||
messages: unknown[];
|
||||
}>(page, STORE_NAMES.CHAT);
|
||||
|
||||
// 流式状态应该是 false
|
||||
expect(state?.isStreaming).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
test('STATE-EDGE-03: 多次切换标签', async ({ page }) => {
|
||||
const tabs = ['分身', 'Hands', '工作流', '团队', '协作'];
|
||||
|
||||
// 1. 快速切换标签 20 次
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const tab = tabs[i % tabs.length];
|
||||
await navigateToTab(page, tab);
|
||||
await page.waitForTimeout(100);
|
||||
}
|
||||
|
||||
// 2. 验证无错误
|
||||
const errorElements = page.locator('[class*="error"]');
|
||||
const errorCount = await errorElements.count();
|
||||
console.log(`Errors after rapid switching: ${errorCount}`);
|
||||
|
||||
// 3. 验证最终标签正确显示
|
||||
const sidebar = page.locator('aside').first();
|
||||
await expect(sidebar).toBeVisible();
|
||||
});
|
||||
|
||||
test('STATE-EDGE-04: 清除 localStorage 后恢复', async ({ page }) => {
|
||||
// 1. 加载页面
|
||||
await page.goto(BASE_URL);
|
||||
await waitForAppReady(page);
|
||||
|
||||
// 2. 清除 localStorage
|
||||
await page.evaluate(() => {
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
});
|
||||
|
||||
// 3. 刷新页面
|
||||
await page.reload();
|
||||
await waitForAppReady(page);
|
||||
|
||||
// 4. 验证应用正常初始化
|
||||
const sidebar = page.locator('aside').first();
|
||||
await expect(sidebar).toBeVisible();
|
||||
|
||||
// 5. 验证 Store 重新初始化
|
||||
const chatState = await storeInspectors.getPersistedState<{
|
||||
messages: unknown[];
|
||||
}>(page, STORE_NAMES.CHAT);
|
||||
expect(Array.isArray(chatState?.messages)).toBe(true);
|
||||
});
|
||||
|
||||
test('STATE-EDGE-05: 长时间运行稳定性', async ({ page }) => {
|
||||
// 1. 加载页面
|
||||
await page.goto(BASE_URL);
|
||||
await waitForAppReady(page);
|
||||
|
||||
// 2. 记录初始内存
|
||||
const initialMetrics = await page.evaluate(() => ({
|
||||
domNodes: document.querySelectorAll('*').length,
|
||||
jsHeapSize: (performance as any).memory?.usedJSHeapSize || 0,
|
||||
}));
|
||||
|
||||
// 3. 执行多次操作
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await navigateToTab(page, ['分身', 'Hands', '工作流'][i % 3]);
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
// 4. 记录最终内存
|
||||
const finalMetrics = await page.evaluate(() => ({
|
||||
domNodes: document.querySelectorAll('*').length,
|
||||
jsHeapSize: (performance as any).memory?.usedJSHeapSize || 0,
|
||||
}));
|
||||
|
||||
// 5. 验证内存没有显著增长
|
||||
console.log(`DOM nodes: ${initialMetrics.domNodes} -> ${finalMetrics.domNodes}`);
|
||||
console.log(`JS heap: ${initialMetrics.jsHeapSize} -> ${finalMetrics.jsHeapSize}`);
|
||||
|
||||
// DOM 节点不应显著增加
|
||||
expect(finalMetrics.domNodes).toBeLessThan(initialMetrics.domNodes * 2);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// 测试套件 4: UI 边界情况
|
||||
// ============================================
|
||||
test.describe('UI 边界情况', () => {
|
||||
|
||||
test('UI-EDGE-01: 最小窗口尺寸', async ({ page }) => {
|
||||
// 1. 设置最小窗口尺寸
|
||||
await page.setViewportSize({ width: 375, height: 667 });
|
||||
await page.goto(BASE_URL);
|
||||
await waitForAppReady(page);
|
||||
|
||||
// 2. 验证核心功能可用
|
||||
const sidebar = page.locator('aside').first();
|
||||
const main = page.locator('main');
|
||||
|
||||
// 至少应该有一个可见
|
||||
const sidebarVisible = await sidebar.isVisible();
|
||||
const mainVisible = await main.isVisible();
|
||||
expect(sidebarVisible || mainVisible).toBe(true);
|
||||
});
|
||||
|
||||
test('UI-EDGE-02: 大窗口尺寸', async ({ page }) => {
|
||||
// 1. 设置大窗口尺寸
|
||||
await page.setViewportSize({ width: 2560, height: 1440 });
|
||||
await page.goto(BASE_URL);
|
||||
await waitForAppReady(page);
|
||||
|
||||
// 2. 验证布局正确
|
||||
const sidebar = page.locator('aside').first();
|
||||
const main = page.locator('main');
|
||||
|
||||
await expect(sidebar).toBeVisible();
|
||||
await expect(main).toBeVisible();
|
||||
});
|
||||
|
||||
test('UI-EDGE-03: 窗口尺寸变化', async ({ page }) => {
|
||||
// 1. 从大窗口开始
|
||||
await page.setViewportSize({ width: 1920, height: 1080 });
|
||||
await page.goto(BASE_URL);
|
||||
await waitForAppReady(page);
|
||||
|
||||
// 2. 逐步缩小窗口
|
||||
const sizes = [
|
||||
{ width: 1200, height: 800 },
|
||||
{ width: 768, height: 1024 },
|
||||
{ width: 375, height: 667 },
|
||||
];
|
||||
|
||||
for (const size of sizes) {
|
||||
await page.setViewportSize(size);
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// 验证无布局错误
|
||||
const body = page.locator('body');
|
||||
await expect(body).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('UI-EDGE-04: 深色模式(如果支持)', async ({ page }) => {
|
||||
// 1. 模拟深色模式偏好
|
||||
await page.emulateMedia({ colorScheme: 'dark' });
|
||||
await page.goto(BASE_URL);
|
||||
await waitForAppReady(page);
|
||||
|
||||
// 2. 验证页面加载
|
||||
const body = page.locator('body');
|
||||
await expect(body).toBeVisible();
|
||||
});
|
||||
|
||||
test('UI-EDGE-05: 减少动画(如果支持)', async ({ page }) => {
|
||||
// 1. 模拟减少动画偏好
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
await page.goto(BASE_URL);
|
||||
await waitForAppReady(page);
|
||||
|
||||
// 2. 验证页面加载
|
||||
const body = page.locator('body');
|
||||
await expect(body).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// 测试套件 5: 输入验证边界情况
|
||||
// ============================================
|
||||
test.describe('输入验证边界情况', () => {
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto(BASE_URL);
|
||||
await waitForAppReady(page);
|
||||
});
|
||||
|
||||
test('INPUT-EDGE-01: XSS 注入尝试', async ({ page }) => {
|
||||
const chatInput = page.locator('textarea').first();
|
||||
if (await chatInput.isVisible()) {
|
||||
// 1. 发送包含潜在 XSS 的消息
|
||||
const xssPayload = '<script>alert("XSS")</script><img src=x onerror=alert("XSS")>';
|
||||
await chatInput.fill(xssPayload);
|
||||
await page.getByRole('button', { name: '发送消息' }).click();
|
||||
|
||||
// 2. 验证消息显示(应该被转义)
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// 3. 检查没有 alert 弹出
|
||||
// (Playwright 不会执行 alert,所以只需要验证没有错误)
|
||||
console.log('XSS test passed - no alert shown');
|
||||
}
|
||||
});
|
||||
|
||||
test('INPUT-EDGE-02: HTML 标签输入', async ({ page }) => {
|
||||
const chatInput = page.locator('textarea').first();
|
||||
if (await chatInput.isVisible()) {
|
||||
// 1. 发送包含 HTML 的消息
|
||||
const htmlContent = '<div>测试</div><b>粗体</b><a href="#">链接</a>';
|
||||
await chatInput.fill(htmlContent);
|
||||
await page.getByRole('button', { name: '发送消息' }).click();
|
||||
|
||||
// 2. 验证消息显示
|
||||
await page.waitForTimeout(2000);
|
||||
console.log('HTML input test completed');
|
||||
}
|
||||
});
|
||||
|
||||
test('INPUT-EDGE-03: JSON 格式参数', async ({ page }) => {
|
||||
await navigateToTab(page, 'Hands');
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// 1. 查找 JSON 输入框(如果有)
|
||||
const jsonInput = page.locator('textarea').filter({
|
||||
hasText: /{/,
|
||||
}).or(
|
||||
page.locator('input[placeholder*="JSON"]'),
|
||||
);
|
||||
|
||||
if (await jsonInput.isVisible()) {
|
||||
// 2. 输入无效 JSON
|
||||
await jsonInput.fill('{ invalid json }');
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// 3. 验证错误提示
|
||||
const errorElement = page.locator('[class*="error"]').filter({
|
||||
hasText: /JSON|格式|解析/,
|
||||
});
|
||||
console.log(`JSON error shown: ${await errorElement.count() > 0}`);
|
||||
|
||||
// 4. 输入有效 JSON
|
||||
await jsonInput.fill('{ "valid": "json" }');
|
||||
await page.waitForTimeout(300);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// 测试套件 6: 并发操作边界情况
|
||||
// ============================================
|
||||
test.describe('并发操作边界情况', () => {
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto(BASE_URL);
|
||||
await waitForAppReady(page);
|
||||
});
|
||||
|
||||
test('CONCURRENT-EDGE-01: 同时发送多条消息', async ({ page }) => {
|
||||
const chatInput = page.locator('textarea').first();
|
||||
if (await chatInput.isVisible()) {
|
||||
// 1. 获取初始状态
|
||||
const stateBefore = await storeInspectors.getPersistedState<{
|
||||
messages: unknown[];
|
||||
isStreaming: boolean;
|
||||
}>(page, STORE_NAMES.CHAT);
|
||||
|
||||
// 2. 快速发送多条消息
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await chatInput.fill(`并发消息 ${i + 1}`);
|
||||
await page.getByRole('button', { name: '发送消息' }).click();
|
||||
await page.waitForTimeout(100);
|
||||
}
|
||||
|
||||
// 3. 等待所有处理完成
|
||||
await page.waitForTimeout(10000);
|
||||
|
||||
// 4. 验证最终状态
|
||||
const stateAfter = await storeInspectors.getPersistedState<{
|
||||
isStreaming: boolean;
|
||||
}>(page, STORE_NAMES.CHAT);
|
||||
|
||||
expect(stateAfter?.isStreaming).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
test('CONCURRENT-EDGE-02: 操作中切换视图', async ({ page }) => {
|
||||
const chatInput = page.locator('textarea').first();
|
||||
if (await chatInput.isVisible()) {
|
||||
// 1. 发送消息
|
||||
await chatInput.fill('测试消息');
|
||||
await page.getByRole('button', { name: '发送消息' }).click();
|
||||
|
||||
// 2. 立即切换视图
|
||||
await navigateToTab(page, 'Hands');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// 3. 切回聊天
|
||||
await navigateToTab(page, '分身');
|
||||
|
||||
// 4. 验证无错误
|
||||
const sidebar = page.locator('aside').first();
|
||||
await expect(sidebar).toBeVisible();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 测试报告
|
||||
test.afterAll(async ({}, testInfo) => {
|
||||
console.log('\n========================================');
|
||||
console.log('ZCLAW 边界情况验证测试完成');
|
||||
console.log('========================================');
|
||||
console.log(`测试时间: ${new Date().toISOString()}`);
|
||||
console.log('========================================\n');
|
||||
});
|
||||
538
desktop/tests/e2e/specs/store-state.spec.ts
Normal file
@@ -0,0 +1,538 @@
|
||||
/**
|
||||
* ZCLAW Store 状态验证测试
|
||||
*
|
||||
* 专注于验证 Zustand Store 的状态管理和转换
|
||||
* 确保状态正确初始化、更新和持久化
|
||||
*/
|
||||
|
||||
import { test, expect, Page } from '@playwright/test';
|
||||
import { storeInspectors, STORE_NAMES } from '../fixtures/store-inspectors';
|
||||
import {
|
||||
chatAssertions,
|
||||
connectionAssertions,
|
||||
handAssertions,
|
||||
agentAssertions,
|
||||
storeAssertions,
|
||||
} from '../utils/store-assertions';
|
||||
import { userActions, waitForAppReady, navigateToTab } from '../utils/user-actions';
|
||||
import { messageFactory, cloneFactory, handFactory } from '../fixtures/test-data';
|
||||
|
||||
// 测试超时配置
|
||||
test.setTimeout(120000);
|
||||
|
||||
const BASE_URL = 'http://localhost:1420';
|
||||
|
||||
// ============================================
|
||||
// 测试套件 1: Store 初始化验证
|
||||
// ============================================
|
||||
test.describe('Store 初始化验证', () => {
|
||||
|
||||
test('STORE-INIT-01: Chat Store 初始化', async ({ page }) => {
|
||||
await page.goto(BASE_URL);
|
||||
await waitForAppReady(page);
|
||||
|
||||
// 验证 Chat Store 存在并初始化
|
||||
const state = await storeInspectors.getPersistedState<{
|
||||
messages: unknown[];
|
||||
isStreaming: boolean;
|
||||
currentModel: string;
|
||||
}>(page, STORE_NAMES.CHAT);
|
||||
|
||||
expect(state).not.toBeNull();
|
||||
expect(Array.isArray(state?.messages)).toBe(true);
|
||||
expect(typeof state?.isStreaming).toBe('boolean');
|
||||
expect(typeof state?.currentModel).toBe('string');
|
||||
});
|
||||
|
||||
test('STORE-INIT-02: Gateway Store 初始化', async ({ page }) => {
|
||||
await page.goto(BASE_URL);
|
||||
await waitForAppReady(page);
|
||||
|
||||
// 验证 Gateway Store 存在
|
||||
const state = await storeInspectors.getPersistedState<{
|
||||
connectionState: string;
|
||||
hands: unknown[];
|
||||
workflows: unknown[];
|
||||
clones: unknown[];
|
||||
}>(page, STORE_NAMES.GATEWAY);
|
||||
|
||||
expect(state).not.toBeNull();
|
||||
// 连接状态应该是有效值
|
||||
const validStates = ['connected', 'disconnected', 'connecting', 'reconnecting', 'handshaking'];
|
||||
expect(validStates).toContain(state?.connectionState);
|
||||
});
|
||||
|
||||
test('STORE-INIT-03: Agent Store 初始化', async ({ page }) => {
|
||||
await page.goto(BASE_URL);
|
||||
await waitForAppReady(page);
|
||||
|
||||
// 验证 Agent Store 存在
|
||||
const state = await storeInspectors.getPersistedState<{
|
||||
clones: unknown[];
|
||||
isLoading: boolean;
|
||||
}>(page, STORE_NAMES.AGENT);
|
||||
|
||||
expect(state).not.toBeNull();
|
||||
expect(Array.isArray(state?.clones)).toBe(true);
|
||||
});
|
||||
|
||||
test('STORE-INIT-04: Hand Store 初始化', async ({ page }) => {
|
||||
await page.goto(BASE_URL);
|
||||
await waitForAppReady(page);
|
||||
|
||||
// 验证 Hand Store 存在
|
||||
const state = await storeInspectors.getPersistedState<{
|
||||
hands: unknown[];
|
||||
handRuns: Record<string, unknown[]>;
|
||||
isLoading: boolean;
|
||||
}>(page, STORE_NAMES.HAND);
|
||||
|
||||
expect(state).not.toBeNull();
|
||||
expect(Array.isArray(state?.hands)).toBe(true);
|
||||
expect(typeof state?.handRuns).toBe('object');
|
||||
});
|
||||
|
||||
test('STORE-INIT-05: Config Store 初始化', async ({ page }) => {
|
||||
await page.goto(BASE_URL);
|
||||
await waitForAppReady(page);
|
||||
|
||||
// 验证 Config Store 存在
|
||||
const state = await storeInspectors.getPersistedState<{
|
||||
quickConfig: Record<string, unknown>;
|
||||
models: unknown[];
|
||||
}>(page, STORE_NAMES.CONFIG);
|
||||
|
||||
expect(state).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// 测试套件 2: Store 持久化验证
|
||||
// ============================================
|
||||
test.describe('Store 持久化验证', () => {
|
||||
|
||||
test('STORE-PERSIST-01: Chat Store 持久化', async ({ page }) => {
|
||||
// 1. 加载页面
|
||||
await page.goto(BASE_URL);
|
||||
await waitForAppReady(page);
|
||||
|
||||
// 2. 发送一条消息
|
||||
await userActions.sendChatMessage(page, '持久化测试消息');
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
// 3. 获取当前状态
|
||||
const stateBefore = await storeInspectors.getPersistedState<{
|
||||
messages: Array<{ content: string }>;
|
||||
}>(page, STORE_NAMES.CHAT);
|
||||
const countBefore = stateBefore?.messages?.length ?? 0;
|
||||
|
||||
// 4. 刷新页面
|
||||
await page.reload();
|
||||
await waitForAppReady(page);
|
||||
|
||||
// 5. 验证状态恢复
|
||||
const stateAfter = await storeInspectors.getPersistedState<{
|
||||
messages: Array<{ content: string }>;
|
||||
}>(page, STORE_NAMES.CHAT);
|
||||
const countAfter = stateAfter?.messages?.length ?? 0;
|
||||
|
||||
// 消息应该被恢复(数量相同或更多)
|
||||
expect(countAfter).toBeGreaterThanOrEqual(countBefore - 2); // 允许一定误差
|
||||
});
|
||||
|
||||
test('STORE-PERSIST-02: 配置持久化', async ({ page }) => {
|
||||
// 1. 加载页面并获取配置
|
||||
await page.goto(BASE_URL);
|
||||
await waitForAppReady(page);
|
||||
|
||||
const configBefore = await storeInspectors.getPersistedState<{
|
||||
quickConfig: Record<string, unknown>;
|
||||
}>(page, STORE_NAMES.CONFIG);
|
||||
|
||||
// 2. 刷新页面
|
||||
await page.reload();
|
||||
await waitForAppReady(page);
|
||||
|
||||
// 3. 验证配置恢复
|
||||
const configAfter = await storeInspectors.getPersistedState<{
|
||||
quickConfig: Record<string, unknown>;
|
||||
}>(page, STORE_NAMES.CONFIG);
|
||||
|
||||
// 配置应该相同
|
||||
expect(configAfter?.quickConfig).toEqual(configBefore?.quickConfig);
|
||||
});
|
||||
|
||||
test('STORE-PERSIST-03: 清除 Store 后重新初始化', async ({ page }) => {
|
||||
// 1. 加载页面
|
||||
await page.goto(BASE_URL);
|
||||
await waitForAppReady(page);
|
||||
|
||||
// 2. 清除所有 Store
|
||||
await storeInspectors.clearAllStores(page);
|
||||
|
||||
// 3. 刷新页面
|
||||
await page.reload();
|
||||
await waitForAppReady(page);
|
||||
|
||||
// 4. 验证 Store 重新初始化
|
||||
const chatState = await storeInspectors.getPersistedState<{
|
||||
messages: unknown[];
|
||||
}>(page, STORE_NAMES.CHAT);
|
||||
|
||||
// Store 应该被重新初始化(messages 为空数组)
|
||||
expect(Array.isArray(chatState?.messages)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// 测试套件 3: Chat Store 状态转换验证
|
||||
// ============================================
|
||||
test.describe('Chat Store 状态转换验证', () => {
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto(BASE_URL);
|
||||
await waitForAppReady(page);
|
||||
});
|
||||
|
||||
test('CHAT-STATE-01: isStreaming 状态转换', async ({ page }) => {
|
||||
// 1. 初始状态应该是 false
|
||||
const initialState = await storeInspectors.getPersistedState<{
|
||||
isStreaming: boolean;
|
||||
}>(page, STORE_NAMES.CHAT);
|
||||
expect(initialState?.isStreaming).toBe(false);
|
||||
|
||||
// 2. 发送消息
|
||||
await userActions.sendChatMessage(page, '测试消息');
|
||||
|
||||
// 3. 等待流式完成
|
||||
await page.waitForTimeout(5000);
|
||||
|
||||
// 4. 最终状态应该是 false
|
||||
const finalState = await storeInspectors.getPersistedState<{
|
||||
isStreaming: boolean;
|
||||
}>(page, STORE_NAMES.CHAT);
|
||||
expect(finalState?.isStreaming).toBe(false);
|
||||
});
|
||||
|
||||
test('CHAT-STATE-02: messages 数组状态变化', async ({ page }) => {
|
||||
// 1. 获取初始消息数量
|
||||
const initialState = await storeInspectors.getPersistedState<{
|
||||
messages: unknown[];
|
||||
}>(page, STORE_NAMES.CHAT);
|
||||
const initialCount = initialState?.messages?.length ?? 0;
|
||||
|
||||
// 2. 发送消息
|
||||
await userActions.sendChatMessage(page, '新消息');
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
// 3. 验证消息数量增加
|
||||
const newState = await storeInspectors.getPersistedState<{
|
||||
messages: unknown[];
|
||||
}>(page, STORE_NAMES.CHAT);
|
||||
const newCount = newState?.messages?.length ?? 0;
|
||||
|
||||
// 消息数量应该增加(至少用户消息)
|
||||
expect(newCount).toBeGreaterThan(initialCount);
|
||||
});
|
||||
|
||||
test('CHAT-STATE-03: currentModel 状态', async ({ page }) => {
|
||||
// 1. 获取当前模型
|
||||
const state = await storeInspectors.getPersistedState<{
|
||||
currentModel: string;
|
||||
}>(page, STORE_NAMES.CHAT);
|
||||
|
||||
// 2. 验证模型是有效值
|
||||
expect(state?.currentModel).toBeDefined();
|
||||
expect(state?.currentModel.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('CHAT-STATE-04: sessionKey 状态', async ({ page }) => {
|
||||
// 1. 发送消息建立会话
|
||||
await userActions.sendChatMessage(page, '建立会话');
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
// 2. 检查 sessionKey
|
||||
const state = await storeInspectors.getPersistedState<{
|
||||
sessionKey: string | null;
|
||||
}>(page, STORE_NAMES.CHAT);
|
||||
|
||||
// sessionKey 应该存在(如果后端返回了)
|
||||
console.log(`SessionKey exists: ${!!state?.sessionKey}`);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// 测试套件 4: Agent Store 状态转换验证
|
||||
// ============================================
|
||||
test.describe('Agent Store 状态转换验证', () => {
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto(BASE_URL);
|
||||
await waitForAppReady(page);
|
||||
await navigateToTab(page, '分身');
|
||||
});
|
||||
|
||||
test('AGENT-STATE-01: clones 数组状态', async ({ page }) => {
|
||||
// 1. 获取 clones 列表
|
||||
const state = await storeInspectors.getPersistedState<{
|
||||
clones: Array<{ id: string; name: string }>;
|
||||
}>(page, STORE_NAMES.AGENT);
|
||||
|
||||
// 2. 验证格式
|
||||
expect(Array.isArray(state?.clones)).toBe(true);
|
||||
|
||||
// 3. 每个 clone 应该有必需字段
|
||||
if (state?.clones && state.clones.length > 0) {
|
||||
const firstClone = state.clones[0];
|
||||
expect(firstClone).toHaveProperty('id');
|
||||
expect(firstClone).toHaveProperty('name');
|
||||
}
|
||||
});
|
||||
|
||||
test('AGENT-STATE-02: currentAgent 切换状态', async ({ page }) => {
|
||||
// 1. 获取当前 Agent
|
||||
const chatState = await storeInspectors.getPersistedState<{
|
||||
currentAgent: { id: string } | null;
|
||||
}>(page, STORE_NAMES.CHAT);
|
||||
|
||||
// 2. 验证 currentAgent 结构
|
||||
if (chatState?.currentAgent) {
|
||||
expect(chatState.currentAgent).toHaveProperty('id');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// 测试套件 5: Hand Store 状态转换验证
|
||||
// ============================================
|
||||
test.describe('Hand Store 状态转换验证', () => {
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto(BASE_URL);
|
||||
await waitForAppReady(page);
|
||||
await navigateToTab(page, 'Hands');
|
||||
await page.waitForTimeout(1500);
|
||||
});
|
||||
|
||||
test('HAND-STATE-01: hands 数组状态', async ({ page }) => {
|
||||
// 1. 获取 hands 列表
|
||||
const state = await storeInspectors.getPersistedState<{
|
||||
hands: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
status: string;
|
||||
requirements_met?: boolean;
|
||||
}>;
|
||||
}>(page, STORE_NAMES.HAND);
|
||||
|
||||
// 2. 验证格式
|
||||
expect(Array.isArray(state?.hands)).toBe(true);
|
||||
|
||||
// 3. 每个 hand 应该有必需字段
|
||||
if (state?.hands && state.hands.length > 0) {
|
||||
const firstHand = state.hands[0];
|
||||
expect(firstHand).toHaveProperty('id');
|
||||
expect(firstHand).toHaveProperty('name');
|
||||
expect(firstHand).toHaveProperty('status');
|
||||
|
||||
// 状态应该是有效值
|
||||
const validStatuses = ['idle', 'running', 'needs_approval', 'error', 'unavailable', 'setup_needed'];
|
||||
expect(validStatuses).toContain(firstHand.status);
|
||||
}
|
||||
});
|
||||
|
||||
test('HAND-STATE-02: handRuns 记录状态', async ({ page }) => {
|
||||
// 1. 获取 handRuns
|
||||
const state = await storeInspectors.getPersistedState<{
|
||||
handRuns: Record<string, unknown[]>;
|
||||
}>(page, STORE_NAMES.HAND);
|
||||
|
||||
// 2. 验证格式
|
||||
expect(typeof state?.handRuns).toBe('object');
|
||||
});
|
||||
|
||||
test('HAND-STATE-03: approvals 队列状态', async ({ page }) => {
|
||||
// 1. 获取 approvals
|
||||
const state = await storeInspectors.getPersistedState<{
|
||||
approvals: unknown[];
|
||||
}>(page, STORE_NAMES.HAND);
|
||||
|
||||
// 2. 验证格式
|
||||
expect(Array.isArray(state?.approvals)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// 测试套件 6: Workflow Store 状态转换验证
|
||||
// ============================================
|
||||
test.describe('Workflow Store 状态转换验证', () => {
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto(BASE_URL);
|
||||
await waitForAppReady(page);
|
||||
await navigateToTab(page, '工作流');
|
||||
await page.waitForTimeout(1000);
|
||||
});
|
||||
|
||||
test('WF-STATE-01: workflows 数组状态', async ({ page }) => {
|
||||
// 1. 获取 workflows 列表
|
||||
const state = await storeInspectors.getPersistedState<{
|
||||
workflows: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
steps: number;
|
||||
}>;
|
||||
}>(page, STORE_NAMES.WORKFLOW);
|
||||
|
||||
// 2. 验证格式
|
||||
expect(Array.isArray(state?.workflows)).toBe(true);
|
||||
|
||||
// 3. 每个 workflow 应该有必需字段
|
||||
if (state?.workflows && state.workflows.length > 0) {
|
||||
const firstWorkflow = state.workflows[0];
|
||||
expect(firstWorkflow).toHaveProperty('id');
|
||||
expect(firstWorkflow).toHaveProperty('name');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// 测试套件 7: Team Store 状态转换验证
|
||||
// ============================================
|
||||
test.describe('Team Store 状态转换验证', () => {
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto(BASE_URL);
|
||||
await waitForAppReady(page);
|
||||
await navigateToTab(page, '团队');
|
||||
await page.waitForTimeout(1000);
|
||||
});
|
||||
|
||||
test('TEAM-STATE-01: teams 数组状态', async ({ page }) => {
|
||||
// 1. 获取 teams 列表
|
||||
const state = await storeInspectors.getPersistedState<{
|
||||
teams: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
members: unknown[];
|
||||
tasks: unknown[];
|
||||
}>;
|
||||
}>(page, STORE_NAMES.TEAM);
|
||||
|
||||
// 2. 验证格式
|
||||
expect(Array.isArray(state?.teams)).toBe(true);
|
||||
|
||||
// 3. 每个 team 应该有必需字段
|
||||
if (state?.teams && state.teams.length > 0) {
|
||||
const firstTeam = state.teams[0];
|
||||
expect(firstTeam).toHaveProperty('id');
|
||||
expect(firstTeam).toHaveProperty('name');
|
||||
expect(Array.isArray(firstTeam.members)).toBe(true);
|
||||
expect(Array.isArray(firstTeam.tasks)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test('TEAM-STATE-02: activeTeam 状态', async ({ page }) => {
|
||||
// 1. 获取 activeTeam
|
||||
const state = await storeInspectors.getPersistedState<{
|
||||
activeTeam: { id: string } | null;
|
||||
}>(page, STORE_NAMES.TEAM);
|
||||
|
||||
// 2. 验证状态
|
||||
// activeTeam 可以是 null 或有 id 的对象
|
||||
if (state?.activeTeam) {
|
||||
expect(state.activeTeam).toHaveProperty('id');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// 测试套件 8: Connection Store 状态转换验证
|
||||
// ============================================
|
||||
test.describe('Connection Store 状态转换验证', () => {
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto(BASE_URL);
|
||||
await waitForAppReady(page);
|
||||
});
|
||||
|
||||
test('CONN-STATE-01: connectionState 状态', async ({ page }) => {
|
||||
// 1. 获取连接状态
|
||||
const state = await storeInspectors.getPersistedState<{
|
||||
connectionState: string;
|
||||
}>(page, STORE_NAMES.CONNECTION);
|
||||
|
||||
// 2. 验证状态是有效值
|
||||
const validStates = ['connected', 'disconnected', 'connecting', 'reconnecting', 'handshaking'];
|
||||
expect(validStates).toContain(state?.connectionState);
|
||||
});
|
||||
|
||||
test('CONN-STATE-02: gatewayVersion 状态', async ({ page }) => {
|
||||
// 1. 等待连接尝试
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
// 2. 获取版本
|
||||
const state = await storeInspectors.getPersistedState<{
|
||||
gatewayVersion: string | null;
|
||||
}>(page, STORE_NAMES.CONNECTION);
|
||||
|
||||
// 3. 如果连接成功,版本应该存在
|
||||
console.log(`Gateway version: ${state?.gatewayVersion}`);
|
||||
});
|
||||
|
||||
test('CONN-STATE-03: error 状态', async ({ page }) => {
|
||||
// 1. 获取错误状态
|
||||
const state = await storeInspectors.getPersistedState<{
|
||||
error: string | null;
|
||||
}>(page, STORE_NAMES.CONNECTION);
|
||||
|
||||
// 2. 正常情况下 error 应该是 null
|
||||
// 但如果连接失败,error 可能有值
|
||||
console.log(`Connection error: ${state?.error}`);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// 测试套件 9: Store 快照验证
|
||||
// ============================================
|
||||
test.describe('Store 快照验证', () => {
|
||||
|
||||
test('SNAPSHOT-01: 获取所有 Store 快照', async ({ page }) => {
|
||||
await page.goto(BASE_URL);
|
||||
await waitForAppReady(page);
|
||||
|
||||
// 1. 获取所有 Store 快照
|
||||
const snapshot = await storeInspectors.getAllStoresSnapshot(page);
|
||||
|
||||
// 2. 验证快照包含预期的 Store
|
||||
console.log('Store snapshot keys:', Object.keys(snapshot));
|
||||
|
||||
// 3. 验证每个 Store 的基本结构
|
||||
for (const [storeName, state] of Object.entries(snapshot)) {
|
||||
console.log(`Store ${storeName}:`, typeof state);
|
||||
expect(state).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
test('SNAPSHOT-02: Store 状态一致性', async ({ page }) => {
|
||||
await page.goto(BASE_URL);
|
||||
await waitForAppReady(page);
|
||||
|
||||
// 1. 获取两次快照
|
||||
const snapshot1 = await storeInspectors.getAllStoresSnapshot(page);
|
||||
await page.waitForTimeout(100);
|
||||
const snapshot2 = await storeInspectors.getAllStoresSnapshot(page);
|
||||
|
||||
// 2. 验证状态一致性(无操作时状态应该相同)
|
||||
expect(snapshot1).toEqual(snapshot2);
|
||||
});
|
||||
});
|
||||
|
||||
// 测试报告
|
||||
test.afterAll(async ({}, testInfo) => {
|
||||
console.log('\n========================================');
|
||||
console.log('ZCLAW Store 状态验证测试完成');
|
||||
console.log('========================================');
|
||||
console.log(`测试时间: ${new Date().toISOString()}`);
|
||||
console.log('========================================\n');
|
||||
});
|
||||