GPU_GUARD_MONOREPO/docs/superpowers/plans/2026-04-12-model-channel-management.md
2026-05-20 21:39:12 +08:00

1057 lines
30 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 模型渠道管理 实施计划
> **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:** 为 Neta-monorepo 新增集中式 LLM 模型渠道管理,改造 Agent/Skill 编辑页为渠道级联选择器,桥接运行时 provider:model 机制。
**Architecture:** 后端新增 `netaclaw_model_channel` 表 + Entity/Service/Controller参照 AI_flow 实现),前端新增模型渠道管理页 + 级联选择器组件,改造现有 Agent 编辑页和 Skill 模型配置组件。运行时在执行前查询渠道表透明获取凭证。
**Tech Stack:** Midway.js 3.20 + TypeORM + Vue 3 + Element Plus + TypeScript
---
## 文件结构
### 新增文件
| 文件 | 职责 |
|------|------|
| `packages/backend/src/modules/netaclaw/entity/model_channel.ts` | TypeORM Entity映射 `netaclaw_model_channel` 表 |
| `packages/backend/src/modules/netaclaw/service/model_channel.ts` | 业务逻辑allModels、testConnection、resolveForAgent |
| `packages/backend/src/modules/netaclaw/controller/admin/model_channel.ts` | Admin APICRUD + allModels + testConnection |
| `packages/frontend/src/modules/agent/views/model-channel.vue` | 模型渠道管理页面 |
| `packages/frontend/src/modules/agent/components/model-channel-selector.vue` | 渠道级联选择器组件 |
### 修改文件
| 文件 | 改动 |
|------|------|
| `packages/backend/src/entities.ts` | 新增 model_channel Entity 导入 |
| `packages/backend/src/modules/netaclaw/runtime/agent.ts` | 接入渠道解析逻辑 |
| `packages/frontend/src/modules/agent/config.ts` | 新增 model-channel 路由 |
| `packages/frontend/src/modules/agent/views/agent-edit.vue` | 模型配置 Tab 改为级联选择器 |
| `packages/frontend/src/modules/agent/components/skill-model.vue` | 改为级联选择器 |
---
## Task 1: 后端 Entity
**Files:**
- Create: `packages/backend/src/modules/netaclaw/entity/model_channel.ts`
- Modify: `packages/backend/src/entities.ts`
- [ ] **Step 1: 创建 model_channel Entity 文件**
```typescript
// packages/backend/src/modules/netaclaw/entity/model_channel.ts
import { BaseEntity } from '../../base/entity/base.js';
import { Column, Entity, Index } from 'typeorm';
/**
* 模型渠道配置表 - 集中管理 LLM 供应商渠道
* 一个渠道对应一组 baseUrl + apiKey可包含多个模型
*/
@Entity('netaclaw_model_channel')
export class NetaClawModelChannelEntity extends BaseEntity {
@Index({ unique: true })
@Column({ comment: '渠道名称', length: 100 })
name: string;
@Index()
@Column({ comment: '供应商类型', length: 50 })
supplier: string;
@Column({ comment: 'API基础地址', length: 500 })
baseUrl: string;
@Column({ comment: 'API密钥', length: 500, nullable: true })
apiKey: string;
@Column({
comment: '可用模型列表 [{name,capability}]',
type: 'json',
nullable: true,
})
models: { name: string; capability: string }[];
@Column({ comment: '描述', type: 'text', nullable: true })
description: string;
@Index()
@Column({ comment: '状态 0=禁用 1=启用', default: 1 })
status: number;
}
```
- [ ] **Step 2: 注册 Entity 到 entities.ts**
`packages/backend/src/entities.ts` 文件末尾的 import 区域和数组中添加:
在最后一行 import`import * as entity34 from './modules/netaclaw/entity/skill';`)之后添加:
```typescript
import * as entity35 from './modules/netaclaw/entity/model_channel';
```
`entities` 数组末尾(`...Object.values(entity34),` 之后)添加:
```typescript
...Object.values(entity35),
```
- [ ] **Step 3: 启动后端验证表自动创建**
Run: `cd packages/backend && npx midway-bin dev`
Expected: 服务启动成功MySQL 中自动创建 `netaclaw_model_channel` 表(因为 config.local.ts 中 `synchronize: true`
- [ ] **Step 4: 提交**
```bash
git add packages/backend/src/modules/netaclaw/entity/model_channel.ts packages/backend/src/entities.ts
git commit -m "feat(backend): 新增 netaclaw_model_channel Entity"
```
---
## Task 2: 后端 Service
**Files:**
- Create: `packages/backend/src/modules/netaclaw/service/model_channel.ts`
- [ ] **Step 1: 创建 model_channel Service**
```typescript
// packages/backend/src/modules/netaclaw/service/model_channel.ts
import { Provide, Scope, ScopeEnum } from '@midwayjs/core';
import { InjectEntityModel } from '@midwayjs/typeorm';
import { Repository, Equal, Like } from 'typeorm';
import { NetaClawModelChannelEntity } from '../entity/model_channel.js';
/**
* 模型渠道管理服务
*/
@Provide()
@Scope(ScopeEnum.Singleton)
export class NetaClawModelChannelService {
@InjectEntityModel(NetaClawModelChannelEntity)
channelRepo: Repository<NetaClawModelChannelEntity>;
/**
* 标准化 models 字段(兼容旧的字符串数组格式)
*/
private normalizeModels(
models: any[]
): { name: string; capability: string }[] {
if (!Array.isArray(models)) return [];
return models.map(m => {
if (typeof m === 'string') return { name: m, capability: 'text' };
return { name: m.name || '', capability: m.capability || 'text' };
});
}
/** 分页查询 */
async page(params: {
page?: number;
size?: number;
keyWord?: string;
supplier?: string;
}) {
const { page = 1, size = 20, keyWord, supplier } = params;
const where: any = {};
if (keyWord) where.name = Like(`%${keyWord}%`);
if (supplier) where.supplier = Equal(supplier);
const [list, total] = await this.channelRepo.findAndCount({
where,
order: { id: 'DESC' },
skip: (page - 1) * size,
take: size,
});
return { list, pagination: { page, size, total } };
}
/** 新增 */
async add(data: Partial<NetaClawModelChannelEntity>) {
const entity = this.channelRepo.create(data);
return this.channelRepo.save(entity);
}
/** 更新 */
async update(data: Partial<NetaClawModelChannelEntity>) {
await this.channelRepo.save(data);
}
/** 删除 */
async delete(ids: number[]) {
await this.channelRepo.delete(ids);
}
/** 详情 */
async info(id: number) {
return this.channelRepo.findOneBy({ id });
}
/**
* 获取所有启用渠道的模型列表(供 Agent/Skill 选择用)
*/
async allModels() {
const channels = await this.channelRepo.find({
where: { status: Equal(1) },
order: { createTime: 'DESC' },
});
return channels.map(ch => ({
channelId: ch.id,
channelName: ch.name,
supplier: ch.supplier,
baseUrl: ch.baseUrl,
apiKey: ch.apiKey,
models: this.normalizeModels(ch.models),
}));
}
/**
* 测试渠道连通性
*/
async testConnection(id: number) {
const channel = await this.channelRepo.findOneBy({ id: Equal(id) });
if (!channel) throw new Error('渠道不存在');
const models = this.normalizeModels(channel.models);
if (models.length === 0) throw new Error('渠道未配置模型');
// 使用 Neta 的 Provider 机制测试
const { parseModelRef, getProvider, initDefaultProviders } = await import(
'../runtime/model_selection.js'
);
initDefaultProviders();
const supplierToProvider: Record<string, string> = {
openai: 'openai',
anthropic: 'anthropic',
deepseek: 'deepseek',
zhipu: 'openai',
tongyi: 'openai',
minimax: 'openai',
volcengine: 'openai',
ollama: 'openai',
azure: 'openai',
};
const providerName = supplierToProvider[channel.supplier] || 'openai';
const startTime = Date.now();
try {
const provider = getProvider(providerName);
await provider.chat({
model: models[0].name,
apiKey: channel.apiKey,
baseUrl: channel.baseUrl,
messages: [{ role: 'user', content: 'hi' }],
maxTokens: 5,
});
const elapsed = Date.now() - startTime;
return { success: true, elapsed, message: `连接成功,耗时 ${elapsed}ms` };
} catch (e: any) {
const elapsed = Date.now() - startTime;
return {
success: false,
elapsed,
message: `连接失败: ${e.message || e}`,
};
}
}
/**
* 为 Agent 运行时解析渠道凭证
* 将 channelId + modelId 转换为 provider:model + apiKey + baseUrl
*/
async resolveForAgent(
channelId: number,
modelId: string
): Promise<{
provider: string;
model: string;
apiKey: string;
baseUrl?: string;
}> {
const channel = await this.channelRepo.findOneBy({ id: Equal(channelId) });
if (!channel) throw new Error(`渠道 ${channelId} 不存在`);
if (channel.status !== 1) throw new Error(`渠道 ${channel.name} 已禁用`);
const supplierToProvider: Record<string, string> = {
openai: 'openai',
anthropic: 'anthropic',
deepseek: 'deepseek',
zhipu: 'openai',
tongyi: 'openai',
minimax: 'openai',
volcengine: 'openai',
ollama: 'openai',
azure: 'openai',
};
return {
provider: supplierToProvider[channel.supplier] || 'openai',
model: modelId,
apiKey: channel.apiKey,
baseUrl: channel.baseUrl,
};
}
}
```
- [ ] **Step 2: 提交**
```bash
git add packages/backend/src/modules/netaclaw/service/model_channel.ts
git commit -m "feat(backend): 新增 NetaClawModelChannelService"
```
---
## Task 3: 后端 Controller
**Files:**
- Create: `packages/backend/src/modules/netaclaw/controller/admin/model_channel.ts`
- [ ] **Step 1: 创建 Admin Controller**
```typescript
// packages/backend/src/modules/netaclaw/controller/admin/model_channel.ts
import { Provide, Inject, Post, Get, Body, Query, Controller } from '@midwayjs/core';
import { Context } from '@midwayjs/koa';
import { NetaClawModelChannelService } from '../../service/model_channel.js';
@Provide()
@Controller('/admin/netaclaw/model_channel')
export class NetaClawModelChannelAdminController {
@Inject()
ctx: Context;
@Inject()
channelService: NetaClawModelChannelService;
@Post('/page')
async page(
@Body()
body: { page?: number; size?: number; keyWord?: string; supplier?: string }
) {
return { code: 1000, data: await this.channelService.page(body) };
}
@Post('/add')
async add(@Body() body: any) {
const result = await this.channelService.add(body);
return { code: 1000, data: result };
}
@Post('/update')
async update(@Body() body: any) {
await this.channelService.update(body);
return { code: 1000, message: 'success' };
}
@Post('/delete')
async delete(@Body() body: { ids: number[] }) {
await this.channelService.delete(body.ids);
return { code: 1000, message: 'success' };
}
@Get('/info')
async info(@Query('id') id: number) {
return { code: 1000, data: await this.channelService.info(id) };
}
@Get('/allModels')
async allModels() {
return { code: 1000, data: await this.channelService.allModels() };
}
@Post('/testConnection')
async testConnection(@Body('id') id: number) {
return { code: 1000, data: await this.channelService.testConnection(id) };
}
}
```
- [ ] **Step 2: 启动后端验证 API 可达**
Run: `cd packages/backend && npx midway-bin dev`
验证: `curl -X POST http://localhost:8003/admin/netaclaw/model_channel/page -H 'Content-Type: application/json' -d '{"page":1,"size":20}'`
Expected: 返回 `{"code":1000,"data":{"list":[],"pagination":{"page":1,"size":20,"total":0}}}`
- [ ] **Step 3: 提交**
```bash
git add packages/backend/src/modules/netaclaw/controller/admin/model_channel.ts
git commit -m "feat(backend): 新增模型渠道管理 Admin Controller"
```
---
## Task 4: 插入系统参数
**Files:**
- 数据操作: `base_sys_param`
- [ ] **Step 1: 通过 MCP 工具插入 model_suppliers 参数**
`base_sys_param` 表中插入:
```sql
INSERT INTO base_sys_param (keyName, name, data, dataType, remark, createTime, updateTime)
VALUES (
'model_suppliers',
'AI模型供应商',
'[{"value":"openai","label":"OpenAI","tagType":""},{"value":"anthropic","label":"Anthropic","tagType":"purple"},{"value":"deepseek","label":"DeepSeek","tagType":"success"},{"value":"zhipu","label":"智谱AI","tagType":"success"},{"value":"tongyi","label":"通义千问","tagType":"warning"},{"value":"minimax","label":"MiniMax","tagType":"danger"},{"value":"volcengine","label":"火山引擎","tagType":"warning"},{"value":"ollama","label":"Ollama","tagType":"info"},{"value":"azure","label":"Azure OpenAI","tagType":""}]',
0,
'AI模型渠道管理用tagType控制前端标签颜色',
NOW(),
NOW()
);
```
- [ ] **Step 2: 插入 model_capabilities 参数**
```sql
INSERT INTO base_sys_param (keyName, name, data, dataType, remark, createTime, updateTime)
VALUES (
'model_capabilities',
'模型能力类型',
'[{"value":"text","label":"纯文本"},{"value":"multimodal","label":"多模态"},{"value":"vision","label":"视觉"},{"value":"image_gen","label":"文生图"},{"value":"tts","label":"语音合成"},{"value":"stt","label":"语音识别"}]',
0,
'AI模型能力分类可在参数列表页面自由扩展',
NOW(),
NOW()
);
```
- [ ] **Step 3: 验证参数可读取**
通过后端 API 验证:`curl http://localhost:8003/admin/base/sys/param/dataByKey?key=model_suppliers`
Expected: 返回 JSON 数组
---
## Task 5: 前端路由配置
**Files:**
- Modify: `packages/frontend/src/modules/agent/config.ts`
- [ ] **Step 1: 添加 model-channel 路由**
`packages/frontend/src/modules/agent/config.ts``views` 数组中,在 `detection-result` 条目之前添加:
```typescript
{
path: '/agent/model-channel',
meta: { label: '模型管理' },
component: () => import('./views/model-channel.vue')
},
```
修改后的完整 views 数组:
```typescript
views: [
{
path: '/agent/chat',
meta: { label: 'Agent 对话' },
component: () => import('./views/chat.vue')
},
{
path: '/agent/agents',
meta: { label: 'Agent 管理' },
component: () => import('./views/agent-list.vue')
},
{
path: '/agent/skills',
meta: { label: 'Skill 管理' },
component: () => import('./views/skills.vue')
},
{
path: '/agent/model-channel',
meta: { label: '模型管理' },
component: () => import('./views/model-channel.vue')
},
{
path: '/agent/detection-result',
meta: { label: '检测结果' },
component: () => import('./views/detection-result.vue')
}
]
```
- [ ] **Step 2: 提交**
```bash
git add packages/frontend/src/modules/agent/config.ts
git commit -m "feat(frontend): 添加模型管理路由"
```
---
## Task 6: 前端模型渠道管理页面
**Files:**
- Create: `packages/frontend/src/modules/agent/views/model-channel.vue`
- [ ] **Step 1: 创建模型渠道管理页面**
从 AI_flow 的 `model-channel.vue` 适配(端口 8003API 路径 `/admin/netaclaw/model_channel/`)。
创建文件 `packages/frontend/src/modules/agent/views/model-channel.vue`,内容参照 AI_flow 的 `AI_flow_FronEnd/src/modules/agent/views/model-channel.vue`,做以下适配:
1. API 路径从 `/admin/agent/model_channel/` 改为 `/admin/netaclaw/model_channel/`
2. 供应商参数加载端点不变(`/admin/base/sys/param/dataByKey?key=model_suppliers`
3. 能力类型参数加载端点不变(`/admin/base/sys/param/dataByKey?key=model_capabilities`
4. `supplierTagType()` 函数改为从 `supplierOptions` 中读取 `tagType` 字段,而非硬编码映射
关键改动点——`supplierTagType` 函数:
```typescript
// 旧AI_flow 硬编码):
function supplierTagType(val: string): '' | 'success' | 'warning' | 'danger' | 'info' {
const map: Record<string, any> = {
openai: '', zhipu: 'success', tongyi: 'warning',
minimax: 'danger', ollama: 'info', deepseek: 'success', azure: '', volcengine: 'warning'
};
return map[val] || 'info';
}
// 新(从参数读取):
function supplierTagType(val: string): '' | 'success' | 'warning' | 'danger' | 'info' {
const found = supplierOptions.value.find(s => s.value === val);
return (found as any)?.tagType || 'info';
}
```
其余代码(模板、筛选、表格、抽屉、批量导入、分页、样式)与 AI_flow 基本一致,只改 API 路径前缀。
- [ ] **Step 2: 启动前端验证页面渲染**
Run: `cd packages/frontend && pnpm dev`
在浏览器访问 `http://localhost:9001`登录后导航到「Agent管理 > 模型管理」,验证:
- 页面正常渲染
- 新增渠道抽屉可打开
- 供应商下拉选项从参数表加载
- [ ] **Step 3: 提交**
```bash
git add packages/frontend/src/modules/agent/views/model-channel.vue
git commit -m "feat(frontend): 新增模型渠道管理页面"
```
---
## Task 7: 前端渠道选择器组件
**Files:**
- Create: `packages/frontend/src/modules/agent/components/model-channel-selector.vue`
- [ ] **Step 1: 创建渠道级联选择器**
从 AI_flow 的 `model-channel-selector.vue` 适配API 路径改为 `/admin/netaclaw/model_channel/allModels`
创建文件 `packages/frontend/src/modules/agent/components/model-channel-selector.vue`,内容参照 AI_flow 的 `AI_flow_FronEnd/src/modules/agent/components/model-channel-selector.vue`,做以下适配:
1. `loadChannels()` 中 API 路径从 `/admin/agent/model_channel/allModels` 改为 `/admin/netaclaw/model_channel/allModels`
2. 其余逻辑v-model 双向绑定、capabilityFilter、渠道筛选、模型选择保持一致
3. 新增「自定义配置」模式:当用户选择 `channelId=0` 时,展示 apiUrl/apiKey/modelId 手动输入表单
`<el-form-item v-if="selectedChannelId && selectedChannelId !== 0" label="选择模型">` 之后,添加自定义配置表单:
```html
<!-- 自定义配置channelId=0 -->
<template v-if="selectedChannelId === 0">
<el-form-item label="API 地址">
<el-input v-model="customConfig.apiUrl" placeholder="https://api.openai.com/v1" />
</el-form-item>
<el-form-item label="API Key">
<el-input v-model="customConfig.apiKey" placeholder="输入 API Key" show-password />
</el-form-item>
<el-form-item label="模型 ID">
<el-input v-model="customConfig.modelId" placeholder="如: gpt-4o" @change="onCustomChange" />
</el-form-item>
</template>
```
Script 中添加 customConfig 响应式数据和 onCustomChange 方法:
```typescript
const customConfig = ref({ apiUrl: '', apiKey: '', modelId: '' });
function onCustomChange() {
const val: ModelValue = {
channelId: 0,
modelId: customConfig.value.modelId,
apiUrl: customConfig.value.apiUrl,
apiKey: customConfig.value.apiKey,
};
emit('update:modelValue', val);
emit('change', val);
}
// 在 onChannelChange 中channelId===0 时触发 custom 模式
function onChannelChange(channelId: number) {
selectedModelId.value = '';
if (channelId === 0) {
// 回填已有自定义配置
if (props.modelValue?.channelId === 0) {
customConfig.value = {
apiUrl: props.modelValue.apiUrl || '',
apiKey: props.modelValue.apiKey || '',
modelId: props.modelValue.modelId || '',
};
}
emit('update:modelValue', { channelId: 0, ...customConfig.value });
emit('change', { channelId: 0, ...customConfig.value });
}
}
```
- [ ] **Step 2: 提交**
```bash
git add packages/frontend/src/modules/agent/components/model-channel-selector.vue
git commit -m "feat(frontend): 新增模型渠道级联选择器组件"
```
---
## Task 8: 改造 Agent 编辑页
**Files:**
- Modify: `packages/frontend/src/modules/agent/views/agent-edit.vue:76-115`
- [ ] **Step 1: 导入选择器组件**
`agent-edit.vue``<script lang="ts" setup>` 中,在 `import type { SkillMeta } ...` 之后添加:
```typescript
import ModelChannelSelector from '../components/model-channel-selector.vue';
```
- [ ] **Step 2: 替换模型配置 Tab 模板**
`agent-edit.vue` 第 76-115 行Tab 4: 模型配置)替换为:
```html
<!-- Tab 4: 模型配置 -->
<el-tab-pane label="模型配置" name="model">
<p class="tab-desc">不配置则使用系统默认LLM</p>
<model-channel-selector v-model="modelConfig" />
<el-form label-width="100px" style="margin-top: 12px">
<el-form-item label="上下文窗口">
<el-input-number
v-model="modelConfig.contextWindow"
:min="1"
:max="2048"
controls-position="right"
/>
<span class="form-tip">单位: k tokens如 256 表示 256k</span>
</el-form-item>
<el-form-item>
<el-button size="small" @click="clearModelConfig">清空(使用默认)</el-button>
</el-form-item>
</el-form>
</el-tab-pane>
```
- [ ] **Step 3: 更新 modelConfig 数据结构**
`modelConfig` ref 改为支持 channelId
```typescript
const modelConfig = ref({
channelId: null as number | null,
modelId: '',
apiKey: '',
apiUrl: '',
contextWindow: null as number | null
});
```
更新 `clearModelConfig` 函数:
```typescript
function clearModelConfig() {
modelConfig.value = { channelId: null, modelId: '', apiKey: '', apiUrl: '', contextWindow: null };
}
```
移除 `fillDefaultLLM` 函数和 `defaultLLM` 常量(不再需要)。
- [ ] **Step 4: 更新 loadAgent 中的 modelConfig 回填**
`loadAgent()` 函数中,更新 modelConfig 回填逻辑:
```typescript
const mc = parseJson(agent.modelConfig, {});
modelConfig.value = {
channelId: mc.channelId || null,
modelId: mc.modelId || '',
apiKey: mc.apiKey || '',
apiUrl: mc.apiUrl || '',
contextWindow: mc.contextWindow || null
};
```
- [ ] **Step 5: 更新 handleSave 中的 modelConfig 序列化**
`handleSave()` 函数中,更新 mc 判断逻辑:
```typescript
const mc =
modelConfig.value.channelId || modelConfig.value.modelId || modelConfig.value.contextWindow
? modelConfig.value
: null;
```
- [ ] **Step 6: 更新 resetForm 中的 modelConfig**
```typescript
modelConfig.value = { channelId: null, modelId: '', apiKey: '', apiUrl: '', contextWindow: null };
```
- [ ] **Step 7: 浏览器验证**
访问 Agent 管理 → 编辑任一 Agent → 模型配置 Tab
验证:
- 渠道选择器正常展示
- 选择渠道后模型下拉出现
- 选择「自定义配置」后手动输入框出现
- 保存后重新打开,选择状态正确回填
- [ ] **Step 8: 提交**
```bash
git add packages/frontend/src/modules/agent/views/agent-edit.vue
git commit -m "feat(frontend): Agent编辑页模型配置改为渠道级联选择器"
```
---
## Task 9: 改造 Skill 模型配置组件
**Files:**
- Modify: `packages/frontend/src/modules/agent/components/skill-model.vue`
- [ ] **Step 1: 导入选择器组件**
`skill-model.vue``<script lang="ts" setup>` 中添加:
```typescript
import ModelChannelSelector from './model-channel-selector.vue';
```
- [ ] **Step 2: 替换「可配置模型」模板**
`skill-model.vue` 第 30-104 行(`<template v-else>` 可配置模型部分)替换为:
```html
<!-- 可配置模型 -->
<template v-else>
<model-channel-selector
v-model="channelModelValue"
:capability-filter="skillCapabilityFilter"
@change="onChannelModelChange"
/>
<!-- 操作按钮 -->
<div class="model-footer">
<el-button
type="primary"
@click="handleSave"
:loading="saving"
>
保存模型配置
</el-button>
<el-button @click="handleReset">恢复默认</el-button>
</div>
</template>
```
- [ ] **Step 3: 添加渠道模型相关逻辑**
在 script 中添加:
```typescript
import { computed } from 'vue';
// 渠道选择器的 v-model 值
const channelModelValue = ref<any>({});
// 根据 Skill 类型推断能力过滤
const skillCapabilityFilter = computed(() => {
const t = props.skill?.skillType;
if (t === 'multimodal') return 'multimodal';
if (t === 'llm') return 'text';
return undefined;
});
function onChannelModelChange(val: any) {
channelModelValue.value = val;
}
```
- [ ] **Step 4: 更新 loadModelConfig**
改写 `loadModelConfig` 函数,加载已保存的渠道配置:
```typescript
async function loadModelConfig() {
const name = props.skill?.name;
if (!name || !needsModel.value || fixedModel.value) return;
channelModelValue.value = {};
try {
const resp = await fetch(
`${config.baseUrl}/admin/netaclaw/skill/modelConfig?name=${name}`,
{ headers: { Authorization: user.token || '' } }
);
const data = await resp.json();
if (data.code === 1000 && data.data) {
const saved = data.data;
channelModelValue.value = {
channelId: saved.channelId || null,
modelId: saved.modelId || '',
apiUrl: saved.apiUrl || '',
apiKey: saved.apiKey || '',
};
}
} catch {
// 加载失败用空值
}
}
```
- [ ] **Step 5: 更新 handleSave**
```typescript
async function handleSave() {
const val = channelModelValue.value;
if (!val.channelId && !val.modelId) {
ElMessage.warning('请选择渠道和模型,或使用自定义配置');
return;
}
saving.value = true;
try {
const resp = await fetch(`${config.baseUrl}/admin/netaclaw/skill/updateModelConfig`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: user.token || ''
},
body: JSON.stringify({
name: props.skill.name,
model: {
channelId: val.channelId,
modelId: val.modelId,
apiUrl: val.apiUrl,
apiKey: val.apiKey,
}
})
});
const data = await resp.json();
if (data.code === 1000) {
ElMessage.success('模型配置已保存');
} else {
ElMessage.error(data.message || '保存失败');
}
} catch {
ElMessage.error('保存失败');
} finally {
saving.value = false;
}
}
```
- [ ] **Step 6: 更新 handleReset**
```typescript
async function handleReset() {
channelModelValue.value = {};
saving.value = true;
try {
const resp = await fetch(`${config.baseUrl}/admin/netaclaw/skill/updateModelConfig`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: user.token || ''
},
body: JSON.stringify({
name: props.skill.name,
model: null
})
});
const data = await resp.json();
if (data.code === 1000) {
ElMessage.success('已清除模型配置');
}
} catch {
// 静默
} finally {
saving.value = false;
}
}
```
- [ ] **Step 7: 移除不再需要的代码**
移除以下不再需要的代码:
- `presets` ref 和 `PresetModel` interface
- `loadPresets()` 函数
- `applyPreset()` 函数
- `modelForm` ref
- `DEFAULT_MODEL` 常量
- `selectedPresetId` ref
- preset-section 相关的 CSS 样式
- [ ] **Step 8: 浏览器验证**
访问 Skill 管理 → 点击任一 Skill 的「配置」按钮 → 模型配置 Tab
验证:
- 渠道选择器正常展示
- multimodal 类型 Skill 只展示 multimodal 能力的模型
- 保存后重新打开,选择状态正确回填
- [ ] **Step 9: 提交**
```bash
git add packages/frontend/src/modules/agent/components/skill-model.vue
git commit -m "feat(frontend): Skill模型配置改为渠道级联选择器"
```
---
## Task 10: 后端运行时桥接
**Files:**
- Modify: `packages/backend/src/modules/netaclaw/runtime/agent.ts:29-44`
- [ ] **Step 1: 改造 runAgent 函数**
`packages/backend/src/modules/netaclaw/runtime/agent.ts` 中,修改 `AgentConfig` 接口(第 7-17 行)添加 channelId 支持:
```typescript
export interface AgentConfig {
name: string;
systemPrompt: string;
model: string; // "provider:model" 或 "model"
apiKey: string;
baseUrl?: string;
channelId?: number; // 新增渠道ID有值时覆盖 model/apiKey/baseUrl
modelId?: string; // 新增渠道中的模型ID
temperature?: number;
maxTokens?: number;
maxToolRounds?: number;
skills?: string[];
}
```
- [ ] **Step 2: 在 runAgent 函数开头添加渠道解析逻辑**
`runAgent` 函数中(第 29 行 `export async function runAgent` 之后),在 `const modelRef = parseModelRef(agentConfig.model);` 之前,添加渠道解析:
```typescript
// 渠道解析:如果指定了 channelId从数据库查询凭证覆盖配置
if (agentConfig.channelId && agentConfig.channelId > 0) {
try {
// 动态导入以避免循环依赖
const { MidwayContainer } = await import('@midwayjs/core');
const { getCurrentApplicationContext } = await import('@midwayjs/core');
const ctx = getCurrentApplicationContext();
const { NetaClawModelChannelService } = await import(
'../service/model_channel.js'
);
const channelService = await ctx.getAsync(NetaClawModelChannelService);
const resolved = await channelService.resolveForAgent(
agentConfig.channelId,
agentConfig.modelId || agentConfig.model
);
agentConfig.model = `${resolved.provider}:${resolved.model}`;
agentConfig.apiKey = resolved.apiKey;
if (resolved.baseUrl) agentConfig.baseUrl = resolved.baseUrl;
} catch (e: any) {
console.warn(`渠道解析失败,使用原始配置: ${e.message}`);
}
}
```
- [ ] **Step 3: 提交**
```bash
git add packages/backend/src/modules/netaclaw/runtime/agent.ts
git commit -m "feat(backend): Agent运行时接入渠道解析"
```
---
## Task 11: 数据库菜单配置
**Files:**
- 数据操作: `base_sys_menu`
- [ ] **Step 1: 插入模型管理菜单**
通过 MCP 工具执行 SQL在 Agent 管理目录下新增菜单项:
```sql
INSERT INTO base_sys_menu (parentId, name, router, icon, orderNum, type, isShow, keepAlive, createTime, updateTime)
VALUES (
112,
'模型管理',
'/agent/model-channel',
'icon-model',
4,
1,
1,
1,
NOW(),
NOW()
);
```
注意:`parentId=112` 是 Agent 管理目录的 ID需确认可先查询 `SELECT id, name FROM base_sys_menu WHERE name='Agent管理' AND type=0`)。
- [ ] **Step 2: 浏览器验证**
刷新页面确认左侧菜单中「Agent管理」目录下出现「模型管理」菜单项点击可跳转到模型渠道管理页面。
---
## Task 12: 端到端验证
- [ ] **Step 1: 新增渠道测试**
在模型管理页面新增一个渠道:
- 名称: "测试-Anthropic"
- 供应商: Anthropic
- Base URL: `https://api.anthropic.com`
- API Key: (填入测试用 Key
- 模型: claude-sonnet-4-20250514 (text)
保存后确认列表中显示。
- [ ] **Step 2: Agent 引用渠道测试**
编辑一个 Agent → 模型配置 Tab → 选择刚建的渠道 → 选择模型 → 保存。
验证:保存成功,重新打开后选择器正确回填。
- [ ] **Step 3: Skill 引用渠道测试**
打开一个 Skill 的详情 → 模型配置 Tab → 选择渠道和模型 → 保存。
验证:保存成功,重新打开后选择器正确回填。
- [ ] **Step 4: 最终提交**
```bash
git add -A
git commit -m "feat: 模型渠道管理完整实现 - Entity/Service/Controller/前端页面/选择器/Agent与Skill改造"
```