GPU_GUARD_MONOREPO/docs/superpowers/plans/2026-04-12-model-channel-management.md

1057 lines
30 KiB
Markdown
Raw Normal View History

2026-05-20 21:39:12 +08:00
# 模型渠道管理 实施计划
> **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改造"
```