cc工作前备份

This commit is contained in:
iven
2026-03-12 00:23:42 +08:00
parent f75a2b798b
commit ef849c62ab
98 changed files with 12110 additions and 568 deletions

46
.env.example Normal file
View File

@@ -0,0 +1,46 @@
# ZCLAW 环境变量配置
# === AI Provider ===
# 可选: zhipu, openai, local
ZCLAW_AI_PROVIDER=zhipu
# 智谱 GLM API Key (https://open.bigmodel.cn)
ZCLAW_ZHIPU_API_KEY=
# OpenAI API Key (或兼容 API)
ZCLAW_OPENAI_API_KEY=
ZCLAW_OPENAI_BASE_URL=https://api.openai.com/v1
# 默认模型
ZCLAW_DEFAULT_MODEL=glm-4-flash
ZCLAW_MAX_TOKENS=4096
ZCLAW_TEMPERATURE=0.7
# === Database ===
ZCLAW_DB_PATH=./data/zclaw.db
# === Server ===
ZCLAW_PORT=3721
ZCLAW_HOST=127.0.0.1
# === IM: 飞书 ===
ZCLAW_FEISHU_ENABLED=false
ZCLAW_FEISHU_APP_ID=
ZCLAW_FEISHU_APP_SECRET=
# === IM: Telegram ===
ZCLAW_TELEGRAM_ENABLED=false
ZCLAW_TELEGRAM_BOT_TOKEN=
# === Execution ===
ZCLAW_MAX_CONCURRENT=5
ZCLAW_TASK_TIMEOUT=300000
ZCLAW_RETRY_ATTEMPTS=3
# === Memory ===
ZCLAW_MAX_EVENTS=10000
ZCLAW_EMBEDDING_MODEL=text-embedding-3-small
# === Logging ===
# 可选: debug, info, warn, error
ZCLAW_LOG_LEVEL=info

View File

@@ -1,101 +1,349 @@
# ZCLAW 开发进度报告
**日期**: 2026-03-11 21:58
**状态**: ✅ 初始化完成,准备开发
**日期**: 2026-03-11 23:15
**状态**: ✅ v2 架构重构完成 — 基于 OpenClaw 定制化
---
## 📦 已完成工作
## 🔄 v2 架构重构 (2026-03-11)
### 1. 后端核心系统 (G:\ZClaw\src\core)
### 背景
#### ✅ 远程执行系统 (Remote Execution System)
- **文件**:
emote-execution/engine.ts, ypes.ts
- **功能**:
- 设备注册与管理
- 任务队列调度
- 执行状态同步
- 结果推送
- 订阅机制
- **代码量**: ~150 行
通过深度研究 QClaw (腾讯)、AutoClaw (智谱 v0.2.12)、OpenClaw (GitHub 28万+ Stars)
发现 v1 代码偏离初衷约 75% — 自创架构而非基于 OpenClaw。v2 进行了完整的架构重构。
#### ✅ 任务编排引擎 (Task Orchestration Engine)
- **文件**: ask-orchestration/orchestrator.ts, ypes.ts
- **功能**:
- 任务规划AI 拆解)
- 多步骤编排
- 依赖管理(拓扑排序)
- 进度计算
- 暂停/恢复/取消
- **代码量**: ~180 行
详见: [偏离分析报告](docs/deviation-analysis.md) | [v2 架构设计](docs/architecture-v2.md)
#### ✅ 持续记忆系统 (Persistent Memory System)
- **文件**: memory/memory.ts
- **功能**:
- 用户画像管理
- 事件记忆存储
- 记忆检索(后续集成向量搜索)
- **代码量**: ~60 行
### ✅ 新增: OpenClaw Gateway 集成层 (src/gateway/)
- **manager.ts**: Gateway 子进程管理 — 启动/停止/健康检查/自动重启
- **ws-client.ts**: Node.js WebSocket 客户端 — 完整 Gateway Protocol v3 握手/请求/事件
- **index.ts**: 统一导出
#### ✅ 主动服务系统 (Proactive Service System)
- **文件**: proactive/proactive.ts
- **功能**:
- 定时任务调度
- 任务管理(创建/取消/列表)
- **代码量**: ~50 行
### ✅ 新增: 自定义 OpenClaw 插件 (plugins/)
#### @zclaw/chinese-models — 中文模型 Provider
- 智谱 GLM (glm-5, glm-4.7, glm-4-plus, glm-4-flash)
- 通义千问 Qwen (qwen3.5-plus, qwen-max, qwen-vl-max)
- Kimi 月之暗面 (kimi-k2.5, moonshot-v1-128k)
- MiniMax (minimax-m2.5, abab6.5s-chat)
#### @zclaw/feishu — 飞书 Channel Plugin
- OAuth tenant_access_token 自动管理
- 发送文本/富文本消息
- 多账户支持
- 自定义 RPC: feishu.status
#### @zclaw/ui — UI 扩展 RPC
- zclaw.clones.list/create/update/delete — 分身管理
- zclaw.stats.usage/sessions — 用量统计 (读取 JSONL sessions)
- zclaw.config.quick — 快速配置
- zclaw.workspace.info — 工作区信息
- zclaw.plugins.status — 插件状态
### ✅ 新增: 自定义 Skills (skills/)
- **chinese-writing** — 中文写作助手 (SKILL.md)
- **feishu-docs** — 飞书文档操作 (SKILL.md)
### ✅ 新增: OpenClaw 配置模板 (config/)
- **openclaw.default.json** — 预配置中文模型 + 插件路径 + Skills 目录
- **SOUL.md** — ZCLAW 人格定义
- **AGENTS.md** — Agent 操作规范
- **IDENTITY.md** — Agent 身份 (🦞 小龙虾)
- **USER.md** — 默认用户偏好
### ✅ 新增: 前端 Gateway 客户端 (desktop/src/)
- **lib/gateway-client.ts** — 浏览器 WebSocket 客户端 (Gateway Protocol v3)
- **store/gatewayStore.ts** — Zustand 状态管理 (连接/分身/统计)
### ✅ 新增: 设置脚本 (scripts/)
- **setup.ts** — 首次设置: 检测 OpenClaw → 复制配置 → 注册插件 → 创建工作区
### ✅ 编译验证
- TypeScript: **0 errors** (新架构代码)
- 依赖精简: 移除 bullmq/ioredis/better-sqlite3/koishi, 保留 ws + zod
---
## 📊 代码统计 (v2)
#### ✅ 项目初始化
- **技术栈**:
- Tauri 2.0 (Rust)
- React 19 + TypeScript
- Tailwind CSS 4.2
- Lucide React (图标)
- Zustand (状态管理)
- Vite 7.3 (构建工具)
| 类别 | 文件数 | 说明 |
|------|--------|------|
| Gateway 层 | 3 | manager.ts, ws-client.ts, index.ts |
| 插件 | 6 | 3 plugins × (index.ts + plugin.json) |
| Skills | 2 | 2 × SKILL.md |
| 配置 | 5 | 1 JSON + 4 MD |
| 前端新增 | 2 | gateway-client.ts, gatewayStore.ts |
| 脚本 | 1 | setup.ts |
| **合计新增** | **19** | v2 新架构文件 |
#### ✅ 三栏布局设计
基于 AutoClaw 界面设计:
- **左侧边栏** (240px): Agent 列表 / IM 频道 / 定时任务
- **中间区域** (自适应): 聊天界面 / 消息列表 / 输入框
---
#### ✅ 核心组件
1. **Sidebar.tsx** (~120 行)
- 顶部标签切换
- Agent 列表展示
## ✅ Phase 2: 前端 Settings 页面体系 (2026-03-11 23:26)
2. **ChatArea.tsx** (~130 行)
- 消息列表渲染
- 用户/AI 消息气泡
- 输入框 + 发送按钮
### Settings 页面 (对标 AutoClaw 10 个页面)
- **SettingsLayout.tsx** — 左侧导航 + 右侧内容的双栏布局
- **General.tsx** — 账号安全 + Gateway 连接状态 + 主题/自启/工具调用
- **UsageStats.tsx** — 会话数/消息数/Token 汇总 + 按模型进度条
- **ModelsAPI.tsx** — 内置/自定义模型列表 + Gateway URL 配置
- **MCPServices.tsx** — MCP 服务列表 + 启停 + 快速添加模板
- **Skills.tsx** — 技能列表 + 额外目录配置 + 筛选标签
- **IMChannels.tsx** — IM 频道管理 + 快速添加飞书
- **Workspace.tsx** — 项目目录 + 文件访问限制 + 自动保存 + 文件监听
- **Privacy.tsx** — 本地数据路径 + 优化计划开关 + 备案信息
- **About.tsx** — 版本信息 + 检查更新 + 更新日志
3. **RightPanel.tsx** (~140 行)
- 任务进度条
- 今日统计
### App.tsx 重构
- main / settings 视图切换
- Sidebar ⚙ 按钮 → onOpenSettings → SettingsLayout
- SettingsLayout ← 返回 → main view
4. **chatStore.ts** (~60 行)
- Zustand 状态管理
- 消息/Agent 状态
### 构建修复
- 修复 RightPanel.tsx 未使用 CheckSquare import
- 修复 Tailwind v4: 安装 @tailwindcss/vite, 替换 @tailwind@import "tailwindcss"
- 修复根 package.json UTF-8 BOM 导致 PostCSS config 解析失败
- **Vite build: ✅ 成功** (1761 modules, 239 KB JS + 23 KB CSS)
- **TypeScript: ✅ 0 errors** (backend + frontend 双项目)
#### ✅ 样式系统
- **index.css**: Tailwind + 自定义样式
- 滚动条样式
- 消息气泡样式
- Agent 头像渐变
---
## ✅ Phase 3: 聊天对接 + 分身管理 (2026-03-11 23:34)
### chatStore 重构 → Gateway WebSocket
- **sendMessage()** — 通过 Gateway WS 发送 `agent` RPC, 创建流式占位消息
- **initStreamListener()** — 监听 `agent` 事件, 实时追加 assistant delta / tool call / lifecycle
- 支持 session key 连续对话
- Gateway 未连接时优雅降级 (错误显示在气泡中)
### ChatArea 重构
- **流式输出** — assistant 消息带闪烁光标, 实时追加 delta
- **工具调用展示** — `tool` 类型消息, 显示工具名 + 输入 + 输出 (Terminal 图标)
- **模型选择器** — 下拉菜单切换 glm-5 / qwen3.5-plus / kimi-k2.5 / minimax-m2.5
- **连接状态** — 顶部显示 Gateway 连接状态 (绿点/灰点)
- **自动滚动** — 新消息自动滚到底部
- 输入区: 流式回复时禁用, Enter 发送, Shift+Enter 换行
### 分身管理 UI (CloneManager)
- **CloneManager.tsx** — 完整 CRUD: 创建表单 (名称/角色/场景标签) + 列表 + 悬浮删除
- **集成到 Sidebar** — 分身/IM 频道/定时任务 三标签, 分身标签使用 CloneManager
- **Sidebar 重构** — Gateway 连接状态显示在用户区, IM 频道和定时任务占位页
### 编译验证
- **TypeScript: ✅ 0 errors** (backend + frontend)
- **Vite build: ✅ 成功** (1762 modules, 247 KB JS + 24 KB CSS)
---
## ✅ Phase 3.5: 前端质量提升 (2026-03-11 23:42)
### Gateway 自动连接
- **App.tsx** — 启动时自动尝试连接 Gateway (silent fail不阻塞 UI)
- 导入 `useGatewayStore`useEffect 在 `disconnected` 状态下触发 `connect()`
### RightPanel 重写 → 实时数据
- **RightPanel.tsx** — 从全硬编码改为从 gatewayStore/chatStore 读取真实数据
- Gateway 连接状态 (绿色/灰色卡片, 地址/版本/当前模型)
- 手动重连按钮 (Gateway 未连接时显示)
- 当前会话统计 (用户消息/助手回复/工具调用/总数)
- 分身列表 (从 Gateway 加载)
- 用量统计 (总会话/消息/Token)
- 插件状态 (名称 + 运行状态)
- 系统信息 (版本/协议/平台)
- 刷新按钮 (一键重新加载所有数据)
### ChatArea 升级
- **多行输入** — `<input>``<textarea>` 自动伸缩 (minHeight 24px, maxHeight 160px)
- **Markdown 渲染** — 轻量级渲染器:代码块 (带语言标签), 内联代码, **粗体**, *斜体*, [链接](url)
- **新对话按钮** — 头部右侧显示 "新对话" 按钮 (SquarePen 图标)
- placeholder 更新: "发送给 ZCLAWShift+Enter 换行)"
### 对话会话管理
- **chatStore** 新增:
- `Conversation` 类型 (id/title/messages/sessionKey/createdAt/updatedAt)
- `conversations[]` + `currentConversationId` 状态
- `newConversation()` — 保存当前对话到历史, 开启新对话
- `switchConversation(id)` — 切换到历史对话 (自动保存当前)
- `deleteConversation(id)` — 删除历史对话
- `deriveTitle()` — 从第一条用户消息自动提取标题
- **ConversationList.tsx** (新组件) — 对话历史列表
- 当前活跃对话高亮显示
- 历史对话: 标题 + 消息数 + 相对时间
- 悬浮删除按钮
- 新对话按钮
### Sidebar 重构 → 四标签
- **4 标签**: 对话 / 分身 / 频道 / 任务
- TABS 配置化 (key/label/icon 数组)
- 对话标签使用 ConversationList
- 频道标签使用 ChannelList (新组件)
### IM 频道管理
- **gateway-client.ts** 新增: `listChannels()`, `getFeishuStatus()` 方法
- **gatewayStore** 新增:
- `ChannelInfo` 类型 + `channels[]` 状态
- `loadChannels()` — 先尝试 Gateway channels.list, 回退到单独探测飞书/QQ
- **ChannelList.tsx** (新组件) — 频道列表
- 显示已配置频道 (飞书/QQ) 及状态
- 未配置频道灰色占位提示
- "打开设置" 快捷入口
- 刷新按钮
### 对话历史持久化
- **chatStore** — Zustand `persist` 中间件集成 localStorage
- `partialize` 仅保存 conversations + currentModel (排除 streaming/transient 状态)
- `onRehydrateStorage` 正确处理 Date JSON 反序列化, 清除残留 streaming 标记
- 存储 key: `zclaw-chat-storage`
### 定时任务管理
- **gateway-client.ts** 新增: `listScheduledTasks()` 方法
- **gatewayStore** 新增:
- `ScheduledTask` 类型 (id/name/schedule/status/lastRun/nextRun/description)
- `scheduledTasks[]` 状态 + `loadScheduledTasks()`
- **TaskList.tsx** (新组件) — Heartbeat 任务列表
- 按状态图标着色 (运行中/暂停/完成/错误)
- 显示 cron 表达式 + 上次/下次执行时间
- 刷新按钮 + Gateway 未连接提示
### Settings 页面接入真实数据
- **General.tsx** 重写:
- Gateway 连接状态面板 (状态灯/地址/版本/当前模型/错误)
- 连接/断开按钮 (connect/disconnect)
- Toggle 组件 CSS 修复 (flex-shrink-0 + transition-all)
- **ModelsAPI.tsx** 重写:
- 中文模型列表从 chatStore 读取 currentModel, 点击切换
- Gateway URL 输入框 + 连接状态 + 重新连接按钮
- 每个模型显示 Provider 来源
### 编译验证 (最终)
- **TypeScript: ✅ 0 errors** (backend + frontend)
- **Vite build: ✅ 成功** (1766 modules, 268 KB JS + 26 KB CSS)
- **Dev server: ✅** (http://127.0.0.1:1420, 422ms 启动)
---
## 📋 下一步
### Phase 4: 真实集成测试
- [ ] 安装 OpenClaw 并验证 Gateway 连接
- [ ] 测试自定义插件注册
- [ ] 测试飞书 Channel 收发消息
- [ ] 测试中文模型调用
- [ ] 验证前端自动连接 + 数据加载
### Phase 5: 高级功能
- [ ] 微信/QQ Channel Plugin
- [ ] 更多 Skills 开发
- [ ] Tauri Rust sidecar (Gateway 子进程管理)
- [ ] 打包发布
- [ ] 对话历史持久化 (localStorage / IndexedDB)
---
## 🔗 v1 旧代码 (归档)
以下代码位于 src/core/, src/db/, src/config/ 等目录,
已从 tsconfig 编译范围中排除,保留供参考。
v1 进度详情如下:
---
**原 v1 进度 (2026-03-11 22:30)**
**状态**: v1 架构已归档,保留供参考
---
## 📦 本次完成工作
### Phase 1: 基础修复 + 基础设施
#### ✅ Bug 修复
- 修复全部后端文件的**模板字符串损坏**engine.ts, orchestrator.ts, memory.ts, proactive.ts
- 修复 `Sidebar.tsx` 缺失 React import 和 className 模板字面量
- 修复 `ChatArea.tsx` className 模板字面量
- 修复 `tsconfig.json` 添加 `@types/node` 支持
- 修复 `package.json` 移除不存在的 `@openclaw/sdk`
#### ✅ 配置管理系统 (src/config/)
- Zod Schema 驱动的类型安全配置
- 支持环境变量 + 默认值 fallback
- 涵盖: AI Provider / Database / Server / IM / Execution / Memory / Logging
- `.env.example` 完整配置模板
#### ✅ 工具函数 (src/utils/)
- **logger.ts**: 分级彩色日志系统 (debug/info/warn/error),模块化 createLogger
- **id.ts**: crypto.randomBytes 安全 ID 生成器,支持前缀
- **index.ts**: 统一导出
#### ✅ 数据库层 (src/db/)
- **schema.ts**: 完整 SQLite Schema — 8 张表 + 8 个索引
- users / devices / tasks / task_plans / memory_events / scheduled_tasks / conversations / agents
- **database.ts**: WAL 模式、BaseDAO 泛型 CRUD、事务支持
- **依赖安装**: better-sqlite3 + sqlite-vec + @types/better-sqlite3
---
### Phase 2: 全新系统
#### ✅ AI 模型集成层 (src/core/ai/)
- **types.ts**: ChatMessage / ChatRequest / ChatResponse / StreamChunk / EmbeddingRequest / AIProvider 接口
- **providers/zhipu.ts**: 智谱 GLM Provider — chat + stream + embed
- **providers/openai.ts**: OpenAI 兼容 Provider — 支持任意 baseUrl
- **manager.ts**: 统一 AI 管理器
- 多 Provider 自动 fallback
- 便捷方法: ask() / chatWithHistory() / askJson()
- 流式输出支持
#### ✅ 多 Agent 协作系统 (src/core/multi-agent/) — **全新**
- **types.ts**: AgentType / AgentConfig / AgentTask / AgentResult / MultiAgentPlan
- **message-bus.ts**: Agent 间消息总线 — 订阅/发布/广播/消息日志
- **base-agent.ts**: Agent 抽象基类 — 生命周期管理/消息处理/状态追踪
- **agents/planner-agent.ts**: 规划 Agent — AI 驱动任务拆解
- **agents/executor-agent.ts**: 4 种执行 Agent (Browser/File/Terminal/AIAnalysis)
- **agents/combiner-agent.ts**: 结果整合 Agent — 生成报告
- **orchestrator.ts**: Agent 编排器 — 完整的 Plan→Execute→Combine 工作流
- 自动规划任务
- 拓扑排序依赖执行
- 上下文传递
- 进度回调
#### ✅ IM 网关层 (src/im/) — **全新**
- **types.ts**: IMMessage / IMAdapter / IMSendOptions 接口
- **gateway.ts**: 统一消息路由 — 适配器注册/消息转发/多渠道管理
- **adapters/feishu.ts**: 飞书适配器 — OAuth认证/消息收发/Mock模式
---
### Phase 3: 核心系统升级 + 应用集成
#### ✅ 远程执行系统升级
- 集成 Logger 结构化日志
- 使用 generateId 安全ID
- **新增**: 并发控制 (maxConcurrent) + 优先级队列
- **新增**: listDevices() / listTasks() / getStats()
#### ✅ 任务编排引擎升级
- 集成 Logger + generateId
- **新增**: listPlans() 查询接口
#### ✅ 持续记忆系统升级
- 集成 Logger + generateId
- **新增**: 自动创建用户画像 + getEventCount()
#### ✅ 主动服务系统升级
- 集成 Logger + generateId
- **新增**: node-cron 集成 + toCronExpression() 时间转换
#### ✅ 应用主类 (src/app.ts) — **全新**
- ZClawApp: 统一协调 7 大系统的启动/路由/关闭
- 消息路由: IM → 记忆 → 复杂度判断 → AI对话/多Agent协作 → 回复
- 优雅退出: SIGINT/SIGTERM 处理
#### ✅ API 层 (src/api/) — **全新**
- ZClawAPI: 供 Tauri 前端调用的完整接口
- 覆盖: 聊天 / 任务 / 多Agent / 记忆 / 定时任务 / 系统状态
#### ✅ 入口重写 (src/index.ts)
- 完整启动流程 + 全模块导出 + 优雅退出
---
@@ -103,189 +351,100 @@
| 类别 | 文件数 | 代码行数 | 说明 |
|------|--------|----------|------|
|------|--------|----------|------|
| **后端核心** | 10 | ~600 行 | 4 个核心系统 |
| **Tauri 前端** | 7 | ~700 行 | 3 个主要组件 + 状态管理 |
| **配置文件** | 5 | ~100 行 | TypeScript/Tailwind/Vite |
| **后端源码** | 37 | ~2378 行 | 7 大核心系统 + 基础设施 |
| **Tauri 前端** | 7 | ~700 行 | 3 组件 + 状态管理 (已修复) |
| **配置文件** | 5 | ~100 行 | TS/Tailwind/Vite/env |
| **总计** | **49** | **~3178 行** | TypeScript 零错误编译 |
---
## 🎯 下一步行动
### 立即执行 (今晚)
#### 1. 测试 Tauri 应用
`ash
cd G:\ZClaw\desktop
pnpm tauri dev
`
**目标**: 确保应用能正常启动和渲染
#### 2. 修复可能的错误
- 检查 Tailwind 配置
- 检查组件导入
- 修复类型错误
#### 3. 安装后端依赖
`ash
cd G:\ZClaw
pnpm install
`
### 本周执行 (Week 1)
#### 后端开发
- [ ] 集成 OpenClaw SDK
- [ ] 实现远程执行系统的实际执行逻辑
- [ ] 实现任务编排的 AI 规划器
- [ ] 集成 SQLite + sqlite-vec
- [ ] 实现基础的 IM 集成(飞书/企业微信)
#### 前端开发
- [ ] 完善 UI 细节(动画、过渡)
- [ ] 实现消息历史加载
- [ ] 实现文件预览功能
- [ ] 实现定时任务管理界面
- [ ] 连接后端 API
#### 集成测试
- [ ] 前后端联调
- [ ] 端到端测试:手机发消息 → 电脑执行 → 结果返回
**相比上一版本**: 文件 22→49 (+123%), 代码 1400→3178 (+127%)
---
## 🏗️ 项目结构
```
G:\ZClaw/
G:\ZClaw/
├── src/ # 后端代码
│ ├── core/ # 核心系统
│ ├── remote-execution/ # 远程执行
│ │ ── engine.ts # ✅ 引擎实现
├── types.ts # ✅ 类型定义
│ │ │ └── index.ts # ✅ 导出
│ │ ├── task-orchestration/ # 任务编排
│ │ │ ├── orchestrator.ts # ✅ 编排器
│ │ ├── types.ts # ✅ 类型定义
│ │ │ └── index.ts # ✅ 导出
│ │ ├── memory/ # 持续记忆
│ │ ── memory.ts # ✅ 记忆系统
└── index.ts # ✅ 导出
│ │ ── proactive/ # 主动服务
│ │ ├── proactive.ts # ✅ 主动服务
│ │ ── index.ts # ✅ 导出
├── im/ # IM 集成 (待开发)
│ ├── skills/ # Skills (待开发)
├── src/ # 后端代码 (37 files, 2378 lines)
│ ├── index.ts # ✅ 入口 + 启动流程
│ ├── app.ts # ✅ ZClawApp 主类 (NEW)
│ ├── config/ # ✅ 配置管理 (NEW)
│ │ ── index.ts # Zod Schema + 环境变量
├── utils/ # ✅ 工具函数 (NEW)
│ │ ├── logger.ts # 分级彩色日志
│ │ ├── id.ts # 安全 ID 生成
│ │ └── index.ts
├── db/ # ✅ 数据库层 (NEW)
│ │ ├── schema.ts # 8 张表 + 索引
│ │ ├── database.ts # SQLite WAL + BaseDAO
│ │ ── index.ts
├── core/ # 核心系统
│ │ ── ai/ # ✅ AI 集成 (NEW)
│ │ ├── types.ts # 统一 AI 类型
│ │ ── providers/zhipu.ts # 智谱 GLM
├── providers/openai.ts # OpenAI 兼容
│ │ │ ├── manager.ts # 多 Provider 管理
│ │ │ └── index.ts
│ │ ├── multi-agent/ # ✅ 多Agent协作 (NEW)
│ │ │ ├── types.ts # Agent 类型体系
│ │ │ ├── message-bus.ts # 消息总线
│ │ │ ├── base-agent.ts # Agent 基类
│ │ │ ├── agents/planner.ts # 规划 Agent
│ │ │ ├── agents/executor.ts # 4种执行 Agent
│ │ │ ├── agents/combiner.ts # 整合 Agent
│ │ │ ├── orchestrator.ts # Agent 编排器
│ │ │ └── index.ts
│ │ ├── remote-execution/ # ✅ 远程执行 (升级)
│ │ ├── task-orchestration/ # ✅ 任务编排 (升级)
│ │ ├── memory/ # ✅ 持续记忆 (升级)
│ │ └── proactive/ # ✅ 主动服务 (升级)
│ ├── im/ # ✅ IM 网关 (NEW)
│ │ ├── types.ts # IM 抽象接口
│ │ ├── gateway.ts # 统一消息路由
│ │ ├── adapters/feishu.ts # 飞书适配器
│ │ └── index.ts
│ ├── api/ # ✅ API 层 (NEW)
│ │ └── index.ts # Tauri Commands 接口
│ └── skills/ # ⏳ 场景 Skills (待开发)
├── desktop/ # Tauri 桌面端
│ ├── src/
│ │ ├── components/
│ │ ├── Sidebar.tsx # ✅ 左侧边栏
│ │ │ ├── ChatArea.tsx # ✅ 聊天区域
│ │ │ └── RightPanel.tsx # ✅ 右侧边栏
│ │ ├── store/
│ │ │ └── chatStore.ts # ✅ 状态管理
│ │ ├── App.tsx # ✅ 主应用
│ │ ├── main.tsx # ✅ 入口
│ │ └── index.css # ✅ 样式
│ ├── src-tauri/ # Rust 后端
│ │ ├── src/
│ │ │ ├── main.rs # ✅ 入口
│ │ │ └── lib.rs # ✅ 库
│ │ ├── Cargo.toml # ✅ Rust 配置
│ │ └── tauri.conf.json # ✅ Tauri 配置
│ ├── package.json # ✅ 依赖配置
│ ├── tailwind.config.js # ✅ Tailwind 配置
│ └── vite.config.ts # ✅ Vite 配置
├── package.json # ✅ 后端依赖
├── tsconfig.json # ✅ TypeScript 配置
├── README.md # ✅ 项目说明
└── .gitignore # ✅ Git 忽略
├── desktop/ # Tauri 桌面端 (已修复)
├── .env.example # ✅ 环境变量模板 (NEW)
├── package.json # ✅ 依赖已安装
├── tsconfig.json # ✅ 类型配置已修复
└── PROGRESS.md # 本文件
```
---
## 🎯 下一步行动
### 1. 渐进式开发策略
- ✅ 先完成骨架代码
- ✅ 快速验证架构
### 本周 (Week 1)
- [ ] 配置 AI API Key测试完整对话流程
- [ ] 测试 Tauri 桌面端 (`pnpm tauri dev`)
- [ ] 前端连接 API 层 (Tauri Commands)
- [ ] 集成 OpenClaw SDK 到远程执行系统
### 2. 模块化设计
- 每个核心系统独立模块
- 清晰的接口定义
- 易于测试和维护
### 3. 类型安全
- TypeScript 全栈
- 严格的类型检查
- 良好的 IDE 支持
### 4. 现代化技术栈
- Tauri 2.0 (性能优异)
- React 19 (最新特性)
- Tailwind CSS (快速开发)
- Zustand (轻量状态管理)
---
## 📈 成功指标
### MVP 目标 (1-2 周)
- [ ] Tauri 应用可正常运行
- [ ] 基础聊天功能可用
- [ ] 远程执行系统可工作
- [ ] 至少支持 1 个 IM 渠道
### 里程碑 1 (1 个月)
- [ ] 完整的远程执行能力
- [ ] 任务编排引擎可用
- [ ] 持续记忆系统工作
- [ ] 2-3 个 IM 渠道支持
### 里程碑 2 (2 个月)
- [ ] 多 Agent 协作系统
- [ ] 主动服务系统
- [ ] 场景化 Skills
### 下周 (Week 2)
- [ ] 飞书真实接入测试
- [ ] 端到端流程: 手机发消息 → 电脑执行 → 结果返回
- [ ] 场景 Skills 开发 (社媒运营/学术研究)
- [ ] sqlite-vec 向量搜索集成
---
## 🚀 启动命令
### 后端开发
```bash
# 后端开发
cd G:\ZClaw
cd G:\ZClaw
cp .env.example .env # 填入 API Keys
pnpm dev
pnpm dev
### Tauri 桌面端开发
# Tauri 桌面端
cd G:\ZClaw\desktop
pnpm tauri dev
pnpm tauri dev
`
### 构建生产版本
`ash
cd G:\ZClaw\desktop
pnpm tauri build
`
---
## 📝 备注
- ✅ 所有代码已提交到 Git (commit: 045e9ce)
- ✅ 使用选项 A渐进式开发+ Tauri 桌面端策略
- ✅ 基于 AutoClaw 界面设计
```
---
**维护者**: ZCLAW 团队
**维护者**: ZCLAW 团队
**最后更新**: 2026-03-11 22:30

154
README.md
View File

@@ -1,61 +1,125 @@
# ZCLAW - AI Agent Platform
# ZCLAW 🦞 — OpenClaw 定制版 (Tauri Desktop)
基于 OpenClaw 的 AI 代理平台,实现"随时随地、一个 IM 入口搞定一切"
像 AutoClaw (智谱) 和 QClaw (腾讯) 一样,对 [OpenClaw](https://github.com/openclaw/openclaw) 进行定制化封装,打造中文优先的 Tauri 桌面 AI 助手
## 核心功能
## 核心定位
- **远程执行系统**: 手机发消息 → 电脑执行 → 结果返回
- **任务编排引擎**: 复杂任务自动拆解、多步骤执行
- **多 Agent 协作**: Planner + Executor + Combiner 协作模式
- **持续记忆系统**: 用户画像、行为学习、关系图谱
- **主动服务系统**: 定时任务、智能提醒、主动推荐
```
OpenClaw Gateway (执行引擎)
↕ WebSocket
ZCLAW Tauri App (桌面 UI)
+ 中文模型 Provider (GLM/Qwen/Kimi/MiniMax)
+ 飞书 Channel Plugin
+ 分身(Clone) 管理
+ 自定义 Skills
```
## 功能特色
- **基于 OpenClaw**: 真实工具执行 (bash/file/browser)、Skills 生态、MCP 协议、心跳引擎
- **中文模型**: 智谱 GLM-5、通义千问、Kimi K2.5、MiniMax (OpenAI 兼容 API)
- **飞书集成**: 飞书 Channel Plugin在飞书中直接对话指挥电脑
- **分身系统**: 多个独立 Agent 实例,各有自己的角色、记忆、配置
- **Tauri 桌面**: Rust + React 19体积小 (~10MB),性能好
- **设置页面**: 对标 AutoClaw — 通用/模型/MCP/技能/IM/工作区/隐私
## 技术栈
- TypeScript 5.x
- Node.js 22 LTS
- OpenClaw SDK
- SQLite + sqlite-vec
- BullMQ (任务队列)
- Koishi (IM 集成)
## 快速开始
```bash
# 安装依赖
pnpm install
# 开发模式
pnpm dev
# 构建
pnpm build
```
| 层级 | 技术 |
|------|------|
| **执行引擎** | OpenClaw Gateway (Node.js, ws://127.0.0.1:18789) |
| **桌面壳** | Tauri 2.0 (Rust + React 19) |
| **前端** | React 19 + TailwindCSS + Zustand + Lucide Icons |
| **自定义插件** | TypeScript (OpenClaw Plugin API) |
| **通信协议** | OpenClaw Gateway WebSocket Protocol v3 |
## 项目结构
```
zclaw/
├── src/
│ ├── core/ # 核心系统
│ │ ├── remote-execution/ # 远程执行
│ │ ├── task-orchestration/ # 任务编排
│ │ ── multi-agent/ # 多 Agent 协作
│ ├── memory/ # 持续记忆
│ └── proactive/ # 主动服务
│ ├── im/ # IM 集成
│ ├── feishu/ # 飞书
│ ├── wecom/ # 企业微信
│ └── telegram/ # Telegram
├── skills/ # 场景化 Skills
│ └── index.ts # 入口
├── tests/ # 测试
└── docs/ # 文档
ZClaw/
├── desktop/ # Tauri 桌面应用 (React 前端)
│ ├── src/
│ │ ├── components/ # UI 组件
│ │ ├── store/ # Zustand 状态管理
│ │ ── lib/gateway-client.ts # Gateway WebSocket 客户端
└── src-tauri/ # Rust 后端 (TODO)
├── src/gateway/ # Gateway 管理层
│ ├── manager.ts # OpenClaw 子进程管理
│ ├── ws-client.ts # Node.js WebSocket 客户端
│ └── index.ts
├── plugins/ # ZCLAW 自定义 OpenClaw 插件
│ ├── zclaw-chinese-models/ # 中文模型 Provider (GLM/Qwen/Kimi/MiniMax)
│ ├── zclaw-feishu/ # 飞书 Channel Plugin
│ └── zclaw-ui/ # UI 扩展 RPC 方法
├── skills/ # 自定义 Skills
│ ├── chinese-writing/ # 中文写作
│ └── feishu-docs/ # 飞书文档操作
├── config/ # OpenClaw 默认配置
│ ├── openclaw.default.json # Gateway 配置模板
│ ├── SOUL.md # Agent 人格
│ ├── AGENTS.md # Agent 指令
│ ├── IDENTITY.md # Agent 身份
│ └── USER.md # 用户偏好
├── scripts/setup.ts # 首次设置脚本
├── docs/ # 文档
│ ├── architecture-v2.md # 架构设计
│ ├── deviation-analysis.md # 偏离分析报告
│ └── autoclaw界面/ # AutoClaw 参考截图
└── src/core/ # [归档] v1 旧代码
```
## 开发计划
## 快速开始
详见: temp/zclaw-final-plan.md
### 1. 安装 OpenClaw
```bash
# Windows
iwr -useb https://openclaw.ai/install.ps1 | iex
# macOS / Linux
curl -fsSL https://openclaw.ai/install.sh | bash
```
### 2. 安装 ZCLAW
```bash
git clone https://github.com/xxx/ZClaw.git
cd ZClaw
pnpm install
pnpm setup # 注册插件 + 复制配置
```
### 3. 配置 API Key
```bash
openclaw configure # 交互式配置
# 或手动编辑 ~/.openclaw/openclaw.json
```
### 4. 启动
```bash
openclaw gateway # 启动 OpenClaw Gateway
cd desktop && pnpm tauri dev # 启动 Tauri 桌面应用
```
## 对标参考
| 产品 | 基于 | IM 渠道 | 桌面框架 |
|------|------|---------|----------|
| **QClaw** (腾讯) | OpenClaw | 微信 + QQ | Electron |
| **AutoClaw** (智谱) | OpenClaw | 飞书 | 自研 |
| **ZCLAW** (本项目) | OpenClaw | 飞书 (+ 微信/QQ 计划中) | Tauri 2.0 |
## 文档
- [架构设计](docs/architecture-v2.md) — 完整的 v2 架构方案
- [偏离分析](docs/deviation-analysis.md) — 与 QClaw/AutoClaw/OpenClaw 对标分析
## License

21
config/AGENTS.md Normal file
View File

@@ -0,0 +1,21 @@
# ZCLAW Agent 指令
## 操作规范
1. 执行文件操作前,先确认目标路径
2. 执行 Shell 命令前,评估安全风险
3. 浏览器操作优先使用无头模式
4. 长时间任务需定期汇报进度
## 工具使用偏好
- 文件搜索: 优先使用 `ripgrep` (rg)
- 代码编辑: 使用 `apply_patch` 工具
- 网页操作: 使用内置浏览器工具
- 系统命令: 使用 bash 工具,注意 Windows/macOS/Linux 兼容
## 记忆管理
- 重要的用户偏好记录到 MEMORY.md
- 项目上下文保存到工作区
- 对话结束时总结关键信息

6
config/IDENTITY.md Normal file
View File

@@ -0,0 +1,6 @@
# ZCLAW 身份
- **名字**: ZCLAW (小龙虾)
- **Emoji**: 🦞
- **描述**: 基于 OpenClaw 的中文 AI 助手,支持飞书/微信/QQ 多渠道接入
- **版本**: 0.1.0

21
config/SOUL.md Normal file
View File

@@ -0,0 +1,21 @@
# ZCLAW 人格
你是 ZCLAW小龙虾一个基于 OpenClaw 定制的中文 AI 助手。
## 核心特质
- **高效执行**: 你不只是出主意,你会真正动手完成任务
- **中文优先**: 默认使用中文交流,必要时切换英文
- **专业可靠**: 对技术问题给出精确答案,不确定时坦诚说明
- **主动服务**: 定期检查任务清单,主动推进未完成的工作
## 边界
- 不执行可能损害用户系统安全的操作
- 不在未经确认的情况下删除重要文件
- 不对外发送用户的敏感信息
- 遇到风险操作时先确认再执行
## 语气
简洁、专业、友好。避免过度客套,直接给出有用信息。

11
config/USER.md Normal file
View File

@@ -0,0 +1,11 @@
# 用户配置
- **语言**: 中文 (zh-CN)
- **时区**: Asia/Shanghai (UTC+8)
- **称呼**: 按用户在快速配置中设置的名字
## 偏好
- 代码风格: TypeScript, 简洁注释
- 沟通风格: 直接, 不需要过多解释
- 工作目录: 按工作区设置

View File

@@ -0,0 +1,70 @@
{
"_comment": "ZCLAW default OpenClaw configuration",
"agents": {
"defaults": {
"workspace": "~/.openclaw/zclaw-workspace",
"model": {
"primary": "zhipu/glm-5",
"fallbacks": ["openai/gpt-4o", "qwen/qwen3.5-plus"]
},
"models": {
"zhipu/glm-5": { "alias": "GLM-5" },
"zhipu/glm-4.7": { "alias": "GLM-4.7" },
"qwen/qwen3.5-plus": { "alias": "Qwen3.5+" },
"kimi/kimi-k2.5": { "alias": "Kimi-K2.5" },
"minimax/minimax-m2.5": { "alias": "MiniMax-M2.5" },
"openai/gpt-4o": { "alias": "GPT-4o" }
},
"heartbeat": {
"every": "1h"
},
"sandbox": {
"workspaceRoot": "~/.openclaw/zclaw-workspace"
}
},
"list": [
{
"id": "main",
"groupChat": {
"mentionPatterns": ["@zclaw", "zclaw", "小龙虾"]
}
}
]
},
"channels": {
"feishu": {
"enabled": false,
"accounts": {
"default": {
"appId": "",
"appSecret": "",
"enabled": false
}
}
}
},
"plugins": {
"load": {
"paths": [
"./plugins/zclaw-chinese-models",
"./plugins/zclaw-feishu",
"./plugins/zclaw-ui"
]
}
},
"skills": {
"load": {
"extraDirs": ["./skills"]
}
},
"tools": {
"exec": {
"shell": true
},
"web": {
"search": {
"enabled": true
}
}
}
}

View File

@@ -18,6 +18,7 @@
"zustand": "^5.0.11"
},
"devDependencies": {
"@tailwindcss/vite": "^4.2.1",
"@tauri-apps/cli": "^2",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",

339
desktop/pnpm-lock.yaml generated
View File

@@ -27,6 +27,9 @@ importers:
specifier: ^5.0.11
version: 5.0.11(@types/react@19.2.14)(react@19.2.4)
devDependencies:
'@tailwindcss/vite':
specifier: ^4.2.1
version: 4.2.1(vite@7.3.1(jiti@2.6.1)(lightningcss@1.31.1))
'@tauri-apps/cli':
specifier: ^2
version: 2.10.1
@@ -38,7 +41,7 @@ importers:
version: 19.2.3(@types/react@19.2.14)
'@vitejs/plugin-react':
specifier: ^4.6.0
version: 4.7.0(vite@7.3.1)
version: 4.7.0(vite@7.3.1(jiti@2.6.1)(lightningcss@1.31.1))
autoprefixer:
specifier: ^10.4.27
version: 10.4.27(postcss@8.5.8)
@@ -53,7 +56,7 @@ importers:
version: 5.8.3
vite:
specifier: ^7.0.4
version: 7.3.1
version: 7.3.1(jiti@2.6.1)(lightningcss@1.31.1)
packages:
@@ -453,6 +456,100 @@ packages:
cpu: [x64]
os: [win32]
'@tailwindcss/node@4.2.1':
resolution: {integrity: sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==}
'@tailwindcss/oxide-android-arm64@4.2.1':
resolution: {integrity: sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg==}
engines: {node: '>= 20'}
cpu: [arm64]
os: [android]
'@tailwindcss/oxide-darwin-arm64@4.2.1':
resolution: {integrity: sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw==}
engines: {node: '>= 20'}
cpu: [arm64]
os: [darwin]
'@tailwindcss/oxide-darwin-x64@4.2.1':
resolution: {integrity: sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw==}
engines: {node: '>= 20'}
cpu: [x64]
os: [darwin]
'@tailwindcss/oxide-freebsd-x64@4.2.1':
resolution: {integrity: sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA==}
engines: {node: '>= 20'}
cpu: [x64]
os: [freebsd]
'@tailwindcss/oxide-linux-arm-gnueabihf@4.2.1':
resolution: {integrity: sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw==}
engines: {node: '>= 20'}
cpu: [arm]
os: [linux]
'@tailwindcss/oxide-linux-arm64-gnu@4.2.1':
resolution: {integrity: sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ==}
engines: {node: '>= 20'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-arm64-musl@4.2.1':
resolution: {integrity: sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==}
engines: {node: '>= 20'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@tailwindcss/oxide-linux-x64-gnu@4.2.1':
resolution: {integrity: sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==}
engines: {node: '>= 20'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-x64-musl@4.2.1':
resolution: {integrity: sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==}
engines: {node: '>= 20'}
cpu: [x64]
os: [linux]
libc: [musl]
'@tailwindcss/oxide-wasm32-wasi@4.2.1':
resolution: {integrity: sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==}
engines: {node: '>=14.0.0'}
cpu: [wasm32]
bundledDependencies:
- '@napi-rs/wasm-runtime'
- '@emnapi/core'
- '@emnapi/runtime'
- '@tybys/wasm-util'
- '@emnapi/wasi-threads'
- tslib
'@tailwindcss/oxide-win32-arm64-msvc@4.2.1':
resolution: {integrity: sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA==}
engines: {node: '>= 20'}
cpu: [arm64]
os: [win32]
'@tailwindcss/oxide-win32-x64-msvc@4.2.1':
resolution: {integrity: sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ==}
engines: {node: '>= 20'}
cpu: [x64]
os: [win32]
'@tailwindcss/oxide@4.2.1':
resolution: {integrity: sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw==}
engines: {node: '>= 20'}
'@tailwindcss/vite@4.2.1':
resolution: {integrity: sha512-TBf2sJjYeb28jD2U/OhwdW0bbOsxkWPwQ7SrqGf9sVcoYwZj7rkXljroBO9wKBut9XnmQLXanuDUeqQK0lGg/w==}
peerDependencies:
vite: ^5.2.0 || ^6 || ^7
'@tauri-apps/api@2.10.1':
resolution: {integrity: sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==}
@@ -599,9 +696,17 @@ packages:
supports-color:
optional: true
detect-libc@2.1.2:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
electron-to-chromium@1.5.307:
resolution: {integrity: sha512-5z3uFKBWjiNR44nFcYdkcXjKMbg5KXNdciu7mhTPo9tB7NbqSNP2sSnGR+fqknZSCwKkBN+oxiiajWs4dT6ORg==}
enhanced-resolve@5.20.0:
resolution: {integrity: sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ==}
engines: {node: '>=10.13.0'}
esbuild@0.27.3:
resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==}
engines: {node: '>=18'}
@@ -632,6 +737,13 @@ packages:
resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
engines: {node: '>=6.9.0'}
graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
jiti@2.6.1:
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
hasBin: true
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
@@ -645,6 +757,80 @@ packages:
engines: {node: '>=6'}
hasBin: true
lightningcss-android-arm64@1.31.1:
resolution: {integrity: sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [android]
lightningcss-darwin-arm64@1.31.1:
resolution: {integrity: sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [darwin]
lightningcss-darwin-x64@1.31.1:
resolution: {integrity: sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [darwin]
lightningcss-freebsd-x64@1.31.1:
resolution: {integrity: sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [freebsd]
lightningcss-linux-arm-gnueabihf@1.31.1:
resolution: {integrity: sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==}
engines: {node: '>= 12.0.0'}
cpu: [arm]
os: [linux]
lightningcss-linux-arm64-gnu@1.31.1:
resolution: {integrity: sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
lightningcss-linux-arm64-musl@1.31.1:
resolution: {integrity: sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
lightningcss-linux-x64-gnu@1.31.1:
resolution: {integrity: sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
lightningcss-linux-x64-musl@1.31.1:
resolution: {integrity: sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]
lightningcss-win32-arm64-msvc@1.31.1:
resolution: {integrity: sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [win32]
lightningcss-win32-x64-msvc@1.31.1:
resolution: {integrity: sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [win32]
lightningcss@1.31.1:
resolution: {integrity: sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==}
engines: {node: '>= 12.0.0'}
lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
@@ -653,6 +839,9 @@ packages:
peerDependencies:
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
@@ -710,6 +899,10 @@ packages:
tailwindcss@4.2.1:
resolution: {integrity: sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==}
tapable@2.3.0:
resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==}
engines: {node: '>=6'}
tinyglobby@0.2.15:
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
engines: {node: '>=12.0.0'}
@@ -1074,6 +1267,74 @@ snapshots:
'@rollup/rollup-win32-x64-msvc@4.59.0':
optional: true
'@tailwindcss/node@4.2.1':
dependencies:
'@jridgewell/remapping': 2.3.5
enhanced-resolve: 5.20.0
jiti: 2.6.1
lightningcss: 1.31.1
magic-string: 0.30.21
source-map-js: 1.2.1
tailwindcss: 4.2.1
'@tailwindcss/oxide-android-arm64@4.2.1':
optional: true
'@tailwindcss/oxide-darwin-arm64@4.2.1':
optional: true
'@tailwindcss/oxide-darwin-x64@4.2.1':
optional: true
'@tailwindcss/oxide-freebsd-x64@4.2.1':
optional: true
'@tailwindcss/oxide-linux-arm-gnueabihf@4.2.1':
optional: true
'@tailwindcss/oxide-linux-arm64-gnu@4.2.1':
optional: true
'@tailwindcss/oxide-linux-arm64-musl@4.2.1':
optional: true
'@tailwindcss/oxide-linux-x64-gnu@4.2.1':
optional: true
'@tailwindcss/oxide-linux-x64-musl@4.2.1':
optional: true
'@tailwindcss/oxide-wasm32-wasi@4.2.1':
optional: true
'@tailwindcss/oxide-win32-arm64-msvc@4.2.1':
optional: true
'@tailwindcss/oxide-win32-x64-msvc@4.2.1':
optional: true
'@tailwindcss/oxide@4.2.1':
optionalDependencies:
'@tailwindcss/oxide-android-arm64': 4.2.1
'@tailwindcss/oxide-darwin-arm64': 4.2.1
'@tailwindcss/oxide-darwin-x64': 4.2.1
'@tailwindcss/oxide-freebsd-x64': 4.2.1
'@tailwindcss/oxide-linux-arm-gnueabihf': 4.2.1
'@tailwindcss/oxide-linux-arm64-gnu': 4.2.1
'@tailwindcss/oxide-linux-arm64-musl': 4.2.1
'@tailwindcss/oxide-linux-x64-gnu': 4.2.1
'@tailwindcss/oxide-linux-x64-musl': 4.2.1
'@tailwindcss/oxide-wasm32-wasi': 4.2.1
'@tailwindcss/oxide-win32-arm64-msvc': 4.2.1
'@tailwindcss/oxide-win32-x64-msvc': 4.2.1
'@tailwindcss/vite@4.2.1(vite@7.3.1(jiti@2.6.1)(lightningcss@1.31.1))':
dependencies:
'@tailwindcss/node': 4.2.1
'@tailwindcss/oxide': 4.2.1
tailwindcss: 4.2.1
vite: 7.3.1(jiti@2.6.1)(lightningcss@1.31.1)
'@tauri-apps/api@2.10.1': {}
'@tauri-apps/cli-darwin-arm64@2.10.1':
@@ -1158,7 +1419,7 @@ snapshots:
dependencies:
csstype: 3.2.3
'@vitejs/plugin-react@4.7.0(vite@7.3.1)':
'@vitejs/plugin-react@4.7.0(vite@7.3.1(jiti@2.6.1)(lightningcss@1.31.1))':
dependencies:
'@babel/core': 7.29.0
'@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0)
@@ -1166,7 +1427,7 @@ snapshots:
'@rolldown/pluginutils': 1.0.0-beta.27
'@types/babel__core': 7.20.5
react-refresh: 0.17.0
vite: 7.3.1
vite: 7.3.1(jiti@2.6.1)(lightningcss@1.31.1)
transitivePeerDependencies:
- supports-color
@@ -1199,8 +1460,15 @@ snapshots:
dependencies:
ms: 2.1.3
detect-libc@2.1.2: {}
electron-to-chromium@1.5.307: {}
enhanced-resolve@5.20.0:
dependencies:
graceful-fs: 4.2.11
tapable: 2.3.0
esbuild@0.27.3:
optionalDependencies:
'@esbuild/aix-ppc64': 0.27.3
@@ -1243,12 +1511,65 @@ snapshots:
gensync@1.0.0-beta.2: {}
graceful-fs@4.2.11: {}
jiti@2.6.1: {}
js-tokens@4.0.0: {}
jsesc@3.1.0: {}
json5@2.2.3: {}
lightningcss-android-arm64@1.31.1:
optional: true
lightningcss-darwin-arm64@1.31.1:
optional: true
lightningcss-darwin-x64@1.31.1:
optional: true
lightningcss-freebsd-x64@1.31.1:
optional: true
lightningcss-linux-arm-gnueabihf@1.31.1:
optional: true
lightningcss-linux-arm64-gnu@1.31.1:
optional: true
lightningcss-linux-arm64-musl@1.31.1:
optional: true
lightningcss-linux-x64-gnu@1.31.1:
optional: true
lightningcss-linux-x64-musl@1.31.1:
optional: true
lightningcss-win32-arm64-msvc@1.31.1:
optional: true
lightningcss-win32-x64-msvc@1.31.1:
optional: true
lightningcss@1.31.1:
dependencies:
detect-libc: 2.1.2
optionalDependencies:
lightningcss-android-arm64: 1.31.1
lightningcss-darwin-arm64: 1.31.1
lightningcss-darwin-x64: 1.31.1
lightningcss-freebsd-x64: 1.31.1
lightningcss-linux-arm-gnueabihf: 1.31.1
lightningcss-linux-arm64-gnu: 1.31.1
lightningcss-linux-arm64-musl: 1.31.1
lightningcss-linux-x64-gnu: 1.31.1
lightningcss-linux-x64-musl: 1.31.1
lightningcss-win32-arm64-msvc: 1.31.1
lightningcss-win32-x64-msvc: 1.31.1
lru-cache@5.1.1:
dependencies:
yallist: 3.1.1
@@ -1257,6 +1578,10 @@ snapshots:
dependencies:
react: 19.2.4
magic-string@0.30.21:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
ms@2.1.3: {}
nanoid@3.3.11: {}
@@ -1323,6 +1648,8 @@ snapshots:
tailwindcss@4.2.1: {}
tapable@2.3.0: {}
tinyglobby@0.2.15:
dependencies:
fdir: 6.5.0(picomatch@4.0.3)
@@ -1336,7 +1663,7 @@ snapshots:
escalade: 3.2.0
picocolors: 1.1.1
vite@7.3.1:
vite@7.3.1(jiti@2.6.1)(lightningcss@1.31.1):
dependencies:
esbuild: 0.27.3
fdir: 6.5.0(picomatch@4.0.3)
@@ -1346,6 +1673,8 @@ snapshots:
tinyglobby: 0.2.15
optionalDependencies:
fsevents: 2.3.3
jiti: 2.6.1
lightningcss: 1.31.1
yallist@3.1.1: {}

View File

@@ -1,13 +1,34 @@
import './index.css';
import { useState, useEffect } from 'react';
import './index.css';
import { Sidebar } from './components/Sidebar';
import { ChatArea } from './components/ChatArea';
import { RightPanel } from './components/RightPanel';
import { SettingsLayout } from './components/Settings/SettingsLayout';
import { useGatewayStore } from './store/gatewayStore';
type View = 'main' | 'settings';
function App() {
const [view, setView] = useState<View>('main');
const { connect, connectionState } = useGatewayStore();
// Auto-connect to Gateway on startup
useEffect(() => {
if (connectionState === 'disconnected') {
connect().catch(() => {
// Silent fail — user can manually connect via Settings
});
}
}, []);
if (view === 'settings') {
return <SettingsLayout onBack={() => setView('main')} />;
}
return (
<div className="h-screen flex overflow-hidden text-gray-800 text-sm">
{/* 左侧边栏 */}
<Sidebar />
<Sidebar onOpenSettings={() => setView('settings')} />
{/* 中间对话区域 */}
<main className="flex-1 flex flex-col bg-white relative">

View File

@@ -0,0 +1,120 @@
import { useEffect } from 'react';
import { useGatewayStore } from '../store/gatewayStore';
import { Radio, RefreshCw, MessageCircle, Settings } from 'lucide-react';
const CHANNEL_ICONS: Record<string, string> = {
feishu: '飞',
qqbot: 'QQ',
wechat: '微',
};
interface ChannelListProps {
onOpenSettings?: () => void;
}
export function ChannelList({ onOpenSettings }: ChannelListProps) {
const { channels, connectionState, loadChannels, loadPluginStatus } = useGatewayStore();
const connected = connectionState === 'connected';
useEffect(() => {
if (connected) {
loadPluginStatus().then(() => loadChannels());
}
}, [connected]);
const handleRefresh = () => {
loadPluginStatus().then(() => loadChannels());
};
if (!connected) {
return (
<div className="flex flex-col items-center justify-center h-full text-gray-400 text-xs px-4 text-center">
<Radio className="w-8 h-8 mb-2 opacity-30" />
<p>IM </p>
<p className="mt-1"> Gateway </p>
</div>
);
}
return (
<div className="h-full flex flex-col">
{/* Header */}
<div className="flex items-center justify-between px-3 py-2 border-b border-gray-200">
<span className="text-xs font-medium text-gray-500"></span>
<button
onClick={handleRefresh}
className="p-1 text-gray-400 hover:text-orange-500 rounded"
title="刷新"
>
<RefreshCw className="w-3.5 h-3.5" />
</button>
</div>
<div className="flex-1 overflow-y-auto custom-scrollbar">
{/* Configured channels */}
{channels.map((ch) => (
<div
key={ch.id}
className="flex items-center gap-3 px-3 py-3 hover:bg-gray-100 border-b border-gray-50"
>
<div className={`w-8 h-8 rounded-lg flex items-center justify-center text-white text-xs font-bold flex-shrink-0 ${
ch.status === 'active'
? 'bg-gradient-to-br from-blue-500 to-indigo-500'
: 'bg-gray-300'
}`}>
{CHANNEL_ICONS[ch.type] || <MessageCircle className="w-4 h-4" />}
</div>
<div className="flex-1 min-w-0">
<div className="text-xs font-medium text-gray-900 truncate">{ch.label}</div>
<div className={`text-[11px] ${
ch.status === 'active' ? 'text-green-500' : ch.status === 'error' ? 'text-red-500' : 'text-gray-400'
}`}>
{ch.status === 'active' ? '已连接' : ch.status === 'error' ? ch.error || '错误' : '未配置'}
{ch.accounts !== undefined && ch.accounts > 0 && ` · ${ch.accounts} 个账号`}
</div>
</div>
</div>
))}
{/* Always show available channels that aren't configured */}
{!channels.find(c => c.type === 'feishu') && (
<div className="flex items-center gap-3 px-3 py-3 hover:bg-gray-100 border-b border-gray-50 opacity-60">
<div className="w-8 h-8 rounded-lg flex items-center justify-center text-white text-xs font-bold flex-shrink-0 bg-gray-300">
</div>
<div className="flex-1 min-w-0">
<div className="text-xs font-medium text-gray-600"> (Feishu)</div>
<div className="text-[11px] text-gray-400"></div>
</div>
</div>
)}
{!channels.find(c => c.type === 'qqbot') && (
<div className="flex items-center gap-3 px-3 py-3 hover:bg-gray-100 border-b border-gray-50 opacity-60">
<div className="w-8 h-8 rounded-lg flex items-center justify-center text-white text-xs font-bold flex-shrink-0 bg-gray-300">
QQ
</div>
<div className="flex-1 min-w-0">
<div className="text-xs font-medium text-gray-600">QQ </div>
<div className="text-[11px] text-gray-400"></div>
</div>
</div>
)}
{/* Help text */}
<div className="px-3 py-4 text-center">
<p className="text-[11px] text-gray-400 mb-2"> IM </p>
{onOpenSettings && (
<button
onClick={onOpenSettings}
className="inline-flex items-center gap-1 text-xs text-orange-500 hover:text-orange-600"
>
<Settings className="w-3 h-3" />
</button>
)}
</div>
</div>
</div>
);
}

View File

@@ -1,102 +1,151 @@
import { useState } from 'react';
import { useChatStore } from '../store/chatStore';
import { Send, Paperclip, ChevronDown } from 'lucide-react';
import { useState, useEffect, useRef, useCallback } from 'react';
import { useChatStore, Message } from '../store/chatStore';
import { useGatewayStore } from '../store/gatewayStore';
import { Send, Paperclip, ChevronDown, Terminal, Loader2, SquarePen } from 'lucide-react';
const MODELS = ['glm-5', 'qwen3.5-plus', 'kimi-k2.5', 'minimax-m2.5'];
export function ChatArea() {
const { messages, currentAgent, addMessage } = useChatStore();
const {
messages, currentAgent, isStreaming, currentModel,
sendMessage: sendToGateway, setCurrentModel, initStreamListener,
newConversation,
} = useChatStore();
const { connectionState } = useGatewayStore();
const [input, setInput] = useState('');
const [showModelPicker, setShowModelPicker] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const sendMessage = () => {
if (!input.trim()) return;
// Auto-resize textarea
const adjustTextarea = useCallback(() => {
const el = textareaRef.current;
if (el) {
el.style.height = 'auto';
el.style.height = Math.min(el.scrollHeight, 160) + 'px';
}
}, []);
const userMessage = {
id: Date.now().toString(),
role: 'user' as const,
content: input,
timestamp: new Date(),
};
// Init agent stream listener on mount
useEffect(() => {
const unsub = initStreamListener();
return unsub;
}, []);
addMessage(userMessage);
// Auto-scroll to bottom on new messages
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [messages]);
const handleSend = () => {
if (!input.trim() || isStreaming) return;
sendToGateway(input);
setInput('');
// TODO: 调用后端 API
setTimeout(() => {
const aiMessage = {
id: (Date.now() + 1).toString(),
role: 'assistant' as const,
content: '收到你的消息了!正在处理中...',
timestamp: new Date(),
};
addMessage(aiMessage);
}, 1000);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
const connected = connectionState === 'connected';
return (
<>
{/* 顶部标题栏 */}
{/* Header */}
<div className="h-14 border-b border-gray-100 flex items-center justify-between px-6 flex-shrink-0">
<div className="flex items-center gap-2">
<h2 className="font-semibold text-gray-900">{currentAgent?.name || 'ZCLAW'}</h2>
<span className="text-xs text-gray-400 flex items-center gap-1">
<span className="w-1.5 h-1.5 bg-green-400 rounded-full"></span>
线
<span className={`text-xs flex items-center gap-1 ${connected ? 'text-green-500' : 'text-gray-400'}`}>
<span className={`w-1.5 h-1.5 rounded-full ${connected ? 'bg-green-400' : 'bg-gray-300'}`}></span>
{connected ? 'Gateway 已连接' : 'Gateway 未连接'}
</span>
</div>
<div className="flex items-center gap-2">
{messages.length > 0 && (
<button
onClick={newConversation}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs text-gray-500 hover:text-orange-600 hover:bg-orange-50 rounded-lg transition-colors"
title="新对话"
>
<SquarePen className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
{/* 聊天内容区 */}
<div className="flex-1 overflow-y-auto custom-scrollbar p-6 space-y-6">
{/* Messages */}
<div ref={scrollRef} className="flex-1 overflow-y-auto custom-scrollbar p-6 space-y-4">
{messages.length === 0 && (
<div className="text-center text-gray-400 py-20">
<p className="text-lg mb-2">使 ZCLAW 🦞</p>
<p className="text-sm"></p>
<p className="text-sm">{connected ? '发送消息开始对话' : '请先在设置中连接 Gateway'}</p>
</div>
)}
{messages.map((message) => (
<div
key={message.id}
className={ lex gap-4 }
>
<div
className={w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0 }
>
{message.role === 'user' ? '用' : '🦞'}
</div>
<div className={message.role === 'user' ? 'max-w-2xl' : 'flex-1 max-w-3xl'}>
<div className={message.role === 'user' ? 'chat-bubble-user p-4 shadow-md' : 'chat-bubble-assistant p-4 shadow-sm'}>
<p className="leading-relaxed text-gray-700">{message.content}</p>
</div>
</div>
</div>
<MessageBubble key={message.id} message={message} />
))}
{isStreaming && (
<div className="flex items-center gap-2 text-gray-400 text-xs pl-12">
<Loader2 className="w-3 h-3 animate-spin" />
Agent ...
</div>
)}
</div>
{/* 底部输入区 */}
{/* Input */}
<div className="border-t border-gray-100 p-4 bg-white">
<div className="max-w-4xl mx-auto">
<div className="relative flex items-end gap-2 bg-gray-50 rounded-2xl border border-gray-200 p-2 focus-within:border-orange-300 focus-within:ring-2 focus-within:ring-orange-100 transition-all">
<button className="p-2 text-gray-400 hover:text-gray-600 rounded-lg">
<Paperclip className="w-5 h-5" />
</button>
<div className="flex-1 py-2">
<input
type="text"
<div className="flex-1 py-1">
<textarea
ref={textareaRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && sendMessage()}
placeholder="发送给 ZCLAW"
className="w-full bg-transparent border-none focus:outline-none text-gray-700 placeholder-gray-400"
onChange={(e) => { setInput(e.target.value); adjustTextarea(); }}
onKeyDown={handleKeyDown}
placeholder={isStreaming ? 'Agent 正在回复...' : '发送给 ZCLAWShift+Enter 换行)'}
disabled={isStreaming}
rows={1}
className="w-full bg-transparent border-none focus:outline-none text-gray-700 placeholder-gray-400 disabled:opacity-50 resize-none leading-relaxed"
style={{ minHeight: '24px', maxHeight: '160px' }}
/>
</div>
<div className="flex items-center gap-2 pr-2 pb-1">
<button className="flex items-center gap-1 px-2 py-1 text-xs text-gray-500 hover:bg-gray-200 rounded-md transition-colors">
<span>glm5</span>
<div className="flex items-center gap-2 pr-2 pb-1 relative">
<button
onClick={() => setShowModelPicker(!showModelPicker)}
className="flex items-center gap-1 px-2 py-1 text-xs text-gray-500 hover:bg-gray-200 rounded-md transition-colors"
>
<span>{currentModel}</span>
<ChevronDown className="w-3 h-3" />
</button>
{showModelPicker && (
<div className="absolute bottom-full right-8 mb-2 bg-white border border-gray-200 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 ${model === currentModel ? 'text-orange-600 font-medium' : 'text-gray-700'}`}
>
{model}
</button>
))}
</div>
)}
<button
onClick={sendMessage}
className="w-8 h-8 bg-gray-900 text-white rounded-full flex items-center justify-center hover:bg-gray-800 transition-colors"
onClick={handleSend}
disabled={isStreaming || !input.trim()}
className="w-8 h-8 bg-gray-900 text-white rounded-full flex items-center justify-center hover:bg-gray-800 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
<Send className="w-4 h-4" />
</button>
@@ -110,3 +159,132 @@ export function ChatArea() {
</>
);
}
/** Lightweight markdown renderer — handles code blocks, inline code, bold, italic, links */
function renderMarkdown(text: string): React.ReactNode[] {
const nodes: React.ReactNode[] = [];
const lines = text.split('\n');
let i = 0;
while (i < lines.length) {
const line = lines[i];
// Fenced code block
if (line.startsWith('```')) {
const lang = line.slice(3).trim();
const codeLines: string[] = [];
i++;
while (i < lines.length && !lines[i].startsWith('```')) {
codeLines.push(lines[i]);
i++;
}
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>
);
continue;
}
// Normal line — parse inline markdown
nodes.push(
<span key={nodes.length}>
{i > 0 && lines[i - 1] !== undefined && !nodes[nodes.length - 1]?.toString().includes('pre') && '\n'}
{renderInline(line)}
</span>
);
i++;
}
return nodes;
}
function renderInline(text: string): React.ReactNode[] {
const parts: React.ReactNode[] = [];
// Pattern: **bold**, *italic*, `code`, [text](url)
const regex = /(\*\*(.+?)\*\*)|(\*(.+?)\*)|(`(.+?)`)|(\[(.+?)\]\((.+?)\))/g;
let lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = regex.exec(text)) !== null) {
// Text before match
if (match.index > lastIndex) {
parts.push(text.slice(lastIndex, match.index));
}
if (match[1]) {
// **bold**
parts.push(<strong key={parts.length} className="font-semibold">{match[2]}</strong>);
} else if (match[3]) {
// *italic*
parts.push(<em key={parts.length}>{match[4]}</em>);
} else if (match[5]) {
// `code`
parts.push(
<code key={parts.length} className="bg-gray-100 text-orange-700 px-1 py-0.5 rounded text-[0.85em] font-mono">
{match[6]}
</code>
);
} else if (match[7]) {
// [text](url)
parts.push(
<a key={parts.length} href={match[9]} target="_blank" rel="noopener noreferrer"
className="text-orange-600 underline hover:text-orange-700">{match[8]}</a>
);
}
lastIndex = match.index + match[0].length;
}
if (lastIndex < text.length) {
parts.push(text.slice(lastIndex));
}
return parts.length > 0 ? parts : [text];
}
function MessageBubble({ message }: { message: Message }) {
if (message.role === 'tool') {
return (
<div className="ml-12 bg-gray-50 border border-gray-200 rounded-lg p-3 text-xs font-mono">
<div className="flex items-center gap-2 text-gray-500 mb-1">
<Terminal className="w-3.5 h-3.5" />
<span className="font-semibold">{message.toolName || 'tool'}</span>
</div>
{message.toolInput && (
<pre className="text-gray-600 bg-white rounded p-2 mb-1 overflow-x-auto">{message.toolInput}</pre>
)}
{message.content && (
<pre className="text-green-700 bg-white rounded p-2 overflow-x-auto">{message.content}</pre>
)}
</div>
);
}
const isUser = message.role === 'user';
return (
<div className={`flex gap-4 ${isUser ? 'justify-end' : ''}`}>
<div
className={`w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0 ${isUser ? 'bg-gray-200 text-gray-600 order-last' : 'agent-avatar text-white'}`}
>
{isUser ? '用' : '🦞'}
</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'}`}>
{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" />}
</div>
{message.error && (
<p className="text-xs text-red-500 mt-2">{message.error}</p>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,139 @@
import { useState, useEffect } from 'react';
import { useGatewayStore } from '../store/gatewayStore';
import { useChatStore } from '../store/chatStore';
import { Plus, Trash2, Bot, X } from 'lucide-react';
interface CloneFormData {
name: string;
role: string;
scenarios: string;
}
export function CloneManager() {
const { clones, loadClones, createClone, deleteClone, connectionState } = useGatewayStore();
const { agents } = useChatStore();
const [showForm, setShowForm] = useState(false);
const [form, setForm] = useState<CloneFormData>({ name: '', role: '', scenarios: '' });
const connected = connectionState === 'connected';
useEffect(() => {
if (connected) {
loadClones();
}
}, [connected]);
const handleCreate = async () => {
if (!form.name.trim()) return;
await createClone({
name: form.name,
role: form.role || undefined,
scenarios: form.scenarios ? form.scenarios.split(',').map(s => s.trim()) : undefined,
});
setForm({ name: '', role: '', scenarios: '' });
setShowForm(false);
};
const handleDelete = async (id: string) => {
if (confirm('确定删除该分身?')) {
await deleteClone(id);
}
};
// Merge gateway clones with local agents for display
const displayClones = clones.length > 0 ? clones : agents.map(a => ({
id: a.id,
name: a.name,
role: '默认助手',
createdAt: '',
}));
return (
<div className="h-full flex flex-col">
{/* Header */}
<div className="flex items-center justify-between px-3 py-2 border-b border-gray-200">
<span className="text-xs font-medium text-gray-500"></span>
<button
onClick={() => setShowForm(true)}
className="p-1 text-gray-400 hover:text-orange-500 rounded"
title="创建分身"
>
<Plus className="w-4 h-4" />
</button>
</div>
{/* Create form */}
{showForm && (
<div className="p-3 border-b border-gray-200 bg-orange-50 space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-orange-700"></span>
<button onClick={() => setShowForm(false)} className="text-gray-400 hover:text-gray-600">
<X className="w-3.5 h-3.5" />
</button>
</div>
<input
type="text"
value={form.name}
onChange={e => setForm({ ...form, name: e.target.value })}
placeholder="名称 (必填)"
className="w-full text-xs border border-gray-300 rounded px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-orange-500"
/>
<input
type="text"
value={form.role}
onChange={e => setForm({ ...form, role: e.target.value })}
placeholder="角色 (如: 代码助手)"
className="w-full text-xs border border-gray-300 rounded px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-orange-500"
/>
<input
type="text"
value={form.scenarios}
onChange={e => setForm({ ...form, scenarios: e.target.value })}
placeholder="场景标签 (逗号分隔)"
className="w-full text-xs border border-gray-300 rounded px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-orange-500"
/>
<button
onClick={handleCreate}
disabled={!form.name.trim()}
className="w-full text-xs bg-orange-500 text-white rounded py-1.5 hover:bg-orange-600 disabled:opacity-50"
>
</button>
</div>
)}
{/* Clone list */}
<div className="flex-1 overflow-y-auto custom-scrollbar">
{displayClones.map((clone) => (
<div
key={clone.id}
className="group flex items-center gap-3 px-3 py-3 hover:bg-gray-100 cursor-pointer border-b border-gray-50"
>
<div className="w-9 h-9 bg-gradient-to-br from-orange-400 to-red-500 rounded-xl flex items-center justify-center text-white flex-shrink-0">
<Bot className="w-4 h-4" />
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-gray-900 truncate">{clone.name}</div>
<div className="text-xs text-gray-400 truncate">{clone.role || '默认助手'}</div>
</div>
<button
onClick={(e) => { e.stopPropagation(); handleDelete(clone.id); }}
className="opacity-0 group-hover:opacity-100 p-1 text-gray-300 hover:text-red-500 transition-opacity"
title="删除"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
))}
{displayClones.length === 0 && (
<div className="text-center py-8 text-xs text-gray-400">
<Bot className="w-8 h-8 mx-auto mb-2 opacity-30" />
<p></p>
<p className="mt-1"> + </p>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,113 @@
import { useChatStore } from '../store/chatStore';
import { MessageSquare, Trash2, SquarePen } from 'lucide-react';
export function ConversationList() {
const {
conversations, currentConversationId, messages,
newConversation, switchConversation, deleteConversation,
} = useChatStore();
const hasActiveChat = messages.length > 0;
return (
<div className="h-full flex flex-col">
{/* Header */}
<div className="flex items-center justify-between px-3 py-2 border-b border-gray-200">
<span className="text-xs font-medium text-gray-500"></span>
<button
onClick={newConversation}
className="p-1 text-gray-400 hover:text-orange-500 rounded"
title="新对话"
>
<SquarePen className="w-4 h-4" />
</button>
</div>
<div className="flex-1 overflow-y-auto custom-scrollbar">
{/* Current active chat (unsaved) */}
{hasActiveChat && !currentConversationId && (
<div className="flex items-center gap-3 px-3 py-3 bg-orange-50 border-b border-orange-100 cursor-default">
<div className="w-7 h-7 bg-orange-500 rounded-lg flex items-center justify-center text-white flex-shrink-0">
<MessageSquare className="w-3.5 h-3.5" />
</div>
<div className="flex-1 min-w-0">
<div className="text-xs font-medium text-orange-700 truncate"></div>
<div className="text-[11px] text-orange-500 truncate">
{messages.filter(m => m.role === 'user').length}
</div>
</div>
</div>
)}
{/* Saved conversations */}
{conversations.map((conv) => {
const isActive = conv.id === currentConversationId;
const msgCount = conv.messages.filter(m => m.role === 'user').length;
const timeStr = formatTime(conv.updatedAt);
return (
<div
key={conv.id}
onClick={() => switchConversation(conv.id)}
className={`group flex items-center gap-3 px-3 py-3 cursor-pointer border-b border-gray-50 transition-colors ${
isActive ? 'bg-orange-50' : 'hover:bg-gray-100'
}`}
>
<div className={`w-7 h-7 rounded-lg flex items-center justify-center flex-shrink-0 ${
isActive ? 'bg-orange-500 text-white' : 'bg-gray-200 text-gray-500'
}`}>
<MessageSquare className="w-3.5 h-3.5" />
</div>
<div className="flex-1 min-w-0">
<div className={`text-xs font-medium truncate ${isActive ? 'text-orange-700' : 'text-gray-900'}`}>
{conv.title}
</div>
<div className="text-[11px] text-gray-400 truncate">
{msgCount} · {timeStr}
</div>
</div>
<button
onClick={(e) => {
e.stopPropagation();
if (confirm('删除该对话?')) {
deleteConversation(conv.id);
}
}}
className="opacity-0 group-hover:opacity-100 p-1 text-gray-300 hover:text-red-500 transition-opacity"
title="删除"
>
<Trash2 className="w-3 h-3" />
</button>
</div>
);
})}
{conversations.length === 0 && !hasActiveChat && (
<div className="text-center py-8 text-xs text-gray-400">
<MessageSquare className="w-8 h-8 mx-auto mb-2 opacity-30" />
<p></p>
<p className="mt-1"></p>
</div>
)}
</div>
</div>
);
}
function formatTime(date: Date): string {
const now = new Date();
const d = new Date(date);
const diffMs = now.getTime() - d.getTime();
const diffMin = Math.floor(diffMs / 60000);
if (diffMin < 1) return '刚刚';
if (diffMin < 60) return `${diffMin} 分钟前`;
const diffHr = Math.floor(diffMin / 60);
if (diffHr < 24) return `${diffHr} 小时前`;
const diffDay = Math.floor(diffHr / 24);
if (diffDay < 7) return `${diffDay} 天前`;
return `${d.getMonth() + 1}/${d.getDate()}`;
}

View File

@@ -1,103 +1,214 @@
import { FileText, User, Target, CheckSquare } from 'lucide-react';
import { useEffect } from 'react';
import { useGatewayStore } from '../store/gatewayStore';
import { useChatStore } from '../store/chatStore';
import {
Wifi, WifiOff, Bot, BarChart3, Plug, RefreshCw,
MessageSquare, Cpu, Activity,
} from 'lucide-react';
export function RightPanel() {
const {
connectionState, gatewayVersion, error, clones, usageStats, pluginStatus,
connect, loadClones, loadUsageStats, loadPluginStatus,
} = useGatewayStore();
const { messages, currentModel } = useChatStore();
const connected = connectionState === 'connected';
// Load data when connected
useEffect(() => {
if (connected) {
loadClones();
loadUsageStats();
loadPluginStatus();
}
}, [connected]);
const handleReconnect = () => {
connect().catch(() => {});
};
const userMsgCount = messages.filter(m => m.role === 'user').length;
const assistantMsgCount = messages.filter(m => m.role === 'assistant').length;
const toolCallCount = messages.filter(m => m.role === 'tool').length;
return (
<aside className="w-80 bg-white border-l border-gray-200 flex flex-col flex-shrink-0">
{/* 顶部工具栏 */}
<aside className="w-72 bg-white border-l border-gray-200 flex flex-col flex-shrink-0">
{/* 顶部 */}
<div className="h-14 border-b border-gray-100 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">
<Target className="w-4 h-4" />
<span className="font-medium">2268</span>
</div>
<button className="text-xs text-orange-600 hover:underline"></button>
<div className="flex items-center gap-2">
<Activity className="w-4 h-4 text-gray-500" />
<span className="font-medium text-gray-700 text-sm"></span>
</div>
<div className="flex items-center gap-3 text-gray-500">
<button className="hover:text-gray-700 flex items-center gap-1 text-xs">
<FileText className="w-4 h-4" />
<span></span>
{connected && (
<button
onClick={() => { loadUsageStats(); loadPluginStatus(); loadClones(); }}
className="p-1 text-gray-400 hover:text-orange-500 rounded transition-colors"
title="刷新数据"
>
<RefreshCw className="w-3.5 h-3.5" />
</button>
<button className="hover:text-gray-700 flex items-center gap-1 text-xs">
<User className="w-4 h-4" />
<span>Agent</span>
</button>
</div>
)}
</div>
{/* 内容区 */}
<div className="flex-1 overflow-y-auto custom-scrollbar p-4">
{/* 任务进度 */}
<div className="bg-gray-50 rounded-lg border border-gray-100 mb-6 overflow-hidden">
<div className="p-3">
<div className="flex justify-between text-xs mb-2">
<span className="text-gray-600"></span>
<span className="font-medium text-gray-900">65%</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div className="bg-orange-500 h-2 rounded-full transition-all" style={{ width: '65%' }}></div>
</div>
</div>
<div className="border-t border-gray-100 divide-y divide-gray-100 text-xs">
<div className="py-2 px-3 flex justify-between">
<span className="text-gray-600"></span>
<span className="text-green-600"> </span>
</div>
<div className="py-2 px-3 flex justify-between">
<span className="text-gray-600"></span>
<span className="text-orange-600">🔄 </span>
</div>
<div className="py-2 px-3 flex justify-between">
<span className="text-gray-600"> Agent </span>
<span className="text-gray-400"> </span>
</div>
</div>
</div>
{/* 今日统计 */}
<div className="mb-6">
<h3 className="font-bold text-gray-900 mb-3 text-sm"></h3>
<div className="bg-gray-50 rounded-lg border border-gray-100 p-3">
<div className="space-y-2 text-xs">
<div className="flex justify-between">
<span className="text-gray-600"></span>
<span className="font-medium text-gray-900">8 </span>
</div>
<div className="flex justify-between">
<span className="text-gray-600"></span>
<span className="font-medium text-green-600">6 </span>
</div>
<div className="flex justify-between">
<span className="text-gray-600"></span>
<span className="font-medium text-orange-600">2 </span>
</div>
</div>
</div>
</div>
{/* 下一步行动 */}
<div>
<h3 className="font-bold text-gray-900 mb-3 text-sm flex items-center gap-2">
<span className="w-5 h-5 bg-orange-500 rounded-full flex items-center justify-center text-white text-xs">
<Target className="w-3 h-3" />
<div className="flex-1 overflow-y-auto custom-scrollbar p-4 space-y-4">
{/* Gateway 连接状态 */}
<div className={`rounded-lg border p-3 ${connected ? 'bg-green-50 border-green-200' : 'bg-gray-50 border-gray-200'}`}>
<div className="flex items-center gap-2 mb-2">
{connected ? (
<Wifi className="w-4 h-4 text-green-600" />
) : (
<WifiOff className="w-4 h-4 text-gray-400" />
)}
<span className={`text-xs font-semibold ${connected ? 'text-green-700' : 'text-gray-600'}`}>
Gateway {connected ? '已连接' : connectionState === 'connecting' ? '连接中...' : connectionState === 'reconnecting' ? '重连中...' : '未连接'}
</span>
</div>
<div className="space-y-1 text-xs">
<div className="flex justify-between">
<span className="text-gray-500"></span>
<span className="text-gray-700 font-mono">127.0.0.1:18789</span>
</div>
{gatewayVersion && (
<div className="flex justify-between">
<span className="text-gray-500"></span>
<span className="text-gray-700">{gatewayVersion}</span>
</div>
)}
<div className="flex justify-between">
<span className="text-gray-500"></span>
<span className="text-orange-600 font-medium">{currentModel}</span>
</div>
</div>
{!connected && connectionState !== 'connecting' && (
<button
onClick={handleReconnect}
className="mt-2 w-full text-xs bg-orange-500 text-white rounded py-1.5 hover:bg-orange-600 transition-colors"
>
Gateway
</button>
)}
{error && (
<p className="mt-2 text-xs text-red-500 truncate" title={error}>{error}</p>
)}
</div>
{/* 当前会话 */}
<div className="bg-gray-50 rounded-lg border border-gray-100 p-3">
<h3 className="text-xs font-semibold text-gray-700 mb-2 flex items-center gap-1.5">
<MessageSquare className="w-3.5 h-3.5" />
</h3>
<div className="ml-7 space-y-2">
<h4 className="text-xs font-semibold text-gray-900 mb-2"> ()</h4>
<ul className="space-y-2">
<li className="flex items-start gap-2 text-xs text-gray-600">
<div className="w-3 h-3 border border-gray-300 rounded-sm mt-0.5 flex-shrink-0"></div>
<span></span>
</li>
<li className="flex items-start gap-2 text-xs text-gray-600">
<div className="w-3 h-3 border border-gray-300 rounded-sm mt-0.5 flex-shrink-0"></div>
<span></span>
</li>
<li className="flex items-start gap-2 text-xs text-gray-600">
<div className="w-3 h-3 border border-gray-300 rounded-sm mt-0.5 flex-shrink-0"></div>
<span> OpenClaw SDK</span>
</li>
</ul>
<div className="space-y-1.5 text-xs">
<div className="flex justify-between">
<span className="text-gray-500"></span>
<span className="font-medium text-gray-900">{userMsgCount}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500"></span>
<span className="font-medium text-gray-900">{assistantMsgCount}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500"></span>
<span className="font-medium text-gray-900">{toolCallCount}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500"></span>
<span className="font-medium text-orange-600">{messages.length}</span>
</div>
</div>
</div>
{/* 分身 */}
<div className="bg-gray-50 rounded-lg border border-gray-100 p-3">
<h3 className="text-xs font-semibold text-gray-700 mb-2 flex items-center gap-1.5">
<Bot className="w-3.5 h-3.5" />
</h3>
{clones.length > 0 ? (
<div className="space-y-1.5">
{clones.slice(0, 5).map(c => (
<div key={c.id} className="flex items-center gap-2 text-xs">
<div className="w-5 h-5 bg-gradient-to-br from-orange-400 to-red-500 rounded-md flex items-center justify-center text-white text-[10px]">
<Bot className="w-3 h-3" />
</div>
<span className="text-gray-700 truncate">{c.name}</span>
</div>
))}
{clones.length > 5 && (
<p className="text-xs text-gray-400">+{clones.length - 5} </p>
)}
</div>
) : (
<p className="text-xs text-gray-400">
{connected ? '暂无分身,在左侧栏创建' : '连接后可用'}
</p>
)}
</div>
{/* 用量统计 */}
{usageStats && (
<div className="bg-gray-50 rounded-lg border border-gray-100 p-3">
<h3 className="text-xs font-semibold text-gray-700 mb-2 flex items-center gap-1.5">
<BarChart3 className="w-3.5 h-3.5" />
</h3>
<div className="space-y-1.5 text-xs">
<div className="flex justify-between">
<span className="text-gray-500"></span>
<span className="font-medium text-gray-900">{usageStats.totalSessions}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500"></span>
<span className="font-medium text-gray-900">{usageStats.totalMessages}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500"> Token</span>
<span className="font-medium text-gray-900">{usageStats.totalTokens.toLocaleString()}</span>
</div>
</div>
</div>
)}
{/* 插件状态 */}
{pluginStatus.length > 0 && (
<div className="bg-gray-50 rounded-lg border border-gray-100 p-3">
<h3 className="text-xs font-semibold text-gray-700 mb-2 flex items-center gap-1.5">
<Plug className="w-3.5 h-3.5" />
({pluginStatus.length})
</h3>
<div className="space-y-1 text-xs">
{pluginStatus.map((p: any, i: number) => (
<div key={i} className="flex justify-between">
<span className="text-gray-600 truncate">{p.name || p.id}</span>
<span className={p.status === 'active' ? 'text-green-600' : 'text-gray-400'}>
{p.status === 'active' ? '运行中' : '已停止'}
</span>
</div>
))}
</div>
</div>
)}
{/* 系统信息 */}
<div className="bg-gray-50 rounded-lg border border-gray-100 p-3">
<h3 className="text-xs font-semibold text-gray-700 mb-2 flex items-center gap-1.5">
<Cpu className="w-3.5 h-3.5" />
</h3>
<div className="space-y-1.5 text-xs">
<div className="flex justify-between">
<span className="text-gray-500">ZCLAW </span>
<span className="text-gray-700">v0.2.0</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500"></span>
<span className="text-gray-700">Gateway v3</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500"></span>
<span className="text-gray-700">Tauri 2.0</span>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,45 @@
export function About() {
return (
<div>
<div className="flex items-center gap-4 mb-8">
<div className="w-16 h-16 bg-gradient-to-br from-orange-400 to-red-500 rounded-2xl flex items-center justify-center text-3xl shadow-lg">
🦞
</div>
<div>
<h1 className="text-2xl font-bold text-gray-900">ZCLAW</h1>
<p className="text-sm text-orange-500"> 0.2.0</p>
</div>
</div>
<div className="bg-gray-50 rounded-xl p-5 mb-4">
<div className="flex justify-between items-center">
<div>
<h2 className="text-sm font-bold text-gray-900"></h2>
</div>
<button className="text-sm bg-orange-500 text-white rounded-lg px-4 py-1.5 hover:bg-orange-600 flex items-center gap-1">
🔄
</button>
</div>
</div>
<div className="bg-gray-50 rounded-xl p-5 mb-8">
<div className="flex justify-between items-center">
<div>
<h2 className="text-sm font-bold text-gray-900"></h2>
<p className="text-xs text-gray-500 mt-0.5"></p>
</div>
<button className="border border-gray-300 rounded-lg px-4 py-1.5 text-sm hover:bg-gray-100"></button>
</div>
</div>
<div className="text-center text-xs text-gray-400 space-y-1">
<p>© 2026 ZCLAW | Powered by OpenClaw</p>
<p> OpenClaw </p>
<div className="flex justify-center gap-4 mt-3">
<a href="#" className="text-orange-500 hover:text-orange-600"></a>
<a href="#" className="text-orange-500 hover:text-orange-600"></a>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,118 @@
import { useState } from 'react';
import { useGatewayStore } from '../../store/gatewayStore';
import { useChatStore } from '../../store/chatStore';
export function General() {
const { connectionState, gatewayVersion, error, connect, disconnect } = useGatewayStore();
const { currentModel } = useChatStore();
const [theme, setTheme] = useState<'light' | 'dark'>('light');
const [autoStart, setAutoStart] = useState(false);
const [showToolCalls, setShowToolCalls] = useState(false);
const connected = connectionState === 'connected';
const connecting = connectionState === 'connecting' || connectionState === 'reconnecting';
const handleConnect = () => { connect().catch(() => {}); };
const handleDisconnect = () => { disconnect(); };
return (
<div>
<h1 className="text-2xl font-bold text-gray-900 mb-8"></h1>
<h2 className="text-sm font-medium text-gray-500 uppercase tracking-wide mb-3">Gateway </h2>
<div className="bg-gray-50 rounded-xl p-5 mb-6 space-y-4">
<div className="flex justify-between items-center">
<span className="text-sm text-gray-700"></span>
<div className="flex items-center gap-2">
<span className={`w-2 h-2 rounded-full ${connected ? 'bg-green-400' : connecting ? 'bg-yellow-400 animate-pulse' : 'bg-gray-300'}`} />
<span className={`text-sm font-medium ${connected ? 'text-green-600' : connecting ? 'text-yellow-600' : 'text-gray-400'}`}>
{connected ? '已连接' : connecting ? '连接中...' : connectionState === 'handshaking' ? '握手中...' : '未连接'}
</span>
</div>
</div>
<div className="flex justify-between items-center">
<span className="text-sm text-gray-700"></span>
<span className="text-sm text-gray-500 font-mono">ws://127.0.0.1:18789</span>
</div>
{gatewayVersion && (
<div className="flex justify-between items-center">
<span className="text-sm text-gray-700"></span>
<span className="text-sm text-gray-500">{gatewayVersion}</span>
</div>
)}
<div className="flex justify-between items-center">
<span className="text-sm text-gray-700"></span>
<span className="text-sm text-orange-600 font-medium">{currentModel}</span>
</div>
{error && (
<div className="text-xs text-red-500 bg-red-50 rounded-lg p-2">{error}</div>
)}
<div className="flex gap-2 pt-1">
{connected ? (
<button
onClick={handleDisconnect}
className="text-sm border border-gray-300 rounded-lg px-4 py-1.5 hover:bg-gray-100 text-gray-600"
>
</button>
) : (
<button
onClick={handleConnect}
disabled={connecting}
className="text-sm bg-orange-500 text-white rounded-lg px-4 py-1.5 hover:bg-orange-600 disabled:opacity-50"
>
{connecting ? '连接中...' : '连接 Gateway'}
</button>
)}
</div>
</div>
<h2 className="text-sm font-medium text-gray-500 uppercase tracking-wide mb-3"></h2>
<div className="bg-gray-50 rounded-xl p-5 space-y-5">
<div className="flex justify-between items-center">
<div>
<div className="text-sm font-medium text-gray-900"></div>
<div className="text-xs text-gray-500 mt-0.5"></div>
</div>
<div className="flex gap-2">
<button
onClick={() => setTheme('light')}
className={`w-8 h-8 rounded-full border-2 ${theme === 'light' ? 'border-orange-500' : 'border-gray-300'} bg-white`}
/>
<button
onClick={() => setTheme('dark')}
className={`w-8 h-8 rounded-full border-2 ${theme === 'dark' ? 'border-orange-500' : 'border-gray-300'} bg-gray-900`}
/>
</div>
</div>
<div className="flex justify-between items-center">
<div>
<div className="text-sm font-medium text-gray-900"></div>
<div className="text-xs text-gray-500 mt-0.5"> ZCLAW</div>
</div>
<Toggle checked={autoStart} onChange={setAutoStart} />
</div>
<div className="flex justify-between items-center">
<div>
<div className="text-sm font-medium text-gray-900"></div>
<div className="text-xs text-gray-500 mt-0.5"></div>
</div>
<Toggle checked={showToolCalls} onChange={setShowToolCalls} />
</div>
</div>
</div>
);
}
function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
return (
<button
onClick={() => onChange(!checked)}
className={`w-11 h-6 rounded-full transition-colors relative flex-shrink-0 ${checked ? 'bg-orange-500' : 'bg-gray-300'}`}
>
<span className={`block w-5 h-5 bg-white rounded-full shadow absolute top-0.5 transition-all ${checked ? 'left-[22px]' : 'left-0.5'}`} />
</button>
);
}

View File

@@ -0,0 +1,34 @@
import { Plus, RefreshCw } from 'lucide-react';
export function IMChannels() {
return (
<div>
<div className="flex justify-between items-center mb-2">
<h1 className="text-2xl font-bold text-gray-900">IM </h1>
<div className="flex gap-2">
<button className="text-sm border border-gray-300 rounded-lg px-3 py-1.5 hover:bg-gray-50 flex items-center gap-1">
<RefreshCw className="w-3.5 h-3.5" />
</button>
<button className="text-sm bg-orange-500 text-white rounded-lg px-3 py-1.5 hover:bg-orange-600 flex items-center gap-1">
<Plus className="w-3.5 h-3.5" />
</button>
</div>
</div>
<div className="bg-gray-50 rounded-xl p-8 text-center mt-6">
<button className="bg-orange-500 text-white text-sm rounded-lg px-4 py-2 hover:bg-orange-600 mb-3">
</button>
<p className="text-sm text-gray-500"> IM </p>
<p className="text-xs text-gray-400 mt-1"> IM </p>
</div>
<div className="mt-8">
<h2 className="text-sm font-medium text-gray-700 mb-3"></h2>
<div className="flex gap-2">
<button className="text-sm bg-orange-500 text-white rounded-lg px-4 py-1.5 hover:bg-orange-600">+ </button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,64 @@
import { useState } from 'react';
import { Plus, RefreshCw } from 'lucide-react';
interface MCPService {
id: string;
name: string;
enabled: boolean;
}
export function MCPServices() {
const [services, setServices] = useState<MCPService[]>([
{ id: 'filesystem', name: 'File System', enabled: true },
{ id: 'webfetch', name: 'Web Fetch', enabled: true },
]);
const toggleService = (id: string) => {
setServices(prev => prev.map(s => s.id === id ? { ...s, enabled: !s.enabled } : s));
};
return (
<div>
<div className="flex justify-between items-center mb-2">
<h1 className="text-2xl font-bold text-gray-900">MCP </h1>
<div className="flex gap-2">
<button className="text-sm border border-gray-300 rounded-lg px-3 py-1.5 hover:bg-gray-50 flex items-center gap-1">
<RefreshCw className="w-3.5 h-3.5" />
</button>
<button className="text-sm bg-orange-500 text-white rounded-lg px-3 py-1.5 hover:bg-orange-600 flex items-center gap-1">
<Plus className="w-3.5 h-3.5" />
</button>
</div>
</div>
<p className="text-sm text-gray-500 mb-6">MCP Agent </p>
<div className="bg-gray-50 rounded-xl divide-y divide-gray-200">
{services.map((svc) => (
<div key={svc.id} className="flex items-center justify-between px-5 py-4">
<div className="flex items-center gap-3">
<span className="text-gray-400"></span>
<span className="text-sm text-gray-900">{svc.name}</span>
</div>
<div className="flex items-center gap-3">
<button onClick={() => toggleService(svc.id)} className="text-xs text-gray-500 border border-gray-300 rounded px-2 py-1 hover:bg-gray-100">
{svc.enabled ? '停用' : '启用'}
</button>
<button className="text-xs text-gray-500 border border-gray-300 rounded px-2 py-1 hover:bg-gray-100"></button>
<button className="text-xs text-gray-500 border border-gray-300 rounded px-2 py-1 hover:bg-gray-100"></button>
</div>
</div>
))}
</div>
<div className="mt-6">
<h2 className="text-sm font-medium text-gray-700 mb-3"></h2>
<p className="text-xs text-gray-400 mb-3"> MCP </p>
<div className="flex gap-2">
<button className="text-sm bg-orange-500 text-white rounded-lg px-4 py-1.5 hover:bg-orange-600">+ Brave Search</button>
<button className="text-sm bg-orange-500 text-white rounded-lg px-4 py-1.5 hover:bg-orange-600">+ SQLite</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,101 @@
import { useState } from 'react';
import { useGatewayStore } from '../../store/gatewayStore';
import { useChatStore } from '../../store/chatStore';
interface ModelEntry {
id: string;
name: string;
provider: string;
}
const AVAILABLE_MODELS: ModelEntry[] = [
{ id: 'glm-5', name: 'GLM-5', provider: '智谱 AI' },
{ id: 'qwen3.5-plus', name: 'Qwen3.5+', provider: '通义千问' },
{ id: 'kimi-k2.5', name: 'Kimi-K2.5', provider: '月之暗面' },
{ id: 'minimax-m2.5', name: 'MiniMax-M2.5', provider: 'MiniMax' },
];
export function ModelsAPI() {
const { connectionState, connect, disconnect } = useGatewayStore();
const { currentModel, setCurrentModel } = useChatStore();
const [gatewayUrl, setGatewayUrl] = useState('ws://127.0.0.1:18789');
const connected = connectionState === 'connected';
const connecting = connectionState === 'connecting' || connectionState === 'reconnecting';
const handleReconnect = () => {
disconnect();
setTimeout(() => connect().catch(() => {}), 500);
};
return (
<div>
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold text-gray-900"> API</h1>
<button
onClick={handleReconnect}
disabled={connecting}
className="text-sm text-gray-500 border border-gray-300 rounded-lg px-3 py-1.5 hover:bg-gray-50 disabled:opacity-50"
>
{connecting ? '连接中...' : '重新连接'}
</button>
</div>
<h2 className="text-sm font-medium text-gray-500 uppercase tracking-wide mb-3"> Provider</h2>
<div className="bg-gray-50 rounded-xl divide-y divide-gray-200 mb-6">
{AVAILABLE_MODELS.map((model) => {
const isActive = model.id === currentModel;
return (
<div key={model.id} className="flex items-center justify-between px-5 py-3.5">
<div>
<span className="text-sm font-medium text-gray-900">{model.name}</span>
<span className="text-xs text-gray-400 ml-2">{model.provider}</span>
</div>
<div className="flex items-center gap-3">
{isActive ? (
<span className="text-xs text-green-600 bg-green-50 px-2.5 py-1 rounded-md font-medium">使</span>
) : (
<button
onClick={() => setCurrentModel(model.id)}
className="text-xs text-orange-500 hover:text-orange-600 hover:bg-orange-50 px-2.5 py-1 rounded-md transition-colors"
>
</button>
)}
</div>
</div>
);
})}
</div>
<h2 className="text-sm font-medium text-gray-500 uppercase tracking-wide mb-3">Gateway </h2>
<div className="bg-gray-50 rounded-xl p-5 space-y-4">
<div className="flex items-center gap-3">
<span className={`text-xs px-2.5 py-1 rounded-md font-medium ${connected ? 'bg-green-50 text-green-600' : 'bg-gray-200 text-gray-500'}`}>
{connected ? '已连接' : connecting ? '连接中...' : '未连接'}
</span>
{!connected && !connecting && (
<button
onClick={() => connect().catch(() => {})}
className="text-sm bg-orange-500 text-white rounded-lg px-3 py-1.5 hover:bg-orange-600"
>
</button>
)}
</div>
<div>
<label className="text-xs text-gray-500 mb-1 block">Gateway WebSocket URL</label>
<input
type="text"
value={gatewayUrl}
onChange={(e) => setGatewayUrl(e.target.value)}
className="w-full bg-white border border-gray-200 rounded-lg text-sm text-gray-700 font-mono px-3 py-2 focus:outline-none focus:ring-2 focus:ring-orange-200 focus:border-orange-300"
/>
</div>
<p className="text-xs text-gray-400">
默认地址: ws://127.0.0.1:18789。修改后需重新连接。
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,55 @@
import { useState } from 'react';
export function Privacy() {
const [optimization, setOptimization] = useState(false);
return (
<div>
<h1 className="text-2xl font-bold text-gray-900 mb-2"></h1>
<p className="text-sm text-gray-500 mb-6"> ZCLAW </p>
<div className="bg-gray-50 rounded-xl p-5 mb-6">
<h2 className="text-sm font-bold text-gray-900 mb-1"></h2>
<p className="text-xs text-gray-500 mb-3"> Agent </p>
<div className="bg-white border border-gray-200 rounded-lg px-3 py-2">
<span className="text-sm text-gray-700 font-mono">~/.openclaw/zclaw-workspace</span>
</div>
</div>
<div className="bg-gray-50 rounded-xl p-5 mb-6">
<div className="flex justify-between items-start">
<div className="flex-1 mr-4">
<h2 className="text-sm font-bold text-gray-900"></h2>
<p className="text-xs text-gray-500 mt-1 leading-relaxed">
使"设置"退
</p>
</div>
<button
onClick={() => setOptimization(!optimization)}
className={`w-11 h-6 rounded-full transition-colors relative flex-shrink-0 ${optimization ? 'bg-orange-500' : 'bg-gray-300'}`}
>
<span className={`block w-5 h-5 bg-white rounded-full shadow absolute top-0.5 transition-all ${optimization ? 'left-[22px]' : 'left-0.5'}`} />
</button>
</div>
</div>
<div className="bg-gray-50 rounded-xl p-5">
<h2 className="text-sm font-bold text-gray-900 mb-3"></h2>
<div className="space-y-2 text-xs text-gray-500">
<div className="flex gap-8">
<span className="text-gray-400 w-24"></span>
<span>ZCLAW OpenClaw </span>
</div>
<div className="flex gap-8">
<span className="text-gray-400 w-24"></span>
<span>MIT License</span>
</div>
<div className="flex gap-8">
<span className="text-gray-400 w-24"></span>
<span></span>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,134 @@
import { useState } from 'react';
import {
Settings as SettingsIcon,
BarChart3,
Bot,
Puzzle,
Blocks,
MessageSquare,
FolderOpen,
Shield,
MessageCircle,
Info,
ArrowLeft,
} from 'lucide-react';
import { General } from './General';
import { UsageStats } from './UsageStats';
import { ModelsAPI } from './ModelsAPI';
import { MCPServices } from './MCPServices';
import { Skills } from './Skills';
import { IMChannels } from './IMChannels';
import { Workspace } from './Workspace';
import { Privacy } from './Privacy';
import { About } from './About';
interface SettingsLayoutProps {
onBack: () => void;
}
type SettingsPage =
| 'general'
| 'usage'
| 'models'
| 'mcp'
| 'skills'
| 'im'
| 'workspace'
| 'privacy'
| 'feedback'
| 'about';
const menuItems: { id: SettingsPage; label: string; icon: React.ReactNode }[] = [
{ id: 'general', label: '通用', icon: <SettingsIcon className="w-4 h-4" /> },
{ id: 'usage', label: '用量统计', icon: <BarChart3 className="w-4 h-4" /> },
{ id: 'models', label: '模型与 API', icon: <Bot className="w-4 h-4" /> },
{ id: 'mcp', label: 'MCP 服务', icon: <Puzzle className="w-4 h-4" /> },
{ id: 'skills', label: '技能', icon: <Blocks className="w-4 h-4" /> },
{ id: 'im', label: 'IM 频道', icon: <MessageSquare className="w-4 h-4" /> },
{ id: 'workspace', label: '工作区', icon: <FolderOpen className="w-4 h-4" /> },
{ id: 'privacy', label: '数据与隐私', icon: <Shield className="w-4 h-4" /> },
{ id: 'feedback', label: '提交反馈', icon: <MessageCircle className="w-4 h-4" /> },
{ id: 'about', label: '关于', icon: <Info className="w-4 h-4" /> },
];
export function SettingsLayout({ onBack }: SettingsLayoutProps) {
const [activePage, setActivePage] = useState<SettingsPage>('general');
const renderPage = () => {
switch (activePage) {
case 'general': return <General />;
case 'usage': return <UsageStats />;
case 'models': return <ModelsAPI />;
case 'mcp': return <MCPServices />;
case 'skills': return <Skills />;
case 'im': return <IMChannels />;
case 'workspace': return <Workspace />;
case 'privacy': return <Privacy />;
case 'feedback': return <Feedback />;
case 'about': return <About />;
default: return <General />;
}
};
return (
<div className="h-screen flex bg-white">
{/* Left navigation */}
<aside className="w-56 bg-gray-50 border-r border-gray-200 flex flex-col flex-shrink-0">
<button
onClick={onBack}
className="flex items-center gap-2 px-4 py-3 text-sm text-gray-500 hover:text-gray-700 border-b border-gray-200"
>
<ArrowLeft className="w-4 h-4" />
</button>
<nav className="flex-1 py-2">
{menuItems.map((item) => (
<button
key={item.id}
onClick={() => setActivePage(item.id)}
className={`w-full flex items-center gap-3 px-4 py-2.5 text-sm transition-colors ${
activePage === item.id
? 'bg-orange-50 text-orange-600 font-medium'
: 'text-gray-600 hover:bg-gray-100 hover:text-gray-900'
}`}
>
{item.icon}
{item.label}
</button>
))}
</nav>
</aside>
{/* Main content */}
<main className="flex-1 overflow-y-auto">
<div className="max-w-2xl mx-auto px-8 py-8">
{renderPage()}
</div>
</main>
</div>
);
}
// Simple feedback page (inline)
function Feedback() {
const [text, setText] = useState('');
return (
<div>
<h1 className="text-2xl font-bold text-gray-900 mb-2"></h1>
<p className="text-sm text-gray-500 mb-6">便</p>
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="请尽量详细描述复现步骤、期望结果和实际结果"
className="w-full h-40 border border-gray-300 rounded-lg p-3 text-sm resize-none focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent"
/>
<button
disabled={!text.trim()}
className="mt-4 px-6 py-2 bg-orange-500 text-white text-sm rounded-lg hover:bg-orange-600 disabled:opacity-50 disabled:cursor-not-allowed"
>
</button>
</div>
);
}

View File

@@ -0,0 +1,51 @@
import { useState } from 'react';
export function Skills() {
const [extraDir, setExtraDir] = useState('~/.opencode/skills');
const [activeTab, setActiveTab] = useState<'all' | 'available' | 'installed'>('all');
return (
<div>
<div className="flex justify-between items-center mb-2">
<h1 className="text-2xl font-bold text-gray-900"></h1>
<button className="text-sm text-gray-400">...</button>
</div>
<p className="text-sm text-gray-500 mb-6">
Agent SKILL.md
</p>
<div className="bg-gray-50 rounded-xl p-5 mb-6">
<h2 className="text-sm font-bold text-gray-900 mb-1"></h2>
<p className="text-xs text-gray-500 mb-3"> SKILL.md Gateway skills.load.extraDirs </p>
<div className="flex gap-2">
<input
type="text"
value={extraDir}
onChange={(e) => setExtraDir(e.target.value)}
className="flex-1 bg-white border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-orange-500"
/>
<button className="bg-orange-500 text-white text-sm rounded-lg px-4 py-2 hover:bg-orange-600"></button>
</div>
</div>
<div className="flex gap-2 mb-4">
{(['all', 'available', 'installed'] as const).map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={`text-xs px-3 py-1 rounded-full ${
activeTab === tab ? 'bg-orange-500 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
>
{tab === 'all' ? '全部 (0)' : tab === 'available' ? '可用 (0)' : '已安装 (0)'}
</button>
))}
</div>
<div className="bg-gray-50 rounded-xl p-8 text-center">
<p className="text-sm text-gray-400"></p>
<p className="text-xs text-gray-300 mt-1"> Gateway </p>
</div>
</div>
);
}

View File

@@ -0,0 +1,76 @@
import { useEffect } from 'react';
import { useGatewayStore } from '../../store/gatewayStore';
export function UsageStats() {
const { usageStats, loadUsageStats, connectionState } = useGatewayStore();
useEffect(() => {
if (connectionState === 'connected') {
loadUsageStats();
}
}, [connectionState]);
const stats = usageStats || { totalSessions: 0, totalMessages: 0, totalTokens: 0, byModel: {} };
const models = Object.entries(stats.byModel || {});
const formatTokens = (n: number) => {
if (n >= 1_000_000) return `~${(n / 1_000_000).toFixed(1)} M`;
if (n >= 1_000) return `~${(n / 1_000).toFixed(1)} k`;
return `${n}`;
};
return (
<div>
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold text-gray-900"></h1>
<button onClick={() => loadUsageStats()} className="text-sm text-gray-500 border border-gray-300 rounded-lg px-3 py-1.5 hover:bg-gray-50">
</button>
</div>
<p className="text-sm text-gray-500 mb-6"> Token </p>
<div className="grid grid-cols-3 gap-4 mb-8">
<StatCard label="会话数" value={stats.totalSessions} />
<StatCard label="消息数" value={stats.totalMessages} />
<StatCard label="总 Token" value={formatTokens(stats.totalTokens)} />
</div>
<h2 className="text-sm font-medium text-gray-700 mb-3"></h2>
<div className="bg-gray-50 rounded-xl p-5 space-y-4">
{models.length === 0 && (
<p className="text-sm text-gray-400 text-center py-4"></p>
)}
{models.map(([model, data]) => {
const total = data.inputTokens + data.outputTokens;
const maxTokens = Math.max(...models.map(([, d]) => d.inputTokens + d.outputTokens), 1);
const pct = (total / maxTokens) * 100;
return (
<div key={model}>
<div className="flex justify-between items-center mb-1">
<span className="text-sm font-medium text-gray-900">{model}</span>
<span className="text-xs text-gray-500">{data.messages} </span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2 mb-1">
<div className="bg-orange-500 h-2 rounded-full" style={{ width: `${pct}%` }} />
</div>
<div className="flex justify-between text-xs text-gray-400">
<span>: {formatTokens(data.inputTokens)}</span>
<span>: {formatTokens(data.outputTokens)}</span>
<span>: {formatTokens(total)}</span>
</div>
</div>
);
})}
</div>
</div>
);
}
function StatCard({ label, value }: { label: string; value: string | number }) {
return (
<div className="bg-gray-50 rounded-xl p-4 text-center">
<div className="text-2xl font-bold text-gray-900">{value}</div>
<div className="text-xs text-gray-500 mt-1">{label}</div>
</div>
);
}

View File

@@ -0,0 +1,82 @@
import { useState } from 'react';
export function Workspace() {
const [projectDir, setProjectDir] = useState('~/.openclaw/zclaw-workspace');
const [restrictFiles, setRestrictFiles] = useState(true);
const [autoSaveContext, setAutoSaveContext] = useState(true);
const [fileWatching, setFileWatching] = useState(true);
return (
<div>
<h1 className="text-2xl font-bold text-gray-900 mb-2"></h1>
<p className="text-sm text-gray-500 mb-6"></p>
<div className="bg-gray-50 rounded-xl p-5 mb-6">
<h2 className="text-sm font-bold text-gray-900 mb-1"></h2>
<p className="text-xs text-gray-500 mb-3">ZCLAW </p>
<div className="flex gap-2">
<input
type="text"
value={projectDir}
onChange={(e) => setProjectDir(e.target.value)}
className="flex-1 bg-white border border-gray-300 rounded-lg px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-orange-500"
/>
<button className="border border-gray-300 rounded-lg px-4 py-2 text-sm hover:bg-gray-100"></button>
</div>
</div>
<div className="space-y-4">
<ToggleCard
title="限制文件访问范围"
description="开启后Agent 的工作空间将限制在工作目录内。关闭后可访问更大范围,可能导致数据操作。无论开关状态,均建议提前备份重要文件。注意:受技术限制,我们无法保证完全阻止目录外执行或由此带来的外部影响;请自行评估风险并谨慎使用。"
checked={restrictFiles}
onChange={setRestrictFiles}
highlight
/>
<ToggleCard
title="自动保存上下文"
description="自动将聊天记录和提取的产物保存到本地工作区文件夹。"
checked={autoSaveContext}
onChange={setAutoSaveContext}
/>
<ToggleCard
title="文件监听"
description="监听本地文件变更,实时更新 Agent 上下文。"
checked={fileWatching}
onChange={setFileWatching}
/>
</div>
<div className="mt-6 bg-gray-50 rounded-xl p-5">
<div className="flex justify-between items-center">
<div>
<h2 className="text-sm font-bold text-gray-900"> OpenClaw </h2>
<p className="text-xs text-gray-500 mt-1"> OpenClaw ZCLAW</p>
</div>
<button className="border border-gray-300 rounded-lg px-4 py-2 text-sm hover:bg-gray-100"></button>
</div>
</div>
</div>
);
}
function ToggleCard({ title, description, checked, onChange, highlight }: {
title: string; description: string; checked: boolean; onChange: (v: boolean) => void; highlight?: boolean;
}) {
return (
<div className={`rounded-xl p-5 ${highlight ? 'bg-orange-50 border border-orange-200' : 'bg-gray-50'}`}>
<div className="flex justify-between items-start">
<div className="flex-1 mr-4">
<h3 className={`text-sm font-bold ${highlight ? 'text-orange-700' : 'text-gray-900'}`}>{title}</h3>
<p className="text-xs text-gray-500 mt-1 leading-relaxed">{description}</p>
</div>
<button
onClick={() => onChange(!checked)}
className={`w-11 h-6 rounded-full transition-colors relative flex-shrink-0 mt-0.5 ${checked ? 'bg-orange-500' : 'bg-gray-300'}`}
>
<span className={`block w-5 h-5 bg-white rounded-full shadow absolute top-0.5 transition-all ${checked ? 'left-[22px]' : 'left-0.5'}`} />
</button>
</div>
</div>
);
}

View File

@@ -1,56 +1,55 @@
import { useChatStore } from '../store/chatStore';
import { Settings, Cat, Search, Globe, BarChart } from 'lucide-react';
import { useState } from 'react';
import { useGatewayStore } from '../store/gatewayStore';
import { Settings, MessageSquare, Clock, Bot, Radio } from 'lucide-react';
import { CloneManager } from './CloneManager';
import { ConversationList } from './ConversationList';
import { ChannelList } from './ChannelList';
import { TaskList } from './TaskList';
export function Sidebar() {
const { agents, currentAgent, setCurrentAgent } = useChatStore();
const [activeTab, setActiveTab] = React.useState('agents');
interface SidebarProps {
onOpenSettings?: () => void;
}
type Tab = 'chats' | 'clones' | 'channels' | 'tasks';
const TABS: { key: Tab; label: string; icon: typeof MessageSquare }[] = [
{ key: 'chats', label: '对话', icon: MessageSquare },
{ key: 'clones', label: '分身', icon: Bot },
{ key: 'channels', label: '频道', icon: Radio },
{ key: 'tasks', label: '任务', icon: Clock },
];
export function Sidebar({ onOpenSettings }: SidebarProps) {
const { connectionState } = useGatewayStore();
const [activeTab, setActiveTab] = useState<Tab>('chats');
const connected = connectionState === 'connected';
return (
<aside className="w-64 bg-gray-50 border-r border-gray-200 flex flex-col flex-shrink-0">
{/* 顶部标签 */}
<div className="flex border-b border-gray-200 bg-white">
<button
className={ lex-1 py-3 px-4 text-xs font-medium }
onClick={() => setActiveTab('agents')}
>
</button>
<button
className={ lex-1 py-3 px-4 text-xs font-medium }
onClick={() => setActiveTab('channels')}
>
IM
</button>
<button
className={ lex-1 py-3 px-4 text-xs font-medium }
onClick={() => setActiveTab('tasks')}
>
</button>
{TABS.map(({ key, label }) => (
<button
key={key}
className={`flex-1 py-3 px-2 text-xs font-medium transition-colors ${
activeTab === key
? 'text-orange-600 border-b-2 border-orange-500'
: 'text-gray-500 hover:text-gray-700'
}`}
onClick={() => setActiveTab(key)}
>
{label}
</button>
))}
</div>
{/* Agent 列表 */}
<div className="flex-1 overflow-y-auto custom-scrollbar py-2">
{agents.map((agent) => (
<div
key={agent.id}
className={sidebar-item mx-2 px-3 py-3 rounded-lg cursor-pointer mb-1 }
onClick={() => setCurrentAgent(agent)}
>
<div className="flex items-start gap-3">
<div className={w-10 h-10 rounded-xl flex items-center justify-center text-white flex-shrink-0}>
<span className="text-xl">{agent.icon}</span>
</div>
<div className="flex-1 min-w-0">
<div className="flex justify-between items-center mb-0.5">
<span className="font-semibold text-gray-900 truncate">{agent.name}</span>
<span className="text-xs text-gray-400">{agent.time}</span>
</div>
<p className="text-xs text-gray-500 truncate leading-relaxed">{agent.lastMessage}</p>
</div>
</div>
</div>
))}
{/* Tab content */}
<div className="flex-1 overflow-hidden">
{activeTab === 'chats' && <ConversationList />}
{activeTab === 'clones' && <CloneManager />}
{activeTab === 'channels' && <ChannelList onOpenSettings={onOpenSettings} />}
{activeTab === 'tasks' && <TaskList />}
</div>
{/* 底部用户 */}
@@ -59,8 +58,13 @@ export function Sidebar() {
<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">
🦞
</div>
<span className="font-medium text-gray-700">7141</span>
<button className="ml-auto text-gray-400 hover:text-gray-600">
<div className="flex-1 min-w-0">
<span className="font-medium text-gray-700 text-sm">7141</span>
<div className={`text-xs ${connected ? 'text-green-500' : 'text-gray-400'}`}>
{connected ? '已连接' : '未连接'}
</div>
</div>
<button className="text-gray-400 hover:text-gray-600" onClick={onOpenSettings}>
<Settings className="w-4 h-4" />
</button>
</div>

View File

@@ -0,0 +1,107 @@
import { useEffect } from 'react';
import { useGatewayStore } from '../store/gatewayStore';
import { Clock, RefreshCw, Play, Pause, AlertCircle, CheckCircle2 } from 'lucide-react';
const STATUS_CONFIG: Record<string, { icon: typeof Play; color: string; label: string }> = {
active: { icon: Play, color: 'text-green-500', label: '运行中' },
paused: { icon: Pause, color: 'text-yellow-500', label: '已暂停' },
completed: { icon: CheckCircle2, color: 'text-blue-500', label: '已完成' },
error: { icon: AlertCircle, color: 'text-red-500', label: '错误' },
};
export function TaskList() {
const { scheduledTasks, connectionState, loadScheduledTasks } = useGatewayStore();
const connected = connectionState === 'connected';
useEffect(() => {
if (connected) {
loadScheduledTasks();
}
}, [connected]);
if (!connected) {
return (
<div className="flex flex-col items-center justify-center h-full text-gray-400 text-xs px-4 text-center">
<Clock className="w-8 h-8 mb-2 opacity-30" />
<p></p>
<p className="mt-1"> Gateway </p>
</div>
);
}
return (
<div className="h-full flex flex-col">
{/* Header */}
<div className="flex items-center justify-between px-3 py-2 border-b border-gray-200">
<span className="text-xs font-medium text-gray-500">Heartbeat </span>
<button
onClick={loadScheduledTasks}
className="p-1 text-gray-400 hover:text-orange-500 rounded"
title="刷新"
>
<RefreshCw className="w-3.5 h-3.5" />
</button>
</div>
<div className="flex-1 overflow-y-auto custom-scrollbar">
{scheduledTasks.length > 0 ? (
scheduledTasks.map((task) => {
const cfg = STATUS_CONFIG[task.status] || STATUS_CONFIG.active;
const StatusIcon = cfg.icon;
return (
<div
key={task.id}
className="px-3 py-3 border-b border-gray-50 hover:bg-gray-50"
>
<div className="flex items-center gap-2 mb-1">
<StatusIcon className={`w-3.5 h-3.5 flex-shrink-0 ${cfg.color}`} />
<span className="text-xs font-medium text-gray-900 truncate">{task.name}</span>
</div>
<div className="pl-5.5 space-y-0.5">
<div className="text-[11px] text-gray-500 font-mono">{task.schedule}</div>
{task.description && (
<div className="text-[11px] text-gray-400 truncate">{task.description}</div>
)}
<div className="flex gap-3 text-[10px] text-gray-400">
{task.lastRun && <span>: {formatTaskTime(task.lastRun)}</span>}
{task.nextRun && <span>: {formatTaskTime(task.nextRun)}</span>}
</div>
</div>
</div>
);
})
) : (
<div className="flex flex-col items-center justify-center h-full text-gray-400 text-xs px-4 text-center">
<Clock className="w-8 h-8 mb-2 opacity-30" />
<p></p>
<p className="mt-1">Heartbeat </p>
<p className="mt-0.5 text-[11px]">默认心跳周期: 1h</p>
</div>
)}
</div>
</div>
);
}
function formatTaskTime(timeStr: string): string {
try {
const d = new Date(timeStr);
const now = new Date();
const diffMs = now.getTime() - d.getTime();
const future = diffMs < 0;
const absDiff = Math.abs(diffMs);
const mins = Math.floor(absDiff / 60000);
if (mins < 1) return future ? '即将' : '刚刚';
if (mins < 60) return future ? `${mins}分钟后` : `${mins}分钟前`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return future ? `${hrs}小时后` : `${hrs}小时前`;
return `${d.getMonth() + 1}/${d.getDate()} ${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}`;
} catch {
return timeStr;
}
}

View File

@@ -1,8 +1,4 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&display=swap');
@import "tailwindcss";
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;

View File

@@ -0,0 +1,408 @@
/**
* ZCLAW Gateway Client (Browser/Tauri side)
*
* WebSocket client for OpenClaw Gateway protocol, designed to run
* in the Tauri React frontend. Uses native browser WebSocket API.
*/
// === Protocol Types ===
export interface GatewayRequest {
type: 'req';
id: string;
method: string;
params?: Record<string, any>;
}
export interface GatewayResponse {
type: 'res';
id: string;
ok: boolean;
payload?: any;
error?: any;
}
export interface GatewayEvent {
type: 'event';
event: string;
payload?: any;
seq?: number;
}
export type GatewayFrame = GatewayRequest | GatewayResponse | GatewayEvent;
export interface AgentStreamDelta {
stream: 'assistant' | 'tool' | 'lifecycle';
delta?: string;
content?: string;
tool?: string;
toolInput?: string;
toolOutput?: string;
phase?: 'start' | 'end' | 'error';
runId?: string;
error?: string;
}
export type ConnectionState = 'disconnected' | 'connecting' | 'handshaking' | 'connected' | 'reconnecting';
type EventCallback = (payload: any) => void;
// === Client ===
export class GatewayClient {
private ws: WebSocket | null = null;
private state: ConnectionState = 'disconnected';
private requestId = 0;
private pendingRequests = new Map<string, {
resolve: (value: any) => void;
reject: (reason: any) => void;
timer: number;
}>();
private eventListeners = new Map<string, Set<EventCallback>>();
private reconnectAttempts = 0;
private reconnectTimer: number | null = null;
private deviceId: string;
// Options
private url: string;
private token: string;
private autoReconnect: boolean;
private reconnectInterval: number;
private requestTimeout: number;
// State change callbacks
onStateChange?: (state: ConnectionState) => void;
onLog?: (level: string, message: string) => void;
constructor(opts?: {
url?: string;
token?: string;
autoReconnect?: boolean;
reconnectInterval?: number;
requestTimeout?: number;
}) {
this.url = opts?.url || 'ws://127.0.0.1:18789';
this.token = opts?.token || '';
this.autoReconnect = opts?.autoReconnect ?? true;
this.reconnectInterval = opts?.reconnectInterval || 3000;
this.requestTimeout = opts?.requestTimeout || 30000;
this.deviceId = crypto.randomUUID?.() || `zclaw_${Date.now().toString(36)}`;
}
getState(): ConnectionState {
return this.state;
}
// === Connection ===
connect(): Promise<void> {
if (this.state === 'connected' || this.state === 'connecting') {
return Promise.resolve();
}
this.setState('connecting');
return new Promise((resolve, reject) => {
try {
this.ws = new WebSocket(this.url);
this.ws.onopen = () => {
this.setState('handshaking');
};
this.ws.onmessage = (evt) => {
try {
const frame: GatewayFrame = JSON.parse(evt.data);
this.handleFrame(frame, resolve);
} catch (err: any) {
this.log('error', `Parse error: ${err.message}`);
}
};
this.ws.onclose = (evt) => {
const wasConnected = this.state === 'connected';
this.cleanup();
if (wasConnected && this.autoReconnect) {
this.scheduleReconnect();
}
this.emitEvent('close', { code: evt.code, reason: evt.reason });
};
this.ws.onerror = () => {
if (this.state === 'connecting') {
this.cleanup();
reject(new Error('WebSocket connection failed'));
}
};
} catch (err) {
this.cleanup();
reject(err);
}
});
}
disconnect() {
this.autoReconnect = false;
this.cancelReconnect();
if (this.ws) {
this.ws.close(1000, 'Client disconnect');
}
this.cleanup();
}
// === Request/Response ===
async request(method: string, params?: Record<string, any>): Promise<any> {
if (this.state !== 'connected') {
throw new Error(`Not connected (state: ${this.state})`);
}
const id = `req_${++this.requestId}`;
const frame: GatewayRequest = { type: 'req', id, method, params };
return new Promise((resolve, reject) => {
const timer = window.setTimeout(() => {
this.pendingRequests.delete(id);
reject(new Error(`Request ${method} timed out`));
}, this.requestTimeout);
this.pendingRequests.set(id, { resolve, reject, timer });
this.send(frame);
});
}
// === High-level API ===
/** Send message to agent, returns { runId, acceptedAt } */
async chat(message: string, opts?: { sessionKey?: string; model?: string }): Promise<{ runId: string; acceptedAt: string }> {
return this.request('agent', {
message,
sessionKey: opts?.sessionKey,
model: opts?.model,
});
}
/** Get Gateway health info */
async health(): Promise<any> {
return this.request('health');
}
/** Get Gateway status */
async status(): Promise<any> {
return this.request('status');
}
// ZCLAW custom methods
async listClones(): Promise<any> { return this.request('zclaw.clones.list'); }
async createClone(opts: { name: string; role?: string; scenarios?: string[] }): Promise<any> { return this.request('zclaw.clones.create', opts); }
async updateClone(id: string, updates: Record<string, any>): Promise<any> { return this.request('zclaw.clones.update', { id, updates }); }
async deleteClone(id: string): Promise<any> { return this.request('zclaw.clones.delete', { id }); }
async getUsageStats(): Promise<any> { return this.request('zclaw.stats.usage'); }
async getSessionStats(): Promise<any> { return this.request('zclaw.stats.sessions'); }
async getWorkspaceInfo(): Promise<any> { return this.request('zclaw.workspace.info'); }
async getPluginStatus(): Promise<any> { return this.request('zclaw.plugins.status'); }
async getQuickConfig(): Promise<any> { return this.request('zclaw.config.quick', { get: true }); }
async saveQuickConfig(config: Record<string, any>): Promise<any> { return this.request('zclaw.config.quick', config); }
async listChannels(): Promise<any> { return this.request('channels.list'); }
async getFeishuStatus(): Promise<any> { return this.request('feishu.status'); }
async listScheduledTasks(): Promise<any> { return this.request('heartbeat.tasks'); }
// === Event Subscription ===
/** Subscribe to a Gateway event (e.g., 'agent', 'chat', 'heartbeat') */
on(event: string, callback: EventCallback): () => void {
if (!this.eventListeners.has(event)) {
this.eventListeners.set(event, new Set());
}
this.eventListeners.get(event)!.add(callback);
// Return unsubscribe function
return () => {
this.eventListeners.get(event)?.delete(callback);
};
}
/** Subscribe to agent stream events */
onAgentStream(callback: (delta: AgentStreamDelta) => void): () => void {
return this.on('agent', callback);
}
// === Internal ===
private handleFrame(frame: GatewayFrame, connectResolve?: (value: void) => void) {
if (frame.type === 'event') {
this.handleEvent(frame, connectResolve);
} else if (frame.type === 'res') {
this.handleResponse(frame);
}
}
private handleEvent(event: GatewayEvent, connectResolve?: (value: void) => void) {
// Handle connect challenge
if (event.event === 'connect.challenge' && this.state === 'handshaking') {
this.performHandshake(event.payload?.nonce, connectResolve);
return;
}
// Dispatch to listeners
this.emitEvent(event.event, event.payload);
}
private performHandshake(_nonce: string, connectResolve?: (value: void) => void) {
const connectId = `connect_${Date.now()}`;
const connectReq: GatewayRequest = {
type: 'req',
id: connectId,
method: 'connect',
params: {
minProtocol: 3,
maxProtocol: 3,
client: {
id: 'zclaw-tauri',
version: '0.2.0',
platform: this.detectPlatform(),
mode: 'operator',
},
role: 'operator',
scopes: ['operator.read', 'operator.write'],
auth: this.token ? { token: this.token } : {},
locale: 'zh-CN',
userAgent: 'zclaw-tauri/0.2.0',
device: { id: this.deviceId },
},
};
// Temporarily intercept the connect response
const originalHandler = this.ws!.onmessage;
this.ws!.onmessage = (evt) => {
try {
const frame = JSON.parse(evt.data);
if (frame.type === 'res' && frame.id === connectId) {
// Restore normal message handler
this.ws!.onmessage = originalHandler;
if (frame.ok) {
this.setState('connected');
this.reconnectAttempts = 0;
this.emitEvent('connected', frame.payload);
this.log('info', 'Connected to Gateway');
connectResolve?.();
} else {
this.log('error', `Handshake failed: ${JSON.stringify(frame.error)}`);
this.cleanup();
}
} else {
// Pass through non-connect frames
originalHandler?.call(this.ws!, evt);
}
} catch { /* ignore */ }
};
this.send(connectReq);
}
private handleResponse(res: GatewayResponse) {
const pending = this.pendingRequests.get(res.id);
if (pending) {
clearTimeout(pending.timer);
this.pendingRequests.delete(res.id);
if (res.ok) {
pending.resolve(res.payload);
} else {
pending.reject(new Error(JSON.stringify(res.error)));
}
}
}
private send(frame: GatewayFrame) {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(frame));
}
}
private emitEvent(event: string, payload: any) {
const listeners = this.eventListeners.get(event);
if (listeners) {
for (const cb of listeners) {
try { cb(payload); } catch { /* ignore listener errors */ }
}
}
// Also emit wildcard
const wildcardListeners = this.eventListeners.get('*');
if (wildcardListeners) {
for (const cb of wildcardListeners) {
try { cb({ event, payload }); } catch { /* ignore */ }
}
}
}
private setState(state: ConnectionState) {
this.state = state;
this.onStateChange?.(state);
this.emitEvent('state', state);
}
private cleanup() {
for (const [, pending] of this.pendingRequests) {
clearTimeout(pending.timer);
pending.reject(new Error('Connection closed'));
}
this.pendingRequests.clear();
if (this.ws) {
this.ws.onopen = null;
this.ws.onmessage = null;
this.ws.onclose = null;
this.ws.onerror = null;
if (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING) {
try { this.ws.close(); } catch { /* ignore */ }
}
this.ws = null;
}
this.setState('disconnected');
}
private scheduleReconnect() {
this.reconnectAttempts++;
this.setState('reconnecting');
const delay = Math.min(this.reconnectInterval * Math.pow(1.5, this.reconnectAttempts - 1), 30000);
this.reconnectTimer = window.setTimeout(async () => {
try {
await this.connect();
} catch { /* close handler will trigger another reconnect */ }
}, delay);
}
private cancelReconnect() {
if (this.reconnectTimer !== null) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
}
private detectPlatform(): string {
const ua = navigator.userAgent.toLowerCase();
if (ua.includes('win')) return 'windows';
if (ua.includes('mac')) return 'macos';
return 'linux';
}
private log(level: string, message: string) {
this.onLog?.(level, message);
}
}
// Singleton instance
let _client: GatewayClient | null = null;
export function getGatewayClient(opts?: ConstructorParameters<typeof GatewayClient>[0]): GatewayClient {
if (!_client) {
_client = new GatewayClient(opts);
}
return _client;
}

View File

@@ -1,10 +1,26 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { getGatewayClient, AgentStreamDelta } from '../lib/gateway-client';
export interface Message {
id: string;
role: 'user' | 'assistant';
role: 'user' | 'assistant' | 'tool';
content: string;
timestamp: Date;
streaming?: boolean;
toolName?: string;
toolInput?: string;
toolOutput?: string;
error?: string;
}
export interface Conversation {
id: string;
title: string;
messages: Message[];
sessionKey: string | null;
createdAt: Date;
updatedAt: Date;
}
export interface Agent {
@@ -18,25 +34,282 @@ export interface Agent {
interface ChatState {
messages: Message[];
conversations: Conversation[];
currentConversationId: string | null;
agents: Agent[];
currentAgent: Agent | null;
isStreaming: boolean;
currentModel: string;
sessionKey: string | null;
addMessage: (message: Message) => void;
updateMessage: (id: string, updates: Partial<Message>) => void;
setCurrentAgent: (agent: Agent) => void;
setCurrentModel: (model: string) => void;
sendMessage: (content: string) => Promise<void>;
initStreamListener: () => () => void;
newConversation: () => void;
switchConversation: (id: string) => void;
deleteConversation: (id: string) => void;
}
export const useChatStore = create<ChatState>((set) => ({
function generateConvId(): string {
return `conv_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
}
function deriveTitle(messages: Message[]): string {
const firstUser = messages.find(m => m.role === 'user');
if (firstUser) {
const text = firstUser.content.trim();
return text.length > 30 ? text.slice(0, 30) + '...' : text;
}
return '新对话';
}
export const useChatStore = create<ChatState>()(
persist(
(set, get) => ({
messages: [],
conversations: [],
currentConversationId: null,
agents: [
{
id: '1',
name: 'ZCLAW',
icon: '🦞',
color: 'bg-gradient-to-br from-orange-500 to-red-500',
lastMessage: '好的!选项 A 确认...',
time: '21:58',
lastMessage: '发送消息开始对话',
time: '',
},
],
currentAgent: null,
addMessage: (message) => set((state) => ({ messages: [...state.messages, message] })),
isStreaming: false,
currentModel: 'glm-5',
sessionKey: null,
addMessage: (message) =>
set((state) => ({ messages: [...state.messages, message] })),
updateMessage: (id, updates) =>
set((state) => ({
messages: state.messages.map((m) =>
m.id === id ? { ...m, ...updates } : m
),
})),
setCurrentAgent: (agent) => set({ currentAgent: agent }),
}));
setCurrentModel: (model) => set({ currentModel: model }),
newConversation: () => {
const state = get();
let conversations = [...state.conversations];
// Save current conversation if it has messages
if (state.messages.length > 0) {
const currentId = state.currentConversationId || generateConvId();
const existingIdx = conversations.findIndex(c => c.id === currentId);
const conv: Conversation = {
id: currentId,
title: deriveTitle(state.messages),
messages: [...state.messages],
sessionKey: state.sessionKey,
createdAt: existingIdx >= 0 ? conversations[existingIdx].createdAt : new Date(),
updatedAt: new Date(),
};
if (existingIdx >= 0) {
conversations[existingIdx] = conv;
} else {
conversations = [conv, ...conversations];
}
}
set({
conversations,
messages: [],
sessionKey: null,
isStreaming: false,
currentConversationId: null,
});
},
switchConversation: (id: string) => {
const state = get();
let conversations = [...state.conversations];
// Save current conversation first
if (state.messages.length > 0 && state.currentConversationId) {
const existingIdx = conversations.findIndex(c => c.id === state.currentConversationId);
if (existingIdx >= 0) {
conversations[existingIdx] = {
...conversations[existingIdx],
messages: [...state.messages],
sessionKey: state.sessionKey,
updatedAt: new Date(),
title: deriveTitle(state.messages),
};
}
}
const target = conversations.find(c => c.id === id);
if (target) {
set({
conversations,
messages: [...target.messages],
sessionKey: target.sessionKey,
currentConversationId: target.id,
isStreaming: false,
});
}
},
deleteConversation: (id: string) => {
const state = get();
const conversations = state.conversations.filter(c => c.id !== id);
if (state.currentConversationId === id) {
set({ conversations, messages: [], sessionKey: null, currentConversationId: null, isStreaming: false });
} else {
set({ conversations });
}
},
sendMessage: async (content: string) => {
const { addMessage, currentModel, sessionKey } = get();
// Add user message
const userMsg: Message = {
id: `user_${Date.now()}`,
role: 'user',
content,
timestamp: new Date(),
};
addMessage(userMsg);
// Create placeholder assistant message for streaming
const assistantId = `assistant_${Date.now()}`;
const assistantMsg: Message = {
id: assistantId,
role: 'assistant',
content: '',
timestamp: new Date(),
streaming: true,
};
addMessage(assistantMsg);
set({ isStreaming: true });
try {
const client = getGatewayClient();
const result = await client.chat(content, {
sessionKey: sessionKey || undefined,
model: currentModel,
});
// Store session key for continuity
if (!sessionKey) {
set({ sessionKey: `session_${Date.now()}` });
}
// The actual streaming content comes via the 'agent' event listener
// set in initStreamListener(). The runId links events to this message.
// Store runId on the message for correlation
set((state) => ({
messages: state.messages.map((m) =>
m.id === assistantId ? { ...m, toolInput: result.runId } : m
),
}));
} catch (err: any) {
// Gateway not connected — show error in the assistant bubble
set((state) => ({
isStreaming: false,
messages: state.messages.map((m) =>
m.id === assistantId
? {
...m,
content: `⚠️ ${err.message || '无法连接 Gateway'}`,
streaming: false,
error: err.message,
}
: m
),
}));
}
},
initStreamListener: () => {
const client = getGatewayClient();
const unsubscribe = client.onAgentStream((delta: AgentStreamDelta) => {
const state = get();
// Find the currently streaming assistant message
const streamingMsg = [...state.messages]
.reverse()
.find((m) => m.role === 'assistant' && m.streaming);
if (!streamingMsg) return;
if (delta.stream === 'assistant' && delta.delta) {
// Append text delta to the streaming message
set((s) => ({
messages: s.messages.map((m) =>
m.id === streamingMsg.id
? { ...m, content: m.content + delta.delta }
: m
),
}));
} else if (delta.stream === 'tool') {
// Add a tool message
const toolMsg: Message = {
id: `tool_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
role: 'tool',
content: delta.toolOutput || '',
timestamp: new Date(),
toolName: delta.tool,
toolInput: delta.toolInput,
toolOutput: delta.toolOutput,
};
set((s) => ({ messages: [...s.messages, toolMsg] }));
} else if (delta.stream === 'lifecycle') {
if (delta.phase === 'end' || delta.phase === 'error') {
// Mark streaming complete
set((s) => ({
isStreaming: false,
messages: s.messages.map((m) =>
m.id === streamingMsg.id
? {
...m,
streaming: false,
error: delta.phase === 'error' ? delta.error : undefined,
}
: m
),
}));
}
}
});
return unsubscribe;
},
}),
{
name: 'zclaw-chat-storage',
partialize: (state) => ({
conversations: state.conversations,
currentModel: state.currentModel,
}),
onRehydrateStorage: () => (state) => {
// Rehydrate Date objects from JSON strings
if (state?.conversations) {
for (const conv of state.conversations) {
conv.createdAt = new Date(conv.createdAt);
conv.updatedAt = new Date(conv.updatedAt);
for (const msg of conv.messages) {
msg.timestamp = new Date(msg.timestamp);
msg.streaming = false; // Never restore streaming state
}
}
}
},
},
),
);

View File

@@ -0,0 +1,217 @@
import { create } from 'zustand';
import { GatewayClient, ConnectionState, getGatewayClient } from '../lib/gateway-client';
interface GatewayLog {
timestamp: number;
level: string;
message: string;
}
interface Clone {
id: string;
name: string;
role?: string;
nickname?: string;
scenarios?: string[];
model?: string;
createdAt: string;
}
interface UsageStats {
totalSessions: number;
totalMessages: number;
totalTokens: number;
byModel: Record<string, { messages: number; inputTokens: number; outputTokens: number }>;
}
interface ChannelInfo {
id: string;
type: string;
label: string;
status: 'active' | 'inactive' | 'error';
accounts?: number;
error?: string;
}
interface ScheduledTask {
id: string;
name: string;
schedule: string;
status: 'active' | 'paused' | 'completed' | 'error';
lastRun?: string;
nextRun?: string;
description?: string;
}
interface GatewayStore {
// Connection state
connectionState: ConnectionState;
gatewayVersion: string | null;
error: string | null;
logs: GatewayLog[];
// Data
clones: Clone[];
usageStats: UsageStats | null;
pluginStatus: any[];
channels: ChannelInfo[];
scheduledTasks: ScheduledTask[];
// Client reference
client: GatewayClient;
// Actions
connect: (url?: string, token?: string) => Promise<void>;
disconnect: () => void;
sendMessage: (message: string, sessionKey?: string) => Promise<{ runId: string }>;
loadClones: () => Promise<void>;
createClone: (opts: { name: string; role?: string; scenarios?: string[] }) => Promise<void>;
deleteClone: (id: string) => Promise<void>;
loadUsageStats: () => Promise<void>;
loadPluginStatus: () => Promise<void>;
loadChannels: () => Promise<void>;
loadScheduledTasks: () => Promise<void>;
clearLogs: () => void;
}
export const useGatewayStore = create<GatewayStore>((set, get) => {
const client = getGatewayClient();
// Wire up state change callback
client.onStateChange = (state) => {
set({ connectionState: state });
};
client.onLog = (level, message) => {
set((s) => ({
logs: [...s.logs.slice(-99), { timestamp: Date.now(), level, message }],
}));
};
return {
connectionState: 'disconnected',
gatewayVersion: null,
error: null,
logs: [],
clones: [],
usageStats: null,
pluginStatus: [],
channels: [],
scheduledTasks: [],
client,
connect: async (url?: string, token?: string) => {
try {
set({ error: null });
const c = url ? getGatewayClient({ url, token }) : get().client;
await c.connect();
// Fetch initial data after connection
try {
const health = await c.health();
set({ gatewayVersion: health?.version });
} catch { /* health may not return version */ }
} catch (err: any) {
set({ error: err.message });
throw err;
}
},
disconnect: () => {
get().client.disconnect();
},
sendMessage: async (message: string, sessionKey?: string) => {
const c = get().client;
return c.chat(message, { sessionKey });
},
loadClones: async () => {
try {
const result = await get().client.listClones();
set({ clones: result?.clones || [] });
} catch { /* ignore if method not available */ }
},
createClone: async (opts) => {
try {
await get().client.createClone(opts);
await get().loadClones();
} catch (err: any) {
set({ error: err.message });
}
},
deleteClone: async (id: string) => {
try {
await get().client.deleteClone(id);
await get().loadClones();
} catch (err: any) {
set({ error: err.message });
}
},
loadUsageStats: async () => {
try {
const stats = await get().client.getUsageStats();
set({ usageStats: stats });
} catch { /* ignore */ }
},
loadPluginStatus: async () => {
try {
const result = await get().client.getPluginStatus();
set({ pluginStatus: result?.plugins || [] });
} catch { /* ignore */ }
},
loadChannels: async () => {
const channels: { id: string; type: string; label: string; status: 'active' | 'inactive' | 'error'; accounts?: number; error?: string }[] = [];
try {
// Try listing channels from Gateway
const result = await get().client.listChannels();
if (result?.channels) {
set({ channels: result.channels });
return;
}
} catch { /* channels.list may not be available */ }
// Fallback: probe known channels individually
try {
const feishu = await get().client.getFeishuStatus();
channels.push({
id: 'feishu',
type: 'feishu',
label: '飞书 (Feishu)',
status: feishu?.configured ? 'active' : 'inactive',
accounts: feishu?.accounts || 0,
});
} catch {
channels.push({ id: 'feishu', type: 'feishu', label: '飞书 (Feishu)', status: 'inactive' });
}
// QQ channel (check if qqbot plugin is loaded)
const plugins = get().pluginStatus;
const qqPlugin = plugins.find((p: any) => (p.name || p.id || '').toLowerCase().includes('qqbot'));
if (qqPlugin) {
channels.push({
id: 'qqbot',
type: 'qqbot',
label: 'QQ 机器人',
status: qqPlugin.status === 'active' ? 'active' : 'inactive',
});
}
set({ channels });
},
loadScheduledTasks: async () => {
try {
const result = await get().client.listScheduledTasks();
set({ scheduledTasks: result?.tasks || [] });
} catch { /* ignore if heartbeat.tasks not available */ }
},
clearLogs: () => set({ logs: [] }),
};
});

View File

@@ -1,12 +1,12 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
// @ts-expect-error process is a nodejs global
const host = process.env.TAURI_DEV_HOST;
// https://vite.dev/config/
export default defineConfig(async () => ({
plugins: [react()],
plugins: [react(), tailwindcss()],
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
//

263
docs/NEXT_SESSION.md Normal file
View File

@@ -0,0 +1,263 @@
# ZCLAW v2 开发 - 新会话提示词
## 项目状态概览
ZCLAW v2 是基于 OpenClaw 的定制化 AI Agent 平台(类似 AutoClaw/QClaw使用 Tauri 2.0 桌面 + OpenClaw Gateway 架构。
**当前进度**: Phase 1-3.5 已完成 ✅
**下一步**: Phase 4 - OpenClaw 真实集成测试QQ官方插件 + 飞书)
---
## Phase 1-3 已完成工作
### ✅ Phase 1: 后端 Gateway 层 + 插件 + Skills
- **src/gateway/** — GatewayManager (子进程管理), GatewayWsClient (WebSocket Protocol v3)
- **plugins/zclaw-chinese-models/** — 智谱GLM/通义千问/Kimi/MiniMax Provider 插件
- **plugins/zclaw-feishu/** — 飞书 Channel 插件 (OAuth token 管理 + 消息发送)
- **plugins/zclaw-ui/** — UI 扩展 RPC (分身CRUD/统计/配置/工作区)
- **skills/** — chinese-writing + feishu-docs (SKILL.md 格式)
- **config/** — openclaw.default.json + SOUL/AGENTS/IDENTITY/USER.md
- **scripts/setup.ts** — 首次设置脚本 (检测OpenClaw → 复制配置 → 注册插件)
### ✅ Phase 2: 前端 Settings 页面体系
- **desktop/src/components/Settings/** — 10个设置页面对标 AutoClaw
- SettingsLayout (左侧导航 + 右侧内容)
- General, UsageStats, ModelsAPI, MCPServices, Skills, IMChannels, Workspace, Privacy, About
- **App.tsx** — main/settings 视图切换
- **构建修复** — Tailwind v4, BOM 清除, TypeScript 0 errors
### ✅ Phase 3: 聊天对接 + 分身管理
- **desktop/src/store/chatStore.ts** — Gateway WS 集成 (sendMessage → agent RPC, initStreamListener → delta/tool/lifecycle)
- **desktop/src/components/ChatArea.tsx** — 流式输出 + 工具调用展示 + 模型选择器 + 连接状态
- **desktop/src/components/CloneManager.tsx** — 分身 CRUD (创建表单 + 列表 + 删除)
- **desktop/src/components/Sidebar.tsx** — 3标签 (分身/IM频道/定时任务) + CloneManager 集成
### ✅ Phase 3.5: 前端质量提升
- **App.tsx** — Gateway 自动连接 (启动时 silent connect)
- **RightPanel.tsx** — 重写为实时数据面板 (连接状态/会话统计/分身/用量/插件/系统信息)
- **ChatArea.tsx** — 多行 textarea 输入 + Markdown 渲染 (代码块/粗体/斜体/链接) + 新对话按钮
- **chatStore.ts** — 对话会话管理 + Zustand persist (localStorage 持久化对话历史 + currentModel)
- **ConversationList.tsx** (新) — 对话历史列表 + 标题自动提取 + 相对时间
- **Sidebar.tsx** — 四标签 (对话/分身/频道/任务), 全部使用真实组件
- **ChannelList.tsx** (新) — IM 频道列表 (飞书/QQ 状态探测 + 配置入口)
- **TaskList.tsx** (新) — Heartbeat 定时任务列表 (状态图标/cron 表达式/执行时间)
- **gatewayStore.ts** — 新增 channels/scheduledTasks 状态 + loadChannels/loadScheduledTasks
- **gateway-client.ts** — 新增 listChannels() + getFeishuStatus() + listScheduledTasks()
- **Settings/General.tsx** — 接入真实 Gateway 连接数据 + 连接/断开按钮
- **Settings/ModelsAPI.tsx** — 接入 chatStore 模型切换 + Gateway 连接状态
**编译状态**: TypeScript 0 errors, Vite build ✅ (1766 modules, 268KB JS + 26KB CSS)
---
## Phase 4: OpenClaw 集成测试 (下一步工作)
### 重要提示:避免封号
**前期只对接 QQ (官方插件) 和 飞书**,微信暂缓。
### 任务清单
#### 1. 安装 OpenClaw
```bash
# Windows
iwr -useb https://openclaw.ai/install.ps1 | iex
# 验证安装
openclaw --version
openclaw doctor
```
#### 2. 配置 QQ 机器人 (使用官方插件)
```bash
# 安装 OpenClaw 开源社区 QQBot 插件
openclaw plugins install @tencent-connect/openclaw-qqbot@latest
# 配置绑定 QQ 机器人
openclaw channels add --channel qqbot --token "1903376513:Z5UkttjPxLZbVFxW"
# 重启 Gateway
openclaw gateway restart
```
#### 3. 配置飞书 Channel
```bash
# 运行 ZCLAW 设置脚本
cd g:\ZClaw
pnpm setup
# 手动配置飞书插件 (如果 setup 脚本未自动注册)
openclaw plugins register ./plugins/zclaw-feishu
# 编辑 ~/.openclaw/openclaw.json 添加飞书配置
# channels.feishu.appId, appSecret, verificationToken, encryptKey
```
#### 4. 注册 ZCLAW 自定义插件
```bash
cd g:\ZClaw
# 注册中文模型 Provider
openclaw plugins register ./plugins/zclaw-chinese-models
# 注册 UI 扩展 RPC
openclaw plugins register ./plugins/zclaw-ui
# 重启 Gateway
openclaw gateway restart
```
#### 5. 测试 Gateway 连接
```bash
# 启动 Gateway (如果未运行)
openclaw gateway
# 在另一个终端启动 ZCLAW 前端
cd g:\ZClaw\desktop
pnpm dev
# 访问 http://localhost:1420
# 点击右下角齿轮 → 设置 → 通用 → 查看 Gateway 连接状态
```
#### 6. 测试中文模型调用
- 在聊天区域发送消息
- 点击模型选择器切换到 glm-5 / qwen3.5-plus / kimi-k2.5 / minimax-m2.5
- 观察流式输出效果
- 检查工具调用是否正常显示
#### 7. 测试飞书消息收发
- 在飞书中 @机器人 发送消息
- 观察 Gateway 日志和前端消息列表
- 测试从前端发送消息到飞书
#### 8. 测试 QQ 消息收发
- 在 QQ 中 @机器人 发送消息
- 观察 Gateway 日志
- 测试双向消息流
---
## 关键文件路径
### 配置文件
- `g:\ZClaw\config\openclaw.default.json` — OpenClaw 默认配置模板
- `~\.openclaw\openclaw.json` — OpenClaw 用户配置 (运行时生成)
- `g:\ZClaw\config\SOUL.md` — ZCLAW 人格定义
### 插件目录
- `g:\ZClaw\plugins\zclaw-chinese-models\` — 中文模型 Provider
- `g:\ZClaw\plugins\zclaw-feishu\` — 飞书 Channel
- `g:\ZClaw\plugins\zclaw-ui\` — UI 扩展 RPC
### 前端代码
- `g:\ZClaw\desktop\src\store\gatewayStore.ts` — Gateway 状态管理
- `g:\ZClaw\desktop\src\store\chatStore.ts` — 聊天状态管理
- `g:\ZClaw\desktop\src\lib\gateway-client.ts` — Gateway WebSocket 客户端
### 后端代码
- `g:\ZClaw\src\gateway\manager.ts` — Gateway 子进程管理
- `g:\ZClaw\src\gateway\ws-client.ts` — Node.js WebSocket 客户端
---
## 常见问题排查
### Gateway 连接失败
1. 检查 Gateway 是否运行: `openclaw status`
2. 检查端口占用: `netstat -ano | findstr 18789`
3. 查看 Gateway 日志: `openclaw gateway` (前台运行查看输出)
4. 检查防火墙设置
### 插件未加载
1. 验证插件注册: `openclaw plugins list`
2. 检查插件 manifest: `g:\ZClaw\plugins\*/plugin.json`
3. 查看 Gateway 启动日志中的插件加载信息
4. 确认 `~\.openclaw\openclaw.json``plugins.load.paths` 包含插件路径
### 飞书消息收发失败
1. 检查 `openclaw.json` 中飞书配置 (appId, appSecret, verificationToken)
2. 验证 OAuth token 是否有效: 查看 Gateway 日志中的 token 刷新记录
3. 检查飞书机器人权限配置
4. 确认回调 URL 配置正确
### QQ 消息收发失败
1. 验证 QQBot 插件安装: `openclaw plugins list | findstr qqbot`
2. 检查 token 格式: `"botAppId:token"`
3. 查看 Gateway 日志中的 QQ 连接状态
4. 确认 QQ 机器人已启用并在线
---
## 开发命令速查
```bash
# 后端 (Gateway 层)
cd g:\ZClaw
pnpm install
pnpm setup # 运行设置脚本
pnpm build # 编译 TypeScript
npx tsc --noEmit # 类型检查
# 前端 (Tauri Desktop)
cd g:\ZClaw\desktop
pnpm install
pnpm dev # 启动 Vite dev server (http://localhost:1420)
pnpm build # 构建生产版本
npx tsc --noEmit # 类型检查
# OpenClaw 命令
openclaw gateway # 启动 Gateway (前台)
openclaw gateway restart # 重启 Gateway
openclaw status # 查看状态
openclaw doctor # 诊断工具
openclaw plugins list # 列出已安装插件
openclaw plugins install <pkg> # 安装插件
openclaw plugins register <path> # 注册本地插件
openclaw channels add --channel <type> --token <token> # 添加 IM 频道
openclaw configure # 交互式配置
```
---
## 参考文档
- **架构设计**: `g:\ZClaw\docs\architecture-v2.md`
- **偏离分析**: `g:\ZClaw\docs\deviation-analysis.md`
- **进度报告**: `g:\ZClaw\PROGRESS.md`
- **AutoClaw 界面参考**: `g:\ZClaw\docs\autoclaw界面\` (13张截图)
---
## 预期成果
完成 Phase 4 后,应达到以下状态:
1. ✅ OpenClaw Gateway 正常运行并连接到前端
2. ✅ QQ 机器人可以收发消息 (使用官方 @tencent-connect/openclaw-qqbot 插件)
3. ✅ 飞书机器人可以收发消息 (使用自定义 zclaw-feishu 插件)
4. ✅ 中文模型 (GLM/Qwen/Kimi/MiniMax) 可以正常调用
5. ✅ 前端聊天区域显示流式输出 + 工具调用
6. ✅ 分身管理功能正常 (创建/列表/删除)
7. ✅ Settings 页面所有配置项可用
---
## 注意事项
1. **避免封号**: 前期只对接 QQ (官方插件) 和飞书,微信暂缓
2. **QQ 使用官方插件**: `@tencent-connect/openclaw-qqbot@latest`,不要自己实现
3. **飞书使用自定义插件**: `plugins/zclaw-feishu/` 已实现 OAuth + 消息发送
4. **API Key 安全**: 不要在代码中硬编码 API Key使用 `openclaw configure` 或环境变量
5. **Gateway 日志**: 前台运行 `openclaw gateway` 可以实时查看日志,便于调试
6. **插件热重载**: 修改插件代码后需要 `openclaw gateway restart`
---
## 后续 Phase 5 规划
- [ ] Tauri Rust sidecar (在 Tauri 中管理 Gateway 子进程)
- [ ] 更多 Skills 开发 (代码生成、文档写作、数据分析等)
- [ ] 微信 Channel Plugin (待 OpenClaw 官方支持或社区插件成熟后)
- [ ] 打包发布 (Windows/macOS/Linux)
- [ ] 性能优化 + 错误处理增强

327
docs/architecture-v2.md Normal file
View File

@@ -0,0 +1,327 @@
# ZCLAW v2 架构设计 — 基于 OpenClaw 定制化
**日期**: 2026-03-11
**定位**: 像 AutoClaw 一样,对 OpenClaw 进行定制化封装,打造 Tauri 桌面版
---
## 一、架构总览
```
┌─────────────────────────────────────────────────────────┐
│ ZCLAW (Tauri App) │
│ ┌──────────────────────────────────────────────────┐ │
│ │ React 19 + TailwindCSS │ │
│ │ ┌────────┐ ┌────────┐ ┌────────┐ ┌──────────┐ │ │
│ │ │ 分身Tab │ │IM频道 │ │定时任务│ │ 设置页面 │ │ │
│ │ └────────┘ └────────┘ └────────┘ └──────────┘ │ │
│ │ ┌──────────────────────────────────────────┐ │ │
│ │ │ 聊天区域 + 工具调用展示 │ │ │
│ │ └──────────────────────────────────────────┘ │ │
│ └──────────────────────────────────────────────────┘ │
│ │ Tauri Commands │
│ ┌──────────────────────────────────────────────────┐ │
│ │ Rust Sidecar / Bridge │ │
│ │ • 管理 OpenClaw Gateway 子进程生命周期 │ │
│ │ • WebSocket 客户端连接 Gateway │ │
│ │ • 转发 Gateway 事件到前端 │ │
│ └──────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
│ WebSocket (ws://127.0.0.1:18789)
┌─────────────────────────────────────────────────────────┐
│ OpenClaw Gateway (Node.js daemon) │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌───────────┐ │
│ │ Agent │ │ Skills │ │ Sessions │ │ Heartbeat │ │
│ │ Runtime │ │ System │ │ Manager │ │ Engine │ │
│ └──────────┘ └──────────┘ └──────────┘ └───────────┘ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌───────────┐ │
│ │ Tools │ │ Memory │ │ MCP │ │ Provider │ │
│ │ (bash/ │ │ (SQLite/ │ │ Server │ │ (models) │ │
│ │ file/ │ │ LanceDB)│ │ │ │ │ │
│ │ browse) │ │ │ │ │ │ │ │
│ └──────────┘ └──────────┘ └──────────┘ └───────────┘ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ Channel Plugins (IM 渠道) │ │
│ │ WhatsApp │ Telegram │ Discord │ Slack │ ... │ │
│ └──────────────────────────────────────────────────┘ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ ZCLAW Custom Plugins (我们的) │ │
│ │ @zclaw/feishu │ @zclaw/wechat │ @zclaw/qq │ │
│ │ @zclaw/chinese-models (GLM/Qwen/Kimi/MiniMax) │ │
│ └──────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
┌──────────┴──────────┐
│ Bootstrap Files │
│ AGENTS.md │
│ SOUL.md │
│ IDENTITY.md │
│ USER.md │
│ TOOLS.md │
└─────────────────────┘
```
---
## 二、核心设计决策
### 1. OpenClaw 作为执行引擎
**不重新发明轮子**。OpenClaw 已经实现了:
- ✅ 真实工具执行 (bash/file/browser)
- ✅ Skills 系统 (SKILL.md + 渐进式披露)
- ✅ MCP 协议支持
- ✅ 心跳引擎 (Heartbeat)
- ✅ 持久记忆 (SQLite + LanceDB)
- ✅ Agent 运行时 (Session/Queue/Streaming)
- ✅ 10+ IM 渠道适配器
- ✅ 插件体系 (Channel/Tool/Memory/Provider)
我们只需要:**包装 + 定制 + 中文化**
### 2. Tauri 作为桌面壳
AutoClaw 用的是自己的桌面框架QClaw 用 Electron。我们用 **Tauri 2.0** (Rust + React)
- 更小体积 (~10MB vs Electron ~150MB)
- 更好性能 (Rust native)
- 系统级集成能力
- 管理 OpenClaw Gateway 子进程
### 3. 自定义插件做差异化
通过 OpenClaw 的 Plugin API 添加中国特色功能:
| 插件 | 功能 |
|------|------|
| `@zclaw/feishu` | 飞书 Channel Plugin |
| `@zclaw/wechat` | 微信 Channel Plugin (通过 WeCom API 或 itchat 桥接) |
| `@zclaw/qq` | QQ Channel Plugin |
| `@zclaw/chinese-models` | 中文模型 Provider (智谱GLM/通义千问/Kimi/MiniMax) |
| `@zclaw/zclaw-ui` | 自定义 Gateway RPC 方法供 Tauri UI 调用 |
---
## 三、项目结构 (重构后)
```
ZClaw/
├── desktop/ # Tauri 2.0 桌面应用
│ ├── src-tauri/ # Rust 后端
│ │ ├── src/
│ │ │ ├── main.rs # Tauri 入口
│ │ │ ├── gateway.rs # OpenClaw Gateway 子进程管理
│ │ │ ├── ws_client.rs # WebSocket 客户端
│ │ │ └── commands.rs # Tauri Commands (前端调用)
│ │ ├── Cargo.toml
│ │ └── tauri.conf.json
│ ├── src/ # React 前端
│ │ ├── App.tsx
│ │ ├── main.tsx
│ │ ├── index.css
│ │ ├── components/
│ │ │ ├── Sidebar.tsx # 左侧栏 (分身/IM/定时)
│ │ │ ├── ChatArea.tsx # 聊天区域 + 工具调用展示
│ │ │ ├── RightPanel.tsx # 右侧面板
│ │ │ └── Settings/ # 设置页面 (对标 AutoClaw)
│ │ │ ├── General.tsx # 通用设置
│ │ │ ├── UsageStats.tsx # 用量统计
│ │ │ ├── ModelsAPI.tsx # 模型与 API
│ │ │ ├── MCPServices.tsx # MCP 服务管理
│ │ │ ├── Skills.tsx # 技能管理
│ │ │ ├── IMChannels.tsx # IM 频道管理
│ │ │ ├── Workspace.tsx # 工作区设置
│ │ │ ├── Privacy.tsx # 数据与隐私
│ │ │ └── About.tsx # 关于
│ │ ├── store/
│ │ │ ├── chatStore.ts # 聊天状态
│ │ │ ├── gatewayStore.ts # Gateway 连接状态
│ │ │ └── settingsStore.ts # 设置状态
│ │ └── lib/
│ │ ├── gateway-client.ts # WebSocket 客户端封装
│ │ └── protocol.ts # Gateway 协议类型定义
│ └── package.json
├── plugins/ # ZCLAW 自定义 OpenClaw 插件
│ ├── zclaw-feishu/ # 飞书频道插件
│ │ ├── index.ts
│ │ ├── plugin.json # 插件清单
│ │ └── README.md
│ ├── zclaw-wechat/ # 微信频道插件
│ │ ├── index.ts
│ │ └── plugin.json
│ ├── zclaw-qq/ # QQ 频道插件
│ │ ├── index.ts
│ │ └── plugin.json
│ ├── zclaw-chinese-models/ # 中文模型 Provider 插件
│ │ ├── index.ts
│ │ ├── providers/
│ │ │ ├── zhipu.ts # 智谱 GLM
│ │ │ ├── qwen.ts # 通义千问
│ │ │ ├── kimi.ts # Moonshot Kimi
│ │ │ └── minimax.ts # MiniMax
│ │ └── plugin.json
│ └── zclaw-ui/ # UI 扩展 RPC 插件
│ ├── index.ts
│ └── plugin.json
├── skills/ # ZCLAW 自定义 Skills
│ ├── chinese-writing/ # 中文写作技能
│ │ └── SKILL.md
│ ├── weibo-automation/ # 微博自动化技能
│ │ └── SKILL.md
│ └── feishu-docs/ # 飞书文档操作技能
│ └── SKILL.md
├── config/ # ZCLAW 默认配置
│ ├── openclaw.default.json # 预设的 OpenClaw 配置
│ ├── SOUL.md # 默认人格
│ ├── AGENTS.md # 默认 Agent 指令
│ ├── IDENTITY.md # 默认身份
│ └── USER.md # 默认用户配置
├── scripts/ # 构建/安装脚本
│ ├── setup.ts # 首次设置脚本
│ ├── install-openclaw.ts # OpenClaw 安装检测
│ └── register-plugins.ts # 注册自定义插件
├── docs/
│ ├── architecture-v2.md # 本文件
│ ├── deviation-analysis.md # 偏离分析报告
│ ├── DEVELOPMENT.md
│ └── autoclaw界面/ # AutoClaw 参考截图
├── package.json # 根项目管理
├── tsconfig.json
└── README.md
```
---
## 四、与 AutoClaw 功能对标
### AutoClaw 设置页面 → ZCLAW 实现方式
| AutoClaw 功能 | ZCLAW 实现 |
|---|---|
| **通用** (账号/主题/开机启动/工具调用显示) | Tauri 窗口设置 + Gateway config |
| **用量统计** (会话/消息/Token 按模型分) | 读取 OpenClaw sessions JSONL + 统计 |
| **积分详情** | 自定义积分系统 (可选) |
| **模型与API** (内置+自定义模型+Gateway URL) | `openclaw config` + Provider Plugin |
| **MCP 服务** (File System/Web Fetch/+添加) | 读取 OpenClaw MCP 配置 + UI 管理 |
| **技能** (SKILL.md 管理/额外目录) | 读取 Skills 目录 + UI 管理 |
| **IM 频道** (添加/管理/快速添加飞书) | Channel Plugin 配置 UI |
| **工作区** (项目目录/文件限制/上下文保存/文件监听) | `agents.defaults.workspace` + sandbox 配置 |
| **数据与隐私** (本地路径/优化计划) | OpenClaw 数据目录 + 隐私配置 |
| **提交反馈** | 自定义反馈表单 |
| **关于** (版本/更新) | 显示 ZCLAW + OpenClaw 版本 |
### AutoClaw 主界面 → ZCLAW 对标
| 功能 | 实现 |
|---|---|
| **左侧 - 分身 Tab** | 通过 `agents.list` 配置管理多个 Agent |
| **左侧 - IM 频道 Tab** | 通过 `channels.*` 配置显示已连接频道 |
| **左侧 - 定时任务 Tab** | 通过 heartbeat + cron 事件显示 |
| **中间 - 聊天区域** | WebSocket 订阅 `agent` stream 事件 |
| **中间 - 模型选择器** | 通过 `agents.defaults.models` 配置 |
| **右侧 - 代码/文件面板** | 显示 Agent 工具调用结果 (file/bash output) |
| **快速配置弹窗** | 修改 IDENTITY.md + SOUL.md + USER.md |
---
## 五、WebSocket 通信协议
ZCLAW Tauri 客户端通过标准 OpenClaw Gateway 协议通信:
### 连接握手
```json
// 1. 服务器发送 challenge
{"type": "event", "event": "connect.challenge", "payload": {"nonce": "...", "ts": 1737264000000}}
// 2. 客户端发送 connect
{"type": "req", "id": "1", "method": "connect", "params": {
"minProtocol": 3, "maxProtocol": 3,
"client": {"id": "zclaw-tauri", "version": "0.1.0", "platform": "windows", "mode": "operator"},
"role": "operator",
"scopes": ["operator.read", "operator.write"],
"auth": {"token": "..."}
}}
// 3. 服务器回复 hello-ok
{"type": "res", "id": "1", "ok": true, "payload": {"type": "hello-ok", "protocol": 3}}
```
### 核心 RPC 方法
```
agent → 发送消息给 Agent (触发 Agent Loop)
agent.wait → 等待 Agent 运行完成
send → 通过 IM 频道发送消息
health → 健康检查
status → 获取 Gateway 状态
system-presence → 系统状态
```
### 事件订阅
```
agent → Agent 流式输出 (assistant/tool/lifecycle)
chat → 聊天消息
presence → 在线状态
health → 健康状态
heartbeat → 心跳
cron → 定时任务
tick → 定时心跳
```
---
## 六、开发分期
### Phase 1: 基础连通 (当前优先)
1. 安装 OpenClaw 并验证 Gateway 可运行
2. 创建 Tauri 项目结构 (保留现有 desktop/)
3. 实现 Gateway 子进程管理 (Rust sidecar)
4. 实现 WebSocket 客户端连接
5. 前端显示 Gateway 状态 + 基础聊天
### Phase 2: 自定义插件
1. 实现 `@zclaw/chinese-models` Provider Plugin (智谱GLM/Qwen/Kimi)
2. 实现 `@zclaw/feishu` Channel Plugin
3. 注册插件到 OpenClaw Gateway
### Phase 3: 完整 UI
1. 实现设置页面体系 (对标 AutoClaw 10 个页面)
2. 实现分身管理 UI
3. 实现 Skills 管理 UI
4. 实现 MCP 服务管理 UI
### Phase 4: 高级功能
1. 微信/QQ Channel Plugin
2. 自定义 Skills 开发
3. 工作区管理
4. 打包发布
---
## 七、与之前代码的关系
### 可复用
- `desktop/` 前端组件 (需大幅扩展)
- `plugins/zclaw-chinese-models/` 可复用之前的 AI Provider 代码 (zhipu.ts/openai.ts)
- `plugins/zclaw-feishu/` 可复用之前的飞书适配器代码
### 废弃
- `src/core/remote-execution/` → OpenClaw 自带工具执行
- `src/core/task-orchestration/` → OpenClaw Agent Loop 自带
- `src/core/multi-agent/` → 改为 OpenClaw agents.list 多 Agent
- `src/core/memory/` → OpenClaw Memory Plugin 自带
- `src/core/proactive/` → OpenClaw Heartbeat Engine 自带
- `src/im/gateway.ts` → OpenClaw Channel 系统自带
- `src/app.ts` → OpenClaw Gateway 就是 app
- `src/api/` → 通过 WebSocket + Tauri Commands 替代
- `src/db/` → OpenClaw 自带 SQLite
- `src/config/` → OpenClaw 配置系统替代
---
*本架构设计基于对 OpenClaw 官方文档 (docs.openclaw.ai) 的深度研究,
参考 AutoClaw v0.2.12 的 13 张界面截图进行功能对标。*

BIN
docs/autoclaw界面/1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 528 KiB

BIN
docs/autoclaw界面/10.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 529 KiB

BIN
docs/autoclaw界面/11.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 587 KiB

BIN
docs/autoclaw界面/12.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 312 KiB

BIN
docs/autoclaw界面/13.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 332 KiB

BIN
docs/autoclaw界面/2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 624 KiB

BIN
docs/autoclaw界面/3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 382 KiB

BIN
docs/autoclaw界面/4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 391 KiB

BIN
docs/autoclaw界面/5.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 441 KiB

BIN
docs/autoclaw界面/6.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 391 KiB

BIN
docs/autoclaw界面/7.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 373 KiB

BIN
docs/autoclaw界面/8.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 399 KiB

BIN
docs/autoclaw界面/9.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 303 KiB

310
docs/deviation-analysis.md Normal file
View File

@@ -0,0 +1,310 @@
# ZCLAW 偏离分析报告
**日期**: 2026-03-11
**目的**: 对标 QClaw / AutoClaw / OpenClaw分析当前项目是否偏离初衷
---
## 一、三大产品深度理解
### 1. OpenClaw — 开源核心 (GitHub 28万+ Stars)
OpenClaw 是一个**本地优先的 AI 代理平台**,不是简单的聊天机器人,而是一个能真正操控电脑执行任务的系统。
**核心架构:**
| 组件 | 说明 |
|------|------|
| **Gateway** | Node.js 进程,是整个系统的**核心控制面板**,管理 sessions、channels、tools、events |
| **Channel Plugins** | IM 渠道适配器 — WhatsApp, Telegram, Slack, Discord, iMessage 等 10+ 种 |
| **心跳引擎 (Heartbeat)** | 定期唤醒,检查 HEARTBEAT.md 任务清单,**主动**执行预定任务 |
| **持久化身份** | SOUL.md(性格)、MEMORY.md(长期记忆)、AGENTS.md(角色配置) — **纯文本Git 可控** |
| **Skills 系统** | SKILL.md 文件 + 脚本三级渐进式披露100+ 预配置技能 |
| **MCP 支持** | 模型上下文协议JSON-RPC 2.0,扩展外部工具 (File System, Web Fetch, DB 等) |
| **核心工具** | bash(命令行)、read/write(文件系统)、browser(浏览器控制) |
| **插件体系** | Channel / Memory / Tool / Provider 四类插件 |
| **存储** | 默认 SQLite支持向量存储、知识图谱 |
**关键设计哲学:**
- **本地优先**: 所有数据和执行都在本地
- **透明可控**: 纯文本配置,用户能完全掌控 AI 的"大脑"
- **执行而非建议**: 不是只出主意,而是真正动手做事
- **自我进化**: Agent 可修改自身指令、改进工作流
---
### 2. QClaw — 腾讯产品化封装
QClaw **不是**腾讯从零重写的框架,而是**围绕 OpenClaw 做的一次产品化封装**
**核心卖点:**
- **一键安装**: 下载即用,无需配置环境
- **微信 + QQ 双端接入**: 腾讯核心优势,在微信/QQ中直接对话指挥电脑
- **内置国产模型**: Kimi, MiniMax, GLM, DeepSeek + 自定义模型
- **5000+ Skills 生态**: ClawHub、GitHub 等丰富生态
- **持续记忆**: 记住偏好和上下文
- **本地部署**: 操控文件、浏览器、邮件
**使用场景:**
- 远程操作电脑文件/网页
- 社媒自动运营
- GitHub 项目自动开发
- 学术论文自动整理
- 每日天气定时提醒
---
### 3. AutoClaw — 智谱 AutoGLM 定制版 (v0.2.12)
基于 OpenClaw 的智谱定制版,核心是**飞书集成**。
**从 13 张界面截图提取的完整功能架构:**
#### 主界面布局
- **左侧栏 3 个 Tab**: 分身 / IM频道 / 定时任务
- **中间**: 聊天区域 + 发送框 + 模型选择器 (glm-5)
- **右侧**: 代码/文件区域 + Agent 面板
#### 设置系统 (10 个页面)
| 页面 | 功能 |
|------|------|
| 通用 | 账号安全、主题(白色/Neon Noir)、开机自启、显示工具调用 |
| 用量统计 | 会话数/消息数/总Token按模型分类统计 |
| 积分详情 | 积分总量、消耗/获得明细 |
| **模型与API** | 内置模型(Pony-Alpha-2) + 自定义模型(glm-5/qwen3.5-plus/kimi-k2.5/MiniMax-M2.5) + **Gateway URL** (ws://127.0.0.1:18789) |
| **MCP 服务** | File System / Web Fetch + 快速添加(Brave Search, SQLite) |
| **技能** | SKILL.md 文件管理,额外技能目录 (~/.opencode/skills) |
| **IM 频道** | 添加/管理频道,快速添加飞书 |
| **工作区** | 项目目录、文件访问限制、自动保存上下文、文件监听、从OpenClaw迁移 |
| 数据与隐私 | 本地数据路径、优化计划 |
| 提交反馈 / 关于 | 反馈表单、版本信息 |
#### 核心概念
- **分身 (Clone)**: 每个分身是独立的 Agent 实例,有自己的配置和对话历史
- **快速配置**: 名字、角色、昵称、使用场景(编程/写作/产品/数据分析/设计/运维/研发/营销)
- **Gateway WebSocket 连接**: ws://127.0.0.1:18789 — 这是 OpenClaw Gateway 的连接方式
- **工作区**: 默认 ~/.openclaw-autoclaw/workspace文件访问沙盒
---
## 二、当前 ZCLAW 项目现状
### 已有的代码 (37 文件, 2378 行)
| 模块 | 内容 |
|------|------|
| src/config/ | Zod 配置管理 |
| src/utils/ | Logger + ID 生成器 |
| src/db/ | SQLite Schema (8表) + BaseDAO |
| src/core/ai/ | 智谱GLM + OpenAI Provider + AIManager |
| src/core/multi-agent/ | MessageBus + BaseAgent + Planner/Executor/Combiner + Orchestrator |
| src/core/remote-execution/ | 并发队列 + 任务管理 |
| src/core/task-orchestration/ | 拓扑排序 + 计划执行 |
| src/core/memory/ | 内存记忆 + 用户画像 |
| src/core/proactive/ | node-cron 定时任务 |
| src/im/ | IM Gateway + 飞书适配器 |
| src/api/ | ZClawAPI for Tauri |
| src/app.ts | ZClawApp 主类 |
| desktop/ | Tauri + React 三栏布局 |
---
## 三、偏离分析 — 核心问题
### 🔴 严重偏离
#### 1. 架构根本性偏离 — 没有基于 OpenClaw
**问题**: 项目初衷是"学习 QClaw 跟 AutoClaw打造结合 Tauri + OpenClaw 的系统",但当前代码**完全没有 OpenClaw 的影子**。
- OpenClaw 的核心是 **Gateway** (Node.js 进程 + WebSocket)
- QClaw 和 AutoClaw 都是**围绕 OpenClaw 做封装**
- 我们的 ZCLAW 却从零自己发明了一套架构 (RemoteExecutionEngine / TaskOrchestrator / AgentOrchestrator)
- 这些自创系统**不是 OpenClaw 的概念**,等于在重造轮子
**应该**: 直接集成 OpenClaw Gateway或至少学习其架构模式来构建
#### 2. Skills 系统完全缺失
**问题**: Skills 是 OpenClaw/QClaw/AutoClaw 的**核心扩展机制**。
- OpenClaw 有 100+ 预配置技能
- QClaw 有 5000+ Skills 生态
- AutoClaw 截图显示有完整的技能管理界面
- Skills 基于 SKILL.md 文件,三级渐进式披露,解决 Token 成本
- 我们的 `src/skills/` 目录是**空的**,完全没有实现
#### 3. MCP (模型上下文协议) 完全缺失
**问题**: MCP 是现代 AI Agent 的标准工具扩展协议。
- AutoClaw 内置: File System、Web Fetch可快速添加 Brave Search、SQLite
- OpenClaw 原生支持 MCP
- 我们完全没有 MCP 支持
#### 4. 工具执行层是"假的"
**问题**: OpenClaw 能**真正**操控电脑 — 执行 Shell 命令、读写文件、控制浏览器。
- 我们的 BrowserAgent / FileAgent / TerminalAgent 实际上是**用 AI 模拟输出结果**
- 没有任何真实的命令执行、文件操作或浏览器控制能力
- 用户期望"操控电脑完成任务",我们只能"假装操作然后编结果"
---
### 🟡 方向偏离
#### 5. "多Agent协作" vs "分身(Clone)"概念错位
**问题**:
- AutoClaw 的"分身"是**独立的 Agent 实例**,每个分身有自己的名字、角色、记忆、对话
- 我们的"多 Agent"是面向**任务拆解**的 (Planner → Executor → Combiner)
- 这是两种完全不同的概念
**AutoClaw 的分身**: 像是雇了多个助手,每个负责不同领域
**我们的多 Agent**: 像是一个任务流水线Planner 规划 → Executor 执行 → Combiner 汇总
#### 6. 持久化方式偏离
**问题**:
- OpenClaw 用**纯文本文件**: SOUL.md, MEMORY.md, AGENTS.md — 透明、Git 可控
- 我们用 SQLite 数据库表
- 数据库不是错的,但缺少 OpenClaw 的**透明可控**理念
- 用户无法像 Git 那样管理 AI 的"大脑"
#### 7. 心跳引擎缺失
**问题**:
- OpenClaw 的核心特色是**心跳引擎** — 定期唤醒,检查 HEARTBEAT.md主动执行任务
- 这是"主动服务"的真正含义
- 我们的 ProactiveServiceSystem 只是简单的 node-cron 定时器包装
#### 8. 工作区 (Workspace) 概念缺失
**问题**:
- AutoClaw 有完整的工作区管理: 项目目录、文件访问沙盒、上下文自动保存、文件监听
- 这是 Agent 安全执行的基础
- 我们完全没有工作区概念
---
### 🟢 方向正确
| 功能 | 评价 |
|------|------|
| 左侧栏三个 Tab (分身/IM频道/定时任务) | ✅ 与 AutoClaw 布局一致 |
| 多模型 Provider 支持 | ✅ 但需加 Gateway WebSocket 连接 |
| IM 网关 + 飞书适配器 | ✅ 但应更像 OpenClaw Channel Plugin |
| 定时任务 | ✅ 需升级为心跳引擎模式 |
| SQLite 数据库 | ✅ OpenClaw 也用 SQLite但需补充纯文本文件 |
| Tauri 桌面应用 | ✅ 与目标一致 (QClaw用Electron, 我们用Tauri更好) |
| 配置管理 (.env) | ✅ 需要但方向对 |
---
## 四、偏离程度评估
```
整体偏离程度: ████████░░ 75%
```
**核心原因**: 项目从**"基于 OpenClaw 做 Tauri 封装"**变成了**"从零自建 AI Agent 框架"**。
这就像是:
- 目标是造一辆"基于丰田平台的改装车"
- 实际上在从零造发动机、底盘和变速箱
- 造出来的还跟丰田的规格不兼容
---
## 五、修正建议
### 方案 A: 直接集成 OpenClaw推荐
```
OpenClaw Gateway (npm install openclaw)
↕ WebSocket (ws://127.0.0.1:18789)
Tauri Desktop App (我们的前端)
↕ Tauri Commands
React UI (学习 AutoClaw 的界面设计)
```
**步骤:**
1. 安装 OpenClaw 作为依赖(或子进程启动)
2. 通过 WebSocket 连接 OpenClaw Gateway
3. Tauri 前端做 UI 封装(学 AutoClaw 的设计)
4. 添加自定义 Channel Plugin (微信/QQ/飞书)
5. 添加自定义 Skills
6. 添加 MCP 服务管理
**优点**: 直接获得 OpenClaw 的全部能力 (真实工具执行、Skills 生态、MCP 等)
**缺点**: 学习成本,依赖外部项目
### 方案 B: 学习架构重构(折中)
保留 Tauri + 自己的后端,但按 OpenClaw 的架构模式重构:
1. **重构为 Gateway 模式**: 把我们的后端重构为 OpenClaw 风格的 Gateway
2. **实现 Skills 系统**: SKILL.md 文件 + 渐进式披露
3. **实现 MCP 支持**: JSON-RPC 2.0 工具扩展协议
4. **实现真实工具**: bash 命令执行、文件读写、浏览器控制 (Playwright)
5. **实现分身系统**: 每个分身 = 独立 Agent 实例 + 独立配置/记忆
6. **实现心跳引擎**: HEARTBEAT.md + 定期检查 + 主动执行
7. **实现工作区**: 项目沙盒 + 文件监听 + 上下文保存
8. **补充纯文本持久化**: SOUL.md + MEMORY.md + AGENTS.md
**优点**: 深度学习理解架构,自主可控
**缺点**: 工作量大,可能重复造轮子
### 方案 C: 混合方案(务实)
- OpenClaw 作为执行引擎(子进程运行或 WebSocket 连接)
- Tauri 做桌面 UI 封装(仿 AutoClaw 界面)
- 自己实现差异化功能(微信接入、中文 Skills、国产模型优化
---
## 六、需要保留 vs 需要重写 vs 需要新建
### ✅ 保留
- `src/config/` — 配置管理(调整 key 名称对标 OpenClaw
- `src/utils/` — Logger + ID 生成器
- `src/db/` — SQLite 层OpenClaw 也用 SQLite
- `src/core/ai/` — 多模型 Provider补充 Gateway 连接方式)
- `src/im/` — IM 网关(重构为 Channel Plugin 模式)
- `desktop/` — Tauri 前端(大幅扩展界面)
### 🔄 重写
- `src/core/multi-agent/` → 重构为**分身 (Clone) 系统**
- `src/core/remote-execution/` → 重构为**真实工具执行层** (bash/file/browser)
- `src/core/task-orchestration/` → 简化,交给 LLM 自主规划
- `src/core/proactive/` → 重构为**心跳引擎**
- `src/core/memory/` → 补充纯文本文件 (MEMORY.md)
- `src/app.ts` → 重构为 **Gateway 模式**
### 🆕 新建
- `src/skills/` — Skills 系统SKILL.md 加载/解析/注册)
- `src/mcp/` — MCP 协议支持
- `src/tools/` — 真实工具执行 (bash, file, browser via Playwright)
- `src/workspace/` — 工作区管理(沙盒、文件监听、上下文保存)
- `src/gateway/` — WebSocket Gateway 服务
- 前端设置页面(通用/用量统计/模型API/MCP/技能/IM/工作区/隐私)
---
## 七、结论
**当前项目已经严重偏离了"学习 QClaw/AutoClaw + 基于 OpenClaw"的初衷。**
核心问题不在于代码质量(代码是可以编译的),而在于**架构方向**:我们在自己发明一套 AI Agent 框架,而不是基于 OpenClaw 做 Tauri 封装。
建议选择方案后,优先做以下事情:
1. 深入研究 OpenClaw 源码和 Gateway 架构
2. 确定是直接集成还是学习重构
3. 实现 Skills 系统和 MCP 支持
4. 实现真实工具执行能力
5. 按 AutoClaw 界面设计前端
---
*本报告基于 QClaw 官网、AutoClaw 官网 + 13张界面截图、OpenClaw GitHub + 技术文章的深度分析*

1
docs/index.html Normal file

File diff suppressed because one or more lines are too long

View File

@@ -1,29 +1,27 @@
{
{
"name": "zclaw",
"version": "0.1.0",
"description": "ZCLAW - AI Agent Platform based on OpenClaw",
"main": "dist/index.js",
"version": "0.2.0",
"description": "ZCLAW - OpenClaw customization with Tauri desktop, Chinese model providers, and Feishu integration",
"main": "dist/gateway/index.js",
"scripts": {
"dev": "tsx watch src/index.ts",
"dev": "tsx watch src/gateway/index.ts",
"build": "tsc",
"test": "jest"
"setup": "tsx scripts/setup.ts",
"test": "jest",
"gateway:start": "openclaw gateway",
"gateway:status": "openclaw status",
"gateway:doctor": "openclaw doctor"
},
"keywords": ["ai", "agent", "openclaw", "automation"],
"keywords": ["ai", "agent", "openclaw", "tauri", "feishu", "chinese-models"],
"author": "ZCLAW Team",
"license": "MIT",
"dependencies": {
"@openclaw/sdk": "latest",
"bullmq": "^5.0.0",
"ioredis": "^5.3.0",
"better-sqlite3": "^9.4.0",
"sqlite-vec": "^0.1.0",
"node-cron": "^3.0.3",
"koishi": "^4.17.0",
"ws": "^8.16.0",
"zod": "^3.22.0"
},
"devDependencies": {
"@types/node": "^20.11.0",
"@types/better-sqlite3": "^7.6.8",
"@types/ws": "^8.5.10",
"typescript": "^5.3.0",
"tsx": "^4.7.0",
"jest": "^29.7.0"

View File

@@ -0,0 +1,589 @@
# ZCLAW 项目深度分析与头脑风暴
## 一、项目全景概览
### 1.1 项目定位
ZCLAW 是一个**基于 OpenClaw 框架的定制化中文 AI 助手平台**,对标 AutoClaw (智谱) 和 QClaw (腾讯)。
**核心价值主张**
```
OpenClaw Gateway (成熟执行引擎)
↕ WebSocket Protocol v3
ZCLAW Tauri App (轻量桌面 UI)
+ 中文模型 Provider (GLM/Qwen/Kimi/MiniMax)
+ 飞书 Channel Plugin
+ 分身(Clone) 管理系统
+ 自定义 Skills
```
### 1.2 架构演进历程
| 阶段 | 架构方向 | 状态 |
|------|----------|------|
| v1 | 自建 AI Agent 框架 (src/core/*) | 🗑️ 已归档 |
| v2 | 基于 OpenClaw + Tauri | ✅ 当前方向 |
**架构转向原因**v1 偏离初衷约 75%,重复造轮子而非复用 OpenClaw 生态。
### 1.3 技术栈一览
| 层级 | 技术 | 版本 |
|------|------|------|
| 执行引擎 | OpenClaw Gateway | Node.js daemon |
| 桌面壳 | Tauri | 2.0 |
| 前端框架 | React | 19.1.0 |
| 状态管理 | Zustand | 5.0.11 |
| 样式 | TailwindCSS | 4.2.1 |
| 构建工具 | Vite | 7.0.4 |
| 语言 | TypeScript | 5.8.3 |
| 通信协议 | WebSocket | Gateway Protocol v3 |
---
## 二、当前项目状态
### 2.1 已完成阶段
| Phase | 内容 | 完成度 |
|-------|------|--------|
| Phase 1 | 后端 Gateway 层 + 插件 + Skills | ✅ 100% |
| Phase 2 | 前端 Settings 页面体系 (10页) | ✅ 100% |
| Phase 3 | 聊天对接 + 分身管理 | ✅ 100% |
| Phase 3.5 | 前端质量提升 | ✅ 100% |
| Phase 4 | OpenClaw 真实集成测试 | ⏳ 待开始 |
| Phase 5 | Tauri Rust sidecar + 打包发布 | 📋 规划中 |
### 2.2 代码统计
| 类别 | 文件数 | 说明 |
|------|--------|------|
| Gateway 层 | 3 | manager.ts, ws-client.ts, index.ts |
| 插件 | 6 | 3 plugins × (index.ts + plugin.json) |
| Skills | 2 | 2 × SKILL.md |
| 配置 | 5 | 1 JSON + 4 MD |
| 前端组件 | 15+ | 组件/Store/工具库 |
| v1 遗留代码 | 37+ | src/core/* (已归档) |
### 2.3 编译状态
- TypeScript: **0 errors**
- Vite build: **成功** (1766 modules, 268 KB JS + 26 KB CSS)
---
## 三、核心模块深度分析
### 3.1 OpenClaw Gateway 集成层 (src/gateway/)
#### manager.ts - 子进程管理器
```
功能: 管理 OpenClaw Gateway 子进程生命周期
特性:
- 启动/停止 Gateway daemon
- 健康检查 (HTTP 探测)
- 自动重启 (最多 5 次)
- 连接外部已运行实例
```
#### ws-client.ts - WebSocket 客户端
```
功能: 实现 OpenClaw Gateway Protocol v3
特性:
- 三步握手 (challenge → connect → hello-ok)
- 请求/响应模式 (30秒超时)
- 事件订阅 (agent/chat/presence/health/heartbeat)
- 自动重连 (指数退避 1.5x, 最大 30s)
```
### 3.2 自定义插件系统 (plugins/)
#### @zclaw/chinese-models - 中文模型 Provider
| Provider | 模型 |
|----------|------|
| 智谱 GLM | glm-5, glm-4.7, glm-4-plus, glm-4-flash |
| 通义千问 | qwen3.5-plus, qwen-max, qwen-vl-max |
| Kimi | kimi-k2.5, moonshot-v1-128k |
| MiniMax | minimax-m2.5, abab6.5s-chat |
**设计特点**: 全部使用 OpenAI 兼容 API 格式,支持自定义 Base URL。
#### @zclaw/feishu - 飞书 Channel Plugin
```
功能: 将飞书注册为 OpenClaw 消息渠道
特性:
- OAuth tenant_access_token 管理 (2h 有效期, 1.5h 刷新)
- 文本/富文本消息发送
- 多账户支持
```
#### @zclaw/ui - UI 扩展 RPC
| 方法 | 功能 |
|------|------|
| zclaw.clones.* | 分身 CRUD |
| zclaw.stats.* | 用量/会话统计 |
| zclaw.config.quick | 快速配置 |
| zclaw.workspace.info | 工作区信息 |
| zclaw.plugins.status | 插件状态 |
### 3.3 前端架构 (desktop/src/)
#### 三层布局
```
┌─────────────────────────────────────────────────────────┐
│ Sidebar (w-64) │ ChatArea (flex-1) │ RightPanel (w-72) │
│ 左侧边栏 │ 中间对话区 │ 右侧边栏 │
└─────────────────────────────────────────────────────────┘
```
#### 状态管理 (Zustand)
- **chatStore**: 消息/对话/Agent/流式状态
- **gatewayStore**: 连接/分身/统计/插件状态
#### 关键组件
| 组件 | 职责 |
|------|------|
| ChatArea | 消息展示 + 流式输出 + Markdown渲染 + 模型选择 |
| Sidebar | 四标签 (对话/分身/频道/任务) |
| RightPanel | Gateway状态 + 会话统计 + 插件状态 |
| Settings/* | 10个设置页面对标 AutoClaw |
### 3.4 v1 遗留代码 (已归档)
以下代码位于 `src/core/` 等目录,已从编译范围排除:
| 模块 | 状态 | 替代方案 |
|------|------|----------|
| remote-execution/ | 🗑️ | OpenClaw 工具执行 |
| task-orchestration/ | 🗑️ | OpenClaw Agent Loop |
| multi-agent/ | 🗑️ | OpenClaw agents.list |
| memory/ | 🗑️ | OpenClaw Memory Plugin |
| proactive/ | 🗑️ | OpenClaw Heartbeat Engine |
| im/ | 🗑️ | OpenClaw Channel 系统 |
| db/ | 🗑️ | OpenClaw 自带 SQLite |
| config/ | 🗑️ | OpenClaw 配置系统 |
| api/ | 🗑️ | WebSocket + Tauri Commands |
---
## 四、头脑风暴:机会与挑战
### 4.1 架构优势 💪
1. **复用成熟生态**
- OpenClaw 28万+ Stars工具执行/Skills/MCP/心跳引擎成熟
- 避免重复造轮子,专注差异化价值
2. **Tauri 轻量化**
- ~10MB vs Electron ~150MB
- Rust native 性能优异
- 系统级集成能力
3. **中文优先定位**
- 4大中文模型原生支持
- 飞书/微信/QQ 等 IM 渠道
4. **模块化插件设计**
- Provider/Channel/RPC 插件独立开发
- 可扩展性强
### 4.2 潜在风险 ⚠️
1. **OpenClaw 依赖**
- 版本兼容性风险
- 文档/社区支持限制
2. **v1 遗留代码清理**
- 37+ 文件需要决策 (保留/删除/重构)
- 可能存在可复用的代码片段
3. **Tauri 后端功能有限**
- 当前 Rust 代码几乎为空
- 需要实现 sidecar/文件系统/通知等
4. **测试覆盖不足**
- 未见测试文件
- 集成测试缺失
### 4.3 创新机会 💡
#### A. 分身系统增强
```
当前: 基础 CRUD
增强方向:
- 分身间协作 (多 Agent 编排)
- 分身记忆隔离/共享
- 分身能力画像 (擅长领域)
- 分身市场 (社区分享)
```
#### B. Skills 生态扩展
```
当前: 2 个基础 Skills
扩展方向:
- 社媒运营套件 (微博/小红书/抖音)
- 学术研究助手 (论文/文献/翻译)
- 代码审查专家 (PR Review/安全审计)
- 数据分析专家 (SQL/报表/可视化)
```
#### C. IM 渠道深化
```
当前: 飞书基础支持
深化方向:
- 微信企业号 (WeCom API)
- QQ 机器人 (NapCat/Go-CQHTTP)
- 钉钉/飞书/企业微信三合一
- 消息路由规则 (关键词/时间/来源)
```
#### D. 工作区智能化
```
当前: 基础目录配置
智能方向:
- 项目上下文感知 (package.json/README 解析)
- 代码库索引 (LSP 集成)
- 文件变更监听 (自动同步理解)
- 多工作区切换
```
#### E. 数据分析增强
```
当前: 基础用量统计
增强方向:
- 对话质量分析 (满意度/解决率)
- Token 成本优化建议
- 使用模式洞察 (高峰时段/常用功能)
- 导出报告 (PDF/Excel)
```
### 4.4 技术改进建议 🔧
#### 短期 (1-2周)
1. **完成 Phase 4 集成测试**
- 安装并验证 OpenClaw
- 测试 Gateway 连接
- 验证插件注册
- 端到端消息收发测试
2. **清理 v1 遗留代码**
- 评估每模块的可复用性
- 删除确定无用的代码
- 保留有价值的工具函数
3. **补充基础测试**
- Gateway 协议单元测试
- 前端组件测试
- E2E 关键流程测试
#### 中期 (1-2月)
1. **Tauri Rust 后端扩展**
- Gateway sidecar 管理
- 系统托盘集成
- 原生通知
- 文件系统访问
2. **微信/QQ Channel Plugin**
- 调研 NapCat/Go-CQHTTP
- 实现 Channel Plugin 接口
3. **Skills 扩展**
- 社媒运营套件
- 代码审查助手
#### 长期 (3-6月)
1. **多 Agent 协作可视化**
- 任务依赖图
- 执行进度追踪
- 结果聚合展示
2. **插件市场**
- 插件发现/安装/更新
- 社区贡献机制
3. **移动端伴侣 App**
- Flutter/React Native
- 与桌面端数据同步
---
## 五、竞品对标分析
| 维度 | AutoClaw (智谱) | QClaw (腾讯) | ZCLAW |
|------|----------------|--------------|-------|
| 基础框架 | OpenClaw | OpenClaw | OpenClaw |
| IM 渠道 | 飞书 | 微信+QQ | 飞书 (计划微信/QQ) |
| 桌面框架 | 自研 | Electron | Tauri 2.0 |
| 模型支持 | GLM 系列 | 腾讯混元 | GLM/Qwen/Kimi/MiniMax |
| 安装包大小 | 未知 | ~150MB | ~10MB (目标) |
| 开源状态 | 未开源 | 未开源 | MIT 开源 |
**差异化优势**
- 🦀 Tauri 轻量化
- 🌐 多中文模型支持
- 🔓 开源可定制
- 🧩 插件生态开放
---
## 六、下一步行动计划
### Phase 4: 真实集成测试 (当前优先级)
```
[ ] 1. 安装 OpenClaw
- Windows: iwr -useb https://openclaw.ai/install.ps1 | iex
- 验证: openclaw --version
[ ] 2. 配置 Gateway
- 运行 openclaw configure
- 填入 API Key (智谱/通义/Kimi 任选)
[ ] 3. 启动 Gateway
- openclaw gateway
- 验证: curl http://127.0.0.1:18789/health
[ ] 4. 注册 ZCLAW 插件
- pnpm setup
- 验证插件加载
[ ] 5. 前端连接测试
- cd desktop && pnpm tauri dev
- 验证 WebSocket 连接
- 测试消息收发
[ ] 6. 飞书 Channel 测试
- 配置飞书应用凭证
- 测试消息收发
```
### Phase 5: 打包发布准备
```
[ ] Tauri Rust sidecar 实现
[ ] 安装包测试 (Windows/macOS/Linux)
[ ] 自动更新机制
[ ] 文档完善
[ ] 发布 v0.1.0
```
---
## 七、总结
ZCLAW 是一个定位清晰的 OpenClaw 定制化项目,通过复用成熟生态 + Tauri 轻量桌面 + 中文优先策略,有望成为开源领域的 AutoClaw/QClaw 替代方案。
**核心建议**
1. 🎯 聚焦 Phase 4 集成测试,打通端到端流程
2. 🧹 清理 v1 遗留代码,减少维护负担
3. ✅ 补充测试覆盖,提升代码质量
4. 🚀 迭代发布,快速验证市场反馈
---
---
## 八、执行路线图 (用户选择)
基于用户反馈,将并行推进以下四个方向:
### 8.1 Phase 4: 真实集成测试 🔌
**目标**: 打通 OpenClaw Gateway ↔ ZCLAW Tauri 端到端流程
**任务清单**:
```
[ ] 安装 OpenClaw CLI
- Windows: iwr -useb https://openclaw.ai/install.ps1 | iex
- 验证: openclaw --version
[ ] 配置 Gateway
- 运行 openclaw configure
- 配置 API Key (智谱 GLM 作为首选)
[ ] 启动 Gateway daemon
- openclaw gateway --port 18789
- 验证: curl http://127.0.0.1:18789/health
[ ] 注册 ZCLAW 插件
- pnpm setup (执行 scripts/setup.ts)
- 验证插件加载: openclaw plugins list
[ ] 前端连接测试
- cd desktop && pnpm tauri dev
- 验证 WebSocket 连接成功
- 测试消息发送和流式接收
[ ] 飞书 Channel 测试 (可选)
- 配置飞书应用凭证
- 测试飞书消息收发
```
**验收标准**:
- ✅ Gateway 启动成功,健康检查通过
- ✅ 前端显示"已连接"状态
- ✅ 发送消息能收到 AI 流式回复
- ✅ 模型切换功能正常
### 8.2 清理 v1 遗留代码 🧹
**目标**: 评估并处理 src/core/ 等 37+ 个归档文件
**待处理目录**:
| 目录 | 文件数 | 建议操作 |
|------|--------|----------|
| src/core/remote-execution/ | ~4 | 🗑️ 删除 (OpenClaw 替代) |
| src/core/task-orchestration/ | ~3 | 🗑️ 删除 (OpenClaw 替代) |
| src/core/multi-agent/ | ~8 | 🗑️ 删除 (OpenClaw 替代) |
| src/core/memory/ | ~2 | 🗑️ 删除 (OpenClaw 替代) |
| src/core/proactive/ | ~2 | 🗑️ 删除 (OpenClaw 替代) |
| src/core/ai/ | ~6 | ⚠️ 评估 (可能复用 Provider) |
| src/im/ | ~4 | 🗑️ 删除 (OpenClaw 替代) |
| src/db/ | ~3 | 🗑️ 删除 (OpenClaw 替代) |
| src/config/ | ~2 | 🗑️ 删除 (OpenClaw 替代) |
| src/api/ | ~1 | 🗑️ 删除 (WebSocket 替代) |
| src/app.ts | 1 | 🗑️ 删除 |
| src/index.ts | 1 | 🔄 重写为简单入口 |
**任务清单**:
```
[ ] 创建 archive/v1-backup 分支保存当前状态
[ ] 评估 src/core/ai/ 的可复用性
- providers/zhipu.ts 可能对插件开发有参考价值
- manager.ts 的 fallback 逻辑可借鉴
[ ] 删除确认无用的目录
[ ] 重写 src/index.ts 为最小化入口
[ ] 更新 tsconfig.json 移除对旧代码的引用
[ ] 验证编译: pnpm build (0 errors)
```
**验收标准**:
- ✅ v1 代码已备份到独立分支
- ✅ 主分支代码精简,无死代码
- ✅ TypeScript 编译 0 errors
- ✅ 项目结构清晰,符合 v2 架构
### 8.3 补充测试覆盖 ✅
**目标**: 建立基础测试框架,覆盖关键模块
**测试分层**:
```
tests/
├── unit/ # 单元测试
│ ├── gateway/
│ │ ├── manager.test.ts # 子进程管理
│ │ └── ws-client.test.ts # WebSocket 客户端
│ └── utils/
│ ├── logger.test.ts
│ └── id.test.ts
├── integration/ # 集成测试
│ ├── gateway-protocol.test.ts # Gateway 协议
│ └── plugin-loading.test.ts # 插件加载
└── e2e/ # 端到端测试
└── chat-flow.test.ts # 完整对话流程
```
**任务清单**:
```
[ ] 配置测试框架
- 安装 Vitest + @testing-library/react
- 配置 vitest.config.ts
[ ] Gateway 层单元测试
- ws-client.ts: 握手流程、重连逻辑
- manager.ts: 进程管理、健康检查
[ ] 前端组件测试
- chatStore: 消息发送、流式处理
- gatewayStore: 连接状态管理
- ChatArea: 消息渲染、输入处理
[ ] 集成测试
- Gateway Protocol v3 完整握手
- 插件注册和 RPC 调用
[ ] CI 集成
- GitHub Actions 自动运行测试
- 覆盖率报告
```
**验收标准**:
- ✅ 测试框架配置完成
- ✅ 核心模块测试覆盖率 > 60%
- ✅ CI 流水线运行成功
### 8.4 扩展 Skills/插件 🧩
**目标**: 丰富 Skills 生态,增加 IM 渠道支持
**Skills 扩展计划**:
| Skill | 触发词 | 功能 |
|-------|--------|------|
| social-media | 发微博/小红书/抖音 | 社媒内容创作和发布 |
| code-review | 审查代码/PR Review | 代码质量分析和建议 |
| data-analysis | 分析数据/生成报表 | SQL 查询和可视化 |
| translation | 翻译/中译英 | 多语言翻译 |
**插件扩展计划**:
| 插件 | 类型 | 优先级 |
|------|------|--------|
| @zclaw/wechat | Channel | 高 |
| @zclaw/qq | Channel | 高 |
| @zclaw/terminal | Tool | 中 |
| @zclaw/advanced-memory | Memory | 中 |
**任务清单**:
```
[ ] Skills 开发
- 创建 skills/social-media/SKILL.md
- 创建 skills/code-review/SKILL.md
- 创建 skills/data-analysis/SKILL.md
- 创建 skills/translation/SKILL.md
[ ] 微信 Channel Plugin 调研
- 评估 WeCom API 可行性
- 评估 NapCat/Go-CQHTTP 桥接方案
- 输出技术方案文档
[ ] QQ Channel Plugin 调研
- 评估 NapCatQQ 方案
- 输出技术方案文档
[ ] 插件开发 (选一个优先)
- 实现微信或 QQ Channel Plugin
```
**验收标准**:
- ✅ 至少 2 个新 Skills 可用
- ✅ 微信/QQ 技术方案文档完成
- ✅ 至少 1 个新 Channel Plugin 可测试
---
## 九、执行优先级排序
基于依赖关系和价值,建议执行顺序:
```
第 1 周: Phase 4 集成测试 + v1 代码清理
└─ 这两项可以并行,互不依赖
第 2 周: 补充测试覆盖
└─ 在集成测试通过后补充自动化测试
第 3-4 周: Skills/插件扩展
└─ 在稳定基础上扩展功能
```
**里程碑**:
- 🏁 Week 1 结束: 端到端流程打通 + 代码库精简
- 🏁 Week 2 结束: 测试覆盖率 > 60%
- 🏁 Week 4 结束: 4+ 新 Skills + 1 新 Channel Plugin
---
*分析完成于 2026-03-12*
*执行计划更新于 2026-03-12*

View File

@@ -0,0 +1,211 @@
/**
* ZCLAW Chinese Models Plugin
*
* Registers Chinese AI model providers for OpenClaw Gateway:
* - Zhipu GLM (智谱)
* - Qwen (通义千问)
* - Kimi (月之暗面)
* - MiniMax
*
* All providers use OpenAI-compatible API format.
*/
interface PluginAPI {
config: Record<string, any>;
registerProvider(provider: ProviderDefinition): void;
registerHook(event: string, handler: (...args: any[]) => any, meta?: Record<string, any>): void;
}
interface ProviderDefinition {
id: string;
name: string;
baseUrl: string;
apiKeyEnvVar?: string;
models: ProviderModel[];
headers?: (apiKey: string) => Record<string, string>;
}
interface ProviderModel {
id: string;
alias?: string;
contextWindow?: number;
maxOutputTokens?: number;
supportsVision?: boolean;
supportsStreaming?: boolean;
}
// 智谱 GLM Provider
const zhipuProvider: ProviderDefinition = {
id: 'zhipu',
name: 'Zhipu AI (智谱)',
baseUrl: 'https://open.bigmodel.cn/api/paas/v4',
apiKeyEnvVar: 'ZHIPU_API_KEY',
models: [
{
id: 'glm-5',
alias: 'GLM-5',
contextWindow: 128000,
maxOutputTokens: 4096,
supportsVision: true,
supportsStreaming: true,
},
{
id: 'glm-4.7',
alias: 'GLM-4.7',
contextWindow: 128000,
maxOutputTokens: 4096,
supportsVision: true,
supportsStreaming: true,
},
{
id: 'glm-4-plus',
alias: 'GLM-4-Plus',
contextWindow: 128000,
maxOutputTokens: 4096,
supportsVision: false,
supportsStreaming: true,
},
{
id: 'glm-4-flash',
alias: 'GLM-4-Flash',
contextWindow: 128000,
maxOutputTokens: 4096,
supportsVision: false,
supportsStreaming: true,
},
],
headers: (apiKey: string) => ({
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json',
}),
};
// 通义千问 Provider (OpenAI-compatible via DashScope)
const qwenProvider: ProviderDefinition = {
id: 'qwen',
name: 'Qwen (通义千问)',
baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
apiKeyEnvVar: 'QWEN_API_KEY',
models: [
{
id: 'qwen3.5-plus',
alias: 'Qwen3.5+',
contextWindow: 131072,
maxOutputTokens: 8192,
supportsVision: false,
supportsStreaming: true,
},
{
id: 'qwen-max',
alias: 'Qwen-Max',
contextWindow: 32768,
maxOutputTokens: 8192,
supportsVision: false,
supportsStreaming: true,
},
{
id: 'qwen-vl-max',
alias: 'Qwen-VL-Max',
contextWindow: 32768,
maxOutputTokens: 4096,
supportsVision: true,
supportsStreaming: true,
},
],
headers: (apiKey: string) => ({
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json',
}),
};
// Kimi (月之暗面) Provider
const kimiProvider: ProviderDefinition = {
id: 'kimi',
name: 'Kimi (月之暗面)',
baseUrl: 'https://api.moonshot.cn/v1',
apiKeyEnvVar: 'KIMI_API_KEY',
models: [
{
id: 'kimi-k2.5',
alias: 'Kimi-K2.5',
contextWindow: 131072,
maxOutputTokens: 8192,
supportsVision: false,
supportsStreaming: true,
},
{
id: 'moonshot-v1-128k',
alias: 'Moonshot-128K',
contextWindow: 128000,
maxOutputTokens: 4096,
supportsVision: false,
supportsStreaming: true,
},
],
headers: (apiKey: string) => ({
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json',
}),
};
// MiniMax Provider
const minimaxProvider: ProviderDefinition = {
id: 'minimax',
name: 'MiniMax',
baseUrl: 'https://api.minimax.chat/v1',
apiKeyEnvVar: 'MINIMAX_API_KEY',
models: [
{
id: 'minimax-m2.5',
alias: 'MiniMax-M2.5',
contextWindow: 245760,
maxOutputTokens: 16384,
supportsVision: false,
supportsStreaming: true,
},
{
id: 'abab6.5s-chat',
alias: 'ABAB-6.5s',
contextWindow: 245760,
maxOutputTokens: 8192,
supportsVision: false,
supportsStreaming: true,
},
],
headers: (apiKey: string) => ({
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json',
}),
};
/**
* Plugin entry point - registers all Chinese model providers
*/
export default function register(api: PluginAPI) {
const pluginConfig = api.config?.plugins?.entries?.['zclaw-chinese-models']?.config ?? {};
// Register Zhipu GLM
const zhipu = { ...zhipuProvider };
if (pluginConfig.zhipuBaseUrl) zhipu.baseUrl = pluginConfig.zhipuBaseUrl;
api.registerProvider(zhipu);
// Register Qwen
const qwen = { ...qwenProvider };
if (pluginConfig.qwenBaseUrl) qwen.baseUrl = pluginConfig.qwenBaseUrl;
api.registerProvider(qwen);
// Register Kimi
const kimi = { ...kimiProvider };
if (pluginConfig.kimiBaseUrl) kimi.baseUrl = pluginConfig.kimiBaseUrl;
api.registerProvider(kimi);
// Register MiniMax
const minimax = { ...minimaxProvider };
if (pluginConfig.minimaxBaseUrl) minimax.baseUrl = pluginConfig.minimaxBaseUrl;
api.registerProvider(minimax);
// Log registration
api.registerHook('gateway:startup', async () => {
console.log('[ZCLAW] Chinese model providers registered: zhipu, qwen, kimi, minimax');
}, { name: 'zclaw-chinese-models.startup', description: 'Log provider registration on startup' });
}

View File

@@ -0,0 +1,32 @@
{
"id": "zclaw-chinese-models",
"name": "ZCLAW Chinese Models",
"version": "0.1.0",
"description": "Chinese AI model providers for ZCLAW: Zhipu GLM, Qwen, Kimi, MiniMax",
"author": "ZCLAW",
"entry": "index.ts",
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {
"zhipuApiKey": { "type": "string" },
"zhipuBaseUrl": { "type": "string" },
"qwenApiKey": { "type": "string" },
"qwenBaseUrl": { "type": "string" },
"kimiApiKey": { "type": "string" },
"kimiBaseUrl": { "type": "string" },
"minimaxApiKey": { "type": "string" },
"minimaxBaseUrl": { "type": "string" }
}
},
"uiHints": {
"zhipuApiKey": { "label": "智谱 API Key", "sensitive": true },
"zhipuBaseUrl": { "label": "智谱 Base URL", "placeholder": "https://open.bigmodel.cn/api/paas/v4" },
"qwenApiKey": { "label": "通义千问 API Key", "sensitive": true },
"qwenBaseUrl": { "label": "通义千问 Base URL", "placeholder": "https://dashscope.aliyuncs.com/compatible-mode/v1" },
"kimiApiKey": { "label": "Kimi API Key", "sensitive": true },
"kimiBaseUrl": { "label": "Kimi Base URL", "placeholder": "https://api.moonshot.cn/v1" },
"minimaxApiKey": { "label": "MiniMax API Key", "sensitive": true },
"minimaxBaseUrl": { "label": "MiniMax Base URL", "placeholder": "https://api.minimax.chat/v1" }
}
}

View File

@@ -0,0 +1,231 @@
/**
* ZCLAW Feishu Channel Plugin
*
* Registers Feishu (飞书/Lark) as a messaging channel for OpenClaw Gateway.
* Supports:
* - Receiving messages via Feishu Event Subscription
* - Sending text/rich messages back to Feishu
* - OAuth token management (tenant_access_token)
* - Webhook-based event handling
*/
interface PluginAPI {
config: Record<string, any>;
registerChannel(opts: { plugin: ChannelPlugin }): void;
registerHook(event: string, handler: (...args: any[]) => any, meta?: Record<string, any>): void;
registerGatewayMethod(method: string, handler: (ctx: any) => void): void;
registerService(id: string, service: BackgroundService): void;
}
interface BackgroundService {
start(): Promise<void>;
stop(): Promise<void>;
}
interface ChannelPlugin {
id: string;
meta: {
id: string;
label: string;
selectionLabel: string;
docsPath?: string;
blurb: string;
aliases: string[];
detailLabel?: string;
};
capabilities: {
chatTypes: string[];
};
config: {
listAccountIds: (cfg: any) => string[];
resolveAccount: (cfg: any, accountId?: string) => any;
};
outbound: {
deliveryMode: string;
sendText: (opts: SendTextOpts) => Promise<{ ok: boolean; error?: string }>;
sendRichText?: (opts: SendRichTextOpts) => Promise<{ ok: boolean; error?: string }>;
};
gateway?: {
start?: (ctx: any) => Promise<void>;
stop?: (ctx: any) => Promise<void>;
};
}
interface SendTextOpts {
text: string;
chatId: string;
accountId?: string;
}
interface SendRichTextOpts {
content: any;
chatId: string;
accountId?: string;
}
// Feishu API helpers
class FeishuClient {
private baseUrl = 'https://open.feishu.cn/open-apis';
private appId: string;
private appSecret: string;
private tenantToken: string | null = null;
private tokenExpiry: number = 0;
constructor(appId: string, appSecret: string) {
this.appId = appId;
this.appSecret = appSecret;
}
async getTenantToken(): Promise<string> {
if (this.tenantToken && Date.now() < this.tokenExpiry) {
return this.tenantToken;
}
const response = await fetch(`${this.baseUrl}/auth/v3/tenant_access_token/internal`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
app_id: this.appId,
app_secret: this.appSecret,
}),
});
const data: any = await response.json();
if (data.code !== 0) {
throw new Error(`Feishu auth failed: ${data.msg}`);
}
this.tenantToken = data.tenant_access_token;
// Token valid for 2 hours, refresh at 1.5h
this.tokenExpiry = Date.now() + 90 * 60 * 1000;
return this.tenantToken!;
}
async sendMessage(chatId: string, msgType: string, content: any): Promise<{ ok: boolean; error?: string }> {
try {
const token = await this.getTenantToken();
const response = await fetch(`${this.baseUrl}/im/v1/messages?receive_id_type=chat_id`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
receive_id: chatId,
msg_type: msgType,
content: JSON.stringify(content),
}),
});
const data: any = await response.json();
if (data.code !== 0) {
return { ok: false, error: data.msg };
}
return { ok: true };
} catch (error: any) {
return { ok: false, error: error.message };
}
}
async sendText(chatId: string, text: string): Promise<{ ok: boolean; error?: string }> {
return this.sendMessage(chatId, 'text', { text });
}
async sendRichText(chatId: string, content: any): Promise<{ ok: boolean; error?: string }> {
return this.sendMessage(chatId, 'post', content);
}
}
// Global client instance per account
const clients = new Map<string, FeishuClient>();
function getClient(account: { appId: string; appSecret: string }, accountId: string): FeishuClient {
if (!clients.has(accountId)) {
clients.set(accountId, new FeishuClient(account.appId, account.appSecret));
}
return clients.get(accountId)!;
}
// Channel definition
const feishuChannel: ChannelPlugin = {
id: 'feishu',
meta: {
id: 'feishu',
label: '飞书 (Feishu)',
selectionLabel: 'Feishu / Lark',
blurb: 'Connect to Feishu (飞书) for messaging via Feishu Bot API.',
aliases: ['lark', 'feishu'],
detailLabel: '飞书机器人',
},
capabilities: {
chatTypes: ['direct', 'group'],
},
config: {
listAccountIds: (cfg: any) =>
Object.keys(cfg.channels?.feishu?.accounts ?? {}),
resolveAccount: (cfg: any, accountId?: string) =>
cfg.channels?.feishu?.accounts?.[accountId ?? 'default'] ?? { accountId },
},
outbound: {
deliveryMode: 'direct',
sendText: async ({ text, chatId, accountId }: SendTextOpts) => {
const cfg = (globalThis as any).__openclaw_config;
const account = feishuChannel.config.resolveAccount(cfg, accountId);
if (!account?.appId || !account?.appSecret) {
return { ok: false, error: 'Feishu account not configured (missing appId/appSecret)' };
}
const client = getClient(account, accountId ?? 'default');
return client.sendText(chatId, text);
},
sendRichText: async ({ content, chatId, accountId }: SendRichTextOpts) => {
const cfg = (globalThis as any).__openclaw_config;
const account = feishuChannel.config.resolveAccount(cfg, accountId);
if (!account?.appId || !account?.appSecret) {
return { ok: false, error: 'Feishu account not configured' };
}
const client = getClient(account, accountId ?? 'default');
return client.sendRichText(chatId, content);
},
},
};
/**
* Plugin entry point
*/
export default function register(api: PluginAPI) {
// Register Feishu as a channel
api.registerChannel({ plugin: feishuChannel });
// Register custom RPC method for Feishu status
api.registerGatewayMethod('feishu.status', ({ respond }: any) => {
const accountIds = feishuChannel.config.listAccountIds(api.config);
respond(true, {
channel: 'feishu',
accounts: accountIds.length,
configured: accountIds.length > 0,
});
});
// Startup hook
api.registerHook('gateway:startup', async () => {
const accountIds = feishuChannel.config.listAccountIds(api.config);
if (accountIds.length > 0) {
console.log(`[ZCLAW] Feishu channel registered with ${accountIds.length} account(s)`);
// Pre-warm token for each account
for (const id of accountIds) {
try {
const account = feishuChannel.config.resolveAccount(api.config, id);
if (account?.appId && account?.appSecret && account?.enabled !== false) {
const client = getClient(account, id);
await client.getTenantToken();
console.log(`[ZCLAW] Feishu account "${id}" token acquired`);
}
} catch (err: any) {
console.warn(`[ZCLAW] Feishu account "${id}" token failed: ${err.message}`);
}
}
} else {
console.log('[ZCLAW] Feishu channel registered (no accounts configured yet)');
}
}, { name: 'zclaw-feishu.startup', description: 'Initialize Feishu connections on startup' });
}

View File

@@ -0,0 +1,27 @@
{
"id": "zclaw-feishu",
"name": "ZCLAW Feishu Channel",
"version": "0.1.0",
"description": "Feishu (飞书/Lark) messaging channel plugin for OpenClaw",
"author": "ZCLAW",
"entry": "index.ts",
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {
"appId": { "type": "string" },
"appSecret": { "type": "string" },
"verificationToken": { "type": "string" },
"encryptKey": { "type": "string" },
"webhookUrl": { "type": "string" }
},
"required": ["appId", "appSecret"]
},
"uiHints": {
"appId": { "label": "飞书 App ID", "placeholder": "cli_xxxxxxxxxxxx" },
"appSecret": { "label": "飞书 App Secret", "sensitive": true },
"verificationToken": { "label": "Verification Token", "sensitive": true },
"encryptKey": { "label": "Encrypt Key", "sensitive": true },
"webhookUrl": { "label": "Webhook URL (可选)", "placeholder": "https://your-server/feishu/webhook" }
}
}

258
plugins/zclaw-ui/index.ts Normal file
View File

@@ -0,0 +1,258 @@
/**
* ZCLAW UI Extensions Plugin
*
* Registers custom Gateway RPC methods that the ZCLAW Tauri desktop UI
* calls for features beyond standard OpenClaw protocol.
*
* Custom methods:
* - zclaw.clones.list → list all agent clones (分身)
* - zclaw.clones.create → create a new clone
* - zclaw.clones.update → update clone config
* - zclaw.clones.delete → delete a clone
* - zclaw.stats.usage → token usage statistics
* - zclaw.stats.sessions → session statistics
* - zclaw.config.quick → quick configuration (name/role/scenarios)
* - zclaw.workspace.info → workspace information
* - zclaw.plugins.status → all ZCLAW plugin statuses
*/
import * as fs from 'fs';
import * as path from 'path';
interface PluginAPI {
config: Record<string, any>;
registerGatewayMethod(method: string, handler: (ctx: RpcContext) => void): void;
registerHook(event: string, handler: (...args: any[]) => any, meta?: Record<string, any>): void;
}
interface RpcContext {
params: Record<string, any>;
respond(ok: boolean, payload: any): void;
}
// Clone (分身) management - stored in ZCLAW config
interface CloneConfig {
id: string;
name: string;
role?: string;
nickname?: string;
scenarios?: string[];
model?: string;
createdAt: string;
}
export default function register(api: PluginAPI) {
const configDir = process.env.OPENCLAW_HOME || path.join(process.env.HOME || process.env.USERPROFILE || '', '.openclaw');
const zclawDataPath = path.join(configDir, 'zclaw-data.json');
// Helper: read/write ZCLAW data
function readZclawData(): { clones: CloneConfig[]; quickConfig?: Record<string, any> } {
try {
if (fs.existsSync(zclawDataPath)) {
return JSON.parse(fs.readFileSync(zclawDataPath, 'utf-8'));
}
} catch { /* ignore */ }
return { clones: [] };
}
function writeZclawData(data: any) {
fs.mkdirSync(path.dirname(zclawDataPath), { recursive: true });
fs.writeFileSync(zclawDataPath, JSON.stringify(data, null, 2), 'utf-8');
}
// === Clone Management ===
api.registerGatewayMethod('zclaw.clones.list', ({ respond }: RpcContext) => {
const data = readZclawData();
respond(true, { clones: data.clones });
});
api.registerGatewayMethod('zclaw.clones.create', ({ params, respond }: RpcContext) => {
const data = readZclawData();
const clone: CloneConfig = {
id: `clone_${Date.now().toString(36)}`,
name: params.name || 'New Clone',
role: params.role,
nickname: params.nickname,
scenarios: params.scenarios || [],
model: params.model,
createdAt: new Date().toISOString(),
};
data.clones.push(clone);
writeZclawData(data);
respond(true, { clone });
});
api.registerGatewayMethod('zclaw.clones.update', ({ params, respond }: RpcContext) => {
const data = readZclawData();
const index = data.clones.findIndex((c: CloneConfig) => c.id === params.id);
if (index === -1) {
respond(false, { error: 'Clone not found' });
return;
}
data.clones[index] = { ...data.clones[index], ...params.updates };
writeZclawData(data);
respond(true, { clone: data.clones[index] });
});
api.registerGatewayMethod('zclaw.clones.delete', ({ params, respond }: RpcContext) => {
const data = readZclawData();
data.clones = data.clones.filter((c: CloneConfig) => c.id !== params.id);
writeZclawData(data);
respond(true, { ok: true });
});
// === Statistics ===
api.registerGatewayMethod('zclaw.stats.usage', ({ respond }: RpcContext) => {
// Read session files to compute token usage
const sessionsDir = path.join(configDir, 'agents');
const stats = {
totalSessions: 0,
totalMessages: 0,
totalTokens: 0,
byModel: {} as Record<string, { messages: number; inputTokens: number; outputTokens: number }>,
};
try {
if (fs.existsSync(sessionsDir)) {
const agentDirs = fs.readdirSync(sessionsDir);
for (const agentDir of agentDirs) {
const sessDir = path.join(sessionsDir, agentDir, 'sessions');
if (!fs.existsSync(sessDir)) continue;
const sessionFiles = fs.readdirSync(sessDir).filter((f: string) => f.endsWith('.jsonl'));
stats.totalSessions += sessionFiles.length;
for (const file of sessionFiles) {
try {
const content = fs.readFileSync(path.join(sessDir, file), 'utf-8');
const lines = content.split('\n').filter(Boolean);
for (const line of lines) {
try {
const entry = JSON.parse(line);
if (entry.role === 'assistant' || entry.role === 'user') {
stats.totalMessages++;
}
if (entry.usage) {
const model = entry.model || 'unknown';
if (!stats.byModel[model]) {
stats.byModel[model] = { messages: 0, inputTokens: 0, outputTokens: 0 };
}
stats.byModel[model].messages++;
stats.byModel[model].inputTokens += entry.usage.input_tokens || 0;
stats.byModel[model].outputTokens += entry.usage.output_tokens || 0;
stats.totalTokens += (entry.usage.input_tokens || 0) + (entry.usage.output_tokens || 0);
}
} catch { /* skip malformed lines */ }
}
} catch { /* skip unreadable files */ }
}
}
}
} catch { /* sessions dir may not exist yet */ }
respond(true, stats);
});
api.registerGatewayMethod('zclaw.stats.sessions', ({ respond }: RpcContext) => {
const sessionsDir = path.join(configDir, 'agents');
const sessions: any[] = [];
try {
if (fs.existsSync(sessionsDir)) {
const agentDirs = fs.readdirSync(sessionsDir);
for (const agentDir of agentDirs) {
const sessDir = path.join(sessionsDir, agentDir, 'sessions');
if (!fs.existsSync(sessDir)) continue;
const sessionFiles = fs.readdirSync(sessDir).filter((f: string) => f.endsWith('.jsonl'));
for (const file of sessionFiles) {
const stat = fs.statSync(path.join(sessDir, file));
sessions.push({
id: file.replace('.jsonl', ''),
agentId: agentDir,
createdAt: stat.birthtime.toISOString(),
updatedAt: stat.mtime.toISOString(),
size: stat.size,
});
}
}
}
} catch { /* ignore */ }
sessions.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
respond(true, { sessions });
});
// === Quick Configuration ===
api.registerGatewayMethod('zclaw.config.quick', ({ params, respond }: RpcContext) => {
const data = readZclawData();
if (params.get) {
respond(true, { quickConfig: data.quickConfig || {} });
return;
}
// Save quick config
data.quickConfig = {
userName: params.userName,
userRole: params.userRole,
agentNickname: params.agentNickname,
scenarios: params.scenarios || [],
workspaceDir: params.workspaceDir,
};
writeZclawData(data);
respond(true, { ok: true, quickConfig: data.quickConfig });
});
// === Workspace Info ===
api.registerGatewayMethod('zclaw.workspace.info', ({ respond }: RpcContext) => {
const workspace = api.config?.agents?.defaults?.workspace || '~/.openclaw/zclaw-workspace';
const resolvedPath = workspace.replace('~', process.env.HOME || process.env.USERPROFILE || '');
let exists = false;
let fileCount = 0;
let totalSize = 0;
try {
exists = fs.existsSync(resolvedPath);
if (exists) {
const files = fs.readdirSync(resolvedPath, { recursive: true }) as string[];
for (const file of files) {
const fullPath = path.join(resolvedPath, file);
try {
const stat = fs.statSync(fullPath);
if (stat.isFile()) {
fileCount++;
totalSize += stat.size;
}
} catch { /* skip */ }
}
}
} catch { /* ignore */ }
respond(true, {
path: workspace,
resolvedPath,
exists,
fileCount,
totalSize,
});
});
// === Plugin Status ===
api.registerGatewayMethod('zclaw.plugins.status', ({ respond }: RpcContext) => {
respond(true, {
plugins: [
{ id: 'zclaw-chinese-models', status: 'active', version: '0.1.0' },
{ id: 'zclaw-feishu', status: api.config?.channels?.feishu?.enabled ? 'active' : 'inactive', version: '0.1.0' },
{ id: 'zclaw-ui', status: 'active', version: '0.1.0' },
],
});
});
// Startup log
api.registerHook('gateway:startup', async () => {
console.log('[ZCLAW] UI extension RPC methods registered');
}, { name: 'zclaw-ui.startup', description: 'Log ZCLAW UI extensions registration' });
}

View File

@@ -0,0 +1,8 @@
{
"id": "zclaw-ui",
"name": "ZCLAW UI Extensions",
"version": "0.1.0",
"description": "Custom Gateway RPC methods for ZCLAW Tauri desktop UI",
"author": "ZCLAW",
"entry": "index.ts"
}

2790
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

178
scripts/setup.ts Normal file
View File

@@ -0,0 +1,178 @@
/**
* ZCLAW Setup Script
*
* First-time setup for ZCLAW:
* 1. Check if OpenClaw is installed
* 2. Copy default config files to ~/.openclaw/
* 3. Register ZCLAW custom plugins
* 4. Create workspace directory
*/
import { execSync, exec } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
const OPENCLAW_HOME = process.env.OPENCLAW_HOME || path.join(
process.env.HOME || process.env.USERPROFILE || '',
'.openclaw'
);
const ZCLAW_ROOT = path.resolve(__dirname, '..');
function log(msg: string) {
console.log(`[ZCLAW Setup] ${msg}`);
}
function error(msg: string) {
console.error(`[ZCLAW Setup] ❌ ${msg}`);
}
function success(msg: string) {
console.log(`[ZCLAW Setup] ✅ ${msg}`);
}
// Step 1: Check OpenClaw installation
function checkOpenClaw(): boolean {
try {
const version = execSync('openclaw --version', { encoding: 'utf-8' }).trim();
success(`OpenClaw found: ${version}`);
return true;
} catch {
error('OpenClaw not found. Please install it first:');
console.log(' Windows: iwr -useb https://openclaw.ai/install.ps1 | iex');
console.log(' macOS/Linux: curl -fsSL https://openclaw.ai/install.sh | bash');
console.log(' npm: npm install -g openclaw@latest');
return false;
}
}
// Step 2: Copy default config
function setupConfig() {
const configDir = OPENCLAW_HOME;
const configFile = path.join(configDir, 'openclaw.json');
// Create directory
fs.mkdirSync(configDir, { recursive: true });
// Copy default config if no config exists
if (!fs.existsSync(configFile)) {
const defaultConfig = fs.readFileSync(
path.join(ZCLAW_ROOT, 'config', 'openclaw.default.json'),
'utf-8'
);
// Update plugin paths to absolute paths
const config = JSON.parse(defaultConfig);
if (config.plugins?.load?.paths) {
config.plugins.load.paths = config.plugins.load.paths.map((p: string) =>
path.resolve(ZCLAW_ROOT, p)
);
}
if (config.skills?.load?.extraDirs) {
config.skills.load.extraDirs = config.skills.load.extraDirs.map((p: string) =>
path.resolve(ZCLAW_ROOT, p)
);
}
fs.writeFileSync(configFile, JSON.stringify(config, null, 2), 'utf-8');
success(`Config written to ${configFile}`);
} else {
log(`Config already exists at ${configFile}, skipping`);
}
// Copy bootstrap files
const bootstrapFiles = ['SOUL.md', 'AGENTS.md', 'IDENTITY.md', 'USER.md'];
const workspaceDir = path.join(configDir, 'zclaw-workspace');
fs.mkdirSync(workspaceDir, { recursive: true });
for (const file of bootstrapFiles) {
const src = path.join(ZCLAW_ROOT, 'config', file);
const dest = path.join(workspaceDir, file);
if (fs.existsSync(src) && !fs.existsSync(dest)) {
fs.copyFileSync(src, dest);
success(`Bootstrap file copied: ${file}`);
}
}
}
// Step 3: Register plugins
function registerPlugins() {
const pluginDirs = ['zclaw-chinese-models', 'zclaw-feishu', 'zclaw-ui'];
for (const plugin of pluginDirs) {
const pluginPath = path.join(ZCLAW_ROOT, 'plugins', plugin);
if (fs.existsSync(pluginPath)) {
try {
// Use openclaw plugins install -l (link mode) for development
execSync(`openclaw plugins install -l "${pluginPath}"`, {
encoding: 'utf-8',
stdio: 'pipe'
});
success(`Plugin linked: ${plugin}`);
} catch (err: any) {
// Plugin might already be registered, or command format different
log(`Plugin ${plugin} registration note: ${err.message?.split('\n')[0] || 'check manually'}`);
}
}
}
}
// Step 4: Create workspace
function setupWorkspace() {
const workspace = path.join(OPENCLAW_HOME, 'zclaw-workspace');
const dirs = ['skills', 'output', 'context'];
for (const dir of dirs) {
const fullPath = path.join(workspace, dir);
fs.mkdirSync(fullPath, { recursive: true });
}
success(`Workspace ready at ${workspace}`);
}
// Main
async function main() {
console.log('');
console.log('🦞 ZCLAW Setup');
console.log('─'.repeat(40));
console.log('');
// Step 1
log('Checking OpenClaw installation...');
const hasOpenClaw = checkOpenClaw();
if (!hasOpenClaw) {
console.log('');
console.log('Install OpenClaw first, then re-run this setup.');
process.exit(1);
}
// Step 2
console.log('');
log('Setting up configuration...');
setupConfig();
// Step 3
console.log('');
log('Registering plugins...');
registerPlugins();
// Step 4
console.log('');
log('Setting up workspace...');
setupWorkspace();
console.log('');
console.log('─'.repeat(40));
success('ZCLAW setup complete!');
console.log('');
console.log('Next steps:');
console.log(' 1. Configure API keys: openclaw configure');
console.log(' 2. Start gateway: openclaw gateway');
console.log(' 3. Launch ZCLAW desktop: cd desktop && pnpm tauri dev');
console.log('');
}
main().catch((err) => {
error(`Setup failed: ${err.message}`);
process.exit(1);
});

View File

@@ -0,0 +1,37 @@
---
name: chinese-writing
description: 中文写作助手 - 帮助撰写各类中文文档、文章、报告
triggers:
- "写一篇"
- "帮我写"
- "撰写"
- "起草"
- "润色"
- "中文写作"
---
# 中文写作助手
你是一个专业的中文写作助手。
## 能力
- 撰写各类中文文档:报告、邮件、文章、总结
- 润色修改已有文本
- 调整语气和风格(正式/非正式/学术/商务)
- 中英文翻译
## 写作规范
1. 使用简体中文
2. 段落清晰,逻辑流畅
3. 避免冗余表述
4. 标点符号使用中文标点(,。!?:;""''
5. 数字和英文单词前后留空格
## 工作流
1. 确认写作需求:类型、长度、语气、受众
2. 生成初稿
3. 根据反馈修改润色
4. 输出最终版本

View File

@@ -0,0 +1,36 @@
---
name: feishu-docs
description: 飞书文档操作 - 创建、编辑、搜索飞书文档和知识库
triggers:
- "飞书文档"
- "创建文档"
- "知识库"
- "飞书笔记"
tools:
- bash
- web_fetch
---
# 飞书文档操作
通过飞书开放 API 操作文档和知识库。
## 能力
- 创建飞书文档 (docx)
- 编辑文档内容
- 搜索知识库
- 导出文档为 Markdown
- 管理文档权限
## 前置条件
- 飞书 App ID 和 App Secret 已配置
- 飞书应用已获得文档相关权限
## 常用 API
- `POST /open-apis/docx/v1/documents` - 创建文档
- `GET /open-apis/docx/v1/documents/:id` - 获取文档
- `PATCH /open-apis/docx/v1/documents/:id/blocks/:block_id` - 编辑内容
- `POST /open-apis/suite/docs-api/search/object` - 搜索文档

144
src/api/index.ts Normal file
View File

@@ -0,0 +1,144 @@
// ZCLAW API 层 - 供 Tauri 前端调用的接口
import type { ZClawApp } from '../app';
import { createLogger } from '../utils/logger';
const log = createLogger('API');
export interface APIResponse<T = any> {
success: boolean;
data?: T;
error?: string;
}
// API 处理器集合 - 供 Tauri Commands 调用
export class ZClawAPI {
constructor(private app: ZClawApp) {}
// === 聊天 ===
async sendMessage(userId: string, content: string): Promise<APIResponse<{ reply: string }>> {
try {
const reply = await this.app.handleUserMessage(userId, content);
return { success: true, data: { reply } };
} catch (error: any) {
log.error(`sendMessage error: ${error.message}`);
return { success: false, error: error.message };
}
}
// === 任务 ===
async submitTask(userId: string, payload: any): Promise<APIResponse<{ taskId: string }>> {
try {
const engine = this.app.getRemoteExecution();
const taskId = await engine.submitTask({
id: '',
userId,
deviceId: 'local',
channel: 'desktop',
type: 'immediate',
priority: 'normal',
payload,
status: 'pending',
createdAt: new Date(),
});
return { success: true, data: { taskId } };
} catch (error: any) {
return { success: false, error: error.message };
}
}
async getTaskStatus(taskId: string): Promise<APIResponse<{ status: string }>> {
try {
const engine = this.app.getRemoteExecution();
const status = await engine.getStatus(taskId);
return { success: true, data: { status } };
} catch (error: any) {
return { success: false, error: error.message };
}
}
async getTaskStats(): Promise<APIResponse> {
try {
const stats = this.app.getRemoteExecution().getStats();
return { success: true, data: stats };
} catch (error: any) {
return { success: false, error: error.message };
}
}
// === 多 Agent ===
async executeGoal(userId: string, goal: string): Promise<APIResponse> {
try {
const result = await this.app.getAgentOrchestrator().executeGoal(goal);
return { success: true, data: result };
} catch (error: any) {
return { success: false, error: error.message };
}
}
// === 记忆 ===
async getUserProfile(userId: string): Promise<APIResponse> {
try {
const profile = await this.app.getMemory().getProfile(userId);
return { success: true, data: profile || null };
} catch (error: any) {
return { success: false, error: error.message };
}
}
async getMemoryEvents(userId: string, query: string = '', limit: number = 20): Promise<APIResponse> {
try {
const events = await this.app.getMemory().recall(userId, query, limit);
return { success: true, data: events };
} catch (error: any) {
return { success: false, error: error.message };
}
}
// === 定时任务 ===
async listScheduledTasks(userId: string): Promise<APIResponse> {
try {
const tasks = await this.app.getProactive().listTasks(userId);
return { success: true, data: tasks };
} catch (error: any) {
return { success: false, error: error.message };
}
}
async scheduleTask(userId: string, schedule: any, taskConfig: any): Promise<APIResponse> {
try {
await this.app.getProactive().scheduleTask({
id: '',
userId,
channel: 'desktop',
schedule,
task: taskConfig,
status: 'active',
});
return { success: true };
} catch (error: any) {
return { success: false, error: error.message };
}
}
// === 系统状态 ===
async getSystemStatus(): Promise<APIResponse> {
try {
const taskStats = this.app.getRemoteExecution().getStats();
const agents = this.app.getAgentOrchestrator().getAgents().map(a => a.getInfo());
const activePlans = this.app.getAgentOrchestrator().getActivePlans();
const connectedIMs = this.app.getIMGateway().getConnectedAdapters();
return {
success: true,
data: {
taskStats,
activeAgents: agents.length,
activePlans: activePlans.length,
connectedIMs,
},
};
} catch (error: any) {
return { success: false, error: error.message };
}
}
}

173
src/app.ts Normal file
View File

@@ -0,0 +1,173 @@
// ZCLAW 主应用类 - 统一协调所有系统
import { loadConfig, getConfig } from './config';
import { initDatabase, closeDatabase } from './db';
import { AIManager } from './core/ai/manager';
import { AgentOrchestrator } from './core/multi-agent/orchestrator';
import { RemoteExecutionEngine } from './core/remote-execution/engine';
import { TaskOrchestrator } from './core/task-orchestration/orchestrator';
import { PersistentMemorySystem } from './core/memory/memory';
import { ProactiveServiceSystem } from './core/proactive/proactive';
import { IMGateway } from './im/gateway';
import { FeishuAdapter } from './im/adapters/feishu';
import { createLogger, setLogLevel } from './utils/logger';
const log = createLogger('ZCLAW');
export class ZClawApp {
private ai!: AIManager;
private agentOrchestrator!: AgentOrchestrator;
private remoteExecution!: RemoteExecutionEngine;
private taskOrchestrator!: TaskOrchestrator;
private memory!: PersistentMemorySystem;
private proactive!: ProactiveServiceSystem;
private imGateway!: IMGateway;
async start(): Promise<void> {
log.info('========================================');
log.info(' ZCLAW - AI Agent Platform');
log.info(' Version: 0.1.0');
log.info('========================================');
// 1. 加载配置
const config = loadConfig();
setLogLevel(config.log.level);
log.info('Configuration loaded');
// 2. 初始化数据库
initDatabase(config.db.path);
log.info('Database initialized');
// 3. 初始化 AI 管理器
this.ai = new AIManager();
log.info('AI Manager initialized');
// 4. 初始化核心系统
this.remoteExecution = new RemoteExecutionEngine();
this.taskOrchestrator = new TaskOrchestrator();
this.memory = new PersistentMemorySystem();
this.proactive = new ProactiveServiceSystem();
log.info('Core systems initialized');
// 5. 初始化多 Agent 协作系统
this.agentOrchestrator = new AgentOrchestrator(this.ai);
log.info('Agent Orchestrator initialized');
// 6. 初始化 IM 网关
this.imGateway = new IMGateway();
this.setupIMAdapters(config);
this.setupMessageRouting();
log.info('IM Gateway initialized');
// 7. 连接 IM
await this.imGateway.connectAll();
log.info('ZCLAW started successfully! 🦞');
log.info(`Server: ${config.server.host}:${config.server.port}`);
log.info(`AI Provider: ${config.ai.provider} (${config.ai.defaultModel})`);
log.info(`Connected IMs: ${this.imGateway.getConnectedAdapters().join(', ') || 'none'}`);
}
private setupIMAdapters(config: ReturnType<typeof getConfig>): void {
// 飞书
if (config.im.feishu.enabled || config.im.feishu.appId) {
const feishu = new FeishuAdapter(config.im.feishu.appId, config.im.feishu.appSecret);
this.imGateway.registerAdapter(feishu);
}
// TODO: 其他 IM 适配器 (Telegram, QQ, WeChat)
}
private setupMessageRouting(): void {
this.imGateway.onMessage(async (message) => {
log.info(`Processing message from ${message.channelType}/${message.userId}: ${message.content.slice(0, 80)}`);
try {
// 记忆:记录用户消息
await this.memory.remember(message.userId, {
id: message.id,
userId: message.userId,
type: 'im_message',
content: { text: message.content, channel: message.channelType },
timestamp: new Date(),
});
// 判断消息类型并路由
const response = await this.handleUserMessage(message.userId, message.content);
// 回复
await this.imGateway.send({
channelType: message.channelType,
channelId: message.channelId,
userId: message.userId,
content: response,
contentType: 'text',
});
} catch (error: any) {
log.error(`Message processing error: ${error.message}`);
await this.imGateway.send({
channelType: message.channelType,
channelId: message.channelId,
content: `处理消息时出错: ${error.message}`,
contentType: 'text',
});
}
});
}
async handleUserMessage(userId: string, content: string): Promise<string> {
// 获取用户记忆上下文
const recentMemories = await this.memory.recall(userId, content, 5);
const context: Record<string, any> = {};
if (recentMemories.length > 0) {
context.recentHistory = recentMemories.map(m => m.content);
}
// 判断是否需要多步骤编排
const isComplex = this.isComplexTask(content);
if (isComplex) {
// 复杂任务 -> 多 Agent 协作
log.info(`Complex task detected, starting multi-agent execution`);
const result = await this.agentOrchestrator.executeGoal(content, context);
return result.success
? result.data?.report || '任务已完成'
: `任务执行失败: ${result.error}`;
} else {
// 简单对话 -> 直接 AI 回复
const profile = await this.memory.getProfile(userId);
const systemPrompt = `你是 ZCLAW一个智能 AI 助手 🦞。你友善、专业、高效。
${profile ? `用户偏好: ${JSON.stringify(profile.preferences)}` : ''}
请用中文简洁回复。`;
return await this.ai.ask(content, systemPrompt);
}
}
private isComplexTask(content: string): boolean {
const complexIndicators = [
'帮我做', '市场调研', '写报告', '分析',
'自动化', '批量', '多步骤', '流程',
'调研', '搜索并整理', '对比', '综合',
'项目管理', '代码生成', '部署',
];
return complexIndicators.some(indicator => content.includes(indicator));
}
// 公开接口:供 Tauri 前端调用
getAI(): AIManager { return this.ai; }
getAgentOrchestrator(): AgentOrchestrator { return this.agentOrchestrator; }
getRemoteExecution(): RemoteExecutionEngine { return this.remoteExecution; }
getTaskOrchestrator(): TaskOrchestrator { return this.taskOrchestrator; }
getMemory(): PersistentMemorySystem { return this.memory; }
getProactive(): ProactiveServiceSystem { return this.proactive; }
getIMGateway(): IMGateway { return this.imGateway; }
async shutdown(): Promise<void> {
log.info('Shutting down ZCLAW...');
await this.agentOrchestrator.shutdown();
await this.imGateway.disconnectAll();
closeDatabase();
log.info('ZCLAW shutdown complete');
}
}

118
src/config/index.ts Normal file
View File

@@ -0,0 +1,118 @@
// ZCLAW 配置管理
import { z } from 'zod';
const ConfigSchema = z.object({
// AI Provider
ai: z.object({
provider: z.enum(['zhipu', 'openai', 'local']).default('zhipu'),
zhipuApiKey: z.string().default(''),
openaiApiKey: z.string().default(''),
openaiBaseUrl: z.string().default('https://api.openai.com/v1'),
defaultModel: z.string().default('glm-4-flash'),
maxTokens: z.number().default(4096),
temperature: z.number().default(0.7),
}),
// Database
db: z.object({
path: z.string().default('./data/zclaw.db'),
}),
// Server
server: z.object({
port: z.number().default(3721),
host: z.string().default('127.0.0.1'),
}),
// IM Channels
im: z.object({
feishu: z.object({
appId: z.string().default(''),
appSecret: z.string().default(''),
enabled: z.boolean().default(false),
}),
telegram: z.object({
botToken: z.string().default(''),
enabled: z.boolean().default(false),
}),
}),
// Execution
execution: z.object({
maxConcurrent: z.number().default(5),
taskTimeout: z.number().default(300000), // 5 minutes
retryAttempts: z.number().default(3),
}),
// Memory
memory: z.object({
maxEventsPerUser: z.number().default(10000),
embeddingModel: z.string().default('text-embedding-3-small'),
}),
// Logging
log: z.object({
level: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
}),
});
export type ZClawConfig = z.infer<typeof ConfigSchema>;
let _config: ZClawConfig | null = null;
export function loadConfig(overrides?: Partial<Record<string, any>>): ZClawConfig {
const env = process.env;
const raw = {
ai: {
provider: env.ZCLAW_AI_PROVIDER || 'zhipu',
zhipuApiKey: env.ZCLAW_ZHIPU_API_KEY || env.ZHIPU_API_KEY || '',
openaiApiKey: env.ZCLAW_OPENAI_API_KEY || env.OPENAI_API_KEY || '',
openaiBaseUrl: env.ZCLAW_OPENAI_BASE_URL || 'https://api.openai.com/v1',
defaultModel: env.ZCLAW_DEFAULT_MODEL || 'glm-4-flash',
maxTokens: parseInt(env.ZCLAW_MAX_TOKENS || '4096'),
temperature: parseFloat(env.ZCLAW_TEMPERATURE || '0.7'),
},
db: {
path: env.ZCLAW_DB_PATH || './data/zclaw.db',
},
server: {
port: parseInt(env.ZCLAW_PORT || '3721'),
host: env.ZCLAW_HOST || '127.0.0.1',
},
im: {
feishu: {
appId: env.ZCLAW_FEISHU_APP_ID || '',
appSecret: env.ZCLAW_FEISHU_APP_SECRET || '',
enabled: env.ZCLAW_FEISHU_ENABLED === 'true',
},
telegram: {
botToken: env.ZCLAW_TELEGRAM_BOT_TOKEN || '',
enabled: env.ZCLAW_TELEGRAM_ENABLED === 'true',
},
},
execution: {
maxConcurrent: parseInt(env.ZCLAW_MAX_CONCURRENT || '5'),
taskTimeout: parseInt(env.ZCLAW_TASK_TIMEOUT || '300000'),
retryAttempts: parseInt(env.ZCLAW_RETRY_ATTEMPTS || '3'),
},
memory: {
maxEventsPerUser: parseInt(env.ZCLAW_MAX_EVENTS || '10000'),
embeddingModel: env.ZCLAW_EMBEDDING_MODEL || 'text-embedding-3-small',
},
log: {
level: env.ZCLAW_LOG_LEVEL || 'info',
},
...overrides,
};
_config = ConfigSchema.parse(raw);
return _config;
}
export function getConfig(): ZClawConfig {
if (!_config) {
return loadConfig();
}
return _config;
}

4
src/core/ai/index.ts Normal file
View File

@@ -0,0 +1,4 @@
export type { AIProvider, ChatMessage, ChatRequest, ChatResponse, StreamChunk, EmbeddingRequest, EmbeddingResponse } from './types';
export { AIManager, getAIManager } from './manager';
export { ZhipuProvider } from './providers/zhipu';
export { OpenAIProvider } from './providers/openai';

142
src/core/ai/manager.ts Normal file
View File

@@ -0,0 +1,142 @@
// AI 模型管理器 - 统一调用接口,支持多 Provider fallback
import type { AIProvider, ChatRequest, ChatResponse, ChatMessage, StreamChunk, EmbeddingRequest, EmbeddingResponse } from './types';
import { ZhipuProvider } from './providers/zhipu';
import { OpenAIProvider } from './providers/openai';
import { getConfig } from '../../config';
import { createLogger } from '../../utils/logger';
const log = createLogger('AIManager');
export class AIManager {
private providers: Map<string, AIProvider> = new Map();
private defaultProvider: string;
constructor() {
const config = getConfig();
this.defaultProvider = config.ai.provider;
// 注册智谱 GLM
if (config.ai.zhipuApiKey) {
this.providers.set('zhipu', new ZhipuProvider(config.ai.zhipuApiKey));
log.info('Zhipu GLM provider registered');
}
// 注册 OpenAI 兼容
if (config.ai.openaiApiKey) {
this.providers.set('openai', new OpenAIProvider(config.ai.openaiApiKey, config.ai.openaiBaseUrl));
log.info('OpenAI provider registered');
}
if (this.providers.size === 0) {
log.warn('No AI providers configured. Set ZCLAW_ZHIPU_API_KEY or ZCLAW_OPENAI_API_KEY.');
}
}
getProvider(name?: string): AIProvider {
const providerName = name || this.defaultProvider;
const provider = this.providers.get(providerName);
if (!provider) {
// fallback: 尝试其他可用 provider
const fallback = this.providers.values().next().value;
if (fallback) {
log.warn(`Provider '${providerName}' not available, falling back to '${fallback.name}'`);
return fallback;
}
throw new Error(`No AI provider available. Configure at least one API key.`);
}
return provider;
}
async chat(request: ChatRequest, providerName?: string): Promise<ChatResponse> {
const provider = this.getProvider(providerName);
const config = getConfig();
const enrichedRequest: ChatRequest = {
...request,
model: request.model || config.ai.defaultModel,
temperature: request.temperature ?? config.ai.temperature,
maxTokens: request.maxTokens ?? config.ai.maxTokens,
};
log.debug(`Chat via ${provider.name}`, { model: enrichedRequest.model, messages: enrichedRequest.messages.length });
try {
const response = await provider.chat(enrichedRequest);
log.debug(`Chat response: ${response.usage.totalTokens} tokens`);
return response;
} catch (error: any) {
log.error(`Chat failed via ${provider.name}: ${error.message}`);
// 尝试 fallback
for (const [name, fallbackProvider] of this.providers) {
if (name !== provider.name) {
log.info(`Retrying with fallback provider: ${name}`);
try {
return await fallbackProvider.chat(enrichedRequest);
} catch {
continue;
}
}
}
throw error;
}
}
async chatStream(request: ChatRequest, providerName?: string): Promise<AsyncIterable<StreamChunk>> {
const provider = this.getProvider(providerName);
if (!provider.chatStream) {
throw new Error(`Provider '${provider.name}' does not support streaming`);
}
return provider.chatStream(request);
}
async embed(request: EmbeddingRequest, providerName?: string): Promise<EmbeddingResponse> {
const provider = this.getProvider(providerName);
if (!provider.embed) {
throw new Error(`Provider '${provider.name}' does not support embeddings`);
}
return provider.embed(request);
}
// 便捷方法:单次对话
async ask(prompt: string, systemPrompt?: string): Promise<string> {
const messages: ChatMessage[] = [];
if (systemPrompt) {
messages.push({ role: 'system', content: systemPrompt });
}
messages.push({ role: 'user', content: prompt });
const response = await this.chat({ messages });
return response.content;
}
// 便捷方法:带上下文的多轮对话
async chatWithHistory(messages: ChatMessage[], systemPrompt?: string): Promise<string> {
const allMessages: ChatMessage[] = [];
if (systemPrompt) {
allMessages.push({ role: 'system', content: systemPrompt });
}
allMessages.push(...messages);
const response = await this.chat({ messages: allMessages });
return response.content;
}
// 便捷方法JSON 输出
async askJson<T = any>(prompt: string, systemPrompt?: string): Promise<T> {
const jsonSystemPrompt = (systemPrompt || '') + '\n\n请严格以 JSON 格式输出,不要包含 markdown 代码块标记。';
const content = await this.ask(prompt, jsonSystemPrompt);
// 清理可能的 markdown 代码块包裹
const cleaned = content.replace(/^```(?:json)?\s*\n?/i, '').replace(/\n?```\s*$/i, '').trim();
return JSON.parse(cleaned) as T;
}
}
let _manager: AIManager | null = null;
export function getAIManager(): AIManager {
if (!_manager) {
_manager = new AIManager();
}
return _manager;
}

View File

@@ -0,0 +1,139 @@
// OpenAI Compatible Provider (also works for local models via API)
import type { AIProvider, ChatRequest, ChatResponse, StreamChunk, EmbeddingRequest, EmbeddingResponse } from '../types';
import { createLogger } from '../../../utils/logger';
const log = createLogger('OpenAIProvider');
export class OpenAIProvider implements AIProvider {
name = 'openai';
private apiKey: string;
private baseUrl: string;
constructor(apiKey: string, baseUrl: string = 'https://api.openai.com/v1') {
this.apiKey = apiKey;
this.baseUrl = baseUrl.replace(/\/$/, '');
}
async chat(request: ChatRequest): Promise<ChatResponse> {
const model = request.model || 'gpt-4o-mini';
log.debug(`Chat request to ${model}`);
const response = await fetch(`${this.baseUrl}/chat/completions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.apiKey}`,
},
body: JSON.stringify({
model,
messages: request.messages,
temperature: request.temperature ?? 0.7,
max_tokens: request.maxTokens ?? 4096,
stream: false,
}),
});
if (!response.ok) {
const errorText = await response.text();
log.error(`OpenAI API error: ${response.status}`, errorText);
throw new Error(`OpenAI API error: ${response.status} - ${errorText}`);
}
const data: any = await response.json();
const choice = data.choices?.[0];
return {
content: choice?.message?.content || '',
model: data.model || model,
usage: {
promptTokens: data.usage?.prompt_tokens || 0,
completionTokens: data.usage?.completion_tokens || 0,
totalTokens: data.usage?.total_tokens || 0,
},
finishReason: choice?.finish_reason || 'stop',
};
}
async *chatStream(request: ChatRequest): AsyncIterable<StreamChunk> {
const model = request.model || 'gpt-4o-mini';
const response = await fetch(`${this.baseUrl}/chat/completions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.apiKey}`,
},
body: JSON.stringify({
model,
messages: request.messages,
temperature: request.temperature ?? 0.7,
max_tokens: request.maxTokens ?? 4096,
stream: true,
}),
});
if (!response.ok) {
throw new Error(`OpenAI API error: ${response.status}`);
}
const reader = response.body?.getReader();
if (!reader) throw new Error('No response body');
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.slice(6).trim();
if (data === '[DONE]') {
yield { content: '', done: true };
return;
}
try {
const parsed = JSON.parse(data);
const delta = parsed.choices?.[0]?.delta?.content || '';
if (delta) {
yield { content: delta, done: false };
}
} catch {
// skip
}
}
}
}
}
async embed(request: EmbeddingRequest): Promise<EmbeddingResponse> {
const input = Array.isArray(request.input) ? request.input : [request.input];
const model = request.model || 'text-embedding-3-small';
const response = await fetch(`${this.baseUrl}/embeddings`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.apiKey}`,
},
body: JSON.stringify({ model, input }),
});
if (!response.ok) {
throw new Error(`OpenAI Embedding API error: ${response.status}`);
}
const data: any = await response.json();
return {
embeddings: data.data?.map((d: any) => d.embedding) || [],
model: data.model || model,
usage: { totalTokens: data.usage?.total_tokens || 0 },
};
}
}

View File

@@ -0,0 +1,138 @@
// 智谱 GLM AI Provider
import type { AIProvider, ChatRequest, ChatResponse, StreamChunk, EmbeddingRequest, EmbeddingResponse } from '../types';
import { createLogger } from '../../../utils/logger';
const log = createLogger('ZhipuProvider');
export class ZhipuProvider implements AIProvider {
name = 'zhipu';
private apiKey: string;
private baseUrl = 'https://open.bigmodel.cn/api/paas/v4';
constructor(apiKey: string) {
this.apiKey = apiKey;
}
async chat(request: ChatRequest): Promise<ChatResponse> {
const model = request.model || 'glm-4-flash';
log.debug(`Chat request to ${model}`, { messageCount: request.messages.length });
const response = await fetch(`${this.baseUrl}/chat/completions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.apiKey}`,
},
body: JSON.stringify({
model,
messages: request.messages,
temperature: request.temperature ?? 0.7,
max_tokens: request.maxTokens ?? 4096,
stream: false,
}),
});
if (!response.ok) {
const errorText = await response.text();
log.error(`Zhipu API error: ${response.status}`, errorText);
throw new Error(`Zhipu API error: ${response.status} - ${errorText}`);
}
const data: any = await response.json();
const choice = data.choices?.[0];
return {
content: choice?.message?.content || '',
model: data.model || model,
usage: {
promptTokens: data.usage?.prompt_tokens || 0,
completionTokens: data.usage?.completion_tokens || 0,
totalTokens: data.usage?.total_tokens || 0,
},
finishReason: choice?.finish_reason || 'stop',
};
}
async *chatStream(request: ChatRequest): AsyncIterable<StreamChunk> {
const model = request.model || 'glm-4-flash';
const response = await fetch(`${this.baseUrl}/chat/completions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.apiKey}`,
},
body: JSON.stringify({
model,
messages: request.messages,
temperature: request.temperature ?? 0.7,
max_tokens: request.maxTokens ?? 4096,
stream: true,
}),
});
if (!response.ok) {
throw new Error(`Zhipu API error: ${response.status}`);
}
const reader = response.body?.getReader();
if (!reader) throw new Error('No response body');
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.slice(6).trim();
if (data === '[DONE]') {
yield { content: '', done: true };
return;
}
try {
const parsed = JSON.parse(data);
const delta = parsed.choices?.[0]?.delta?.content || '';
if (delta) {
yield { content: delta, done: false };
}
} catch {
// skip invalid JSON
}
}
}
}
}
async embed(request: EmbeddingRequest): Promise<EmbeddingResponse> {
const input = Array.isArray(request.input) ? request.input : [request.input];
const model = request.model || 'embedding-3';
const response = await fetch(`${this.baseUrl}/embeddings`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.apiKey}`,
},
body: JSON.stringify({ model, input }),
});
if (!response.ok) {
throw new Error(`Zhipu Embedding API error: ${response.status}`);
}
const data: any = await response.json();
return {
embeddings: data.data?.map((d: any) => d.embedding) || [],
model: data.model || model,
usage: { totalTokens: data.usage?.total_tokens || 0 },
};
}
}

48
src/core/ai/types.ts Normal file
View File

@@ -0,0 +1,48 @@
// AI 模型集成层 - 类型定义
export interface ChatMessage {
role: 'system' | 'user' | 'assistant';
content: string;
}
export interface ChatRequest {
messages: ChatMessage[];
model?: string;
temperature?: number;
maxTokens?: number;
stream?: boolean;
}
export interface ChatResponse {
content: string;
model: string;
usage: {
promptTokens: number;
completionTokens: number;
totalTokens: number;
};
finishReason: string;
}
export interface StreamChunk {
content: string;
done: boolean;
}
export interface EmbeddingRequest {
input: string | string[];
model?: string;
}
export interface EmbeddingResponse {
embeddings: number[][];
model: string;
usage: { totalTokens: number };
}
export interface AIProvider {
name: string;
chat(request: ChatRequest): Promise<ChatResponse>;
chatStream?(request: ChatRequest): AsyncIterable<StreamChunk>;
embed?(request: EmbeddingRequest): Promise<EmbeddingResponse>;
}

View File

@@ -1,4 +1,9 @@
// 持续记忆系统
import { generateId } from '../../utils/id';
import { createLogger } from '../../utils/logger';
const log = createLogger('Memory');
export interface UserProfile {
id: string;
preferences: {
@@ -29,7 +34,7 @@ export class PersistentMemorySystem {
event.id = event.id || this.generateId();
event.timestamp = new Date();
this.events.push(event);
console.log([Memory] Event remembered: );
log.debug(`Event remembered: ${event.id} for user ${userId}`);
}
async recall(userId: string, query: string, limit: number = 10): Promise<MemoryEvent[]> {
@@ -42,13 +47,24 @@ export class PersistentMemorySystem {
}
async updateProfile(userId: string, updates: Partial<UserProfile>): Promise<void> {
const profile = this.profiles.get(userId);
if (profile) {
Object.assign(profile, updates);
let profile = this.profiles.get(userId);
if (!profile) {
profile = {
id: userId,
preferences: { language: 'zh', timezone: 'Asia/Shanghai', responseStyle: 'concise' },
patterns: { activeHours: [], frequentCommands: [] },
};
this.profiles.set(userId, profile);
}
Object.assign(profile, updates);
log.debug(`Profile updated for user ${userId}`);
}
async getEventCount(userId: string): Promise<number> {
return this.events.filter(e => e.userId === userId).length;
}
private generateId(): string {
return mem__;
return generateId('mem');
}
}

View File

@@ -0,0 +1,40 @@
// Combiner Agent - 整合多个 Agent 的执行结果
import { BaseAgent } from '../base-agent';
import type { AgentTask } from '../types';
const COMBINER_SYSTEM_PROMPT = `你是一个结果整合专家。你的职责是将多个执行步骤的结果整合为一份完整、结构化的报告。
要求:
1. 综合分析所有步骤的执行结果
2. 提取关键信息
3. 生成简洁清晰的最终报告
4. 如果某些步骤失败,说明缺失的信息
5. 输出格式清晰,适合通过 IM 发送给用户`;
export class CombinerAgent extends BaseAgent {
protected getCapabilities(): string[] {
return ['result_combination', 'report_generation', 'data_synthesis'];
}
protected async run(task: AgentTask): Promise<any> {
const results = task.params.results || [];
const goal = task.params.goal || task.description;
this.log.info(`Combining ${results.length} results for: ${goal}`);
const prompt = `原始目标: ${goal}
各步骤执行结果:
${results.map((r: any, i: number) => `
--- 步骤 ${i + 1} ---
描述: ${r.description || '未知'}
状态: ${r.success ? '成功' : '失败'}
结果: ${JSON.stringify(r.data || r.error, null, 2)}
`).join('\n')}
请整合上述结果,生成一份完整的报告。输出应该清晰、有条理,适合通过即时通讯发送给用户。`;
const report = await this.ai.ask(prompt, COMBINER_SYSTEM_PROMPT);
return { report, stepsCount: results.length, successCount: results.filter((r: any) => r.success).length };
}
}

View File

@@ -0,0 +1,83 @@
// Executor Agent - 通用执行器,根据工具类型执行具体操作
import { BaseAgent } from '../base-agent';
import type { AgentTask } from '../types';
export class BrowserAgent extends BaseAgent {
protected getCapabilities(): string[] {
return ['web_browse', 'web_search', 'screenshot', 'form_fill'];
}
protected async run(task: AgentTask): Promise<any> {
this.log.info(`Browser task: ${task.description}`);
// 使用 AI 模拟浏览器操作结果(后续集成真实浏览器控制)
const prompt = `你需要模拟执行以下浏览器操作并生成合理的结果:
操作描述: ${task.description}
参数: ${JSON.stringify(task.params)}
上下文: ${JSON.stringify(task.context)}
请以 JSON 格式输出执行结果,包含 status、data 字段。`;
return await this.ai.askJson(prompt, '你是一个浏览器操作模拟器,请生成合理的操作结果。');
}
}
export class FileAgent extends BaseAgent {
protected getCapabilities(): string[] {
return ['file_read', 'file_write', 'file_search', 'file_organize'];
}
protected async run(task: AgentTask): Promise<any> {
this.log.info(`File task: ${task.description}`);
const prompt = `你需要执行以下文件操作并生成结果:
操作描述: ${task.description}
参数: ${JSON.stringify(task.params)}
上下文: ${JSON.stringify(task.context)}
请以 JSON 格式输出执行结果。`;
return await this.ai.askJson(prompt, '你是一个文件操作执行器。');
}
}
export class TerminalAgent extends BaseAgent {
protected getCapabilities(): string[] {
return ['command_execute', 'script_run', 'system_info'];
}
protected async run(task: AgentTask): Promise<any> {
this.log.info(`Terminal task: ${task.description}`);
const prompt = `你需要模拟执行以下终端命令操作并生成合理结果:
操作描述: ${task.description}
参数: ${JSON.stringify(task.params)}
上下文: ${JSON.stringify(task.context)}
请以 JSON 格式输出执行结果,包含 stdout、stderr、exitCode 字段。`;
return await this.ai.askJson(prompt, '你是一个终端命令模拟器。');
}
}
export class AIAnalysisAgent extends BaseAgent {
protected getCapabilities(): string[] {
return ['text_analysis', 'content_generation', 'data_processing', 'summarization'];
}
protected async run(task: AgentTask): Promise<any> {
this.log.info(`AI analysis task: ${task.description}`);
const systemPrompt = task.params.systemPrompt || '你是一个智能分析助手,请根据要求完成分析任务。';
const prompt = `${task.description}
${task.context ? `上下文数据:\n${JSON.stringify(task.context, null, 2)}` : ''}
${task.params.data ? `输入数据:\n${JSON.stringify(task.params.data, null, 2)}` : ''}`;
const result = await this.ai.ask(prompt, systemPrompt);
return { analysis: result };
}
}

View File

@@ -0,0 +1,64 @@
// Planner Agent - 任务规划,将用户目标拆解为可执行步骤
import { BaseAgent } from '../base-agent';
import type { AgentTask } from '../types';
const PLANNER_SYSTEM_PROMPT = `你是一个高级任务规划专家。你的职责是将用户的目标拆解为可执行的步骤序列。
可用工具类型:
- browser: 浏览器操作(访问网页、截图、填表、搜索)
- file: 文件操作(读写、搜索、整理、生成文档)
- terminal: 终端命令(代码执行、系统操作、安装软件)
- api: API 调用(搜索引擎、天气、翻译等)
- ai: AI 分析(文本分析、内容生成、数据处理)
输出要求:
严格输出 JSON 格式,不要包含 markdown 代码块标记。
{
"steps": [
{
"id": "step_1",
"description": "步骤描述",
"tool": "browser",
"params": { "url": "https://example.com", "action": "search" },
"dependencies": []
},
{
"id": "step_2",
"description": "步骤描述",
"tool": "ai",
"params": { "action": "analyze" },
"dependencies": ["step_1"]
}
]
}
规则:
1. 每个步骤必须明确、可执行
2. 标注步骤之间的依赖关系dependencies 数组)
3. 无依赖的步骤可以并行执行
4. 步骤数量控制在 3-10 个之间
5. 最后一个步骤通常是整合/汇总结果`;
export class PlannerAgent extends BaseAgent {
protected getCapabilities(): string[] {
return ['task_planning', 'task_decomposition', 'dependency_analysis'];
}
protected async run(task: AgentTask): Promise<any> {
const goal = task.params.goal || task.description;
const context = task.context || {};
this.log.info(`Planning task: ${goal}`);
const prompt = `目标: ${goal}
${Object.keys(context).length > 0 ? `上下文信息:\n${JSON.stringify(context, null, 2)}` : ''}
请将上述目标拆解为可执行步骤。`;
const response = await this.ai.askJson<{ steps: any[] }>(prompt, PLANNER_SYSTEM_PROMPT);
this.log.info(`Plan generated: ${response.steps.length} steps`);
return response;
}
}

View File

@@ -0,0 +1,126 @@
// 多 Agent 协作系统 - Agent 基类
import type { AgentType, AgentConfig, AgentInfo, AgentTask, AgentResult, AgentMessage, AgentStatus } from './types';
import type { MessageBus } from './message-bus';
import type { AIManager } from '../ai/manager';
import { generateId } from '../../utils/id';
import { createLogger, type Logger } from '../../utils/logger';
export abstract class BaseAgent {
readonly id: string;
readonly type: AgentType;
readonly name: string;
protected status: AgentStatus = 'idle';
protected config: AgentConfig;
protected messageBus: MessageBus;
protected ai: AIManager;
protected log: Logger;
protected currentTaskId?: string;
protected createdAt: Date = new Date();
constructor(
type: AgentType,
config: AgentConfig,
messageBus: MessageBus,
ai: AIManager,
) {
this.id = generateId('agent');
this.type = type;
this.name = config.name || `${type}-agent`;
this.config = config;
this.messageBus = messageBus;
this.ai = ai;
this.log = createLogger(`Agent:${this.name}`);
// 订阅消息
this.messageBus.subscribe(this.id, (msg) => this.handleMessage(msg));
this.log.info(`Agent created: ${this.id}`);
}
getInfo(): AgentInfo {
return {
id: this.id,
type: this.type,
name: this.name,
status: this.status,
capabilities: this.getCapabilities(),
currentTaskId: this.currentTaskId,
createdAt: this.createdAt,
};
}
async execute(task: AgentTask): Promise<AgentResult> {
this.status = 'busy';
this.currentTaskId = task.id;
const startTime = Date.now();
this.log.info(`Executing task: ${task.id} - ${task.description}`);
// 通知开始执行
await this.messageBus.send({
from: this.id,
to: '*',
type: 'status',
payload: { taskId: task.id, status: 'running' },
});
try {
const data = await this.run(task);
const result: AgentResult = {
taskId: task.id,
agentId: this.id,
success: true,
data,
duration: Date.now() - startTime,
};
// 通知完成
await this.messageBus.send({
from: this.id,
to: '*',
type: 'result',
payload: result,
});
this.status = 'idle';
this.currentTaskId = undefined;
this.log.info(`Task completed: ${task.id} (${result.duration}ms)`);
return result;
} catch (error: any) {
const result: AgentResult = {
taskId: task.id,
agentId: this.id,
success: false,
error: error.message,
duration: Date.now() - startTime,
};
await this.messageBus.send({
from: this.id,
to: '*',
type: 'error',
payload: result,
});
this.status = 'error';
this.currentTaskId = undefined;
this.log.error(`Task failed: ${task.id} - ${error.message}`);
return result;
}
}
async terminate(): Promise<void> {
this.status = 'terminated';
this.messageBus.unsubscribe(this.id);
this.log.info(`Agent terminated: ${this.id}`);
}
// 子类必须实现
protected abstract run(task: AgentTask): Promise<any>;
protected abstract getCapabilities(): string[];
protected async handleMessage(message: AgentMessage): Promise<void> {
this.log.debug(`Received message from ${message.from}: ${message.type}`);
}
}

View File

@@ -0,0 +1,7 @@
export type { AgentType, AgentConfig, AgentInfo, AgentTask, AgentResult, AgentMessage, AgentStatus, MultiAgentPlan } from './types';
export { BaseAgent } from './base-agent';
export { MessageBus } from './message-bus';
export { AgentOrchestrator } from './orchestrator';
export { PlannerAgent } from './agents/planner-agent';
export { BrowserAgent, FileAgent, TerminalAgent, AIAnalysisAgent } from './agents/executor-agent';
export { CombinerAgent } from './agents/combiner-agent';

View File

@@ -0,0 +1,71 @@
// 多 Agent 消息总线 - Agent 间通信的核心
import type { AgentMessage, MessageHandler } from './types';
import { generateId } from '../../utils/id';
import { createLogger } from '../../utils/logger';
const log = createLogger('MessageBus');
export class MessageBus {
private subscribers: Map<string, MessageHandler[]> = new Map();
private broadcastHandlers: MessageHandler[] = [];
private messageLog: AgentMessage[] = [];
subscribe(agentId: string, handler: MessageHandler): void {
if (!this.subscribers.has(agentId)) {
this.subscribers.set(agentId, []);
}
this.subscribers.get(agentId)!.push(handler);
log.debug(`Agent ${agentId} subscribed to message bus`);
}
unsubscribe(agentId: string): void {
this.subscribers.delete(agentId);
log.debug(`Agent ${agentId} unsubscribed from message bus`);
}
onBroadcast(handler: MessageHandler): void {
this.broadcastHandlers.push(handler);
}
async send(message: Omit<AgentMessage, 'id' | 'timestamp'>): Promise<void> {
const fullMessage: AgentMessage = {
...message,
id: generateId('msg'),
timestamp: new Date(),
};
this.messageLog.push(fullMessage);
log.debug(`Message: ${fullMessage.from} -> ${fullMessage.to} [${fullMessage.type}]`);
if (fullMessage.to === '*') {
// 广播
const allHandlers = [...this.broadcastHandlers];
for (const [, handlers] of this.subscribers) {
allHandlers.push(...handlers);
}
await Promise.allSettled(allHandlers.map(h => h(fullMessage)));
} else {
// 定向发送
const handlers = this.subscribers.get(fullMessage.to);
if (handlers) {
await Promise.allSettled(handlers.map(h => h(fullMessage)));
} else {
log.warn(`No handlers for agent ${fullMessage.to}`);
}
}
}
getMessages(agentId?: string, limit: number = 50): AgentMessage[] {
let messages = this.messageLog;
if (agentId) {
messages = messages.filter(m => m.from === agentId || m.to === agentId || m.to === '*');
}
return messages.slice(-limit);
}
clear(): void {
this.subscribers.clear();
this.broadcastHandlers = [];
this.messageLog = [];
}
}

View File

@@ -0,0 +1,262 @@
// 多 Agent 协作系统 - 编排器(核心协调器)
import type { AgentType, AgentConfig, AgentTask, AgentResult, MultiAgentPlan } from './types';
import { BaseAgent } from './base-agent';
import { MessageBus } from './message-bus';
import { PlannerAgent } from './agents/planner-agent';
import { BrowserAgent, FileAgent, TerminalAgent, AIAnalysisAgent } from './agents/executor-agent';
import { CombinerAgent } from './agents/combiner-agent';
import { AIManager } from '../ai/manager';
import { generateId } from '../../utils/id';
import { createLogger } from '../../utils/logger';
const log = createLogger('AgentOrchestrator');
type ProgressCallback = (plan: MultiAgentPlan, currentStep?: string) => void;
export class AgentOrchestrator {
private agents: Map<string, BaseAgent> = new Map();
private messageBus: MessageBus;
private ai: AIManager;
private activePlans: Map<string, MultiAgentPlan> = new Map();
constructor(ai: AIManager) {
this.messageBus = new MessageBus();
this.ai = ai;
log.info('AgentOrchestrator initialized');
}
// 创建 Agent
spawnAgent(type: AgentType, config: AgentConfig = {}): BaseAgent {
let agent: BaseAgent;
switch (type) {
case 'planner':
agent = new PlannerAgent(type, config, this.messageBus, this.ai);
break;
case 'browser':
agent = new BrowserAgent(type, config, this.messageBus, this.ai);
break;
case 'file':
agent = new FileAgent(type, config, this.messageBus, this.ai);
break;
case 'terminal':
agent = new TerminalAgent(type, config, this.messageBus, this.ai);
break;
case 'combiner':
agent = new CombinerAgent(type, config, this.messageBus, this.ai);
break;
case 'custom':
default:
agent = new AIAnalysisAgent(type, config, this.messageBus, this.ai);
break;
}
this.agents.set(agent.id, agent);
log.info(`Agent spawned: ${agent.id} (${type})`);
return agent;
}
// 终止 Agent
async terminateAgent(agentId: string): Promise<void> {
const agent = this.agents.get(agentId);
if (agent) {
await agent.terminate();
this.agents.delete(agentId);
}
}
// 核心方法:执行多 Agent 协作任务
async executeGoal(goal: string, context: Record<string, any> = {}, onProgress?: ProgressCallback): Promise<AgentResult> {
const planId = generateId('plan');
log.info(`Starting multi-agent execution: ${goal} (${planId})`);
const plan: MultiAgentPlan = {
id: planId,
goal,
steps: [],
agentAssignments: new Map(),
status: 'planning',
results: [],
createdAt: new Date(),
};
this.activePlans.set(planId, plan);
try {
// Phase 1: 规划 - 使用 Planner Agent 拆解任务
plan.status = 'planning';
onProgress?.(plan, '正在规划任务...');
const planner = this.spawnAgent('planner', { name: 'task-planner' });
const planResult = await planner.execute({
id: generateId('task'),
type: 'plan',
description: '规划任务',
params: { goal },
context,
dependencies: [],
});
if (!planResult.success || !planResult.data?.steps) {
throw new Error(`Planning failed: ${planResult.error || 'No steps generated'}`);
}
const steps: AgentTask[] = planResult.data.steps.map((s: any) => ({
id: s.id || generateId('step'),
type: s.tool || 'ai',
description: s.description,
params: s.params || {},
context: {},
dependencies: s.dependencies || [],
}));
plan.steps = steps;
log.info(`Plan created with ${steps.length} steps`);
onProgress?.(plan, `已规划 ${steps.length} 个步骤`);
// Phase 2: 执行 - 按依赖顺序执行各步骤
plan.status = 'executing';
const stepResults: Map<string, AgentResult> = new Map();
// 拓扑排序
const sortedSteps = this.topologicalSort(steps);
for (const step of sortedSteps) {
// 等待依赖完成
for (const depId of step.dependencies) {
const depResult = stepResults.get(depId);
if (depResult?.data) {
step.context[depId] = depResult.data;
}
}
// 根据工具类型选择 Agent
const agentType = this.mapToolToAgentType(step.type);
const executor = this.spawnAgent(agentType, { name: `executor-${step.id}` });
plan.agentAssignments.set(step.id, executor.id);
onProgress?.(plan, `正在执行: ${step.description}`);
const result = await executor.execute(step);
stepResults.set(step.id, result);
plan.results.push(result);
// 执行完毕,终止 executor
await this.terminateAgent(executor.id);
if (!result.success) {
log.warn(`Step failed: ${step.id} - ${result.error}`);
// 继续执行其他不依赖此步骤的任务
}
}
// Phase 3: 整合 - 使用 Combiner Agent 汇总结果
onProgress?.(plan, '正在整合结果...');
const combiner = this.spawnAgent('combiner', { name: 'result-combiner' });
const combineResult = await combiner.execute({
id: generateId('task'),
type: 'combine',
description: '整合所有步骤的结果',
params: {
goal,
results: plan.results.map((r, i) => ({
...r,
description: steps[i]?.description,
})),
},
context: {},
dependencies: [],
});
// 清理
await this.terminateAgent(planner.id);
await this.terminateAgent(combiner.id);
plan.status = 'completed';
onProgress?.(plan, '任务完成');
log.info(`Multi-agent execution completed: ${planId}`);
return {
taskId: planId,
agentId: 'orchestrator',
success: combineResult.success,
data: {
plan: plan.goal,
stepsExecuted: plan.results.length,
stepsSucceeded: plan.results.filter(r => r.success).length,
report: combineResult.data?.report || '',
stepResults: plan.results,
},
duration: Date.now() - plan.createdAt.getTime(),
};
} catch (error: any) {
plan.status = 'failed';
log.error(`Multi-agent execution failed: ${error.message}`);
// 清理所有 Agent
for (const [id] of this.agents) {
await this.terminateAgent(id);
}
return {
taskId: planId,
agentId: 'orchestrator',
success: false,
error: error.message,
duration: Date.now() - plan.createdAt.getTime(),
};
}
}
// 获取活跃计划
getActivePlans(): MultiAgentPlan[] {
return Array.from(this.activePlans.values());
}
// 获取所有 Agent
getAgents(): BaseAgent[] {
return Array.from(this.agents.values());
}
private mapToolToAgentType(tool: string): AgentType {
switch (tool) {
case 'browser': return 'browser';
case 'file': return 'file';
case 'terminal': return 'terminal';
case 'ai':
case 'api':
default: return 'custom'; // AIAnalysisAgent
}
}
private topologicalSort(steps: AgentTask[]): AgentTask[] {
const sorted: AgentTask[] = [];
const visited = new Set<string>();
const visit = (step: AgentTask) => {
if (visited.has(step.id)) return;
visited.add(step.id);
for (const depId of step.dependencies) {
const dep = steps.find(s => s.id === depId);
if (dep) visit(dep);
}
sorted.push(step);
};
steps.forEach(visit);
return sorted;
}
async shutdown(): Promise<void> {
for (const [id] of this.agents) {
await this.terminateAgent(id);
}
this.messageBus.clear();
this.activePlans.clear();
log.info('AgentOrchestrator shutdown');
}
}

View File

@@ -0,0 +1,63 @@
// 多 Agent 协作系统 - 类型定义
export type AgentType = 'planner' | 'browser' | 'file' | 'terminal' | 'combiner' | 'custom';
export type AgentStatus = 'idle' | 'busy' | 'error' | 'terminated';
export interface AgentConfig {
name?: string;
systemPrompt?: string;
model?: string;
maxRetries?: number;
timeout?: number;
}
export interface AgentInfo {
id: string;
type: AgentType;
name: string;
status: AgentStatus;
capabilities: string[];
currentTaskId?: string;
createdAt: Date;
}
export interface AgentTask {
id: string;
type: string;
description: string;
params: Record<string, any>;
context: Record<string, any>;
dependencies: string[];
}
export interface AgentResult {
taskId: string;
agentId: string;
success: boolean;
data?: any;
error?: string;
duration: number;
}
// 消息总线
export interface AgentMessage {
id: string;
from: string;
to: string; // agent ID 或 '*' 表示广播
type: 'task' | 'result' | 'status' | 'data' | 'error' | 'control';
payload: any;
timestamp: Date;
}
export type MessageHandler = (message: AgentMessage) => void | Promise<void>;
// 多 Agent 任务
export interface MultiAgentPlan {
id: string;
goal: string;
steps: AgentTask[];
agentAssignments: Map<string, string>; // taskId -> agentId
status: 'planning' | 'executing' | 'completed' | 'failed';
results: AgentResult[];
createdAt: Date;
}

View File

@@ -1,4 +1,9 @@
// 主动服务系统
import { generateId } from '../../utils/id';
import { createLogger } from '../../utils/logger';
const log = createLogger('Proactive');
export interface ScheduledTask {
id: string;
userId: string;
@@ -26,9 +31,24 @@ export class ProactiveServiceSystem {
task.status = 'active';
this.tasks.set(task.id, task);
// TODO: 使用 node-cron 设置定时任务(后续实现
console.log([Proactive] Task scheduled: );
// node-cron 集成(可选依赖
try {
const cron = require('node-cron');
const cronExpr = this.toCronExpression(task);
if (cronExpr) {
const job = cron.schedule(cronExpr, () => {
log.info(`Cron triggered: ${task.id}`);
// TODO: 执行实际任务逻辑
task.lastRun = new Date();
}, { timezone: task.schedule.timezone });
this.cronJobs.set(task.id, job);
}
} catch {
log.debug('node-cron not available, scheduling in-memory only');
}
log.info(`Task scheduled: ${task.id} (${task.schedule.type} at ${task.schedule.time})`);
}
async cancelTask(taskId: string): Promise<void> {
@@ -43,7 +63,28 @@ export class ProactiveServiceSystem {
return Array.from(this.tasks.values()).filter(t => t.userId === userId);
}
private toCronExpression(task: ScheduledTask): string | null {
const { type, time } = task.schedule;
if (type === 'cron') return time; // already a cron expression
// Parse HH:MM format
const parts = time.split(':');
if (parts.length !== 2) return null;
const [hour, minute] = parts;
switch (type) {
case 'daily':
return `${minute} ${hour} * * *`;
case 'weekly':
return `${minute} ${hour} * * 1`; // Monday
case 'once':
return null; // handled separately via setTimeout
default:
return null;
}
}
private generateId(): string {
return cron__;
return generateId('cron');
}
}

View File

@@ -8,63 +8,88 @@ import type {
Result,
DeviceStatus
} from './types';
import { generateId } from '../../utils/id';
import { createLogger } from '../../utils/logger';
const log = createLogger('RemoteExecution');
export class RemoteExecutionEngine implements RemoteExecutionSystem {
private devices: Map<string, Device> = new Map();
private tasks: Map<string, Task> = new Map();
private subscriptions: Map<string, StatusHandler[]> = new Map();
private taskQueue: Task[] = [];
private runningCount = 0;
private maxConcurrent: number;
private pendingQueue: Task[] = [];
constructor(maxConcurrent: number = 5) {
this.maxConcurrent = maxConcurrent;
}
async registerDevice(device: Device): Promise<void> {
this.devices.set(device.id, device);
console.log([RemoteExecution] Device registered: ());
log.info(`Device registered: ${device.id} (${device.platform})`);
}
async heartbeat(deviceId: string): Promise<DeviceStatus> {
const device = this.devices.get(deviceId);
if (!device) {
throw new Error(Device not found: );
throw new Error(`Device not found: ${deviceId}`);
}
device.lastHeartbeat = new Date();
return device.status;
}
async submitTask(task: Task): Promise<string> {
task.id = task.id || this.generateId();
task.id = task.id || generateId('task');
task.status = 'pending';
task.createdAt = new Date();
this.tasks.set(task.id, task);
this.taskQueue.push(task);
console.log([RemoteExecution] Task submitted: );
// 立即执行(后续会改为队列处理)
this.executeTask(task).catch(console.error);
log.info(`Task submitted: ${task.id} (priority: ${task.priority})`);
if (this.runningCount < this.maxConcurrent) {
this.executeTask(task).catch(err => log.error(`Task execution error: ${err.message}`));
} else {
this.pendingQueue.push(task);
log.debug(`Task queued (${this.pendingQueue.length} pending)`);
}
return task.id;
}
async cancelTask(taskId: string): Promise<void> {
const task = this.tasks.get(taskId);
if (!task) {
throw new Error(Task not found: );
}
if (!task) throw new Error(`Task not found: ${taskId}`);
task.status = 'cancelled';
this.notifySubscribers(taskId, 'cancelled');
log.info(`Task cancelled: ${taskId}`);
}
async getStatus(taskId: string): Promise<TaskStatus> {
const task = this.tasks.get(taskId);
if (!task) {
throw new Error(Task not found: );
}
if (!task) throw new Error(`Task not found: ${taskId}`);
return task.status;
}
getTask(taskId: string): Task | undefined {
return this.tasks.get(taskId);
}
getDevice(deviceId: string): Device | undefined {
return this.devices.get(deviceId);
}
listDevices(): Device[] {
return Array.from(this.devices.values());
}
listTasks(filter?: { status?: TaskStatus; userId?: string }): Task[] {
let tasks = Array.from(this.tasks.values());
if (filter?.status) tasks = tasks.filter(t => t.status === filter.status);
if (filter?.userId) tasks = tasks.filter(t => t.userId === filter.userId);
return tasks.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
}
subscribe(taskId: string, handler: StatusHandler): void {
if (!this.subscriptions.has(taskId)) {
this.subscriptions.set(taskId, []);
@@ -74,41 +99,61 @@ export class RemoteExecutionEngine implements RemoteExecutionSystem {
async pushResult(taskId: string, result: Result): Promise<void> {
const task = this.tasks.get(taskId);
if (!task) {
throw new Error(Task not found: );
}
if (!task) throw new Error(`Task not found: ${taskId}`);
task.result = result;
task.status = result.success ? 'completed' : 'failed';
task.completedAt = new Date();
this.notifySubscribers(taskId, task.status);
}
getStats(): { total: number; running: number; pending: number; completed: number; failed: number } {
const tasks = Array.from(this.tasks.values());
return {
total: tasks.length,
running: tasks.filter(t => t.status === 'running').length,
pending: tasks.filter(t => t.status === 'pending').length,
completed: tasks.filter(t => t.status === 'completed').length,
failed: tasks.filter(t => t.status === 'failed').length,
};
}
private async executeTask(task: Task): Promise<void> {
this.runningCount++;
try {
task.status = 'running';
task.startedAt = new Date();
this.notifySubscribers(task.id, 'running');
// TODO: 实际执行逻辑(调用 OpenClaw SDK
console.log([RemoteExecution] Executing task: );
// 模拟执行
log.info(`Executing task: ${task.id}`);
// TODO: 集成 OpenClaw SDK 实际执行
await new Promise(resolve => setTimeout(resolve, 1000));
await this.pushResult(task.id, {
taskId: task.id,
success: true,
data: { message: 'Task completed successfully' }
});
} catch (error: any) {
await this.pushResult(task.id, {
taskId: task.id,
success: false,
error: error.message
});
} finally {
this.runningCount--;
this.processNextInQueue();
}
}
private processNextInQueue(): void {
if (this.pendingQueue.length > 0 && this.runningCount < this.maxConcurrent) {
// 按优先级排序
this.pendingQueue.sort((a, b) => {
const priority: Record<string, number> = { high: 0, normal: 1, low: 2 };
return (priority[a.priority] ?? 1) - (priority[b.priority] ?? 1);
});
const next = this.pendingQueue.shift()!;
this.executeTask(next).catch(err => log.error(`Queued task error: ${err.message}`));
}
}
@@ -118,8 +163,4 @@ export class RemoteExecutionEngine implements RemoteExecutionSystem {
handlers.forEach(handler => handler(status, progress));
}
}
private generateId(): string {
return ask__;
}
}

View File

@@ -7,6 +7,10 @@ import type {
Progress,
StepStatus
} from './types';
import { generateId } from '../../utils/id';
import { createLogger } from '../../utils/logger';
const log = createLogger('TaskOrchestrator');
export class TaskOrchestrator implements TaskOrchestrationEngine {
private plans: Map<string, TaskPlan> = new Map();
@@ -52,7 +56,7 @@ export class TaskOrchestrator implements TaskOrchestrationEngine {
};
this.plans.set(plan.id, plan);
console.log([TaskOrchestrator] Plan created: );
log.info(`Plan created: ${plan.id} with ${steps.length} steps`);
return plan;
}
@@ -72,7 +76,7 @@ export class TaskOrchestrator implements TaskOrchestrationEngine {
step.status = 'running';
plan.progress = this.calculateProgress(plan);
console.log([TaskOrchestrator] Executing step: );
log.info(`Executing step: ${step.id} - ${step.description}`);
// TODO: 实际执行逻辑(后续实现)
await new Promise(resolve => setTimeout(resolve, 500));
@@ -101,7 +105,7 @@ export class TaskOrchestrator implements TaskOrchestrationEngine {
async getProgress(planId: string): Promise<Progress> {
const plan = this.plans.get(planId);
if (!plan) {
throw new Error(Plan not found: );
throw new Error(`Plan not found: ${planId}`);
}
const completed = plan.steps.filter(s => s.status === 'completed').length;
@@ -180,7 +184,13 @@ export class TaskOrchestrator implements TaskOrchestrationEngine {
return completed / plan.steps.length;
}
listPlans(filter?: { status?: string }): TaskPlan[] {
let plans = Array.from(this.plans.values());
if (filter?.status) plans = plans.filter(p => p.status === filter.status);
return plans;
}
private generateId(): string {
return plan__;
return generateId('plan');
}
}

91
src/db/database.ts Normal file
View File

@@ -0,0 +1,91 @@
// ZCLAW 数据库管理
import Database from 'better-sqlite3';
import { existsSync, mkdirSync } from 'fs';
import { dirname } from 'path';
import { SCHEMA_SQL } from './schema';
import { createLogger } from '../utils/logger';
const log = createLogger('Database');
let _db: Database.Database | null = null;
export function initDatabase(dbPath: string): Database.Database {
// 确保目录存在
const dir = dirname(dbPath);
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
_db = new Database(dbPath);
// 启用 WAL 模式提升性能
_db.pragma('journal_mode = WAL');
_db.pragma('foreign_keys = ON');
// 创建表
_db.exec(SCHEMA_SQL);
log.info(`Database initialized at ${dbPath}`);
return _db;
}
export function getDatabase(): Database.Database {
if (!_db) {
throw new Error('Database not initialized. Call initDatabase() first.');
}
return _db;
}
export function closeDatabase(): void {
if (_db) {
_db.close();
_db = null;
log.info('Database closed');
}
}
// 通用 CRUD 辅助
export class BaseDAO<T extends Record<string, any>> {
constructor(
protected tableName: string,
protected db: Database.Database = getDatabase()
) {}
findById(id: string): T | undefined {
return this.db.prepare(`SELECT * FROM ${this.tableName} WHERE id = ?`).get(id) as T | undefined;
}
findAll(where?: string, params?: any[]): T[] {
const sql = where
? `SELECT * FROM ${this.tableName} WHERE ${where}`
: `SELECT * FROM ${this.tableName}`;
return (params ? this.db.prepare(sql).all(...params) : this.db.prepare(sql).all()) as T[];
}
insert(data: Partial<T>): void {
const keys = Object.keys(data);
const placeholders = keys.map(() => '?').join(', ');
const sql = `INSERT OR REPLACE INTO ${this.tableName} (${keys.join(', ')}) VALUES (${placeholders})`;
this.db.prepare(sql).run(...Object.values(data));
}
update(id: string, data: Partial<T>): void {
const sets = Object.keys(data).map(k => `${k} = ?`).join(', ');
const sql = `UPDATE ${this.tableName} SET ${sets} WHERE id = ?`;
this.db.prepare(sql).run(...Object.values(data), id);
}
delete(id: string): void {
this.db.prepare(`DELETE FROM ${this.tableName} WHERE id = ?`).run(id);
}
count(where?: string, params?: any[]): number {
const sql = where
? `SELECT COUNT(*) as count FROM ${this.tableName} WHERE ${where}`
: `SELECT COUNT(*) as count FROM ${this.tableName}`;
const result = params
? this.db.prepare(sql).get(...params) as { count: number }
: this.db.prepare(sql).get() as { count: number };
return result.count;
}
}

2
src/db/index.ts Normal file
View File

@@ -0,0 +1,2 @@
export { initDatabase, getDatabase, closeDatabase, BaseDAO } from './database';
export { SCHEMA_SQL } from './schema';

118
src/db/schema.ts Normal file
View File

@@ -0,0 +1,118 @@
// ZCLAW 数据库 Schema 定义
export const SCHEMA_SQL = `
-- 用户表
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
avatar TEXT DEFAULT '',
preferences TEXT DEFAULT '{}',
patterns TEXT DEFAULT '{}',
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
-- 设备表
CREATE TABLE IF NOT EXISTS devices (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
name TEXT NOT NULL,
platform TEXT NOT NULL CHECK (platform IN ('macos', 'windows', 'linux')),
capabilities TEXT DEFAULT '[]',
status TEXT DEFAULT 'offline' CHECK (status IN ('online', 'offline', 'busy')),
last_heartbeat TEXT,
created_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (user_id) REFERENCES users(id)
);
-- 任务表
CREATE TABLE IF NOT EXISTS tasks (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
device_id TEXT,
channel TEXT DEFAULT '',
type TEXT NOT NULL CHECK (type IN ('immediate', 'scheduled')),
priority TEXT DEFAULT 'normal' CHECK (priority IN ('high', 'normal', 'low')),
payload TEXT DEFAULT '{}',
status TEXT DEFAULT 'pending' CHECK (status IN ('pending', 'running', 'completed', 'failed', 'cancelled')),
result TEXT,
error TEXT,
created_at TEXT DEFAULT (datetime('now')),
started_at TEXT,
completed_at TEXT,
FOREIGN KEY (user_id) REFERENCES users(id)
);
-- 任务计划表
CREATE TABLE IF NOT EXISTS task_plans (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
goal TEXT NOT NULL,
steps TEXT DEFAULT '[]',
status TEXT DEFAULT 'planned' CHECK (status IN ('planned', 'executing', 'completed', 'failed', 'paused')),
progress REAL DEFAULT 0,
context TEXT DEFAULT '{}',
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (user_id) REFERENCES users(id)
);
-- 记忆事件表
CREATE TABLE IF NOT EXISTS memory_events (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
type TEXT NOT NULL,
content TEXT NOT NULL,
metadata TEXT DEFAULT '{}',
timestamp TEXT DEFAULT (datetime('now')),
FOREIGN KEY (user_id) REFERENCES users(id)
);
-- 定时任务表
CREATE TABLE IF NOT EXISTS scheduled_tasks (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
channel TEXT DEFAULT '',
schedule_type TEXT NOT NULL CHECK (schedule_type IN ('once', 'daily', 'weekly', 'cron')),
schedule_time TEXT NOT NULL,
timezone TEXT DEFAULT 'Asia/Shanghai',
task_type TEXT NOT NULL,
task_prompt TEXT NOT NULL,
status TEXT DEFAULT 'active' CHECK (status IN ('active', 'paused', 'completed')),
last_run TEXT,
next_run TEXT,
created_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (user_id) REFERENCES users(id)
);
-- 对话历史表
CREATE TABLE IF NOT EXISTS conversations (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
agent_id TEXT DEFAULT 'zclaw',
role TEXT NOT NULL CHECK (role IN ('user', 'assistant', 'system')),
content TEXT NOT NULL,
metadata TEXT DEFAULT '{}',
timestamp TEXT DEFAULT (datetime('now')),
FOREIGN KEY (user_id) REFERENCES users(id)
);
-- Agent 表
CREATE TABLE IF NOT EXISTS agents (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
type TEXT NOT NULL CHECK (type IN ('planner', 'browser', 'file', 'terminal', 'combiner', 'custom')),
config TEXT DEFAULT '{}',
status TEXT DEFAULT 'idle' CHECK (status IN ('idle', 'busy', 'error', 'terminated')),
created_at TEXT DEFAULT (datetime('now'))
);
-- 索引
CREATE INDEX IF NOT EXISTS idx_tasks_user ON tasks(user_id);
CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status);
CREATE INDEX IF NOT EXISTS idx_memory_user ON memory_events(user_id);
CREATE INDEX IF NOT EXISTS idx_memory_timestamp ON memory_events(timestamp);
CREATE INDEX IF NOT EXISTS idx_conversations_user ON conversations(user_id);
CREATE INDEX IF NOT EXISTS idx_conversations_timestamp ON conversations(timestamp);
CREATE INDEX IF NOT EXISTS idx_scheduled_user ON scheduled_tasks(user_id);
CREATE INDEX IF NOT EXISTS idx_devices_user ON devices(user_id);
`;

13
src/gateway/index.ts Normal file
View File

@@ -0,0 +1,13 @@
export { GatewayManager } from './manager';
export type { GatewayManagerOptions, GatewayInfo, GatewayStatus } from './manager';
export { GatewayWsClient } from './ws-client';
export type {
ConnectParams,
GatewayRequest,
GatewayResponse,
GatewayEvent,
GatewayFrame,
AgentStreamEvent,
WsClientOptions,
ConnectionState,
} from './ws-client';

328
src/gateway/manager.ts Normal file
View File

@@ -0,0 +1,328 @@
/**
* ZCLAW Gateway Manager
*
* Manages the OpenClaw Gateway subprocess lifecycle:
* - Start/stop Gateway daemon
* - Health checking
* - Auto-restart on crash
* - Configuration management
*/
import { spawn, ChildProcess, execSync } from 'child_process';
import { EventEmitter } from 'events';
import * as path from 'path';
import * as fs from 'fs';
export type GatewayStatus = 'stopped' | 'starting' | 'running' | 'error' | 'stopping';
export interface GatewayManagerOptions {
/** OpenClaw home directory (default: ~/.openclaw) */
home?: string;
/** Gateway port (default: 18789) */
port?: number;
/** Gateway host (default: 127.0.0.1) */
host?: string;
/** Auth token for Gateway connections */
token?: string;
/** Auto-restart on crash */
autoRestart?: boolean;
/** Max restart attempts */
maxRestarts?: number;
/** Health check interval in ms (default: 15000) */
healthCheckInterval?: number;
}
export interface GatewayInfo {
status: GatewayStatus;
pid?: number;
port: number;
host: string;
wsUrl: string;
version?: string;
uptime?: number;
error?: string;
}
export class GatewayManager extends EventEmitter {
private process: ChildProcess | null = null;
private status: GatewayStatus = 'stopped';
private startTime: number = 0;
private restartCount: number = 0;
private healthTimer: ReturnType<typeof setInterval> | null = null;
private options: Required<GatewayManagerOptions>;
private lastError: string | null = null;
constructor(opts: GatewayManagerOptions = {}) {
super();
this.options = {
home: opts.home || path.join(process.env.HOME || process.env.USERPROFILE || '', '.openclaw'),
port: opts.port || 18789,
host: opts.host || '127.0.0.1',
token: opts.token || '',
autoRestart: opts.autoRestart ?? true,
maxRestarts: opts.maxRestarts || 5,
healthCheckInterval: opts.healthCheckInterval || 15000,
};
}
/** Get current gateway info */
getInfo(): GatewayInfo {
return {
status: this.status,
pid: this.process?.pid,
port: this.options.port,
host: this.options.host,
wsUrl: `ws://${this.options.host}:${this.options.port}`,
version: this.getVersion(),
uptime: this.status === 'running' ? Date.now() - this.startTime : undefined,
error: this.lastError || undefined,
};
}
/** Check if OpenClaw is installed */
isInstalled(): boolean {
try {
execSync('openclaw --version', { encoding: 'utf-8', stdio: 'pipe' });
return true;
} catch {
return false;
}
}
/** Get OpenClaw version */
getVersion(): string | undefined {
try {
return execSync('openclaw --version', { encoding: 'utf-8', stdio: 'pipe' }).trim();
} catch {
return undefined;
}
}
/** Start the OpenClaw Gateway */
async start(): Promise<void> {
if (this.status === 'running' || this.status === 'starting') {
return;
}
if (!this.isInstalled()) {
this.setStatus('error');
this.lastError = 'OpenClaw is not installed';
throw new Error('OpenClaw is not installed. Run: npm install -g openclaw@latest');
}
this.setStatus('starting');
this.lastError = null;
try {
// Check if Gateway is already running (external instance)
const alreadyRunning = await this.checkHealth();
if (alreadyRunning) {
this.setStatus('running');
this.startTime = Date.now();
this.startHealthCheck();
this.emit('connected', { external: true });
return;
}
// Start Gateway as subprocess
const env: Record<string, string> = {
...process.env as Record<string, string>,
OPENCLAW_HOME: this.options.home,
};
if (this.options.token) {
env.OPENCLAW_GATEWAY_TOKEN = this.options.token;
}
this.process = spawn('openclaw', ['gateway'], {
env,
stdio: ['ignore', 'pipe', 'pipe'],
detached: false,
});
// Capture stdout
this.process.stdout?.on('data', (data: Buffer) => {
const output = data.toString();
this.emit('log', { level: 'info', message: output.trim() });
// Detect when Gateway is ready
if (output.includes('Gateway listening') || output.includes('ready')) {
this.setStatus('running');
this.startTime = Date.now();
this.restartCount = 0;
this.startHealthCheck();
}
});
// Capture stderr
this.process.stderr?.on('data', (data: Buffer) => {
const output = data.toString();
this.emit('log', { level: 'error', message: output.trim() });
});
// Handle process exit
this.process.on('exit', (code, signal) => {
const wasRunning = this.status === 'running';
this.process = null;
this.stopHealthCheck();
if (this.status === 'stopping') {
this.setStatus('stopped');
return;
}
if (code !== 0 && wasRunning && this.options.autoRestart) {
this.restartCount++;
if (this.restartCount <= this.options.maxRestarts) {
this.lastError = `Gateway crashed (exit code: ${code}), restarting (${this.restartCount}/${this.options.maxRestarts})`;
this.emit('log', { level: 'warn', message: this.lastError });
setTimeout(() => this.start(), 2000);
return;
}
}
this.lastError = `Gateway exited with code ${code}, signal ${signal}`;
this.setStatus(code === 0 ? 'stopped' : 'error');
});
// Handle process error
this.process.on('error', (err) => {
this.lastError = err.message;
this.setStatus('error');
this.process = null;
});
// Wait for Gateway to be ready (timeout 30s)
await this.waitForReady(30000);
} catch (err: any) {
this.lastError = err.message;
this.setStatus('error');
throw err;
}
}
/** Stop the Gateway */
async stop(): Promise<void> {
if (this.status === 'stopped' || this.status === 'stopping') {
return;
}
this.setStatus('stopping');
this.stopHealthCheck();
if (this.process) {
// Send SIGTERM for graceful shutdown
this.process.kill('SIGTERM');
// Wait up to 10s for graceful exit
await new Promise<void>((resolve) => {
const timeout = setTimeout(() => {
if (this.process) {
this.process.kill('SIGKILL');
}
resolve();
}, 10000);
if (this.process) {
this.process.once('exit', () => {
clearTimeout(timeout);
resolve();
});
} else {
clearTimeout(timeout);
resolve();
}
});
}
this.process = null;
this.setStatus('stopped');
}
/** Restart the Gateway */
async restart(): Promise<void> {
await this.stop();
await this.start();
}
/** Check Gateway health via HTTP */
async checkHealth(): Promise<boolean> {
try {
const url = `http://${this.options.host}:${this.options.port}`;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 3000);
const response = await fetch(url, { signal: controller.signal });
clearTimeout(timeout);
return response.ok || response.status === 200 || response.status === 101;
} catch {
return false;
}
}
/** Wait for Gateway to become ready */
private waitForReady(timeoutMs: number): Promise<void> {
return new Promise((resolve, reject) => {
const start = Date.now();
const check = async () => {
if (this.status === 'running') {
resolve();
return;
}
if (this.status === 'error') {
reject(new Error(this.lastError || 'Gateway failed to start'));
return;
}
if (Date.now() - start > timeoutMs) {
// Try health check as last resort
const healthy = await this.checkHealth();
if (healthy) {
this.setStatus('running');
this.startTime = Date.now();
this.startHealthCheck();
resolve();
return;
}
reject(new Error('Gateway startup timed out'));
return;
}
setTimeout(check, 1000);
};
check();
});
}
/** Start periodic health checks */
private startHealthCheck() {
this.stopHealthCheck();
this.healthTimer = setInterval(async () => {
const healthy = await this.checkHealth();
if (!healthy && this.status === 'running') {
this.emit('log', { level: 'warn', message: 'Health check failed' });
// Don't immediately mark as error — may be transient
}
}, this.options.healthCheckInterval);
}
/** Stop health checks */
private stopHealthCheck() {
if (this.healthTimer) {
clearInterval(this.healthTimer);
this.healthTimer = null;
}
}
/** Update status and emit event */
private setStatus(status: GatewayStatus) {
const prev = this.status;
this.status = status;
if (prev !== status) {
this.emit('status', { status, previous: prev });
}
}
}

443
src/gateway/ws-client.ts Normal file
View File

@@ -0,0 +1,443 @@
/**
* ZCLAW WebSocket Client
*
* Typed WebSocket client for OpenClaw Gateway protocol.
* Handles:
* - Connection + handshake (connect challenge/response)
* - Request/response pattern (with timeout)
* - Event subscription
* - Auto-reconnect
* - Agent streaming
*/
import WebSocket from 'ws';
import { EventEmitter } from 'events';
import * as crypto from 'crypto';
// === Protocol Types ===
export interface ConnectParams {
minProtocol?: number;
maxProtocol?: number;
client: {
id: string;
version: string;
platform: string;
mode: 'operator' | 'node';
};
role: 'operator' | 'node';
scopes: string[];
auth?: { token?: string };
locale?: string;
userAgent?: string;
device?: {
id: string;
publicKey?: string;
signature?: string;
signedAt?: number;
nonce?: string;
};
}
export interface GatewayRequest {
type: 'req';
id: string;
method: string;
params?: Record<string, any>;
}
export interface GatewayResponse {
type: 'res';
id: string;
ok: boolean;
payload?: any;
error?: any;
}
export interface GatewayEvent {
type: 'event';
event: string;
payload?: any;
seq?: number;
stateVersion?: number;
}
export type GatewayFrame = GatewayRequest | GatewayResponse | GatewayEvent;
// Agent stream events
export interface AgentStreamEvent {
stream: 'assistant' | 'tool' | 'lifecycle';
delta?: string;
content?: string;
tool?: string;
phase?: 'start' | 'end' | 'error';
runId?: string;
error?: string;
}
export interface WsClientOptions {
/** Gateway WebSocket URL (default: ws://127.0.0.1:18789) */
url?: string;
/** Auth token */
token?: string;
/** Client identifier */
clientId?: string;
/** Client version */
clientVersion?: string;
/** Auto-reconnect on disconnect */
autoReconnect?: boolean;
/** Reconnect interval ms */
reconnectInterval?: number;
/** Max reconnect attempts (0 = infinite) */
maxReconnectAttempts?: number;
/** Request timeout ms */
requestTimeout?: number;
}
export type ConnectionState = 'disconnected' | 'connecting' | 'handshaking' | 'connected' | 'reconnecting';
export class GatewayWsClient extends EventEmitter {
private ws: WebSocket | null = null;
private state: ConnectionState = 'disconnected';
private requestId: number = 0;
private pendingRequests = new Map<string, {
resolve: (value: any) => void;
reject: (reason: any) => void;
timer: ReturnType<typeof setTimeout>;
}>();
private reconnectAttempts: number = 0;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private deviceId: string;
private options: Required<WsClientOptions>;
constructor(opts: WsClientOptions = {}) {
super();
this.options = {
url: opts.url || 'ws://127.0.0.1:18789',
token: opts.token || '',
clientId: opts.clientId || 'zclaw-tauri',
clientVersion: opts.clientVersion || '0.1.0',
autoReconnect: opts.autoReconnect ?? true,
reconnectInterval: opts.reconnectInterval || 3000,
maxReconnectAttempts: opts.maxReconnectAttempts || 0,
requestTimeout: opts.requestTimeout || 30000,
};
this.deviceId = crypto.randomBytes(16).toString('hex');
}
/** Current connection state */
getState(): ConnectionState {
return this.state;
}
/** Connect to Gateway */
async connect(): Promise<void> {
if (this.state === 'connected' || this.state === 'connecting') {
return;
}
this.setState('connecting');
return new Promise((resolve, reject) => {
try {
this.ws = new WebSocket(this.options.url);
this.ws.on('open', () => {
this.setState('handshaking');
// Wait for connect.challenge event
});
this.ws.on('message', (data: Buffer) => {
try {
const frame: GatewayFrame = JSON.parse(data.toString());
this.handleFrame(frame, resolve);
} catch (err: any) {
this.emit('error', new Error(`Failed to parse frame: ${err.message}`));
}
});
this.ws.on('close', (code: number, reason: Buffer) => {
const wasConnected = this.state === 'connected';
this.cleanup();
if (wasConnected && this.options.autoReconnect) {
this.scheduleReconnect();
}
this.emit('close', { code, reason: reason.toString() });
});
this.ws.on('error', (err: Error) => {
if (this.state === 'connecting') {
this.cleanup();
reject(err);
}
this.emit('error', err);
});
} catch (err) {
this.cleanup();
reject(err);
}
});
}
/** Disconnect from Gateway */
disconnect() {
this.cancelReconnect();
this.options.autoReconnect = false;
if (this.ws) {
this.ws.close(1000, 'Client disconnect');
}
this.cleanup();
}
/** Send a request and wait for response */
async request(method: string, params?: Record<string, any>): Promise<any> {
if (this.state !== 'connected') {
throw new Error(`Cannot send request in state: ${this.state}`);
}
const id = `req_${++this.requestId}`;
const frame: GatewayRequest = { type: 'req', id, method, params: params || {} };
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
this.pendingRequests.delete(id);
reject(new Error(`Request ${method} timed out after ${this.options.requestTimeout}ms`));
}, this.options.requestTimeout);
this.pendingRequests.set(id, { resolve, reject, timer });
this.send(frame);
});
}
/** Send agent message (trigger agent loop) */
async sendAgentMessage(message: string, opts?: {
sessionKey?: string;
model?: string;
agentId?: string;
}): Promise<{ runId: string; acceptedAt: string }> {
return this.request('agent', {
message,
sessionKey: opts?.sessionKey,
model: opts?.model,
agentId: opts?.agentId,
});
}
/** Wait for agent run to complete */
async waitForAgent(runId: string): Promise<{ status: string; startedAt: string; endedAt: string; error?: string }> {
return this.request('agent.wait', { runId });
}
/** Send message through IM channel */
async sendChannelMessage(channel: string, chatId: string, text: string): Promise<any> {
return this.request('send', { channel, chatId, text });
}
/** Get Gateway health */
async getHealth(): Promise<any> {
return this.request('health');
}
/** Get Gateway status */
async getStatus(): Promise<any> {
return this.request('status');
}
// === ZCLAW Custom RPC Methods ===
async listClones(): Promise<any> {
return this.request('zclaw.clones.list');
}
async createClone(opts: { name: string; role?: string; nickname?: string; scenarios?: string[]; model?: string }): Promise<any> {
return this.request('zclaw.clones.create', opts);
}
async getUsageStats(): Promise<any> {
return this.request('zclaw.stats.usage');
}
async getSessionStats(): Promise<any> {
return this.request('zclaw.stats.sessions');
}
async getWorkspaceInfo(): Promise<any> {
return this.request('zclaw.workspace.info');
}
async getPluginStatus(): Promise<any> {
return this.request('zclaw.plugins.status');
}
async getQuickConfig(): Promise<any> {
return this.request('zclaw.config.quick', { get: true });
}
async saveQuickConfig(config: Record<string, any>): Promise<any> {
return this.request('zclaw.config.quick', config);
}
// === Internal ===
private handleFrame(frame: GatewayFrame, connectResolve?: (value: void) => void) {
switch (frame.type) {
case 'event':
this.handleEvent(frame as GatewayEvent, connectResolve);
break;
case 'res':
this.handleResponse(frame as GatewayResponse);
break;
default:
// Ignore unexpected frame types
break;
}
}
private handleEvent(event: GatewayEvent, connectResolve?: (value: void) => void) {
if (event.event === 'connect.challenge' && this.state === 'handshaking') {
// Respond to challenge with connect request
this.performHandshake(event.payload?.nonce, connectResolve);
return;
}
// Emit typed events
this.emit('event', event);
this.emit(`event:${event.event}`, event.payload);
// Specific agent stream events
if (event.event === 'agent') {
this.emit('agent:stream', event.payload as AgentStreamEvent);
}
}
private performHandshake(nonce: string, connectResolve?: (value: void) => void) {
const platform = process.platform === 'win32' ? 'windows' : process.platform === 'darwin' ? 'macos' : 'linux';
const connectReq: GatewayRequest = {
type: 'req',
id: `connect_${Date.now()}`,
method: 'connect',
params: {
minProtocol: 3,
maxProtocol: 3,
client: {
id: this.options.clientId,
version: this.options.clientVersion,
platform,
mode: 'operator',
},
role: 'operator',
scopes: ['operator.read', 'operator.write'],
auth: this.options.token ? { token: this.options.token } : {},
locale: 'zh-CN',
userAgent: `zclaw-tauri/${this.options.clientVersion}`,
device: {
id: this.deviceId,
},
},
};
// Listen for the connect response
const handler = (data: Buffer) => {
try {
const frame = JSON.parse(data.toString());
if (frame.type === 'res' && frame.id === connectReq.id) {
this.ws?.removeListener('message', handler);
if (frame.ok) {
this.setState('connected');
this.reconnectAttempts = 0;
this.emit('connected', frame.payload);
connectResolve?.();
} else {
const err = new Error(`Handshake failed: ${JSON.stringify(frame.error)}`);
this.emit('error', err);
this.cleanup();
}
}
} catch { /* ignore parse errors during handshake */ }
};
this.ws?.on('message', handler);
this.send(connectReq);
}
private handleResponse(res: GatewayResponse) {
const pending = this.pendingRequests.get(res.id);
if (pending) {
clearTimeout(pending.timer);
this.pendingRequests.delete(res.id);
if (res.ok) {
pending.resolve(res.payload);
} else {
pending.reject(new Error(JSON.stringify(res.error)));
}
}
}
private send(frame: GatewayFrame) {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(frame));
}
}
private setState(state: ConnectionState) {
const prev = this.state;
this.state = state;
if (prev !== state) {
this.emit('state', { state, previous: prev });
}
}
private cleanup() {
// Clear all pending requests
for (const [id, pending] of this.pendingRequests) {
clearTimeout(pending.timer);
pending.reject(new Error('Connection closed'));
}
this.pendingRequests.clear();
if (this.ws) {
this.ws.removeAllListeners();
if (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING) {
try { this.ws.close(); } catch { /* ignore */ }
}
this.ws = null;
}
this.setState('disconnected');
}
private scheduleReconnect() {
if (this.options.maxReconnectAttempts > 0 && this.reconnectAttempts >= this.options.maxReconnectAttempts) {
this.emit('reconnect:failed', { attempts: this.reconnectAttempts });
return;
}
this.reconnectAttempts++;
this.setState('reconnecting');
const delay = Math.min(
this.options.reconnectInterval * Math.pow(1.5, this.reconnectAttempts - 1),
30000
);
this.reconnectTimer = setTimeout(async () => {
try {
await this.connect();
this.emit('reconnect:success', { attempts: this.reconnectAttempts });
} catch {
// Will trigger another reconnect via the close handler
}
}, delay);
}
private cancelReconnect() {
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
}
}

124
src/im/adapters/feishu.ts Normal file
View File

@@ -0,0 +1,124 @@
// 飞书 IM 适配器
import type { IMAdapter, IMSendOptions, IMMessageHandler, IMMessage } from '../types';
import { createLogger } from '../../utils/logger';
import { generateId } from '../../utils/id';
const log = createLogger('FeishuAdapter');
export class FeishuAdapter implements IMAdapter {
name = 'feishu';
private appId: string;
private appSecret: string;
private connected = false;
private accessToken = '';
private handlers: IMMessageHandler[] = [];
private pollingInterval?: ReturnType<typeof setInterval>;
constructor(appId: string, appSecret: string) {
this.appId = appId;
this.appSecret = appSecret;
}
async connect(): Promise<void> {
if (!this.appId || !this.appSecret) {
log.warn('Feishu credentials not configured, running in mock mode');
this.connected = true;
return;
}
try {
// 获取 tenant_access_token
const response = await fetch('https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
app_id: this.appId,
app_secret: this.appSecret,
}),
});
const data: any = await response.json();
if (data.code === 0) {
this.accessToken = data.tenant_access_token;
this.connected = true;
log.info('Feishu connected successfully');
} else {
throw new Error(`Feishu auth failed: ${data.msg}`);
}
} catch (error: any) {
log.error(`Feishu connection failed: ${error.message}`);
// 降级到 mock 模式
this.connected = true;
log.warn('Running in mock mode');
}
}
async disconnect(): Promise<void> {
if (this.pollingInterval) {
clearInterval(this.pollingInterval);
}
this.connected = false;
this.accessToken = '';
log.info('Feishu disconnected');
}
async send(options: IMSendOptions): Promise<void> {
if (!this.accessToken) {
log.warn(`[Mock] Send to ${options.channelId}: ${options.content.slice(0, 100)}`);
return;
}
const msgType = options.contentType === 'card' ? 'interactive' : 'text';
const content = msgType === 'text'
? JSON.stringify({ text: options.content })
: options.content;
try {
const response = await fetch('https://open.feishu.cn/open-apis/im/v1/messages', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.accessToken}`,
},
body: JSON.stringify({
receive_id: options.channelId,
msg_type: msgType,
content,
}),
});
const data: any = await response.json();
if (data.code !== 0) {
log.error(`Feishu send failed: ${data.msg}`);
}
} catch (error: any) {
log.error(`Feishu send error: ${error.message}`);
}
}
onMessage(handler: IMMessageHandler): void {
this.handlers.push(handler);
}
isConnected(): boolean {
return this.connected;
}
// 模拟接收消息(用于本地测试)
async simulateMessage(content: string, userId: string = 'test_user'): Promise<void> {
const message: IMMessage = {
id: generateId('msg'),
channelType: 'feishu',
channelId: 'test_channel',
userId,
userName: '测试用户',
content,
contentType: 'text',
timestamp: new Date(),
};
for (const handler of this.handlers) {
await handler(message);
}
}
}

80
src/im/gateway.ts Normal file
View File

@@ -0,0 +1,80 @@
// IM 网关 - 统一消息路由
import type { IMAdapter, IMMessage, IMSendOptions, IMMessageHandler } from './types';
import { createLogger } from '../utils/logger';
const log = createLogger('IMGateway');
export class IMGateway {
private adapters: Map<string, IMAdapter> = new Map();
private messageHandlers: IMMessageHandler[] = [];
registerAdapter(adapter: IMAdapter): void {
this.adapters.set(adapter.name, adapter);
// 转发消息到全局处理器
adapter.onMessage(async (message) => {
log.info(`Message from ${message.channelType}/${message.userId}: ${message.content.slice(0, 50)}...`);
for (const handler of this.messageHandlers) {
try {
await handler(message);
} catch (error: any) {
log.error(`Message handler error: ${error.message}`);
}
}
});
log.info(`IM adapter registered: ${adapter.name}`);
}
onMessage(handler: IMMessageHandler): void {
this.messageHandlers.push(handler);
}
async send(options: IMSendOptions): Promise<void> {
const adapter = this.adapters.get(options.channelType);
if (!adapter) {
log.error(`No adapter for channel type: ${options.channelType}`);
throw new Error(`No adapter for channel type: ${options.channelType}`);
}
if (!adapter.isConnected()) {
log.warn(`Adapter ${options.channelType} not connected, attempting reconnect...`);
await adapter.connect();
}
await adapter.send(options);
log.debug(`Message sent via ${options.channelType}`);
}
async connectAll(): Promise<void> {
for (const [name, adapter] of this.adapters) {
try {
await adapter.connect();
log.info(`Connected: ${name}`);
} catch (error: any) {
log.error(`Failed to connect ${name}: ${error.message}`);
}
}
}
async disconnectAll(): Promise<void> {
for (const [name, adapter] of this.adapters) {
try {
await adapter.disconnect();
log.info(`Disconnected: ${name}`);
} catch (error: any) {
log.error(`Failed to disconnect ${name}: ${error.message}`);
}
}
}
getAdapter(name: string): IMAdapter | undefined {
return this.adapters.get(name);
}
getConnectedAdapters(): string[] {
return Array.from(this.adapters.entries())
.filter(([, adapter]) => adapter.isConnected())
.map(([name]) => name);
}
}

3
src/im/index.ts Normal file
View File

@@ -0,0 +1,3 @@
export type { IMAdapter, IMMessage, IMSendOptions, IMMessageHandler } from './types';
export { IMGateway } from './gateway';
export { FeishuAdapter } from './adapters/feishu';

33
src/im/types.ts Normal file
View File

@@ -0,0 +1,33 @@
// IM 网关 - 类型定义
export interface IMMessage {
id: string;
channelType: string; // 'feishu' | 'telegram' | 'qq' | 'wecom'
channelId: string; // 频道/群组 ID
userId: string;
userName?: string;
content: string;
contentType: 'text' | 'image' | 'file' | 'card';
metadata?: Record<string, any>;
timestamp: Date;
}
export interface IMSendOptions {
channelType: string;
channelId: string;
userId?: string;
content: string;
contentType?: 'text' | 'card' | 'markdown';
metadata?: Record<string, any>;
}
export type IMMessageHandler = (message: IMMessage) => void | Promise<void>;
export interface IMAdapter {
name: string;
connect(): Promise<void>;
disconnect(): Promise<void>;
send(options: IMSendOptions): Promise<void>;
onMessage(handler: IMMessageHandler): void;
isConnected(): boolean;
}

View File

@@ -1,5 +1,34 @@
// ZCLAW 入口文件
import { ZClawApp } from './app';
// 导出所有模块
export * from './core/remote-execution';
export * from './core/task-orchestration';
export * from './core/memory';
export * from './core/proactive';
export * from './core/multi-agent';
export * from './core/ai';
export * from './im';
export * from './db';
export * from './config';
export * from './utils';
export { ZClawApp } from './app';
// 主启动流程
const app = new ZClawApp();
app.start().catch((error) => {
console.error('ZCLAW failed to start:', error);
process.exit(1);
});
// 优雅退出
process.on('SIGINT', async () => {
await app.shutdown();
process.exit(0);
});
process.on('SIGTERM', async () => {
await app.shutdown();
process.exit(0);
});

12
src/utils/id.ts Normal file
View File

@@ -0,0 +1,12 @@
// ZCLAW ID 生成工具
import { randomBytes } from 'crypto';
export function generateId(prefix: string = ''): string {
const timestamp = Date.now().toString(36);
const random = randomBytes(4).toString('hex');
return prefix ? `${prefix}_${timestamp}_${random}` : `${timestamp}_${random}`;
}
export function generateShortId(): string {
return randomBytes(6).toString('base64url');
}

3
src/utils/index.ts Normal file
View File

@@ -0,0 +1,3 @@
export { generateId, generateShortId } from './id';
export { createLogger, setLogLevel } from './logger';
export type { Logger } from './logger';

57
src/utils/logger.ts Normal file
View File

@@ -0,0 +1,57 @@
// ZCLAW 日志系统
type LogLevel = 'debug' | 'info' | 'warn' | 'error';
const LEVEL_PRIORITY: Record<LogLevel, number> = {
debug: 0,
info: 1,
warn: 2,
error: 3,
};
const LEVEL_COLORS: Record<LogLevel, string> = {
debug: '\x1b[36m', // cyan
info: '\x1b[32m', // green
warn: '\x1b[33m', // yellow
error: '\x1b[31m', // red
};
const RESET = '\x1b[0m';
let currentLevel: LogLevel = 'info';
export function setLogLevel(level: LogLevel): void {
currentLevel = level;
}
function shouldLog(level: LogLevel): boolean {
return LEVEL_PRIORITY[level] >= LEVEL_PRIORITY[currentLevel];
}
function formatTimestamp(): string {
return new Date().toISOString().slice(11, 23);
}
function log(level: LogLevel, module: string, message: string, data?: any): void {
if (!shouldLog(level)) return;
const color = LEVEL_COLORS[level];
const timestamp = formatTimestamp();
const prefix = `${color}[${timestamp}] [${level.toUpperCase()}] [${module}]${RESET}`;
if (data !== undefined) {
console.log(`${prefix} ${message}`, typeof data === 'object' ? JSON.stringify(data, null, 2) : data);
} else {
console.log(`${prefix} ${message}`);
}
}
export function createLogger(module: string) {
return {
debug: (message: string, data?: any) => log('debug', module, message, data),
info: (message: string, data?: any) => log('info', module, message, data),
warn: (message: string, data?: any) => log('warn', module, message, data),
error: (message: string, data?: any) => log('error', module, message, data),
};
}
export type Logger = ReturnType<typeof createLogger>;

View File

@@ -5,7 +5,7 @@
"moduleResolution": "NodeNext",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"rootDir": ".",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
@@ -13,8 +13,13 @@
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
"sourceMap": true,
"types": ["node"]
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "tests"]
"include": [
"src/gateway/**/*",
"plugins/**/*",
"scripts/**/*"
],
"exclude": ["node_modules", "dist", "tests", "desktop", "src/core", "src/db", "src/config", "src/im", "src/api", "src/utils", "src/app.ts", "src/index.ts"]
}