1560 lines
47 KiB
Markdown
1560 lines
47 KiB
Markdown
|
|
# NetaClaw Phase 1: 清理旧代码 + 核心迁移 实施计划
|
|||
|
|
|
|||
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|||
|
|
|
|||
|
|
**Goal:** 移除 LangChain/Tauri/旧 Skill,从 OpenClaw 迁移 Agent 核心运行时,使系统能进行基本的 Agent 对话。
|
|||
|
|
|
|||
|
|
**Architecture:** Midway.js 后端保留业务模块(base/dict/task/user),新增 netaclaw 模块承载从 OpenClaw 迁移的 Agent 引擎。Agent 运行时采用 ReAct 循环(Think→Act→Observe),工具系统基于 TypeBox Schema,通过 WebSocket 与前端通信。
|
|||
|
|
|
|||
|
|
**Tech Stack:** Midway.js 3.20 / TypeScript 5.9 / TypeBox / @anthropic-ai/sdk / WebSocket (ws)
|
|||
|
|
|
|||
|
|
**OpenClaw 源码位置:** `c:/Users/lixin/Desktop/RZYX_ZT/openclaw-main`
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 文件结构
|
|||
|
|
|
|||
|
|
### 删除的文件/目录
|
|||
|
|
- `packages/desktop/` — 整个 Tauri 桌面端
|
|||
|
|
- `packages/skills/` — 旧 Python/Node skill 目录
|
|||
|
|
- `packages/backend/src/modules/agent/` — 旧 LangChain Agent 模块
|
|||
|
|
- `packages/backend/src/modules/audit/` — 旧审核模块(依赖 agent)
|
|||
|
|
- `packages/backend/src/modules/flow/` — 旧流程引擎(依赖 LangChain)
|
|||
|
|
- `packages/backend/src/modules/know/` — 旧知识库(依赖 LangChain)
|
|||
|
|
- `packages/backend/src/modules/ontology/` — 旧本体论模块
|
|||
|
|
|
|||
|
|
### 新建的文件
|
|||
|
|
| 文件 | 职责 |
|
|||
|
|
|------|------|
|
|||
|
|
| `packages/backend/src/modules/netaclaw/module.ts` | Midway.js 模块注册 |
|
|||
|
|
| `packages/backend/src/modules/netaclaw/config.ts` | NetaClaw 配置定义 |
|
|||
|
|
| `packages/backend/src/modules/netaclaw/tools/common.ts` | AgentTool 接口 + 工具结果构建器 |
|
|||
|
|
| `packages/backend/src/modules/netaclaw/tools/builtin/bash.ts` | Bash 命令执行工具 |
|
|||
|
|
| `packages/backend/src/modules/netaclaw/tools/builtin/file.ts` | 文件读写工具 |
|
|||
|
|
| `packages/backend/src/modules/netaclaw/tools/builtin/web_search.ts` | 网页搜索工具 |
|
|||
|
|
| `packages/backend/src/modules/netaclaw/runtime/agent.ts` | Agent ReAct 核心循环 |
|
|||
|
|
| `packages/backend/src/modules/netaclaw/runtime/attempt.ts` | 单次执行尝试 |
|
|||
|
|
| `packages/backend/src/modules/netaclaw/runtime/model_selection.ts` | 模型选择与故障转移 |
|
|||
|
|
| `packages/backend/src/modules/netaclaw/runtime/thinking.ts` | 思考级别控制 |
|
|||
|
|
| `packages/backend/src/modules/netaclaw/gateway/server.ts` | WebSocket 服务 |
|
|||
|
|
| `packages/backend/src/modules/netaclaw/gateway/session.ts` | 会话管理 |
|
|||
|
|
| `packages/backend/src/modules/netaclaw/gateway/protocol.ts` | 消息协议定义 |
|
|||
|
|
| `packages/backend/src/modules/netaclaw/plugins/plugin_entry.ts` | 插件定义接口 |
|
|||
|
|
| `packages/backend/src/modules/netaclaw/plugins/plugin_api.ts` | 插件 API |
|
|||
|
|
| `packages/backend/src/modules/netaclaw/plugins/llm_providers/openai.ts` | OpenAI 提供商 |
|
|||
|
|
| `packages/backend/src/modules/netaclaw/plugins/llm_providers/anthropic.ts` | Anthropic 提供商 |
|
|||
|
|
| `packages/backend/src/modules/netaclaw/plugins/llm_providers/deepseek.ts` | DeepSeek 提供商 |
|
|||
|
|
| `packages/backend/src/modules/netaclaw/service/chat.ts` | 对话服务(HTTP/SSE 入口) |
|
|||
|
|
| `packages/backend/src/modules/netaclaw/controller/chat.ts` | 对话控制器 |
|
|||
|
|
| `packages/backend/src/modules/netaclaw/entity/session.ts` | 会话实体 |
|
|||
|
|
| `packages/backend/src/modules/netaclaw/entity/message.ts` | 消息实体 |
|
|||
|
|
| `skills/hello-world/SKILL.md` | 示例 Skill(验证 Skill 加载机制) |
|
|||
|
|
|
|||
|
|
### 修改的文件
|
|||
|
|
| 文件 | 改动 |
|
|||
|
|
|------|------|
|
|||
|
|
| `packages/backend/package.json` | 移除 @langchain/* 依赖,新增 @sinclair/typebox、@anthropic-ai/sdk、ws |
|
|||
|
|
| `packages/backend/src/config/config.default.ts` | 新增 netaclaw 配置段 |
|
|||
|
|
| `packages/backend/src/configuration.ts` | 注册 netaclaw 模块,移除旧模块引用 |
|
|||
|
|
| `pnpm-workspace.yaml` | 移除 desktop、skills 包路径 |
|
|||
|
|
| `package.json` (root) | 移除 desktop 相关脚本 |
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Task 1: 清理旧代码和依赖
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Delete: `packages/desktop/` (整个目录)
|
|||
|
|
- Delete: `packages/skills/` (整个目录)
|
|||
|
|
- Delete: `packages/backend/src/modules/agent/` (整个目录)
|
|||
|
|
- Delete: `packages/backend/src/modules/audit/` (整个目录)
|
|||
|
|
- Delete: `packages/backend/src/modules/flow/` (整个目录)
|
|||
|
|
- Delete: `packages/backend/src/modules/know/` (整个目录)
|
|||
|
|
- Delete: `packages/backend/src/modules/ontology/` (整个目录)
|
|||
|
|
- Modify: `pnpm-workspace.yaml`
|
|||
|
|
- Modify: `package.json` (root)
|
|||
|
|
- Modify: `packages/backend/package.json`
|
|||
|
|
- Modify: `packages/backend/src/configuration.ts`
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: 删除 Tauri 桌面端和旧 Skills 目录**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
rm -rf packages/desktop packages/skills
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 2: 删除旧的 LangChain Agent 及依赖模块**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
rm -rf packages/backend/src/modules/agent
|
|||
|
|
rm -rf packages/backend/src/modules/audit
|
|||
|
|
rm -rf packages/backend/src/modules/flow
|
|||
|
|
rm -rf packages/backend/src/modules/know
|
|||
|
|
rm -rf packages/backend/src/modules/ontology
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 3: 更新 pnpm-workspace.yaml,移除 desktop 和 skills**
|
|||
|
|
|
|||
|
|
```yaml
|
|||
|
|
packages:
|
|||
|
|
- 'packages/*'
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 4: 更新根 package.json scripts,移除 desktop 相关命令**
|
|||
|
|
|
|||
|
|
移除 `dev:all` 中的 desktop 引用,保留 backend 和 frontend:
|
|||
|
|
|
|||
|
|
```json
|
|||
|
|
"scripts": {
|
|||
|
|
"dev": "pnpm --parallel --filter @neta/backend --filter @neta/frontend dev",
|
|||
|
|
"build": "pnpm --filter './packages/*' build",
|
|||
|
|
"test": "pnpm --filter './packages/*' test",
|
|||
|
|
"lint": "pnpm --filter './packages/*' lint",
|
|||
|
|
"clean": "pnpm --filter './packages/*' clean && rm -rf node_modules"
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 5: 从 backend/package.json 移除 LangChain 及旧依赖**
|
|||
|
|
|
|||
|
|
移除以下依赖:
|
|||
|
|
```
|
|||
|
|
@langchain/classic
|
|||
|
|
@langchain/cohere
|
|||
|
|
@langchain/community
|
|||
|
|
@langchain/core
|
|||
|
|
@langchain/deepseek
|
|||
|
|
@langchain/langgraph
|
|||
|
|
@langchain/langgraph-checkpoint
|
|||
|
|
@langchain/mcp-adapters
|
|||
|
|
@langchain/ollama
|
|||
|
|
@langchain/openai
|
|||
|
|
@langchain/textsplitters
|
|||
|
|
langchain
|
|||
|
|
chromadb
|
|||
|
|
crawlee
|
|||
|
|
puppeteer-core
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
新增以下依赖:
|
|||
|
|
```
|
|||
|
|
@sinclair/typebox: ^0.34.49
|
|||
|
|
@anthropic-ai/sdk: ^0.81.0
|
|||
|
|
ws: ^8.18.0
|
|||
|
|
openai: ^4.73.0
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
运行:`pnpm install`
|
|||
|
|
|
|||
|
|
- [ ] **Step 6: 更新 configuration.ts,移除旧模块引用**
|
|||
|
|
|
|||
|
|
从 `packages/backend/src/configuration.ts` 中移除对 agent/audit/flow/know/ontology 模块的所有 import 和引用。如果这些模块是通过 Cool Admin 自动扫描加载的(`src/modules/` 目录扫描),则删除目录后无需额外修改 configuration.ts。
|
|||
|
|
|
|||
|
|
验证:检查 configuration.ts 中是否有显式 import 这些模块。
|
|||
|
|
|
|||
|
|
- [ ] **Step 7: 验证后端能正常启动**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
cd packages/backend && pnpm dev
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
预期:后端在 8003 端口启动,无 import 错误。可能有数据库表缺失警告(旧表),这是正常的。
|
|||
|
|
|
|||
|
|
- [ ] **Step 8: 提交**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add -A
|
|||
|
|
git commit -m "chore: remove Tauri, LangChain, old agent/audit/flow/know/ontology modules"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Task 2: NetaClaw 工具系统基础
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Create: `packages/backend/src/modules/netaclaw/tools/common.ts`
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: 创建 netaclaw 模块目录结构**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
mkdir -p packages/backend/src/modules/netaclaw/{tools/builtin,runtime,gateway,plugins/llm_providers,service,controller,entity,platforms}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 2: 创建 AgentTool 接口和工具结果构建器**
|
|||
|
|
|
|||
|
|
参考 OpenClaw `src/agents/tools/common.ts`,创建 `packages/backend/src/modules/netaclaw/tools/common.ts`:
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
import { Type, Static, TSchema } from '@sinclair/typebox';
|
|||
|
|
|
|||
|
|
// --- 工具接口 ---
|
|||
|
|
|
|||
|
|
export type AgentTool<TParams extends TSchema, TResult> = {
|
|||
|
|
name: string;
|
|||
|
|
label: string;
|
|||
|
|
description: string;
|
|||
|
|
parameters: TParams;
|
|||
|
|
execute(id: string, params: Static<TParams>): Promise<TResult>;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
export type AgentToolWithMeta<TParams extends TSchema, TResult> =
|
|||
|
|
AgentTool<TParams, TResult> & {
|
|||
|
|
ownerOnly?: boolean;
|
|||
|
|
displaySummary?: string;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
export type AnyAgentTool = AgentToolWithMeta<any, unknown>;
|
|||
|
|
|
|||
|
|
// --- 工具结果 ---
|
|||
|
|
|
|||
|
|
export type ToolResultContent =
|
|||
|
|
| { type: 'text'; text: string }
|
|||
|
|
| { type: 'json'; data: unknown }
|
|||
|
|
| { type: 'image'; url: string; mimeType?: string };
|
|||
|
|
|
|||
|
|
export function textResult(text: string): ToolResultContent {
|
|||
|
|
return { type: 'text', text };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export function jsonResult(data: unknown): ToolResultContent {
|
|||
|
|
return { type: 'json', data };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export function imageResult(url: string, mimeType?: string): ToolResultContent {
|
|||
|
|
return { type: 'image', url, mimeType };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// --- 工具错误 ---
|
|||
|
|
|
|||
|
|
export class ToolInputError extends Error {
|
|||
|
|
constructor(message: string) {
|
|||
|
|
super(message);
|
|||
|
|
this.name = 'ToolInputError';
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export class ToolAuthorizationError extends Error {
|
|||
|
|
constructor(message: string) {
|
|||
|
|
super(message);
|
|||
|
|
this.name = 'ToolAuthorizationError';
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// --- 参数读取辅助 ---
|
|||
|
|
|
|||
|
|
export function readStringParam(params: Record<string, unknown>, key: string): string {
|
|||
|
|
const val = params[key];
|
|||
|
|
if (typeof val !== 'string') throw new ToolInputError(`参数 "${key}" 必须是字符串`);
|
|||
|
|
return val;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export function readOptionalStringParam(params: Record<string, unknown>, key: string): string | undefined {
|
|||
|
|
const val = params[key];
|
|||
|
|
if (val === undefined || val === null) return undefined;
|
|||
|
|
if (typeof val !== 'string') throw new ToolInputError(`参数 "${key}" 必须是字符串`);
|
|||
|
|
return val;
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 3: 提交**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add packages/backend/src/modules/netaclaw/
|
|||
|
|
git commit -m "feat(netaclaw): add AgentTool interface and tool result builders"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Task 3: LLM 提供商插件
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Create: `packages/backend/src/modules/netaclaw/plugins/plugin_entry.ts`
|
|||
|
|
- Create: `packages/backend/src/modules/netaclaw/plugins/llm_providers/anthropic.ts`
|
|||
|
|
- Create: `packages/backend/src/modules/netaclaw/plugins/llm_providers/openai.ts`
|
|||
|
|
- Create: `packages/backend/src/modules/netaclaw/plugins/llm_providers/deepseek.ts`
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: 创建插件定义接口**
|
|||
|
|
|
|||
|
|
创建 `packages/backend/src/modules/netaclaw/plugins/plugin_entry.ts`:
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
import { AnyAgentTool } from '../tools/common.js';
|
|||
|
|
|
|||
|
|
// --- 插件接口 ---
|
|||
|
|
|
|||
|
|
export interface NetaClawPluginDefinition {
|
|||
|
|
id: string;
|
|||
|
|
name: string;
|
|||
|
|
version: string;
|
|||
|
|
tools?: () => AnyAgentTool[];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// --- LLM 提供商接口 ---
|
|||
|
|
|
|||
|
|
export interface LLMMessage {
|
|||
|
|
role: 'system' | 'user' | 'assistant' | 'tool';
|
|||
|
|
content: string;
|
|||
|
|
toolCalls?: ToolCall[];
|
|||
|
|
toolCallId?: string;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export interface ToolCall {
|
|||
|
|
id: string;
|
|||
|
|
name: string;
|
|||
|
|
arguments: string; // JSON string
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export interface LLMResponse {
|
|||
|
|
content: string;
|
|||
|
|
toolCalls?: ToolCall[];
|
|||
|
|
thinking?: string;
|
|||
|
|
usage?: { inputTokens: number; outputTokens: number };
|
|||
|
|
stopReason?: 'end_turn' | 'tool_use' | 'max_tokens';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export type ThinkLevel = 'off' | 'minimal' | 'low' | 'medium' | 'high' | 'adaptive';
|
|||
|
|
|
|||
|
|
export interface LLMProviderConfig {
|
|||
|
|
provider: string;
|
|||
|
|
model: string;
|
|||
|
|
apiKey: string;
|
|||
|
|
baseUrl?: string;
|
|||
|
|
temperature?: number;
|
|||
|
|
maxTokens?: number;
|
|||
|
|
thinkLevel?: ThinkLevel;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export interface LLMProvider {
|
|||
|
|
id: string;
|
|||
|
|
name: string;
|
|||
|
|
chat(messages: LLMMessage[], tools: AnyAgentTool[], config: LLMProviderConfig): Promise<LLMResponse>;
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 2: 创建 Anthropic 提供商**
|
|||
|
|
|
|||
|
|
创建 `packages/backend/src/modules/netaclaw/plugins/llm_providers/anthropic.ts`:
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
import Anthropic from '@anthropic-ai/sdk';
|
|||
|
|
import { LLMProvider, LLMMessage, LLMResponse, LLMProviderConfig, ToolCall } from '../plugin_entry.js';
|
|||
|
|
import { AnyAgentTool } from '../../tools/common.js';
|
|||
|
|
|
|||
|
|
export class AnthropicProvider implements LLMProvider {
|
|||
|
|
id = 'anthropic';
|
|||
|
|
name = 'Anthropic Claude';
|
|||
|
|
|
|||
|
|
async chat(messages: LLMMessage[], tools: AnyAgentTool[], config: LLMProviderConfig): Promise<LLMResponse> {
|
|||
|
|
const client = new Anthropic({ apiKey: config.apiKey, baseURL: config.baseUrl });
|
|||
|
|
|
|||
|
|
const systemMsg = messages.find(m => m.role === 'system');
|
|||
|
|
const chatMessages = messages
|
|||
|
|
.filter(m => m.role !== 'system')
|
|||
|
|
.map(m => this.toAnthropicMessage(m));
|
|||
|
|
|
|||
|
|
const anthropicTools = tools.map(t => ({
|
|||
|
|
name: t.name,
|
|||
|
|
description: t.description,
|
|||
|
|
input_schema: t.parameters as Record<string, unknown>,
|
|||
|
|
}));
|
|||
|
|
|
|||
|
|
const response = await client.messages.create({
|
|||
|
|
model: config.model,
|
|||
|
|
max_tokens: config.maxTokens ?? 4096,
|
|||
|
|
system: systemMsg?.content ?? '',
|
|||
|
|
messages: chatMessages,
|
|||
|
|
tools: anthropicTools.length > 0 ? anthropicTools : undefined,
|
|||
|
|
temperature: config.temperature,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
return this.parseResponse(response);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private toAnthropicMessage(msg: LLMMessage): Anthropic.MessageParam {
|
|||
|
|
if (msg.role === 'assistant' && msg.toolCalls?.length) {
|
|||
|
|
return {
|
|||
|
|
role: 'assistant',
|
|||
|
|
content: [
|
|||
|
|
...(msg.content ? [{ type: 'text' as const, text: msg.content }] : []),
|
|||
|
|
...msg.toolCalls.map(tc => ({
|
|||
|
|
type: 'tool_use' as const,
|
|||
|
|
id: tc.id,
|
|||
|
|
name: tc.name,
|
|||
|
|
input: JSON.parse(tc.arguments),
|
|||
|
|
})),
|
|||
|
|
],
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
if (msg.role === 'tool') {
|
|||
|
|
return {
|
|||
|
|
role: 'user',
|
|||
|
|
content: [{ type: 'tool_result' as const, tool_use_id: msg.toolCallId!, content: msg.content }],
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
return { role: msg.role as 'user' | 'assistant', content: msg.content };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private parseResponse(response: Anthropic.Message): LLMResponse {
|
|||
|
|
let content = '';
|
|||
|
|
const toolCalls: ToolCall[] = [];
|
|||
|
|
|
|||
|
|
for (const block of response.content) {
|
|||
|
|
if (block.type === 'text') content += block.text;
|
|||
|
|
if (block.type === 'tool_use') {
|
|||
|
|
toolCalls.push({ id: block.id, name: block.name, arguments: JSON.stringify(block.input) });
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
content,
|
|||
|
|
toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
|
|||
|
|
usage: { inputTokens: response.usage.input_tokens, outputTokens: response.usage.output_tokens },
|
|||
|
|
stopReason: response.stop_reason === 'tool_use' ? 'tool_use' : 'end_turn',
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 3: 创建 OpenAI 提供商**
|
|||
|
|
|
|||
|
|
创建 `packages/backend/src/modules/netaclaw/plugins/llm_providers/openai.ts`:
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
import OpenAI from 'openai';
|
|||
|
|
import { LLMProvider, LLMMessage, LLMResponse, LLMProviderConfig, ToolCall } from '../plugin_entry.js';
|
|||
|
|
import { AnyAgentTool } from '../../tools/common.js';
|
|||
|
|
|
|||
|
|
export class OpenAIProvider implements LLMProvider {
|
|||
|
|
id = 'openai';
|
|||
|
|
name = 'OpenAI';
|
|||
|
|
|
|||
|
|
async chat(messages: LLMMessage[], tools: AnyAgentTool[], config: LLMProviderConfig): Promise<LLMResponse> {
|
|||
|
|
const client = new OpenAI({ apiKey: config.apiKey, baseURL: config.baseUrl });
|
|||
|
|
|
|||
|
|
const openaiMessages = messages.map(m => this.toOpenAIMessage(m));
|
|||
|
|
const openaiTools = tools.map(t => ({
|
|||
|
|
type: 'function' as const,
|
|||
|
|
function: { name: t.name, description: t.description, parameters: t.parameters as Record<string, unknown> },
|
|||
|
|
}));
|
|||
|
|
|
|||
|
|
const response = await client.chat.completions.create({
|
|||
|
|
model: config.model,
|
|||
|
|
messages: openaiMessages,
|
|||
|
|
tools: openaiTools.length > 0 ? openaiTools : undefined,
|
|||
|
|
max_tokens: config.maxTokens ?? 4096,
|
|||
|
|
temperature: config.temperature,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const choice = response.choices[0];
|
|||
|
|
const toolCalls: ToolCall[] = (choice.message.tool_calls ?? []).map(tc => ({
|
|||
|
|
id: tc.id,
|
|||
|
|
name: tc.function.name,
|
|||
|
|
arguments: tc.function.arguments,
|
|||
|
|
}));
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
content: choice.message.content ?? '',
|
|||
|
|
toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
|
|||
|
|
usage: response.usage
|
|||
|
|
? { inputTokens: response.usage.prompt_tokens, outputTokens: response.usage.completion_tokens }
|
|||
|
|
: undefined,
|
|||
|
|
stopReason: choice.finish_reason === 'tool_calls' ? 'tool_use' : 'end_turn',
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private toOpenAIMessage(msg: LLMMessage): OpenAI.ChatCompletionMessageParam {
|
|||
|
|
if (msg.role === 'tool') {
|
|||
|
|
return { role: 'tool', content: msg.content, tool_call_id: msg.toolCallId! };
|
|||
|
|
}
|
|||
|
|
if (msg.role === 'assistant' && msg.toolCalls?.length) {
|
|||
|
|
return {
|
|||
|
|
role: 'assistant',
|
|||
|
|
content: msg.content || null,
|
|||
|
|
tool_calls: msg.toolCalls.map(tc => ({
|
|||
|
|
id: tc.id, type: 'function' as const,
|
|||
|
|
function: { name: tc.name, arguments: tc.arguments },
|
|||
|
|
})),
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
return { role: msg.role, content: msg.content } as OpenAI.ChatCompletionMessageParam;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 4: 创建 DeepSeek 提供商(复用 OpenAI 兼容接口)**
|
|||
|
|
|
|||
|
|
创建 `packages/backend/src/modules/netaclaw/plugins/llm_providers/deepseek.ts`:
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
import { OpenAIProvider } from './openai.js';
|
|||
|
|
|
|||
|
|
export class DeepSeekProvider extends OpenAIProvider {
|
|||
|
|
id = 'deepseek';
|
|||
|
|
name = 'DeepSeek';
|
|||
|
|
// DeepSeek 使用 OpenAI 兼容 API,仅需覆盖 baseUrl 默认值
|
|||
|
|
// 调用时传入 config.baseUrl = 'https://api.deepseek.com'
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 5: 提交**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add packages/backend/src/modules/netaclaw/plugins/
|
|||
|
|
git commit -m "feat(netaclaw): add plugin interface and LLM providers (Anthropic, OpenAI, DeepSeek)"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Task 4: Agent 运行时核心(ReAct 循环)
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Create: `packages/backend/src/modules/netaclaw/runtime/agent.ts`
|
|||
|
|
- Create: `packages/backend/src/modules/netaclaw/runtime/attempt.ts`
|
|||
|
|
- Create: `packages/backend/src/modules/netaclaw/runtime/model_selection.ts`
|
|||
|
|
- Create: `packages/backend/src/modules/netaclaw/runtime/thinking.ts`
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: 创建思考级别控制**
|
|||
|
|
|
|||
|
|
创建 `packages/backend/src/modules/netaclaw/runtime/thinking.ts`:
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
import { ThinkLevel } from '../plugins/plugin_entry.js';
|
|||
|
|
|
|||
|
|
export function resolveThinkingDefault(model: string): ThinkLevel {
|
|||
|
|
if (model.includes('claude-3-5') || model.includes('claude-4')) return 'medium';
|
|||
|
|
if (model.includes('deepseek-r1') || model.includes('o1') || model.includes('o3')) return 'high';
|
|||
|
|
return 'off';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export function isThinkingSupported(model: string): boolean {
|
|||
|
|
const thinkingModels = ['claude-3-5-sonnet', 'claude-4', 'deepseek-r1', 'o1', 'o3', 'qwq'];
|
|||
|
|
return thinkingModels.some(m => model.includes(m));
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 2: 创建模型选择与故障转移**
|
|||
|
|
|
|||
|
|
创建 `packages/backend/src/modules/netaclaw/runtime/model_selection.ts`:
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
import { LLMProviderConfig } from '../plugins/plugin_entry.js';
|
|||
|
|
import { LLMProvider } from '../plugins/plugin_entry.js';
|
|||
|
|
import { AnthropicProvider } from '../plugins/llm_providers/anthropic.js';
|
|||
|
|
import { OpenAIProvider } from '../plugins/llm_providers/openai.js';
|
|||
|
|
import { DeepSeekProvider } from '../plugins/llm_providers/deepseek.js';
|
|||
|
|
|
|||
|
|
const providers: Map<string, LLMProvider> = new Map();
|
|||
|
|
|
|||
|
|
export function registerProvider(provider: LLMProvider): void {
|
|||
|
|
providers.set(provider.id, provider);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export function getProvider(id: string): LLMProvider {
|
|||
|
|
const p = providers.get(id);
|
|||
|
|
if (!p) throw new Error(`LLM 提供商 "${id}" 未注册`);
|
|||
|
|
return p;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export function initDefaultProviders(): void {
|
|||
|
|
registerProvider(new AnthropicProvider());
|
|||
|
|
registerProvider(new OpenAIProvider());
|
|||
|
|
registerProvider(new DeepSeekProvider());
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export interface ModelRef {
|
|||
|
|
provider: string;
|
|||
|
|
model: string;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export function parseModelRef(modelStr: string): ModelRef {
|
|||
|
|
// 格式: "provider:model" 或 "model"(默认 openai)
|
|||
|
|
const parts = modelStr.split(':');
|
|||
|
|
if (parts.length === 2) return { provider: parts[0], model: parts[1] };
|
|||
|
|
// 根据模型名推断提供商
|
|||
|
|
if (modelStr.startsWith('claude')) return { provider: 'anthropic', model: modelStr };
|
|||
|
|
if (modelStr.startsWith('deepseek')) return { provider: 'deepseek', model: modelStr };
|
|||
|
|
return { provider: 'openai', model: modelStr };
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 3: 创建单次执行尝试**
|
|||
|
|
|
|||
|
|
创建 `packages/backend/src/modules/netaclaw/runtime/attempt.ts`:
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
import { LLMMessage, LLMResponse, LLMProviderConfig } from '../plugins/plugin_entry.js';
|
|||
|
|
import { AnyAgentTool } from '../tools/common.js';
|
|||
|
|
import { getProvider } from './model_selection.js';
|
|||
|
|
|
|||
|
|
export interface AttemptParams {
|
|||
|
|
messages: LLMMessage[];
|
|||
|
|
tools: AnyAgentTool[];
|
|||
|
|
config: LLMProviderConfig;
|
|||
|
|
maxToolRounds?: number;
|
|||
|
|
onToken?: (text: string) => void;
|
|||
|
|
onThinking?: (text: string) => void;
|
|||
|
|
onToolCall?: (name: string, args: Record<string, unknown>) => void;
|
|||
|
|
onToolResult?: (name: string, result: string) => void;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export interface AttemptResult {
|
|||
|
|
messages: LLMMessage[];
|
|||
|
|
finalContent: string;
|
|||
|
|
usage: { inputTokens: number; outputTokens: number };
|
|||
|
|
toolCallCount: number;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export async function runAttempt(params: AttemptParams): Promise<AttemptResult> {
|
|||
|
|
const { messages, tools, config, maxToolRounds = 20, onToken, onThinking, onToolCall, onToolResult } = params;
|
|||
|
|
const provider = getProvider(config.provider);
|
|||
|
|
const conversation = [...messages];
|
|||
|
|
let totalInput = 0;
|
|||
|
|
let totalOutput = 0;
|
|||
|
|
let toolCallCount = 0;
|
|||
|
|
|
|||
|
|
for (let round = 0; round < maxToolRounds; round++) {
|
|||
|
|
const response: LLMResponse = await provider.chat(conversation, tools, config);
|
|||
|
|
|
|||
|
|
totalInput += response.usage?.inputTokens ?? 0;
|
|||
|
|
totalOutput += response.usage?.outputTokens ?? 0;
|
|||
|
|
|
|||
|
|
if (response.thinking) onThinking?.(response.thinking);
|
|||
|
|
if (response.content) onToken?.(response.content);
|
|||
|
|
|
|||
|
|
// 无工具调用 → 结束
|
|||
|
|
if (!response.toolCalls?.length) {
|
|||
|
|
conversation.push({ role: 'assistant', content: response.content });
|
|||
|
|
return {
|
|||
|
|
messages: conversation,
|
|||
|
|
finalContent: response.content,
|
|||
|
|
usage: { inputTokens: totalInput, outputTokens: totalOutput },
|
|||
|
|
toolCallCount,
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 有工具调用 → 执行工具
|
|||
|
|
conversation.push({
|
|||
|
|
role: 'assistant',
|
|||
|
|
content: response.content,
|
|||
|
|
toolCalls: response.toolCalls,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
for (const tc of response.toolCalls) {
|
|||
|
|
toolCallCount++;
|
|||
|
|
onToolCall?.(tc.name, JSON.parse(tc.arguments));
|
|||
|
|
|
|||
|
|
const tool = tools.find(t => t.name === tc.name);
|
|||
|
|
let resultText: string;
|
|||
|
|
|
|||
|
|
if (!tool) {
|
|||
|
|
resultText = `错误: 工具 "${tc.name}" 不存在`;
|
|||
|
|
} else {
|
|||
|
|
try {
|
|||
|
|
const result = await tool.execute(tc.id, JSON.parse(tc.arguments));
|
|||
|
|
resultText = typeof result === 'string' ? result : JSON.stringify(result);
|
|||
|
|
} catch (err: any) {
|
|||
|
|
resultText = `工具执行错误: ${err.message}`;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
onToolResult?.(tc.name, resultText);
|
|||
|
|
conversation.push({ role: 'tool', content: resultText, toolCallId: tc.id });
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 超过最大轮次
|
|||
|
|
const lastAssistant = conversation.filter(m => m.role === 'assistant').pop();
|
|||
|
|
return {
|
|||
|
|
messages: conversation,
|
|||
|
|
finalContent: lastAssistant?.content ?? '达到最大工具调用轮次',
|
|||
|
|
usage: { inputTokens: totalInput, outputTokens: totalOutput },
|
|||
|
|
toolCallCount,
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 4: 创建 Agent 核心循环**
|
|||
|
|
|
|||
|
|
创建 `packages/backend/src/modules/netaclaw/runtime/agent.ts`:
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
import { LLMMessage, LLMProviderConfig } from '../plugins/plugin_entry.js';
|
|||
|
|
import { AnyAgentTool } from '../tools/common.js';
|
|||
|
|
import { runAttempt, AttemptResult } from './attempt.js';
|
|||
|
|
import { parseModelRef } from './model_selection.js';
|
|||
|
|
import { resolveThinkingDefault } from './thinking.js';
|
|||
|
|
|
|||
|
|
export interface AgentConfig {
|
|||
|
|
name: string;
|
|||
|
|
systemPrompt: string;
|
|||
|
|
model: string; // "provider:model" 或 "model"
|
|||
|
|
apiKey: string;
|
|||
|
|
baseUrl?: string;
|
|||
|
|
temperature?: number;
|
|||
|
|
maxTokens?: number;
|
|||
|
|
maxToolRounds?: number;
|
|||
|
|
skills?: string[]; // 加载的 SKILL.md 名称列表
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export interface AgentRunParams {
|
|||
|
|
agentConfig: AgentConfig;
|
|||
|
|
tools: AnyAgentTool[];
|
|||
|
|
userMessage: string;
|
|||
|
|
history?: LLMMessage[];
|
|||
|
|
onToken?: (text: string) => void;
|
|||
|
|
onThinking?: (text: string) => void;
|
|||
|
|
onToolCall?: (name: string, args: Record<string, unknown>) => void;
|
|||
|
|
onToolResult?: (name: string, result: string) => void;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export async function runAgent(params: AgentRunParams): Promise<AttemptResult> {
|
|||
|
|
const { agentConfig, tools, userMessage, history = [], onToken, onThinking, onToolCall, onToolResult } = params;
|
|||
|
|
|
|||
|
|
const modelRef = parseModelRef(agentConfig.model);
|
|||
|
|
const thinkLevel = resolveThinkingDefault(modelRef.model);
|
|||
|
|
|
|||
|
|
const llmConfig: LLMProviderConfig = {
|
|||
|
|
provider: modelRef.provider,
|
|||
|
|
model: modelRef.model,
|
|||
|
|
apiKey: agentConfig.apiKey,
|
|||
|
|
baseUrl: agentConfig.baseUrl,
|
|||
|
|
temperature: agentConfig.temperature,
|
|||
|
|
maxTokens: agentConfig.maxTokens,
|
|||
|
|
thinkLevel,
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const messages: LLMMessage[] = [
|
|||
|
|
{ role: 'system', content: agentConfig.systemPrompt },
|
|||
|
|
...history,
|
|||
|
|
{ role: 'user', content: userMessage },
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
return runAttempt({
|
|||
|
|
messages,
|
|||
|
|
tools,
|
|||
|
|
config: llmConfig,
|
|||
|
|
maxToolRounds: agentConfig.maxToolRounds,
|
|||
|
|
onToken,
|
|||
|
|
onThinking,
|
|||
|
|
onToolCall,
|
|||
|
|
onToolResult,
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 5: 提交**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add packages/backend/src/modules/netaclaw/runtime/
|
|||
|
|
git commit -m "feat(netaclaw): add Agent ReAct runtime with model selection and tool execution loop"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Task 5: 内置工具(Bash + File)
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Create: `packages/backend/src/modules/netaclaw/tools/builtin/bash.ts`
|
|||
|
|
- Create: `packages/backend/src/modules/netaclaw/tools/builtin/file.ts`
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: 创建 Bash 命令执行工具**
|
|||
|
|
|
|||
|
|
创建 `packages/backend/src/modules/netaclaw/tools/builtin/bash.ts`:
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
import { Type } from '@sinclair/typebox';
|
|||
|
|
import { exec } from 'child_process';
|
|||
|
|
import { promisify } from 'util';
|
|||
|
|
import { AgentToolWithMeta, textResult } from '../common.js';
|
|||
|
|
|
|||
|
|
const execAsync = promisify(exec);
|
|||
|
|
|
|||
|
|
const BashParams = Type.Object({
|
|||
|
|
command: Type.String({ description: '要执行的 shell 命令' }),
|
|||
|
|
cwd: Type.Optional(Type.String({ description: '工作目录' })),
|
|||
|
|
timeout: Type.Optional(Type.Number({ description: '超时时间(毫秒),默认 30000' })),
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
export const bashTool: AgentToolWithMeta<typeof BashParams, unknown> = {
|
|||
|
|
name: 'bash',
|
|||
|
|
label: '执行命令',
|
|||
|
|
description: '在 shell 中执行命令并返回输出',
|
|||
|
|
parameters: BashParams,
|
|||
|
|
async execute(_id, params) {
|
|||
|
|
const timeout = params.timeout ?? 30000;
|
|||
|
|
try {
|
|||
|
|
const { stdout, stderr } = await execAsync(params.command, {
|
|||
|
|
cwd: params.cwd,
|
|||
|
|
timeout,
|
|||
|
|
maxBuffer: 1024 * 1024 * 10, // 10MB
|
|||
|
|
});
|
|||
|
|
const output = stdout + (stderr ? `\n[stderr]: ${stderr}` : '');
|
|||
|
|
return textResult(output || '(无输出)');
|
|||
|
|
} catch (err: any) {
|
|||
|
|
return textResult(`命令执行失败: ${err.message}\n${err.stderr ?? ''}`);
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 2: 创建文件读写工具**
|
|||
|
|
|
|||
|
|
创建 `packages/backend/src/modules/netaclaw/tools/builtin/file.ts`:
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
import { Type } from '@sinclair/typebox';
|
|||
|
|
import * as fs from 'fs/promises';
|
|||
|
|
import * as path from 'path';
|
|||
|
|
import { AgentToolWithMeta, textResult, ToolInputError } from '../common.js';
|
|||
|
|
|
|||
|
|
const ReadFileParams = Type.Object({
|
|||
|
|
path: Type.String({ description: '文件绝对路径' }),
|
|||
|
|
encoding: Type.Optional(Type.String({ description: '编码,默认 utf-8' })),
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
export const readFileTool: AgentToolWithMeta<typeof ReadFileParams, unknown> = {
|
|||
|
|
name: 'read_file',
|
|||
|
|
label: '读取文件',
|
|||
|
|
description: '读取指定路径的文件内容',
|
|||
|
|
parameters: ReadFileParams,
|
|||
|
|
async execute(_id, params) {
|
|||
|
|
try {
|
|||
|
|
const content = await fs.readFile(params.path, { encoding: (params.encoding ?? 'utf-8') as BufferEncoding });
|
|||
|
|
return textResult(content);
|
|||
|
|
} catch (err: any) {
|
|||
|
|
return textResult(`读取文件失败: ${err.message}`);
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const WriteFileParams = Type.Object({
|
|||
|
|
path: Type.String({ description: '文件绝对路径' }),
|
|||
|
|
content: Type.String({ description: '要写入的内容' }),
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
export const writeFileTool: AgentToolWithMeta<typeof WriteFileParams, unknown> = {
|
|||
|
|
name: 'write_file',
|
|||
|
|
label: '写入文件',
|
|||
|
|
description: '将内容写入指定路径的文件(自动创建目录)',
|
|||
|
|
parameters: WriteFileParams,
|
|||
|
|
async execute(_id, params) {
|
|||
|
|
try {
|
|||
|
|
await fs.mkdir(path.dirname(params.path), { recursive: true });
|
|||
|
|
await fs.writeFile(params.path, params.content, 'utf-8');
|
|||
|
|
return textResult(`文件已写入: ${params.path}`);
|
|||
|
|
} catch (err: any) {
|
|||
|
|
return textResult(`写入文件失败: ${err.message}`);
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const ListDirParams = Type.Object({
|
|||
|
|
path: Type.String({ description: '目录路径' }),
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
export const listDirTool: AgentToolWithMeta<typeof ListDirParams, unknown> = {
|
|||
|
|
name: 'list_dir',
|
|||
|
|
label: '列出目录',
|
|||
|
|
description: '列出指定目录下的文件和子目录',
|
|||
|
|
parameters: ListDirParams,
|
|||
|
|
async execute(_id, params) {
|
|||
|
|
try {
|
|||
|
|
const entries = await fs.readdir(params.path, { withFileTypes: true });
|
|||
|
|
const lines = entries.map(e => `${e.isDirectory() ? '[DIR]' : '[FILE]'} ${e.name}`);
|
|||
|
|
return textResult(lines.join('\n') || '(空目录)');
|
|||
|
|
} catch (err: any) {
|
|||
|
|
return textResult(`列出目录失败: ${err.message}`);
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 3: 提交**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add packages/backend/src/modules/netaclaw/tools/builtin/
|
|||
|
|
git commit -m "feat(netaclaw): add builtin tools (bash, read_file, write_file, list_dir)"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Task 6: 数据库实体 + 会话管理 + WebSocket Gateway
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Create: `packages/backend/src/modules/netaclaw/entity/session.ts`
|
|||
|
|
- Create: `packages/backend/src/modules/netaclaw/entity/message.ts`
|
|||
|
|
- Create: `packages/backend/src/modules/netaclaw/gateway/protocol.ts`
|
|||
|
|
- Create: `packages/backend/src/modules/netaclaw/gateway/session.ts`
|
|||
|
|
- Create: `packages/backend/src/modules/netaclaw/gateway/server.ts`
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: 创建会话实体**
|
|||
|
|
|
|||
|
|
创建 `packages/backend/src/modules/netaclaw/entity/session.ts`:
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
import { BaseEntity } from '@cool-midway/core';
|
|||
|
|
import { Column, Entity } from 'typeorm';
|
|||
|
|
|
|||
|
|
@Entity('netaclaw_session')
|
|||
|
|
export class NetaClawSessionEntity extends BaseEntity {
|
|||
|
|
@Column({ comment: '会话ID', unique: true })
|
|||
|
|
sessionId: string;
|
|||
|
|
|
|||
|
|
@Column({ comment: '用户ID', nullable: true })
|
|||
|
|
userId: string;
|
|||
|
|
|
|||
|
|
@Column({ comment: 'Agent名称' })
|
|||
|
|
agentName: string;
|
|||
|
|
|
|||
|
|
@Column({ comment: '会话标题', nullable: true })
|
|||
|
|
title: string;
|
|||
|
|
|
|||
|
|
@Column({ comment: '使用的模型', nullable: true })
|
|||
|
|
model: string;
|
|||
|
|
|
|||
|
|
@Column({ comment: '状态: active/archived', default: 'active' })
|
|||
|
|
status: string;
|
|||
|
|
|
|||
|
|
@Column({ type: 'json', comment: '元数据', nullable: true })
|
|||
|
|
metadata: Record<string, unknown>;
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 2: 创建消息实体**
|
|||
|
|
|
|||
|
|
创建 `packages/backend/src/modules/netaclaw/entity/message.ts`:
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
import { BaseEntity } from '@cool-midway/core';
|
|||
|
|
import { Column, Entity, Index } from 'typeorm';
|
|||
|
|
|
|||
|
|
@Entity('netaclaw_message')
|
|||
|
|
export class NetaClawMessageEntity extends BaseEntity {
|
|||
|
|
@Index()
|
|||
|
|
@Column({ comment: '会话ID' })
|
|||
|
|
sessionId: string;
|
|||
|
|
|
|||
|
|
@Column({ comment: '角色: system/user/assistant/tool' })
|
|||
|
|
role: string;
|
|||
|
|
|
|||
|
|
@Column({ type: 'longtext', comment: '消息内容' })
|
|||
|
|
content: string;
|
|||
|
|
|
|||
|
|
@Column({ type: 'longtext', comment: '思考内容', nullable: true })
|
|||
|
|
thinking: string;
|
|||
|
|
|
|||
|
|
@Column({ type: 'json', comment: '工具调用', nullable: true })
|
|||
|
|
toolCalls: unknown;
|
|||
|
|
|
|||
|
|
@Column({ comment: '工具调用ID', nullable: true })
|
|||
|
|
toolCallId: string;
|
|||
|
|
|
|||
|
|
@Column({ comment: 'Skill名称', nullable: true })
|
|||
|
|
skillName: string;
|
|||
|
|
|
|||
|
|
@Column({ type: 'json', comment: '元数据', nullable: true })
|
|||
|
|
metadata: Record<string, unknown>;
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 3: 创建消息协议定义**
|
|||
|
|
|
|||
|
|
创建 `packages/backend/src/modules/netaclaw/gateway/protocol.ts`:
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
// --- 客户端 → 服务端 ---
|
|||
|
|
|
|||
|
|
export interface ClientChatMessage {
|
|||
|
|
type: 'chat';
|
|||
|
|
sessionId: string;
|
|||
|
|
content: string;
|
|||
|
|
agentName?: string;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export interface ClientPingMessage {
|
|||
|
|
type: 'ping';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export type ClientMessage = ClientChatMessage | ClientPingMessage;
|
|||
|
|
|
|||
|
|
// --- 服务端 → 客户端 ---
|
|||
|
|
|
|||
|
|
export interface ServerTokenEvent {
|
|||
|
|
type: 'token';
|
|||
|
|
sessionId: string;
|
|||
|
|
content: string;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export interface ServerThinkingEvent {
|
|||
|
|
type: 'thinking';
|
|||
|
|
sessionId: string;
|
|||
|
|
content: string;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export interface ServerToolCallEvent {
|
|||
|
|
type: 'tool_call';
|
|||
|
|
sessionId: string;
|
|||
|
|
toolName: string;
|
|||
|
|
args: Record<string, unknown>;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export interface ServerToolResultEvent {
|
|||
|
|
type: 'tool_result';
|
|||
|
|
sessionId: string;
|
|||
|
|
toolName: string;
|
|||
|
|
result: string;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export interface ServerDoneEvent {
|
|||
|
|
type: 'done';
|
|||
|
|
sessionId: string;
|
|||
|
|
usage?: { inputTokens: number; outputTokens: number };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export interface ServerErrorEvent {
|
|||
|
|
type: 'error';
|
|||
|
|
sessionId: string;
|
|||
|
|
message: string;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export interface ServerPongEvent {
|
|||
|
|
type: 'pong';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export type ServerEvent =
|
|||
|
|
| ServerTokenEvent
|
|||
|
|
| ServerThinkingEvent
|
|||
|
|
| ServerToolCallEvent
|
|||
|
|
| ServerToolResultEvent
|
|||
|
|
| ServerDoneEvent
|
|||
|
|
| ServerErrorEvent
|
|||
|
|
| ServerPongEvent;
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 4: 创建会话管理服务**
|
|||
|
|
|
|||
|
|
创建 `packages/backend/src/modules/netaclaw/gateway/session.ts`:
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
import { Provide, Inject, Scope, ScopeEnum } from '@midwayjs/core';
|
|||
|
|
import { InjectEntityModel } from '@midwayjs/typeorm';
|
|||
|
|
import { Repository } from 'typeorm';
|
|||
|
|
import { v4 as uuid } from 'uuid';
|
|||
|
|
import { NetaClawSessionEntity } from '../entity/session.js';
|
|||
|
|
import { NetaClawMessageEntity } from '../entity/message.js';
|
|||
|
|
import { LLMMessage } from '../plugins/plugin_entry.js';
|
|||
|
|
|
|||
|
|
@Provide()
|
|||
|
|
@Scope(ScopeEnum.Singleton)
|
|||
|
|
export class NetaClawSessionService {
|
|||
|
|
@InjectEntityModel(NetaClawSessionEntity)
|
|||
|
|
sessionRepo: Repository<NetaClawSessionEntity>;
|
|||
|
|
|
|||
|
|
@InjectEntityModel(NetaClawMessageEntity)
|
|||
|
|
messageRepo: Repository<NetaClawMessageEntity>;
|
|||
|
|
|
|||
|
|
async createSession(agentName: string, userId?: string): Promise<string> {
|
|||
|
|
const sessionId = uuid();
|
|||
|
|
await this.sessionRepo.save({
|
|||
|
|
sessionId,
|
|||
|
|
agentName,
|
|||
|
|
userId,
|
|||
|
|
status: 'active',
|
|||
|
|
});
|
|||
|
|
return sessionId;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async getOrCreateSession(sessionId: string | undefined, agentName: string): Promise<string> {
|
|||
|
|
if (sessionId) {
|
|||
|
|
const existing = await this.sessionRepo.findOneBy({ sessionId });
|
|||
|
|
if (existing) return sessionId;
|
|||
|
|
}
|
|||
|
|
return this.createSession(agentName);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async loadHistory(sessionId: string): Promise<LLMMessage[]> {
|
|||
|
|
const messages = await this.messageRepo.find({
|
|||
|
|
where: { sessionId },
|
|||
|
|
order: { createTime: 'ASC' },
|
|||
|
|
});
|
|||
|
|
return messages.map(m => ({
|
|||
|
|
role: m.role as LLMMessage['role'],
|
|||
|
|
content: m.content,
|
|||
|
|
toolCalls: m.toolCalls as any,
|
|||
|
|
toolCallId: m.toolCallId,
|
|||
|
|
}));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async saveMessage(sessionId: string, msg: LLMMessage & { thinking?: string; skillName?: string }): Promise<void> {
|
|||
|
|
await this.messageRepo.save({
|
|||
|
|
sessionId,
|
|||
|
|
role: msg.role,
|
|||
|
|
content: msg.content,
|
|||
|
|
thinking: msg.thinking,
|
|||
|
|
toolCalls: msg.toolCalls,
|
|||
|
|
toolCallId: msg.toolCallId,
|
|||
|
|
skillName: msg.skillName,
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async updateTitle(sessionId: string, title: string): Promise<void> {
|
|||
|
|
await this.sessionRepo.update({ sessionId }, { title });
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 5: 创建 WebSocket Gateway 服务**
|
|||
|
|
|
|||
|
|
创建 `packages/backend/src/modules/netaclaw/gateway/server.ts`:
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
import { Provide, Inject, Scope, ScopeEnum, Init, Logger } from '@midwayjs/core';
|
|||
|
|
import { ILogger } from '@midwayjs/logger';
|
|||
|
|
import { Context } from '@midwayjs/socketio';
|
|||
|
|
import { OnWSConnection, OnWSMessage, WSController } from '@midwayjs/socketio';
|
|||
|
|
import { NetaClawSessionService } from './session.js';
|
|||
|
|
import { ClientMessage, ServerEvent } from './protocol.js';
|
|||
|
|
import { runAgent, AgentConfig, AgentRunParams } from '../runtime/agent.js';
|
|||
|
|
import { bashTool } from '../tools/builtin/bash.js';
|
|||
|
|
import { readFileTool, writeFileTool, listDirTool } from '../tools/builtin/file.js';
|
|||
|
|
import { AnyAgentTool } from '../tools/common.js';
|
|||
|
|
import { initDefaultProviders } from '../runtime/model_selection.js';
|
|||
|
|
|
|||
|
|
@WSController('/netaclaw')
|
|||
|
|
export class NetaClawGateway {
|
|||
|
|
@Inject()
|
|||
|
|
ctx: Context;
|
|||
|
|
|
|||
|
|
@Logger()
|
|||
|
|
logger: ILogger;
|
|||
|
|
|
|||
|
|
@Inject()
|
|||
|
|
sessionService: NetaClawSessionService;
|
|||
|
|
|
|||
|
|
private defaultTools: AnyAgentTool[] = [bashTool, readFileTool, writeFileTool, listDirTool];
|
|||
|
|
|
|||
|
|
@Init()
|
|||
|
|
async init() {
|
|||
|
|
initDefaultProviders();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@OnWSConnection()
|
|||
|
|
async onConnect() {
|
|||
|
|
this.logger.info('[NetaClaw] 客户端已连接: %s', this.ctx.id);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@OnWSMessage('message')
|
|||
|
|
async onMessage(data: string) {
|
|||
|
|
let msg: ClientMessage;
|
|||
|
|
try {
|
|||
|
|
msg = JSON.parse(data);
|
|||
|
|
} catch {
|
|||
|
|
this.send({ type: 'error', sessionId: '', message: '无效的 JSON 消息' });
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (msg.type === 'ping') {
|
|||
|
|
this.send({ type: 'pong' });
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (msg.type === 'chat') {
|
|||
|
|
await this.handleChat(msg.sessionId, msg.content, msg.agentName);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private async handleChat(sessionId: string, content: string, agentName?: string) {
|
|||
|
|
const agent = agentName ?? 'default';
|
|||
|
|
const sid = await this.sessionService.getOrCreateSession(sessionId, agent);
|
|||
|
|
|
|||
|
|
// 保存用户消息
|
|||
|
|
await this.sessionService.saveMessage(sid, { role: 'user', content });
|
|||
|
|
|
|||
|
|
// 加载历史
|
|||
|
|
const history = await this.sessionService.loadHistory(sid);
|
|||
|
|
|
|||
|
|
// TODO: 从数据库加载 AgentConfig,当前使用默认配置
|
|||
|
|
const agentConfig: AgentConfig = {
|
|||
|
|
name: agent,
|
|||
|
|
systemPrompt: '你是 NetaClaw 电商运营助手,帮助用户管理电商平台的商品、订单和营销活动。',
|
|||
|
|
model: process.env.NETACLAW_MODEL ?? 'anthropic:claude-sonnet-4-20250514',
|
|||
|
|
apiKey: process.env.NETACLAW_API_KEY ?? '',
|
|||
|
|
maxToolRounds: 20,
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const result = await runAgent({
|
|||
|
|
agentConfig,
|
|||
|
|
tools: this.defaultTools,
|
|||
|
|
userMessage: content,
|
|||
|
|
history: history.slice(0, -1), // 排除刚保存的用户消息(已在 messages 中)
|
|||
|
|
onToken: text => this.send({ type: 'token', sessionId: sid, content: text }),
|
|||
|
|
onThinking: text => this.send({ type: 'thinking', sessionId: sid, content: text }),
|
|||
|
|
onToolCall: (name, args) => this.send({ type: 'tool_call', sessionId: sid, toolName: name, args }),
|
|||
|
|
onToolResult: (name, res) => this.send({ type: 'tool_result', sessionId: sid, toolName: name, result: res }),
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// 保存助手回复
|
|||
|
|
await this.sessionService.saveMessage(sid, { role: 'assistant', content: result.finalContent });
|
|||
|
|
|
|||
|
|
this.send({ type: 'done', sessionId: sid, usage: result.usage });
|
|||
|
|
} catch (err: any) {
|
|||
|
|
this.logger.error('[NetaClaw] Agent 执行错误: %s', err.message);
|
|||
|
|
this.send({ type: 'error', sessionId: sid, message: err.message });
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private send(event: ServerEvent) {
|
|||
|
|
this.ctx.emit('message', JSON.stringify(event));
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 6: 提交**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add packages/backend/src/modules/netaclaw/entity/ packages/backend/src/modules/netaclaw/gateway/
|
|||
|
|
git commit -m "feat(netaclaw): add session/message entities, WS gateway, and session management"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Task 7: Midway.js 模块注册 + 配置 + Skill 加载器
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Create: `packages/backend/src/modules/netaclaw/config.ts`
|
|||
|
|
- Create: `packages/backend/src/modules/netaclaw/module.ts`
|
|||
|
|
- Create: `packages/backend/src/modules/netaclaw/service/skill_loader.ts`
|
|||
|
|
- Create: `packages/backend/src/modules/netaclaw/controller/chat.ts`
|
|||
|
|
- Create: `skills/hello-world/SKILL.md`
|
|||
|
|
- Modify: `packages/backend/src/config/config.default.ts`
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: 创建 NetaClaw 配置定义**
|
|||
|
|
|
|||
|
|
创建 `packages/backend/src/modules/netaclaw/config.ts`:
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
export interface NetaClawConfig {
|
|||
|
|
// 默认模型(格式: "provider:model")
|
|||
|
|
defaultModel: string;
|
|||
|
|
// API Keys(按提供商)
|
|||
|
|
apiKeys: Record<string, string>;
|
|||
|
|
// Skill 目录路径
|
|||
|
|
skillsDir: string;
|
|||
|
|
// 本地数据目录
|
|||
|
|
dataDir: string;
|
|||
|
|
// 最大工具调用轮次
|
|||
|
|
maxToolRounds: number;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export const defaultNetaClawConfig: NetaClawConfig = {
|
|||
|
|
defaultModel: 'anthropic:claude-sonnet-4-20250514',
|
|||
|
|
apiKeys: {},
|
|||
|
|
skillsDir: './skills',
|
|||
|
|
dataDir: '~/.neta',
|
|||
|
|
maxToolRounds: 20,
|
|||
|
|
};
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 2: 创建 Skill 加载器(SKILL.md 格式)**
|
|||
|
|
|
|||
|
|
创建 `packages/backend/src/modules/netaclaw/service/skill_loader.ts`:
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
import { Provide, Scope, ScopeEnum, Config, Logger, Init } from '@midwayjs/core';
|
|||
|
|
import { ILogger } from '@midwayjs/logger';
|
|||
|
|
import * as fs from 'fs/promises';
|
|||
|
|
import * as path from 'path';
|
|||
|
|
|
|||
|
|
export interface SkillMeta {
|
|||
|
|
name: string;
|
|||
|
|
description: string;
|
|||
|
|
metadata?: Record<string, unknown>;
|
|||
|
|
content: string; // SKILL.md 正文(去掉 frontmatter)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@Provide()
|
|||
|
|
@Scope(ScopeEnum.Singleton)
|
|||
|
|
export class SkillLoaderService {
|
|||
|
|
@Logger()
|
|||
|
|
logger: ILogger;
|
|||
|
|
|
|||
|
|
private skills: Map<string, SkillMeta> = new Map();
|
|||
|
|
private skillsDir: string;
|
|||
|
|
|
|||
|
|
@Init()
|
|||
|
|
async init() {
|
|||
|
|
this.skillsDir = path.resolve(process.cwd(), 'skills');
|
|||
|
|
await this.scanSkills();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async scanSkills(): Promise<void> {
|
|||
|
|
try {
|
|||
|
|
const entries = await fs.readdir(this.skillsDir, { withFileTypes: true });
|
|||
|
|
for (const entry of entries) {
|
|||
|
|
if (!entry.isDirectory()) continue;
|
|||
|
|
const skillMdPath = path.join(this.skillsDir, entry.name, 'SKILL.md');
|
|||
|
|
try {
|
|||
|
|
const raw = await fs.readFile(skillMdPath, 'utf-8');
|
|||
|
|
const skill = this.parseSkillMd(raw);
|
|||
|
|
if (skill) {
|
|||
|
|
this.skills.set(skill.name, skill);
|
|||
|
|
this.logger.info('[SkillLoader] 已加载 Skill: %s', skill.name);
|
|||
|
|
}
|
|||
|
|
} catch {
|
|||
|
|
// SKILL.md 不存在,跳过
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
this.logger.info('[SkillLoader] 共加载 %d 个 Skill', this.skills.size);
|
|||
|
|
} catch {
|
|||
|
|
this.logger.warn('[SkillLoader] Skills 目录不存在: %s', this.skillsDir);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private parseSkillMd(raw: string): SkillMeta | null {
|
|||
|
|
const fmMatch = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
|
|||
|
|
if (!fmMatch) return null;
|
|||
|
|
|
|||
|
|
const frontmatter = fmMatch[1];
|
|||
|
|
const content = fmMatch[2].trim();
|
|||
|
|
|
|||
|
|
// 简单 YAML 解析(name 和 description)
|
|||
|
|
const nameMatch = frontmatter.match(/^name:\s*(.+)$/m);
|
|||
|
|
const descMatch = frontmatter.match(/^description:\s*(.+)$/m);
|
|||
|
|
|
|||
|
|
if (!nameMatch) return null;
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
name: nameMatch[1].trim(),
|
|||
|
|
description: descMatch?.[1]?.trim() ?? '',
|
|||
|
|
content,
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
getSkill(name: string): SkillMeta | undefined {
|
|||
|
|
return this.skills.get(name);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
getAllSkills(): SkillMeta[] {
|
|||
|
|
return Array.from(this.skills.values());
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
getSkillPrompt(names: string[]): string {
|
|||
|
|
const parts: string[] = [];
|
|||
|
|
for (const name of names) {
|
|||
|
|
const skill = this.skills.get(name);
|
|||
|
|
if (skill) {
|
|||
|
|
parts.push(`## Skill: ${skill.name}\n${skill.content}`);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return parts.length > 0 ? `\n\n# 可用技能\n\n${parts.join('\n\n')}` : '';
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 3: 创建 HTTP 对话控制器(SSE 备选方案)**
|
|||
|
|
|
|||
|
|
创建 `packages/backend/src/modules/netaclaw/controller/chat.ts`:
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
import { Provide, Inject, Post, Body, Controller, Logger } from '@midwayjs/core';
|
|||
|
|
import { ILogger } from '@midwayjs/logger';
|
|||
|
|
import { Context } from '@midwayjs/koa';
|
|||
|
|
import { NetaClawSessionService } from '../gateway/session.js';
|
|||
|
|
import { SkillLoaderService } from '../service/skill_loader.js';
|
|||
|
|
import { runAgent, AgentConfig } from '../runtime/agent.js';
|
|||
|
|
import { bashTool } from '../tools/builtin/bash.js';
|
|||
|
|
import { readFileTool, writeFileTool, listDirTool } from '../tools/builtin/file.js';
|
|||
|
|
import { initDefaultProviders } from '../runtime/model_selection.js';
|
|||
|
|
|
|||
|
|
@Provide()
|
|||
|
|
@Controller('/open/netaclaw')
|
|||
|
|
export class NetaClawChatController {
|
|||
|
|
@Inject()
|
|||
|
|
ctx: Context;
|
|||
|
|
|
|||
|
|
@Logger()
|
|||
|
|
logger: ILogger;
|
|||
|
|
|
|||
|
|
@Inject()
|
|||
|
|
sessionService: NetaClawSessionService;
|
|||
|
|
|
|||
|
|
@Inject()
|
|||
|
|
skillLoader: SkillLoaderService;
|
|||
|
|
|
|||
|
|
@Post('/chat')
|
|||
|
|
async chat(@Body() body: { sessionId?: string; message: string; agentName?: string }) {
|
|||
|
|
initDefaultProviders();
|
|||
|
|
|
|||
|
|
const agentName = body.agentName ?? 'default';
|
|||
|
|
const sessionId = await this.sessionService.getOrCreateSession(body.sessionId, agentName);
|
|||
|
|
|
|||
|
|
await this.sessionService.saveMessage(sessionId, { role: 'user', content: body.message });
|
|||
|
|
const history = await this.sessionService.loadHistory(sessionId);
|
|||
|
|
|
|||
|
|
// 构建系统提示(含 Skill 文档)
|
|||
|
|
const skillPrompt = this.skillLoader.getSkillPrompt([]);
|
|||
|
|
const systemPrompt = `你是 NetaClaw 电商运营助手,帮助用户管理电商平台的商品、订单和营销活动。${skillPrompt}`;
|
|||
|
|
|
|||
|
|
const agentConfig: AgentConfig = {
|
|||
|
|
name: agentName,
|
|||
|
|
systemPrompt,
|
|||
|
|
model: process.env.NETACLAW_MODEL ?? 'anthropic:claude-sonnet-4-20250514',
|
|||
|
|
apiKey: process.env.NETACLAW_API_KEY ?? '',
|
|||
|
|
maxToolRounds: 20,
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const tools = [bashTool, readFileTool, writeFileTool, listDirTool];
|
|||
|
|
|
|||
|
|
const result = await runAgent({
|
|||
|
|
agentConfig,
|
|||
|
|
tools,
|
|||
|
|
userMessage: body.message,
|
|||
|
|
history: history.slice(0, -1),
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
await this.sessionService.saveMessage(sessionId, { role: 'assistant', content: result.finalContent });
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
sessionId,
|
|||
|
|
content: result.finalContent,
|
|||
|
|
usage: result.usage,
|
|||
|
|
toolCallCount: result.toolCallCount,
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@Post('/skills')
|
|||
|
|
async listSkills() {
|
|||
|
|
return this.skillLoader.getAllSkills().map(s => ({ name: s.name, description: s.description }));
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 4: 创建 Midway.js 模块注册文件**
|
|||
|
|
|
|||
|
|
创建 `packages/backend/src/modules/netaclaw/module.ts`:
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
// NetaClaw 模块 - Midway.js 自动扫描加载
|
|||
|
|
// 本文件确保模块被 Cool Admin 框架识别
|
|||
|
|
export default {
|
|||
|
|
name: 'netaclaw',
|
|||
|
|
description: 'NetaClaw 电商浏览器自动化 Agent 引擎',
|
|||
|
|
};
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 5: 创建示例 Skill**
|
|||
|
|
|
|||
|
|
创建 `skills/hello-world/SKILL.md`:
|
|||
|
|
|
|||
|
|
```markdown
|
|||
|
|
---
|
|||
|
|
name: hello-world
|
|||
|
|
description: 示例 Skill,用于验证 Skill 加载机制
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
# Hello World Skill
|
|||
|
|
|
|||
|
|
## 触发条件
|
|||
|
|
用户说"你好"或要求测试 Skill 系统
|
|||
|
|
|
|||
|
|
## 工作流程
|
|||
|
|
1. 向用户问好
|
|||
|
|
2. 列出当前可用的工具
|
|||
|
|
3. 展示一个简单的文件操作示例
|
|||
|
|
|
|||
|
|
## 规则约束
|
|||
|
|
- 这是一个演示 Skill,不执行任何危险操作
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 6: 更新 config.default.ts 添加 netaclaw 配置段**
|
|||
|
|
|
|||
|
|
在 `packages/backend/src/config/config.default.ts` 中添加:
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
// 在 export default 对象中添加:
|
|||
|
|
netaclaw: {
|
|||
|
|
defaultModel: 'anthropic:claude-sonnet-4-20250514',
|
|||
|
|
apiKeys: {
|
|||
|
|
anthropic: process.env.NETACLAW_ANTHROPIC_KEY ?? '',
|
|||
|
|
openai: process.env.NETACLAW_OPENAI_KEY ?? '',
|
|||
|
|
deepseek: process.env.NETACLAW_DEEPSEEK_KEY ?? '',
|
|||
|
|
},
|
|||
|
|
skillsDir: './skills',
|
|||
|
|
dataDir: '~/.neta',
|
|||
|
|
maxToolRounds: 20,
|
|||
|
|
},
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 7: 验证后端启动**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
cd packages/backend && pnpm dev
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
预期:后端在 8003 端口启动,日志中可见 `[SkillLoader] 已加载 Skill: hello-world`。
|
|||
|
|
|
|||
|
|
测试 HTTP 接口(不需要真实 API Key 也能验证路由注册):
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
curl -X POST http://localhost:8003/open/netaclaw/skills
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
预期返回:`[{"name":"hello-world","description":"示例 Skill,用于验证 Skill 加载机制"}]`
|
|||
|
|
|
|||
|
|
- [ ] **Step 8: 提交**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add packages/backend/src/modules/netaclaw/ skills/ packages/backend/src/config/
|
|||
|
|
git commit -m "feat(netaclaw): add module registration, skill loader, HTTP chat controller, and hello-world skill"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Task 8: 端到端验证
|
|||
|
|
|
|||
|
|
**Files:** 无新建文件
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: 设置环境变量**
|
|||
|
|
|
|||
|
|
在项目根目录 `.env` 中添加(使用你的真实 API Key):
|
|||
|
|
|
|||
|
|
```env
|
|||
|
|
NETACLAW_MODEL=anthropic:claude-sonnet-4-20250514
|
|||
|
|
NETACLAW_API_KEY=your-anthropic-api-key
|
|||
|
|
NETACLAW_ANTHROPIC_KEY=your-anthropic-api-key
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 2: 启动后端并测试对话**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
cd packages/backend && pnpm dev
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
测试基本对话:
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
curl -X POST http://localhost:8003/open/netaclaw/chat \
|
|||
|
|
-H "Content-Type: application/json" \
|
|||
|
|
-d '{"message": "你好,请列出当前目录的文件"}'
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
预期:返回 JSON,包含 `sessionId`、`content`(助手回复,应包含目录列表)、`usage`、`toolCallCount`(应 >= 1,因为调用了 list_dir 工具)。
|
|||
|
|
|
|||
|
|
- [ ] **Step 3: 测试会话连续性**
|
|||
|
|
|
|||
|
|
使用上一步返回的 sessionId:
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
curl -X POST http://localhost:8003/open/netaclaw/chat \
|
|||
|
|
-H "Content-Type: application/json" \
|
|||
|
|
-d '{"sessionId": "<上一步的sessionId>", "message": "刚才列出了哪些文件?"}'
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
预期:助手能回忆上一轮对话内容。
|
|||
|
|
|
|||
|
|
- [ ] **Step 4: 提交最终状态**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add -A
|
|||
|
|
git commit -m "feat(netaclaw): Phase 1 complete - core agent runtime with tool execution and session management"
|
|||
|
|
```
|