Files
abot/admin/dashboard/templates/system_llm.html
liuwei 061f2b8084 feat: 重构LLM配置为Provider模板+Dify应用+Scene绑定
变更项:

1. 新增 LLM 目录数据层(t_llm_provider_templates/t_llm_dify_apps/t_llm_backends/t_llm_scenes/t_llm_catalog_meta),支持三层配置管理。

2. Robot 启动接入 llm_catalog_db:自动建表并从旧 llm(backends/scenes) 配置迁移初始化。

3. LLMRegistry 改为优先读取目录模型并按 scene 解析:dify_app 自动合并 Provider 模板与 app_key 差异,降低重复配置。

4. system 蓝图 /api/system/llm_config 改为目录模型读写,新增完整校验(provider引用、app_key、scene目标合法性)。

5. system_llm 页面重构为四块:Provider 模板、Dify 应用、通用 Backend、Scene 绑定,并展示插件依赖拓扑。

6. 保留 YAML 旧结构兜底展示与运行时回退,保证目录表异常时系统仍可运行。
2026-04-20 15:09:24 +08:00

497 lines
24 KiB
HTML
Raw 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.
{% extends "base.html" %}
{% block title %}LLM目录配置 - 机器人管理后台{% endblock %}
{% block content %}
<div class="page-shell system-page">
<div class="page-hero">
<div class="page-hero-copy">
<div class="page-eyebrow">LLM Catalog</div>
<h1>LLM目录配置</h1>
<p>按 Provider 模板、Dify 应用、Scene 绑定三层维护,减少重复配置和切换成本。</p>
</div>
<div class="page-hero-actions">
<el-button size="mini" plain @click="loadLlmConfig">刷新</el-button>
<el-button size="mini" type="success" @click="saveLlmConfig">保存配置</el-button>
</div>
</div>
<el-card class="workspace-card" shadow="hover">
<div slot="header" class="workspace-header">
<div>
<h3>目录元信息</h3>
<p>默认场景用于兜底路由,建议始终设置一个稳定可用的场景。</p>
</div>
<div class="config-meta" v-if="configPath">
<span>配置源:{% raw %}{{ configPath }}{% endraw %}</span>
</div>
</div>
<el-form label-width="110px" class="llm-form">
<el-form-item label="默认场景">
<el-select v-model="catalog.default_scene" placeholder="请选择默认场景" filterable clearable style="width: 360px;">
<el-option v-for="item in sceneNameOptions" :key="item" :label="item" :value="item"></el-option>
</el-select>
</el-form-item>
</el-form>
</el-card>
<el-card class="workspace-card" shadow="hover">
<div slot="header" class="workspace-header">
<div>
<h3>1. Provider 模板</h3>
<p>公共连接参数只配置一次base_url、endpoint、mode、超时等。</p>
</div>
<el-button size="mini" @click="addProvider">新增模板</el-button>
</div>
<div class="section-list" v-if="catalog.providers.length">
<el-card v-for="(item, index) in catalog.providers" :key="item.uid" class="entry-card" shadow="never">
<div slot="header" class="entry-header">
<strong>{% raw %}{{ item.name || `Provider ${index + 1}` }}{% endraw %}</strong>
<el-button type="text" class="danger-text" @click="removeProvider(index)">删除</el-button>
</div>
<div class="entry-grid">
<el-input v-model="item.name" placeholder="模板名例如dify_workflow_default"></el-input>
<el-select v-model="item.provider_type" placeholder="Provider类型">
<el-option label="dify" value="dify"></el-option>
<el-option label="openai_compatible" value="openai_compatible"></el-option>
</el-select>
<el-input v-model="item.config.api_base_url" placeholder="API Base URL"></el-input>
<el-input v-model="item.config.endpoint" placeholder="Endpoint例如workflows/run"></el-input>
<el-input v-model="item.config.mode" placeholder="mode例如workflow"></el-input>
<el-input v-model="item.config.response_mode" placeholder="response_mode例如blocking"></el-input>
<el-input-number v-model="item.config.request_timeout" :min="1" :step="1" placeholder="request_timeout"></el-input-number>
<el-input-number v-model="item.config.max_retries" :min="1" :step="1" placeholder="max_retries"></el-input-number>
</div>
</el-card>
</div>
<el-empty v-else description="暂无 Provider 模板,先新增一个"></el-empty>
</el-card>
<el-card class="workspace-card" shadow="hover">
<div slot="header" class="workspace-header">
<div>
<h3>2. Dify 应用</h3>
<p>每个应用只维护 app_key 和少量差异项,不再重复写 URL/endpoint 公共参数。</p>
</div>
<el-button size="mini" @click="addDifyApp">新增应用</el-button>
</div>
<div class="section-list" v-if="catalog.dify_apps.length">
<el-card v-for="(item, index) in catalog.dify_apps" :key="item.uid" class="entry-card" shadow="never">
<div slot="header" class="entry-header">
<strong>{% raw %}{{ item.name || `DifyApp ${index + 1}` }}{% endraw %}</strong>
<el-button type="text" class="danger-text" @click="removeDifyApp(index)">删除</el-button>
</div>
<div class="entry-grid">
<el-input v-model="item.name" placeholder="应用名例如chat_main"></el-input>
<el-select v-model="item.provider_template" placeholder="绑定 Provider 模板" filterable>
<el-option v-for="name in providerNameOptions" :key="name" :label="name" :value="name"></el-option>
</el-select>
<el-input v-model="item.app_key" placeholder="Dify app key例如app-xxxx"></el-input>
<el-input v-model="item.workflow_output_key" placeholder="workflow_output_key例如text"></el-input>
<el-input v-model="item.config.mode" placeholder="覆盖 mode可选"></el-input>
<el-input v-model="item.config.endpoint" placeholder="覆盖 endpoint可选"></el-input>
<el-input v-model="item.config.response_mode" placeholder="覆盖 response_mode可选"></el-input>
<el-input-number v-model="item.config.request_timeout" :min="1" :step="1" placeholder="覆盖 request_timeout可选"></el-input-number>
</div>
</el-card>
</div>
<el-empty v-else description="暂无 Dify 应用"></el-empty>
</el-card>
<el-card class="workspace-card" shadow="hover">
<div slot="header" class="workspace-header">
<div>
<h3>3. 通用 Backend可选</h3>
<p>用于 openai_compatible 或其他非 Dify 直连能力。</p>
</div>
<el-button size="mini" @click="addBackend">新增Backend</el-button>
</div>
<div class="section-list" v-if="catalog.backends.length">
<el-card v-for="(item, index) in catalog.backends" :key="item.uid" class="entry-card" shadow="never">
<div slot="header" class="entry-header">
<strong>{% raw %}{{ item.name || `Backend ${index + 1}` }}{% endraw %}</strong>
<el-button type="text" class="danger-text" @click="removeBackend(index)">删除</el-button>
</div>
<div class="entry-grid">
<el-input v-model="item.name" placeholder="backend名例如openai_main"></el-input>
<el-input v-model="item.config.provider" placeholder="provider例如openai_compatible"></el-input>
<el-input v-model="item.config.api_base_url" placeholder="api_base_url"></el-input>
<el-input v-model="item.config.endpoint" placeholder="endpoint"></el-input>
<el-input v-model="item.config.api_key" placeholder="api_key"></el-input>
<el-input v-model="item.config.model" placeholder="model"></el-input>
<el-input-number v-model="item.config.temperature" :min="0" :max="2" :step="0.05"></el-input-number>
<el-input-number v-model="item.config.max_tokens" :min="1" :step="1"></el-input-number>
</div>
</el-card>
</div>
<el-empty v-else description="暂无通用 Backend"></el-empty>
</el-card>
<el-card class="workspace-card" shadow="hover">
<div slot="header" class="workspace-header">
<div>
<h3>4. Scene 绑定</h3>
<p>业务场景绑定 dify_app 或 backend插件只配置 scene。</p>
</div>
<el-button size="mini" @click="addScene">新增场景</el-button>
</div>
<div class="section-list" v-if="catalog.scenes.length">
<div class="scene-row" v-for="(item, index) in catalog.scenes" :key="item.uid">
<el-input v-model="item.name" placeholder="scene 名例如chat.main"></el-input>
<el-select v-model="item.target_type" placeholder="目标类型">
<el-option label="dify_app" value="dify_app"></el-option>
<el-option label="backend" value="backend"></el-option>
</el-select>
<el-select v-model="item.target_ref" placeholder="目标引用" filterable>
<el-option v-for="target in getSceneTargetOptions(item.target_type)" :key="target" :label="target" :value="target"></el-option>
</el-select>
<el-button type="text" class="danger-text" @click="removeScene(index)">删除</el-button>
</div>
</div>
<el-empty v-else description="暂无 Scene 绑定"></el-empty>
</el-card>
<el-card class="workspace-card" shadow="hover">
<div slot="header" class="workspace-header">
<div>
<h3>插件依赖拓扑</h3>
<p>显示 插件 -> scene -> target_type/target_ref -> provider便于评估切换影响。</p>
</div>
</div>
<el-table :data="topologyRows" size="mini" style="width: 100%">
<el-table-column prop="plugin" label="插件" min-width="120"></el-table-column>
<el-table-column prop="section" label="配置段" min-width="120"></el-table-column>
<el-table-column prop="scene" label="Scene" min-width="140">
<template slot-scope="scope">
<el-tag size="mini" :type="scope.row.valid_scene ? 'success' : 'danger'">
{% raw %}{{ scope.row.scene || '-' }}{% endraw %}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="target_type" label="TargetType" min-width="110"></el-table-column>
<el-table-column prop="target_ref" label="TargetRef" min-width="160">
<template slot-scope="scope">
<el-tag size="mini" :type="scope.row.valid_target ? 'success' : 'danger'">
{% raw %}{{ scope.row.target_ref || '-' }}{% endraw %}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="provider" label="Provider" min-width="120"></el-table-column>
</el-table>
</el-card>
</div>
{% endblock %}
{% block scripts %}
<script>
new Vue({
el: '#app',
mixins: [baseApp],
data() {
return {
currentView: '17',
configPath: '',
topologyRows: [],
catalog: {
default_scene: '',
providers: [],
dify_apps: [],
backends: [],
scenes: []
}
}
},
computed: {
sceneNameOptions() {
return (this.catalog.scenes || []).map(item => item.name).filter(Boolean);
},
providerNameOptions() {
return (this.catalog.providers || []).map(item => item.name).filter(Boolean);
},
backendNameOptions() {
return (this.catalog.backends || []).map(item => item.name).filter(Boolean);
},
difyAppNameOptions() {
return (this.catalog.dify_apps || []).map(item => item.name).filter(Boolean);
}
},
mounted() {
this.currentView = '17';
this.loadLlmConfig();
},
methods: {
newUid() {
return `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
},
// Provider 模板:只放公共字段,避免 Dify 每个应用重复填写。
newProvider() {
return {
uid: this.newUid(),
name: '',
provider_type: 'dify',
enabled: true,
config: {
provider: 'dify',
api_base_url: '',
endpoint: 'workflows/run',
mode: 'workflow',
response_mode: 'blocking',
request_timeout: 60,
max_retries: 3,
retry_delay_seconds: 1.0
}
};
},
// Dify 应用:只保留 app_key 与少量覆盖项,维护成本最低。
newDifyApp() {
return {
uid: this.newUid(),
name: '',
provider_template: '',
app_key: '',
workflow_output_key: 'text',
enabled: true,
config: {
mode: '',
endpoint: '',
response_mode: '',
request_timeout: 60
}
};
},
newBackend() {
return {
uid: this.newUid(),
name: '',
enabled: true,
config: {
provider: 'openai_compatible',
api_base_url: '',
endpoint: 'chat/completions',
api_key: '',
model: '',
temperature: 0.7,
max_tokens: 1024
}
};
},
newScene() {
return {
uid: this.newUid(),
name: '',
target_type: 'dify_app',
target_ref: '',
enabled: true
};
},
normalizeProvider(item) {
return {
uid: this.newUid(),
name: item.name || '',
provider_type: item.provider_type || 'dify',
enabled: item.enabled !== false,
config: { ...(item.config || {}) }
};
},
normalizeDifyApp(item) {
return {
uid: this.newUid(),
name: item.name || '',
provider_template: item.provider_template || '',
app_key: item.app_key || '',
workflow_output_key: item.workflow_output_key || 'text',
enabled: item.enabled !== false,
config: { ...(item.config || {}) }
};
},
normalizeBackend(item) {
return {
uid: this.newUid(),
name: item.name || '',
enabled: item.enabled !== false,
config: { ...(item.config || {}) }
};
},
normalizeScene(item) {
return {
uid: this.newUid(),
name: item.name || '',
target_type: item.target_type || 'dify_app',
target_ref: item.target_ref || '',
enabled: item.enabled !== false
};
},
addProvider() { this.catalog.providers.push(this.newProvider()); },
removeProvider(index) {
const removed = this.catalog.providers[index];
this.catalog.providers.splice(index, 1);
if (removed && removed.name) {
(this.catalog.dify_apps || []).forEach(app => {
if (app.provider_template === removed.name) {
app.provider_template = '';
}
});
}
},
addDifyApp() { this.catalog.dify_apps.push(this.newDifyApp()); },
removeDifyApp(index) {
const removed = this.catalog.dify_apps[index];
this.catalog.dify_apps.splice(index, 1);
if (removed && removed.name) {
(this.catalog.scenes || []).forEach(scene => {
if (scene.target_type === 'dify_app' && scene.target_ref === removed.name) {
scene.target_ref = '';
}
});
}
},
addBackend() { this.catalog.backends.push(this.newBackend()); },
removeBackend(index) {
const removed = this.catalog.backends[index];
this.catalog.backends.splice(index, 1);
if (removed && removed.name) {
(this.catalog.scenes || []).forEach(scene => {
if (scene.target_type === 'backend' && scene.target_ref === removed.name) {
scene.target_ref = '';
}
});
}
},
addScene() { this.catalog.scenes.push(this.newScene()); },
removeScene(index) {
const removed = this.catalog.scenes[index];
this.catalog.scenes.splice(index, 1);
if (removed && removed.name && this.catalog.default_scene === removed.name) {
this.catalog.default_scene = '';
}
},
getSceneTargetOptions(targetType) {
if (targetType === 'backend') {
return this.backendNameOptions;
}
return this.difyAppNameOptions;
},
async loadLlmConfig() {
try {
const response = await axios.get('/api/system/llm_config');
if (!response.data.success) {
this.$message.error(response.data.message || '读取 LLM 目录失败');
return;
}
const data = response.data.data || {};
this.configPath = data.config_path || '';
this.catalog.default_scene = data.default_scene || '';
this.catalog.providers = (data.providers || []).map(item => this.normalizeProvider(item));
this.catalog.dify_apps = (data.dify_apps || []).map(item => this.normalizeDifyApp(item));
this.catalog.backends = (data.backends || []).map(item => this.normalizeBackend(item));
this.catalog.scenes = (data.scenes || []).map(item => this.normalizeScene(item));
this.topologyRows = data.topology_rows || [];
} catch (error) {
this.$message.error(error.response?.data?.message || '读取 LLM 目录失败');
}
},
async saveLlmConfig() {
// 严格校验:防止提交残缺目录导致运行时无法路由。
const invalidProvider = (this.catalog.providers || []).find(item => !String(item.name || '').trim());
if (invalidProvider) {
this.$message.error('Provider 模板名称不能为空');
return;
}
const invalidApp = (this.catalog.dify_apps || []).find(item => {
return !String(item.name || '').trim()
|| !String(item.provider_template || '').trim()
|| !String(item.app_key || '').trim();
});
if (invalidApp) {
this.$message.error('Dify 应用缺少必填项name/provider_template/app_key');
return;
}
const invalidScene = (this.catalog.scenes || []).find(item => {
return !String(item.name || '').trim()
|| !String(item.target_type || '').trim()
|| !String(item.target_ref || '').trim();
});
if (invalidScene) {
this.$message.error('Scene 绑定缺少必填项name/target_type/target_ref');
return;
}
if (this.catalog.default_scene && !this.sceneNameOptions.includes(this.catalog.default_scene)) {
this.$message.error('默认场景不存在,请重新选择');
return;
}
const payload = {
default_scene: this.catalog.default_scene || '',
providers: (this.catalog.providers || []).map(item => {
const cleaned = { ...item };
delete cleaned.uid;
return cleaned;
}),
dify_apps: (this.catalog.dify_apps || []).map(item => {
const cleaned = { ...item };
delete cleaned.uid;
return cleaned;
}),
backends: (this.catalog.backends || []).map(item => {
const cleaned = { ...item };
delete cleaned.uid;
return cleaned;
}),
scenes: (this.catalog.scenes || []).map(item => {
const cleaned = { ...item };
delete cleaned.uid;
return cleaned;
})
};
try {
const response = await axios.post('/api/system/llm_config', payload);
if (response.data.success) {
this.$message.success(response.data.message || '保存成功');
this.loadLlmConfig();
} else {
this.$message.error(response.data.message || '保存失败');
}
} catch (error) {
this.$message.error(error.response?.data?.message || '保存失败');
}
}
}
});
</script>
<style>
.system-page { display: flex; flex-direction: column; gap: 20px; }
.page-hero {
display: flex; align-items: flex-end; justify-content: space-between; gap: 24px;
background: linear-gradient(135deg, rgba(248,250,252,0.96), rgba(226,232,240,0.88));
border: 1px solid rgba(148,163,184,0.16);
border-radius: 24px; padding: 28px 32px; box-shadow: 0 18px 48px rgba(15,23,42,0.06);
}
.page-eyebrow { font-size: 12px; text-transform: uppercase; letter-spacing: .08em; color: #0ea5e9; font-weight: 700; margin-bottom: 8px; }
.page-hero-copy h1 { font-size: 30px; line-height: 1.1; margin-bottom: 10px; color: #0f172a; }
.page-hero-copy p { color: #64748b; font-size: 14px; }
.workspace-card { border-radius: 18px; border: 1px solid rgba(148,163,184,0.16); }
.workspace-header { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
.workspace-header h3 { font-size: 18px; margin-bottom: 4px; }
.workspace-header p { font-size: 13px; color: #64748b; }
.config-meta { display: flex; gap: 12px; color: #64748b; font-size: 12px; flex-wrap: wrap; }
.section-list { display: flex; flex-direction: column; gap: 12px; }
.entry-card { border: 1px solid rgba(148,163,184,0.16); border-radius: 14px; }
.entry-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.entry-grid { display: grid; grid-template-columns: repeat(2, minmax(220px, 1fr)); gap: 10px 14px; }
.scene-row {
display: grid;
grid-template-columns: minmax(260px, 1fr) minmax(160px, 220px) minmax(220px, 1fr) auto;
gap: 10px;
align-items: center;
}
.danger-text { color: #dc2626; }
@media (max-width: 960px) {
.page-hero { flex-direction: column; align-items: flex-start; }
.workspace-header { flex-direction: column; align-items: flex-start; }
.entry-grid { grid-template-columns: 1fr; }
.scene-row { grid-template-columns: 1fr; }
}
</style>
{% endblock %}