Files
abot/admin/dashboard/templates/system_llm.html
liuwei a0a6ea8e08 ui: 新增Dify高级配置折叠面板
变更项:

1. Dify应用卡片新增‘展开/收起高级配置’按钮,默认收起。

2. 高级面板内提供可选覆盖字段:mode/endpoint/response_mode/request_timeout。

3. 保持主表单只展示刚需字段,兼顾简洁维护与高级调优。

4. 保存时移除前端状态字段 advanced_open,避免污染后端配置数据。
2026-04-20 15:22:10 +08:00

549 lines
26 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="field-tips">
<span>字段说明:</span>
<span>`api_base_url`=Dify 服务地址;`endpoint`=工作流接口(常用 `workflows/run``mode`=workflow/chat`request_timeout`=秒。</span>
</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="field-tips">
<span>字段说明:</span>
<span>`name`=应用标识;`provider_template`=复用哪个公共模板;`app_key`=Dify 应用 Key`workflow_output_key`=工作流输出字段(常用 `text`)。</span>
</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>
<div class="entry-actions">
<el-button type="text" @click="item.advanced_open = !item.advanced_open">
{% raw %}{{ item.advanced_open ? '收起高级配置' : '展开高级配置' }}{% endraw %}
</el-button>
<el-button type="text" class="danger-text" @click="removeDifyApp(index)">删除</el-button>
</div>
</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"
type="password"
show-password
placeholder="Dify app key例如app-xxxx">
</el-input>
<el-input v-model="item.workflow_output_key" placeholder="workflow_output_key例如text"></el-input>
</div>
<div class="advanced-panel" v-if="item.advanced_open">
<div class="advanced-title">高级覆盖项(可选)</div>
<div class="entry-grid">
<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>
</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(),
advanced_open: false,
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(),
advanced_open: false,
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;
delete cleaned.advanced_open;
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; }
.field-tips {
margin: 0 0 12px;
padding: 10px 12px;
border-radius: 10px;
background: rgba(15, 23, 42, 0.04);
color: #475569;
font-size: 12px;
line-height: 1.5;
display: flex;
gap: 8px;
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-actions { display: flex; align-items: center; gap: 10px; }
.entry-grid { display: grid; grid-template-columns: repeat(2, minmax(220px, 1fr)); gap: 10px 14px; }
.advanced-panel {
margin-top: 12px;
padding: 12px;
border-radius: 10px;
border: 1px dashed rgba(148,163,184,0.5);
background: rgba(148,163,184,0.06);
}
.advanced-title {
font-size: 12px;
color: #475569;
margin-bottom: 10px;
font-weight: 600;
}
.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 %}