389 lines
15 KiB
Markdown
389 lines
15 KiB
Markdown
# 多模态图片识别工具 & 工具模型分类 & 对话附件上传 实施计划
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task.
|
||
|
||
**Goal:** 在工具管理中新增模型依赖分类和图片识别工具,在 Agent 对话页面新增附件上传功能。
|
||
|
||
**Architecture:** 后端扩展 tool entity 新增 requiresModel/modelChannelId/modelId 字段,新增 image_recognize 工具(工厂函数接收已解析凭证),复用现有 LLM provider 层调用模型。附件信息存 metadata,通过 prompt_builder 注入 LLM messages,content 保持纯净。前端附件功能拆分为 3 个独立子组件。
|
||
|
||
**Tech Stack:** Midway.js + TypeORM + TypeBox + Socket.IO, Vue 3 + Element Plus + Pinia, OpenAI 兼容 API (火山引擎)
|
||
|
||
**Spec:** `docs/superpowers/specs/2026-04-26-multimodal-tool-design.md`
|
||
|
||
---
|
||
|
||
### Task 1: Tool Entity 新增模型配置字段
|
||
|
||
**Files:**
|
||
- Modify: `packages/backend/src/modules/netaclaw/entity/tool.ts:48`
|
||
|
||
- [ ] **Step 1:** 在 `tool.ts` 的 `extra` 字段前(第 48 行前)新增:
|
||
```typescript
|
||
@Column({ comment: '是否需要大模型配置 0否 1是', default: 0 })
|
||
requiresModel: number;
|
||
|
||
@Column({ comment: '关联模型渠道ID', nullable: true })
|
||
modelChannelId: number;
|
||
|
||
@Column({ comment: '关联模型ID', length: 100, nullable: true })
|
||
modelId: string;
|
||
```
|
||
|
||
- [ ] **Step 2:** 启动后端验证自动建表,用 MCP 验证 `DESCRIBE netaclaw_tool;`
|
||
|
||
- [ ] **Step 3:** Commit `feat(netaclaw): tool entity 新增 requiresModel/modelChannelId/modelId`
|
||
|
||
---
|
||
|
||
### Task 2: Catalog Schema 扩展 + Registry 同步
|
||
|
||
**Files:**
|
||
- Modify: `packages/backend/src/modules/netaclaw/tools/catalog.ts:14`
|
||
- Modify: `packages/backend/src/modules/netaclaw/service/tool_registry.ts:28-49,96-128`
|
||
|
||
- [ ] **Step 1:** `catalog.ts` ToolSchema 接口第 14 行后新增 `requiresModel?: boolean;`
|
||
注意:`modelChannelId` 和 `modelId` 是运行时配置,只通过管理界面设置,不进 catalog。
|
||
|
||
- [ ] **Step 2:** `tool_registry.ts` createDefaults 返回对象中 `extra: null` 前新增:
|
||
```typescript
|
||
requiresModel: s.requiresModel ? 1 : 0,
|
||
```
|
||
|
||
- [ ] **Step 3:** `tool_registry.ts` syncCatalogToDb 更新对象中新增:
|
||
```typescript
|
||
requiresModel: typeof current.requiresModel === 'number' ? current.requiresModel : defaults.requiresModel,
|
||
```
|
||
|
||
- [ ] **Step 4:** `tool_registry.ts` update 方法后新增 getToolModelConfig:
|
||
```typescript
|
||
async getToolModelConfig(toolName: string): Promise<{
|
||
modelChannelId: number; modelId: string; promptHint: string | null;
|
||
} | null> {
|
||
const tool = await this.toolRepo.findOneBy({ name: toolName });
|
||
if (!tool?.modelChannelId || !tool?.modelId) return null;
|
||
return { modelChannelId: tool.modelChannelId, modelId: tool.modelId, promptHint: tool.promptHint };
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5:** Commit `feat(netaclaw): catalog 扩展 requiresModel + registry 同步和查询`
|
||
|
||
---
|
||
|
||
### Task 3: Tool Controller 新增 requiresModel 筛选
|
||
|
||
**Files:**
|
||
- Modify: `packages/backend/src/modules/netaclaw/controller/admin/tool.ts:26`
|
||
- Modify: `packages/backend/src/modules/netaclaw/service/tool_registry.ts:130-155`
|
||
|
||
- [ ] **Step 1:** controller page 参数第 26 行后新增 `requiresModel?: number;`
|
||
|
||
- [ ] **Step 2:** registry page 方法参数新增 `requiresModel?: number;`,解构加入,where 中新增:
|
||
```typescript
|
||
if (typeof requiresModel === 'number') where.requiresModel = requiresModel;
|
||
```
|
||
|
||
- [ ] **Step 3:** Commit `feat(netaclaw): tool page 接口支持 requiresModel 筛选`
|
||
|
||
---
|
||
### Task 4: 实现 image_recognize 工具(复用 LLM Provider 层)
|
||
|
||
**Files:**
|
||
- Create: `packages/backend/src/modules/netaclaw/tools/builtin/image_recognize.ts`
|
||
- Modify: `packages/backend/src/modules/netaclaw/tools/catalog.ts:64`
|
||
|
||
- [ ] **Step 1:** 创建 `tools/builtin/image_recognize.ts`。工厂函数接收已解析的凭证对象(不是 service),通过项目现有 LLM provider 层调用模型:
|
||
|
||
```typescript
|
||
import { Type, Static } from '@sinclair/typebox';
|
||
import { type AnyAgentTool, textResult } from '../common.js';
|
||
import { registerSchema } from '../catalog.js';
|
||
|
||
const DEFAULT_PROMPT = `你是一个专业的图像分析助手。请按以下步骤分析图片:
|
||
1. **图像分类**:首先识别图片类型(如:身份证、驾驶证、行驶证、营业执照、发票、商品图片、截图、照片、表格、图表、手写文字、印刷文字等)。
|
||
2. **结构化提取**:根据图片类型,提取关键信息:
|
||
- 证件类:提取所有字段(姓名、证件号、有效期、地址等)
|
||
- 票据类:提取金额、日期、项目明细等
|
||
- 商品类:提取品名、规格、价格、品牌等
|
||
- 表格/图表类:提取数据结构和关键数值
|
||
- 其他类:详细描述画面内容
|
||
3. **详细描述**:对图片内容进行全面、详细的文字描述,不遗漏任何可见信息。
|
||
4. **质量评估**:简要说明图片清晰度、是否有遮挡或模糊区域。
|
||
请以结构化格式输出分析结果。`;
|
||
|
||
const Params = Type.Object({
|
||
image: Type.String({ description: '图片URL或base64编码字符串' }),
|
||
prompt: Type.Optional(Type.String({ description: '分析提示词' })),
|
||
});
|
||
|
||
export interface ImageRecognizeCredentials {
|
||
baseUrl: string;
|
||
apiKey: string;
|
||
supplier: string;
|
||
modelId: string;
|
||
promptHint: string | null;
|
||
}
|
||
|
||
export function createImageRecognizeTool(creds: ImageRecognizeCredentials): AnyAgentTool {
|
||
return {
|
||
name: 'image_recognize',
|
||
label: '图片识别',
|
||
description: '分析图片内容,支持证件识别、OCR、商品识别等。传入图片URL或base64。',
|
||
parameters: Params,
|
||
async execute(_id, params: Static<typeof Params>) {
|
||
const systemPrompt = creds.promptHint || DEFAULT_PROMPT;
|
||
const userPrompt = params.prompt
|
||
? `${systemPrompt}\n\n用户补充要求:${params.prompt}`
|
||
: systemPrompt;
|
||
|
||
const imageUrl = params.image.startsWith('http')
|
||
? params.image
|
||
: params.image.startsWith('data:')
|
||
? params.image
|
||
: `data:image/png;base64,${params.image}`;
|
||
|
||
// 复用项目 LLM provider 层(openai 兼容协议)
|
||
const { getProvider, supplierToProvider } = await import('../../plugins/llm_providers/index.js');
|
||
const providerName = supplierToProvider[creds.supplier] || 'openai';
|
||
const provider = getProvider(providerName);
|
||
|
||
const result = await provider.chat({
|
||
baseUrl: creds.baseUrl,
|
||
apiKey: creds.apiKey,
|
||
model: creds.modelId,
|
||
messages: [{
|
||
role: 'user',
|
||
content: [
|
||
{ type: 'text', text: userPrompt },
|
||
{ type: 'image_url', image_url: { url: imageUrl } },
|
||
],
|
||
}],
|
||
maxTokens: 4096,
|
||
});
|
||
|
||
return textResult(result.content ?? '模型未返回内容');
|
||
},
|
||
};
|
||
}
|
||
|
||
registerSchema({
|
||
name: 'image_recognize',
|
||
toolset: 'vision',
|
||
description: '分析图片内容,支持证件识别、OCR、商品识别等。',
|
||
capability: 'multimodal',
|
||
visibility: 'tool',
|
||
isCore: false,
|
||
canDisable: true,
|
||
supportsPromptHint: true,
|
||
requiresModel: true,
|
||
});
|
||
```
|
||
|
||
注意:需要先确认 `plugins/llm_providers/` 的 provider.chat() 方法是否支持 multimodal content parts。如果不支持,需要在 provider 层扩展,而不是绕过它。
|
||
|
||
- [ ] **Step 2:** `catalog.ts` 末尾新增 `import './builtin/image_recognize.js';`
|
||
|
||
- [ ] **Step 3:** Commit `feat(netaclaw): 实现 image_recognize 工具(复用 LLM provider 层)`
|
||
|
||
---
|
||
|
||
### Task 5: Tool Resolver 注入 image_recognize(resolve 阶段排除未配置工具)
|
||
|
||
**Files:**
|
||
- Modify: `packages/backend/src/modules/netaclaw/service/tool_resolver.ts:0-30,607-611`
|
||
|
||
- [ ] **Step 1:** `tool_resolver.ts` 顶部新增 import:
|
||
```typescript
|
||
import { createImageRecognizeTool } from '../tools/builtin/image_recognize.js';
|
||
import { NetaClawModelChannelService } from './model_channel.js';
|
||
```
|
||
|
||
在类中注入:
|
||
```typescript
|
||
@Inject()
|
||
modelChannelService: NetaClawModelChannelService;
|
||
```
|
||
|
||
- [ ] **Step 2:** resolve() 方法中 escalate 注入后(约第 611 行后)新增。关键:模型未配置时不注入工具,LLM 不会看到它:
|
||
```typescript
|
||
if (filteredNames.includes('image_recognize')) {
|
||
const toolModelConfig = await this.toolRegistry.getToolModelConfig('image_recognize');
|
||
if (toolModelConfig) {
|
||
const channelCreds = await this.modelChannelService.resolveForAgent(toolModelConfig.modelChannelId);
|
||
if (channelCreds) {
|
||
runtimeTools.push(createImageRecognizeTool({
|
||
baseUrl: channelCreds.baseUrl,
|
||
apiKey: channelCreds.apiKey,
|
||
supplier: channelCreds.supplier,
|
||
modelId: toolModelConfig.modelId,
|
||
promptHint: toolModelConfig.promptHint,
|
||
}));
|
||
} else {
|
||
disabledReasons.push({ name: 'image_recognize', reason: 'model_channel_unavailable' });
|
||
}
|
||
} else {
|
||
disabledReasons.push({ name: 'image_recognize', reason: 'model_not_configured' });
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3:** 启动后端验证,调用 `/admin/netaclaw/tool/sync` 确认 image_recognize 出现。
|
||
|
||
- [ ] **Step 4:** Commit `feat(netaclaw): tool resolver 注入 image_recognize(resolve 阶段排除未配置)`
|
||
|
||
---
|
||
|
||
### Task 6: 前端工具管理页改造
|
||
|
||
**Files:**
|
||
- Modify: `packages/frontend/src/modules/agent/views/tools.vue`
|
||
|
||
- [ ] **Step 1:** 筛选栏新增"模型依赖"下拉(在 capability 筛选后):
|
||
```html
|
||
<el-select v-model="filters.requiresModel" placeholder="模型依赖" clearable style="width:140px">
|
||
<el-option label="需要模型" :value="1" />
|
||
<el-option label="不需要模型" :value="0" />
|
||
</el-select>
|
||
```
|
||
filters 对象新增 `requiresModel: undefined`,loadData 请求参数加入。
|
||
|
||
- [ ] **Step 2:** 表格新增"模型配置"列(capability 列后):
|
||
```html
|
||
<el-table-column label="模型配置" width="180">
|
||
<template #default="{ row }">
|
||
<span v-if="!row.requiresModel">-</span>
|
||
<el-tag v-else-if="row.modelId" type="success" size="small">{{ row.modelId }}</el-tag>
|
||
<el-tag v-else type="warning" size="small">未配置</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
```
|
||
|
||
- [ ] **Step 3:** 编辑抽屉新增模型配置区域(当 requiresModel===1 时显示):渠道下拉 + 模型联动下拉 + 提示词 textarea。调用 `service.netaclaw.model_channel.allModels()` 获取多模态模型列表。
|
||
|
||
- [ ] **Step 4:** 启动前端验证:筛选、表格列、编辑抽屉模型配置。
|
||
|
||
- [ ] **Step 5:** Commit `feat(frontend): 工具管理页新增模型依赖筛选和模型配置编辑`
|
||
|
||
---
|
||
### Task 7: WebSocket 协议扩展附件 + 后端消息处理
|
||
|
||
**Files:**
|
||
- Modify: `packages/backend/src/modules/netaclaw/gateway/protocol.ts:1-10`
|
||
- Modify: `packages/backend/src/modules/netaclaw/gateway/server.ts`
|
||
|
||
- [ ] **Step 1:** `protocol.ts` 顶部新增 ChatAttachment 接口:
|
||
```typescript
|
||
export interface ChatAttachment {
|
||
id: string;
|
||
type: 'image' | 'video' | 'pdf' | 'document' | 'other';
|
||
url: string;
|
||
name: string;
|
||
size: number;
|
||
mimeType: string;
|
||
role?: 'start_frame' | 'end_frame';
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2:** ClientChatMessage 第 9 行后新增 `attachments?: ChatAttachment[];`
|
||
|
||
- [ ] **Step 3:** `server.ts` 中处理 chat 消息时,将 attachments 存入 message metadata(不修改 content):
|
||
```typescript
|
||
const metadata: Record<string, unknown> = {};
|
||
if (msg.attachments?.length) {
|
||
metadata.attachments = msg.attachments;
|
||
}
|
||
// 存储消息时传入 metadata
|
||
```
|
||
|
||
- [ ] **Step 4:** Commit `feat(netaclaw): WebSocket 协议扩展附件 + 消息 metadata 存储`
|
||
|
||
---
|
||
|
||
### Task 8: Prompt Builder 附件信息注入
|
||
|
||
**Files:**
|
||
- Modify: `packages/backend/src/modules/netaclaw/service/prompt_builder.ts`
|
||
|
||
- [ ] **Step 1:** 在 prompt_builder 构造 LLM messages 时,检查用户消息的 metadata.attachments。如果存在附件,在用户消息后追加一条附件提示 message:
|
||
```typescript
|
||
if (userMessage.metadata?.attachments?.length) {
|
||
const attachments = userMessage.metadata.attachments as ChatAttachment[];
|
||
const desc = attachments.map(a => {
|
||
const typeLabel = { image: '图片', video: '视频', pdf: 'PDF', document: '文件', other: '文件' }[a.type];
|
||
return `- ${typeLabel}: ${a.name} (URL: ${a.url})`;
|
||
}).join('\n');
|
||
messages.push({
|
||
role: 'user',
|
||
content: `[系统提示] 用户上传了以下附件:\n${desc}\n如需分析图片内容,请使用 image_recognize 工具,传入图片URL。`,
|
||
});
|
||
}
|
||
```
|
||
|
||
这样 content 保持纯净,附件信息通过独立 message 注入 LLM。
|
||
|
||
- [ ] **Step 2:** Commit `feat(netaclaw): prompt builder 注入附件信息到 LLM messages`
|
||
|
||
---
|
||
|
||
### Task 9: 前端类型定义 + WebSocket 适配
|
||
|
||
**Files:**
|
||
- Modify: `packages/frontend/src/modules/agent/types/index.d.ts`
|
||
- Modify: `packages/frontend/src/modules/agent/hooks/websocket.ts`
|
||
|
||
- [ ] **Step 1:** `types/index.d.ts` 新增 ChatAttachment 接口(与后端 protocol.ts 一致)。WSClientMessage 的 chat 类型新增 `attachments?: ChatAttachment[]`。
|
||
|
||
- [ ] **Step 2:** 确认 `websocket.ts` 的 ExtendedWSClientMessage 类型能包含 attachments 字段。
|
||
|
||
- [ ] **Step 3:** Commit `feat(frontend): 前端类型定义新增 ChatAttachment`
|
||
|
||
---
|
||
|
||
### Task 10: 前端对话附件上传组件
|
||
|
||
**Files:**
|
||
- Create: `packages/frontend/src/modules/agent/components/chat/ChatAttachmentButton.vue`
|
||
- Create: `packages/frontend/src/modules/agent/components/chat/ChatAttachmentPreview.vue`
|
||
- Modify: `packages/frontend/src/modules/agent/components/chat/ChatComposer.vue`
|
||
|
||
- [ ] **Step 1:** 创建 ChatAttachmentButton.vue — 回形针按钮 + 隐藏 file input,emit `@select(files: File[])`
|
||
|
||
- [ ] **Step 2:** 创建 ChatAttachmentPreview.vue — 横向滚动预览条,缩略图/文件图标/删除/首尾帧标记/上传进度
|
||
|
||
- [ ] **Step 3:** 改造 ChatComposer.vue:
|
||
- 集成 ChatAttachmentButton(textarea 左侧)和 ChatAttachmentPreview(textarea 上方)
|
||
- 支持拖拽(@dragover + @drop)和粘贴(@paste 检测 clipboardData.files)
|
||
- 文件通过 `/admin/base/comm/upload` 上传到 Space(复用现有上传基础设施)
|
||
- 保持 `send` 事件名,通过可选 payload 传递附件:`emit('send', attachments)`
|
||
- 无附件时 `emit('send')` 仍然兼容
|
||
|
||
- [ ] **Step 4:** `chat.vue` 中 handleSend 方法适配附件参数:
|
||
```typescript
|
||
function handleSend(attachments?: ChatAttachment[]) {
|
||
const msg = {
|
||
type: 'chat', sessionId, content: inputText.value,
|
||
agentId, leafEntryId,
|
||
...(attachments?.length ? { attachments } : {}),
|
||
};
|
||
ws.send(msg);
|
||
inputText.value = '';
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5:** Commit `feat(frontend): Agent 对话附件上传(按钮/预览/拖拽/粘贴)`
|
||
|
||
---
|
||
|
||
### Task 11: 消息气泡附件展示
|
||
|
||
**Files:**
|
||
- Create: `packages/frontend/src/modules/agent/components/chat/MessageAttachments.vue`
|
||
- Modify: `packages/frontend/src/modules/agent/components/message-item.vue`
|
||
|
||
- [ ] **Step 1:** 创建 MessageAttachments.vue — 图片网格缩略图(el-image 放大)、视频/PDF/文档文件图标+文件名
|
||
|
||
- [ ] **Step 2:** `message-item.vue` 中用户消息气泡内,检查 metadata.attachments 渲染 MessageAttachments。content 保持原样显示,无需过滤。
|
||
|
||
- [ ] **Step 3:** 启动前后端,完整测试:上传图片 → 发送 → Agent 调用 image_recognize → 返回分析结果 → 消息气泡显示缩略图
|
||
|
||
- [ ] **Step 4:** Commit `feat(frontend): 消息气泡附件展示`
|