diff --git a/OPENCLAW_INTEGRATION_PLAN.md b/OPENCLAW_INTEGRATION_PLAN.md new file mode 100644 index 0000000..c93d2b7 --- /dev/null +++ b/OPENCLAW_INTEGRATION_PLAN.md @@ -0,0 +1,221 @@ +# OpenClaw 集成方案设计 + +> 状态: 讨论中,尚未开始实现 +> 日期: 2026-03-07 + +--- + +## 一、OpenClaw 是什么 + +- **GitHub**: https://github.com/openclaw/openclaw +- **定性**: 开源自主 AI Agent,2026年初爆火,前身为 Clawdbot/Moltbot +- **完全开源**: 有完整源码 + Dockerfile,可以 fork 定制 +- **核心架构**: + +``` +Gateway 进程 (ws://127.0.0.1:18789) + ├── CLI 客户端 ← WebSocket RPC + ├── Web UI ← WebSocket RPC + ├── channel 插件 ← WebSocket RPC (WhatsApp/Telegram/Slack/Discord 等) + ├── native 节点 ← WebSocket RPC (macOS/iOS/Android) + └── HTTP POST /tools/invoke ← 直接调用工具 +``` + +Gateway 是唯一的控制平面,所有子系统通过它通信,没有子系统直接互相通信。 + +- **卷挂载**: + - `~/.openclaw` → `/home/node/.openclaw` (配置目录) + - `~/openclaw/workspace` → `/home/node/.openclaw/workspace` (工作区/用户数据) +- **关键环境变量**: + - `OPENCLAW_GATEWAY_TOKEN` — 内部 API 认证 token + - `CLAUDE_AI_SESSION_KEY` / Anthropic API Key + +--- + +## 二、集成目标 + +用户在 IT0 App 里对话,iAgent 帮用户: +1. 在指定服务器上部署一个属于用户自己的真实 OpenClaw 实例 +2. iAgent 可以管理(启动/停止/监控)用户的 OpenClaw 实例 +3. 用户可以通过 IT0 App 直接向自己的 OpenClaw 下发任务,看到结果 +4. 计费、用量、状态监控全部打通到 IT0 平台 + +--- + +## 三、技术方案 + +### 3.1 自定义 Docker 镜像 (openclaw-bridge) + +基于 OpenClaw 官方源码 (`openclaw/openclaw`) 构建我们自己的镜像,**单容器,两进程共存**(不用 sidecar,便于多租户在同一物理服务器上并发部署): + +``` +容器内进程: + ├── openclaw gateway (原生,端口 18789 内部,不对外暴露) + └── it0-bridge (新增 Node/NestJS 服务,端口 3000 对外) + ├── 连接 OpenClaw Gateway WebSocket RPC (ws://127.0.0.1:18789) + ├── 对外暴露 IT0 标准 REST API + ├── 接收 iAgent 下发的任务 → 转发给 OpenClaw + ├── 订阅 OpenClaw 输出 → 回传给 IT0 agent-service + └── 上报健康状态 / 用量统计 +``` + +镜像名: `it0hub/openclaw-bridge:latest` + +多租户并发部署示例(同一台物理服务器): +```bash +docker run -d --name openclaw-{userId1} -p 3101:3000 -v /data/openclaw/{userId1}:/home/node/.openclaw ... +docker run -d --name openclaw-{userId2} -p 3102:3000 -v /data/openclaw/{userId2}:/home/node/.openclaw ... +``` +每个实例端口独立、数据目录隔离,iAgent 通过容器名管理生命周期。 + +### 3.2 部署流程 + +iAgent(我们的 Claude Code 智能体)通过已有的 SSH 工具执行: + +```bash +docker run -d \ + --name openclaw-{userId} \ + -p {port}:3000 \ + -v /data/openclaw/{userId}:/home/node/.openclaw \ + -e OPENCLAW_GATEWAY_TOKEN={token} \ + -e IT0_AGENT_SERVICE_URL=https://it0api.szaiai.com \ + -e IT0_TENANT_ID={tenantId} \ + -e IT0_INSTANCE_ID={instanceId} \ + -e IT0_AUTH_TOKEN={authToken} \ + -e CLAUDE_API_KEY={claudeKey} \ + it0/openclaw-bridge:latest +``` + +### 3.3 IT0 后台记录 (agent_instances 表) + +```sql +-- 记录每个用户的 OpenClaw 实例 +agent_instances ( + id, tenant_id, user_id, + server_host, -- 部署在哪台服务器 + container_name, -- docker 容器名 + port, -- 对外暴露端口 + status, -- running/stopped/error + gateway_token, -- OpenClaw gateway token (加密存储) + claude_api_key, -- 用户的 Anthropic key (加密存储) + config, -- JSONB,OpenClaw 配置 + created_at, updated_at +) +``` + +### 3.4 iAgent 可执行的管理操作 + +通过 SSH 工具调用: +- `docker start/stop/restart openclaw-{userId}` +- `docker logs openclaw-{userId}` +- `docker stats openclaw-{userId}` + +通过 IT0 Bridge REST API 调用: +- `POST /task` — 向 OpenClaw 提交任务 +- `GET /status` — 查询实例运行状态 +- `GET /sessions` — 查询对话历史 +- `GET /metrics` — 获取用量数据 + +--- + +## 四、已确认决策 + +1. **目标服务器来源** ✅ + - 两种都支持: + - IT0 公司提供统一服务器池(用户购买套餐,自动分配实例) + - 用户自带服务器(用户在 IT0 中录入 SSH 凭证,iAgent 登录部署) + +2. **用户访问 OpenClaw 的方式** ✅ + - OpenClaw 自身支持多 channel(Telegram、WhatsApp、Line 等),用户直接用这些 channel 与自己的 OpenClaw 实例交互 + - IT0 的职责是**管理层**:部署、启停、监控、保障稳定运行,而不是成为主要对话入口 + - IT0 App 展示实例状态、日志、用量,并提供各 channel 的接入配置引导 + +3. **Claude API Key 归属** ✅ + - 用户购买 IT0 套餐,IT0 统一承担 Anthropic API 费用 + - IT0 的 `CLAUDE_API_KEY` 注入到每个 OpenClaw 容器,用量计入对应 tenant 的配额 + +4. **openclaw-bridge 镜像托管** ✅ + - 同时支持两种: + - Docker Hub 公开仓库(`it0hub/openclaw-bridge:latest`)——方便外部服务器拉取 + - 我们自己的私有 Registry——内网/自有服务器池使用 + - 镜像本身不含任何密钥,所有凭证通过环境变量运行时注入 + +5. **OpenClaw 版本更新策略** ✅ + - 不存在复杂的版本跟踪问题:OpenClaw 发布新版时,我们拉取最新源码重新 build 一个包含 IT0 Bridge 的新镜像即可 + - CI/CD: 监听 openclaw/openclaw 上游 release → 自动触发我们的镜像 build → 推送到 Docker Hub + 私有 Registry + +--- + +## 五、架构补充:服务器池 + +### 5.1 归属 +- **inventory-service** 新增 `PoolServer` 实体(平台级,public schema,仅 platform_admin 可管理) + +### 5.2 数据结构 +```sql +-- public schema(非租户隔离) +CREATE TABLE pool_servers ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(100) NOT NULL, -- 别名,如"训练服务器" + host VARCHAR(255) NOT NULL, -- IP 或域名 + ssh_port INT NOT NULL DEFAULT 22, + ssh_user VARCHAR(100) NOT NULL, + ssh_key TEXT NOT NULL, -- 加密存储的私钥内容 + max_instances INT NOT NULL DEFAULT 10, -- 管理员手动设置上限 + status VARCHAR(20) DEFAULT 'active', -- active / maintenance / offline + region VARCHAR(100), -- 可选,地区标注 + notes TEXT, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); +``` + +### 5.3 SSH 凭证存储 +- SSH 私钥加密后存入 `pool_servers.ssh_key`(AES-256,密钥来自 agent-service 环境变量) +- agent-service 在部署时解密私钥,写入临时文件,SSH 连接后立即删除临时文件 + +### 5.4 容量分配逻辑 +``` +iAgent 部署新实例时: + 1. 查询 inventory-service: GET /api/v1/inventory/pool-servers?available=true + 2. 返回 current_instances < max_instances 的服务器列表 + 3. 选用量最少的那台(最优分布) + 4. SSH 连接 → docker run → 成功后 current_instances + 1 +``` + +--- + +## 六、实现阶段规划(已确认) + +### Phase 1: 服务器池 + 基础部署能力 + +**inventory-service** +- [ ] DB 迁移: `pool_servers` 表(public schema) +- [ ] PoolServer 实体 + Repository + CRUD REST 接口 +- [ ] Kong 路由: `/api/v1/inventory/pool-servers`(platform_admin only) + +**agent-service** +- [ ] DB 迁移: `agent_instances` 表(tenant schema) +- [ ] AgentInstance 实体 + Repository + CRUD REST 接口 +- [ ] SSH 私钥加解密工具(AES-256) +- [ ] 部署工具: 查询服务器池 → SSH → docker run → 记录实例 + +**openclaw-bridge 镜像** +- [ ] 基于 openclaw/openclaw 源码,新建 `packages/openclaw-bridge/` 目录 +- [ ] 编写 it0-bridge 进程(Node.js,连接 OpenClaw Gateway WS 18789,对外 REST 3000) +- [ ] 编写 Dockerfile(两进程,supervisord 管理) +- [ ] 推送到 Docker Hub: `it0hub/openclaw-bridge:latest` + +**Web Admin** +- [ ] 服务器池管理页面 `/server-pool`(列表 + 添加 + 删除 + 容量编辑) +- [ ] OpenClaw 实例列表页 `/openclaw-instances`(跨租户,platform_admin 视角) + +### Phase 2: App 集成 +- [ ] my_agents_page 展示真实实例列表 + 状态 +- [ ] 实例详情页: 状态 / 日志 / channel 配置引导(Telegram/WhatsApp/Line 接入) +- [ ] iAgent 对话支持部署指令识别("帮我创建一个 OpenClaw") + +### Phase 3: 计费与监控 +- [ ] 用量上报到 billing-service(每个实例的 token 用量) +- [ ] 套餐限制(免费版: 0个实例,Pro: 1个,Enterprise: N个) +- [ ] 实例健康监控 + 自动重启(心跳检测) diff --git a/deploy/docker/docker-compose.yml b/deploy/docker/docker-compose.yml index 90a2ff9..a4a1f71 100644 --- a/deploy/docker/docker-compose.yml +++ b/deploy/docker/docker-compose.yml @@ -140,6 +140,9 @@ services: - OPENAI_API_KEY=${OPENAI_API_KEY} - OPENAI_BASE_URL=${OPENAI_BASE_URL} - AGENT_SERVICE_PORT=3002 + - INVENTORY_SERVICE_URL=http://inventory-service:3004 + - INTERNAL_API_KEY=${INTERNAL_API_KEY:-changeme-internal-key} + - VAULT_MASTER_KEY=${VAULT_MASTER_KEY:-dev-vault-key} healthcheck: test: ["CMD-SHELL", "node -e \"require('http').get('http://localhost:3002/',r=>{process.exit(r.statusCode<500?0:1)}).on('error',()=>process.exit(1))\""] interval: 30s @@ -206,6 +209,7 @@ services: - DB_DATABASE=${POSTGRES_DB:-it0} - REDIS_URL=redis://redis:6379 - VAULT_MASTER_KEY=${VAULT_MASTER_KEY:-dev-vault-key} + - INTERNAL_API_KEY=${INTERNAL_API_KEY:-changeme-internal-key} - INVENTORY_SERVICE_PORT=3004 healthcheck: test: ["CMD-SHELL", "node -e \"require('http').get('http://localhost:3004/',r=>{process.exit(r.statusCode<500?0:1)}).on('error',()=>process.exit(1))\""] diff --git a/it0-web-admin/src/app/(admin)/openclaw-instances/page.tsx b/it0-web-admin/src/app/(admin)/openclaw-instances/page.tsx new file mode 100644 index 0000000..be5db83 --- /dev/null +++ b/it0-web-admin/src/app/(admin)/openclaw-instances/page.tsx @@ -0,0 +1,233 @@ +'use client'; + +import { useState } from 'react'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import { RefreshCw, Boxes, CheckCircle, AlertCircle, Clock, XCircle, StopCircle } from 'lucide-react'; + +interface AgentInstance { + id: string; + userId: string; + name: string; + agentType: string; + poolServerId?: string; + serverHost: string; + hostPort: number; + containerName: string; + status: 'deploying' | 'running' | 'stopped' | 'error' | 'removed'; + errorMessage?: string; + config: Record; + hasToken: boolean; + createdAt: string; + updatedAt: string; +} + +const API = '/api/proxy/api/v1/agent/instances'; + +async function fetchInstances(): Promise { + const res = await fetch(API); + if (!res.ok) throw new Error('Failed to load instances'); + return res.json(); +} + +async function stopInstance(id: string): Promise { + const res = await fetch(`${API}/${id}/stop`, { method: 'PUT' }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error((err as any).message ?? 'Failed to stop instance'); + } +} + +async function removeInstance(id: string): Promise { + const res = await fetch(`${API}/${id}`, { method: 'DELETE' }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error((err as any).message ?? 'Failed to remove instance'); + } +} + +const STATUS_CONFIG = { + deploying: { icon: , label: '部署中', color: 'text-blue-400' }, + running: { icon: , label: '运行中', color: 'text-green-500' }, + stopped: { icon: , label: '已停止', color: 'text-yellow-500' }, + error: { icon: , label: '错误', color: 'text-red-500' }, + removed: { icon: , label: '已移除', color: 'text-muted-foreground' }, +}; + +function StatusBadge({ status }: { status: AgentInstance['status'] }) { + const cfg = STATUS_CONFIG[status]; + return ( +
+ {cfg.icon} + {cfg.label} +
+ ); +} + +function formatDate(iso: string) { + return new Date(iso).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }); +} + +export default function OpenClawInstancesPage() { + const queryClient = useQueryClient(); + const [statusFilter, setStatusFilter] = useState('all'); + + const { data: instances = [], isLoading, error, refetch } = useQuery({ + queryKey: ['openclaw-instances'], + queryFn: fetchInstances, + refetchInterval: 15_000, // Auto-refresh every 15s to catch status changes + }); + + const filtered = statusFilter === 'all' + ? instances + : instances.filter((i) => i.status === statusFilter); + + const counts = instances.reduce((acc, i) => { + acc[i.status] = (acc[i.status] ?? 0) + 1; + return acc; + }, {} as Record); + + const handleStop = async (inst: AgentInstance) => { + if (!confirm(`确定要停止实例「${inst.name}」吗?`)) return; + try { + await stopInstance(inst.id); + toast.success('实例已停止'); + queryClient.invalidateQueries({ queryKey: ['openclaw-instances'] }); + } catch (err) { + toast.error(err instanceof Error ? err.message : '操作失败'); + } + }; + + const handleRemove = async (inst: AgentInstance) => { + if (!confirm(`确定要移除实例「${inst.name}」吗?容器将被销毁,数据卷保留。`)) return; + try { + await removeInstance(inst.id); + toast.success('实例已移除'); + queryClient.invalidateQueries({ queryKey: ['openclaw-instances'] }); + } catch (err) { + toast.error(err instanceof Error ? err.message : '移除失败'); + } + }; + + const FILTER_TABS: { value: AgentInstance['status'] | 'all'; label: string }[] = [ + { value: 'all', label: `全部 (${instances.length})` }, + { value: 'running', label: `运行中 (${counts.running ?? 0})` }, + { value: 'deploying', label: `部署中 (${counts.deploying ?? 0})` }, + { value: 'stopped', label: `已停止 (${counts.stopped ?? 0})` }, + { value: 'error', label: `错误 (${counts.error ?? 0})` }, + ]; + + return ( +
+ {/* Header */} +
+
+

OpenClaw 实例

+

+ 跨租户查看所有用户的 OpenClaw 智能体实例 +

+
+ +
+ + {/* Status filter tabs */} +
+ {FILTER_TABS.map(({ value, label }) => ( + + ))} +
+ + {/* Error */} + {error && ( +
+ 加载失败:{error instanceof Error ? error.message : String(error)} +
+ )} + + {/* Loading */} + {isLoading && ( +
+ {[1, 2, 3].map((i) => ( +
+ ))} +
+ )} + + {/* Instance list */} + {!isLoading && !error && ( +
+ {filtered.length === 0 ? ( +
+ +

暂无实例数据

+
+ ) : ( + filtered.map((inst) => ( +
+
+
+
+ + {inst.name} + {inst.agentType} +
+
+ 容器: {inst.containerName} + 地址: {inst.serverHost}:{inst.hostPort} + 用户ID: {inst.userId.slice(0, 8)}… + 创建: {formatDate(inst.createdAt)} +
+ {inst.errorMessage && ( +

+ {inst.errorMessage} +

+ )} +
+ + {/* Actions */} + {inst.status !== 'removed' && ( +
+ {inst.status === 'running' && ( + + )} + {(inst.status === 'stopped' || inst.status === 'error') && ( + + )} +
+ )} +
+
+ )) + )} +
+ )} +
+ ); +} diff --git a/it0-web-admin/src/app/(admin)/server-pool/page.tsx b/it0-web-admin/src/app/(admin)/server-pool/page.tsx new file mode 100644 index 0000000..113a0cf --- /dev/null +++ b/it0-web-admin/src/app/(admin)/server-pool/page.tsx @@ -0,0 +1,357 @@ +'use client'; + +import { useState } from 'react'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import { RefreshCw, Plus, Trash2, Pencil, Server, CheckCircle, AlertCircle, WrenchIcon } from 'lucide-react'; + +interface PoolServer { + id: string; + name: string; + host: string; + sshPort: number; + sshUser: string; + maxInstances: number; + currentInstances: number; + status: 'active' | 'maintenance' | 'offline'; + region?: string; + notes?: string; + hasKey: boolean; + available: boolean; + freeSlots: number; + createdAt: string; +} + +const API = '/api/proxy/api/v1/inventory/pool-servers'; + +async function fetchServers(): Promise { + const res = await fetch(API); + if (!res.ok) throw new Error('Failed to load pool servers'); + return res.json(); +} + +async function createServer(data: Record): Promise { + const res = await fetch(API, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err.message ?? 'Failed to create server'); + } + return res.json(); +} + +async function updateServer(id: string, data: Record): Promise { + const res = await fetch(`${API}/${id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err.message ?? 'Failed to update server'); + } + return res.json(); +} + +async function deleteServer(id: string): Promise { + const res = await fetch(`${API}/${id}`, { method: 'DELETE' }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err.message ?? 'Failed to delete server'); + } +} + +const STATUS_ICONS = { + active: , + maintenance: , + offline: , +}; + +const STATUS_LABELS = { active: '运行中', maintenance: '维护中', offline: '离线' }; + +// ── Add/Edit Modal ────────────────────────────────────────────────────────── + +function ServerModal({ + server, + onClose, + onSave, +}: { + server?: PoolServer; + onClose: () => void; + onSave: (data: Record) => Promise; +}) { + const [saving, setSaving] = useState(false); + const [form, setForm] = useState({ + name: server?.name ?? '', + host: server?.host ?? '', + sshPort: server?.sshPort ?? 22, + sshUser: server?.sshUser ?? '', + sshKey: '', + maxInstances: server?.maxInstances ?? 10, + status: server?.status ?? 'active', + region: server?.region ?? '', + notes: server?.notes ?? '', + }); + + const set = (field: string) => (e: React.ChangeEvent) => + setForm((prev) => ({ ...prev, [field]: e.target.value })); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setSaving(true); + try { + const payload: Record = { ...form }; + if (!form.sshKey) delete payload.sshKey; // Don't send empty key on edit + await onSave(payload); + onClose(); + } catch (err) { + toast.error(err instanceof Error ? err.message : '保存失败'); + } finally { + setSaving(false); + } + }; + + const inputClass = 'w-full rounded-md border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary'; + const labelClass = 'block text-xs text-muted-foreground mb-1'; + + return ( +
+
+
+

{server ? '编辑服务器' : '添加服务器'}

+ +
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ +