cc工作前备份
46
.env.example
Normal 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
|
||||
633
PROGRESS.md
@@ -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 更新: "发送给 ZCLAW(Shift+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
@@ -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
@@ -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
@@ -0,0 +1,6 @@
|
||||
# ZCLAW 身份
|
||||
|
||||
- **名字**: ZCLAW (小龙虾)
|
||||
- **Emoji**: 🦞
|
||||
- **描述**: 基于 OpenClaw 的中文 AI 助手,支持飞书/微信/QQ 多渠道接入
|
||||
- **版本**: 0.1.0
|
||||
21
config/SOUL.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# ZCLAW 人格
|
||||
|
||||
你是 ZCLAW(小龙虾),一个基于 OpenClaw 定制的中文 AI 助手。
|
||||
|
||||
## 核心特质
|
||||
|
||||
- **高效执行**: 你不只是出主意,你会真正动手完成任务
|
||||
- **中文优先**: 默认使用中文交流,必要时切换英文
|
||||
- **专业可靠**: 对技术问题给出精确答案,不确定时坦诚说明
|
||||
- **主动服务**: 定期检查任务清单,主动推进未完成的工作
|
||||
|
||||
## 边界
|
||||
|
||||
- 不执行可能损害用户系统安全的操作
|
||||
- 不在未经确认的情况下删除重要文件
|
||||
- 不对外发送用户的敏感信息
|
||||
- 遇到风险操作时先确认再执行
|
||||
|
||||
## 语气
|
||||
|
||||
简洁、专业、友好。避免过度客套,直接给出有用信息。
|
||||
11
config/USER.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# 用户配置
|
||||
|
||||
- **语言**: 中文 (zh-CN)
|
||||
- **时区**: Asia/Shanghai (UTC+8)
|
||||
- **称呼**: 按用户在快速配置中设置的名字
|
||||
|
||||
## 偏好
|
||||
|
||||
- 代码风格: TypeScript, 简洁注释
|
||||
- 沟通风格: 直接, 不需要过多解释
|
||||
- 工作目录: 按工作区设置
|
||||
70
config/openclaw.default.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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: {}
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
120
desktop/src/components/ChannelList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 正在回复...' : '发送给 ZCLAW(Shift+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>
|
||||
);
|
||||
}
|
||||
|
||||
139
desktop/src/components/CloneManager.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
113
desktop/src/components/ConversationList.tsx
Normal 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()}`;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
45
desktop/src/components/Settings/About.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
118
desktop/src/components/Settings/General.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
34
desktop/src/components/Settings/IMChannels.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
64
desktop/src/components/Settings/MCPServices.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
101
desktop/src/components/Settings/ModelsAPI.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
55
desktop/src/components/Settings/Privacy.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
134
desktop/src/components/Settings/SettingsLayout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
51
desktop/src/components/Settings/Skills.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
76
desktop/src/components/Settings/UsageStats.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
82
desktop/src/components/Settings/Workspace.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
107
desktop/src/components/TaskList.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
408
desktop/src/lib/gateway-client.ts
Normal 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;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
217
desktop/src/store/gatewayStore.ts
Normal 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: [] }),
|
||||
};
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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
|
After Width: | Height: | Size: 528 KiB |
BIN
docs/autoclaw界面/10.png
Normal file
|
After Width: | Height: | Size: 529 KiB |
BIN
docs/autoclaw界面/11.png
Normal file
|
After Width: | Height: | Size: 587 KiB |
BIN
docs/autoclaw界面/12.png
Normal file
|
After Width: | Height: | Size: 312 KiB |
BIN
docs/autoclaw界面/13.png
Normal file
|
After Width: | Height: | Size: 332 KiB |
BIN
docs/autoclaw界面/2.png
Normal file
|
After Width: | Height: | Size: 624 KiB |
BIN
docs/autoclaw界面/3.png
Normal file
|
After Width: | Height: | Size: 382 KiB |
BIN
docs/autoclaw界面/4.png
Normal file
|
After Width: | Height: | Size: 391 KiB |
BIN
docs/autoclaw界面/5.png
Normal file
|
After Width: | Height: | Size: 441 KiB |
BIN
docs/autoclaw界面/6.png
Normal file
|
After Width: | Height: | Size: 391 KiB |
BIN
docs/autoclaw界面/7.png
Normal file
|
After Width: | Height: | Size: 373 KiB |
BIN
docs/autoclaw界面/8.png
Normal file
|
After Width: | Height: | Size: 399 KiB |
BIN
docs/autoclaw界面/9.png
Normal file
|
After Width: | Height: | Size: 303 KiB |
310
docs/deviation-analysis.md
Normal 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
28
package.json
@@ -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"
|
||||
|
||||
589
plans/splendid-orbiting-tarjan.md
Normal 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*
|
||||
211
plugins/zclaw-chinese-models/index.ts
Normal 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' });
|
||||
}
|
||||
32
plugins/zclaw-chinese-models/plugin.json
Normal 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" }
|
||||
}
|
||||
}
|
||||
231
plugins/zclaw-feishu/index.ts
Normal 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' });
|
||||
}
|
||||
27
plugins/zclaw-feishu/plugin.json
Normal 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
@@ -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' });
|
||||
}
|
||||
8
plugins/zclaw-ui/plugin.json
Normal 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
178
scripts/setup.ts
Normal 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);
|
||||
});
|
||||
37
skills/chinese-writing/SKILL.md
Normal file
@@ -0,0 +1,37 @@
|
||||
---
|
||||
name: chinese-writing
|
||||
description: 中文写作助手 - 帮助撰写各类中文文档、文章、报告
|
||||
triggers:
|
||||
- "写一篇"
|
||||
- "帮我写"
|
||||
- "撰写"
|
||||
- "起草"
|
||||
- "润色"
|
||||
- "中文写作"
|
||||
---
|
||||
|
||||
# 中文写作助手
|
||||
|
||||
你是一个专业的中文写作助手。
|
||||
|
||||
## 能力
|
||||
|
||||
- 撰写各类中文文档:报告、邮件、文章、总结
|
||||
- 润色修改已有文本
|
||||
- 调整语气和风格(正式/非正式/学术/商务)
|
||||
- 中英文翻译
|
||||
|
||||
## 写作规范
|
||||
|
||||
1. 使用简体中文
|
||||
2. 段落清晰,逻辑流畅
|
||||
3. 避免冗余表述
|
||||
4. 标点符号使用中文标点(,。!?:;""'')
|
||||
5. 数字和英文单词前后留空格
|
||||
|
||||
## 工作流
|
||||
|
||||
1. 确认写作需求:类型、长度、语气、受众
|
||||
2. 生成初稿
|
||||
3. 根据反馈修改润色
|
||||
4. 输出最终版本
|
||||
36
skills/feishu-docs/SKILL.md
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
139
src/core/ai/providers/openai.ts
Normal 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 },
|
||||
};
|
||||
}
|
||||
}
|
||||
138
src/core/ai/providers/zhipu.ts
Normal 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
@@ -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>;
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
40
src/core/multi-agent/agents/combiner-agent.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
83
src/core/multi-agent/agents/executor-agent.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
64
src/core/multi-agent/agents/planner-agent.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
126
src/core/multi-agent/base-agent.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
7
src/core/multi-agent/index.ts
Normal 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';
|
||||
71
src/core/multi-agent/message-bus.ts
Normal 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 = [];
|
||||
}
|
||||
}
|
||||
262
src/core/multi-agent/orchestrator.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
63
src/core/multi-agent/types.ts
Normal 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;
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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__;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -0,0 +1,2 @@
|
||||
export { initDatabase, getDatabase, closeDatabase, BaseDAO } from './database';
|
||||
export { SCHEMA_SQL } from './schema';
|
||||
118
src/db/schema.ts
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
29
src/index.ts
@@ -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
@@ -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
@@ -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
@@ -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>;
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||