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

30 KiB
Raw Blame History

模型渠道管理 实施计划

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 文件

// 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 区域和数组中添加:

在最后一行 importimport * as entity34 from './modules/netaclaw/entity/skill';)之后添加:

import * as entity35 from './modules/netaclaw/entity/model_channel';

entities 数组末尾(...Object.values(entity34), 之后)添加:

  ...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: 提交
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

// 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: 提交
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

// 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: 提交
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 表中插入:

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 参数
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.tsviews 数组中,在 detection-result 条目之前添加:

			{
				path: '/agent/model-channel',
				meta: { label: '模型管理' },
				component: () => import('./views/model-channel.vue')
			},

修改后的完整 views 数组:

		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: 提交
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 函数:

// 旧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: 提交

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="选择模型"> 之后,添加自定义配置表单:

			<!-- 自定义配置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 方法:

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: 提交
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 } ... 之后添加:

import ModelChannelSelector from '../components/model-channel-selector.vue';
  • Step 2: 替换模型配置 Tab 模板

agent-edit.vue 第 76-115 行Tab 4: 模型配置)替换为:

			<!-- 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

const modelConfig = ref({
	channelId: null as number | null,
	modelId: '',
	apiKey: '',
	apiUrl: '',
	contextWindow: null as number | null
});

更新 clearModelConfig 函数:

function clearModelConfig() {
	modelConfig.value = { channelId: null, modelId: '', apiKey: '', apiUrl: '', contextWindow: null };
}

移除 fillDefaultLLM 函数和 defaultLLM 常量(不再需要)。

  • Step 4: 更新 loadAgent 中的 modelConfig 回填

loadAgent() 函数中,更新 modelConfig 回填逻辑:

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 判断逻辑:

const mc =
	modelConfig.value.channelId || modelConfig.value.modelId || modelConfig.value.contextWindow
		? modelConfig.value
		: null;
  • Step 6: 更新 resetForm 中的 modelConfig
modelConfig.value = { channelId: null, modelId: '', apiKey: '', apiUrl: '', contextWindow: null };
  • Step 7: 浏览器验证

访问 Agent 管理 → 编辑任一 Agent → 模型配置 Tab

验证:

  • 渠道选择器正常展示

  • 选择渠道后模型下拉出现

  • 选择「自定义配置」后手动输入框出现

  • 保存后重新打开,选择状态正确回填

  • Step 8: 提交

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> 中添加:

import ModelChannelSelector from './model-channel-selector.vue';
  • Step 2: 替换「可配置模型」模板

skill-model.vue 第 30-104 行(<template v-else> 可配置模型部分)替换为:

		<!-- 可配置模型 -->
		<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 中添加:

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 函数,加载已保存的渠道配置:

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
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
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: 提交

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 支持:

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); 之前,添加渠道解析:

  // 渠道解析:如果指定了 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: 提交
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 管理目录下新增菜单项:

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: 最终提交
git add -A
git commit -m "feat: 模型渠道管理完整实现 - Entity/Service/Controller/前端页面/选择器/Agent与Skill改造"