Files
abot/admin/dashboard/templates/system_llm.html
liuwei ef49588485 refactor: 移除LLM旧兼容入口并统一scene单路由
变更项:

1. LLMRegistry 仅保留 scene 入口,删除 backend_name/backend_ref/scene_ref 等兼容解析分支,未声明 scene 时仅保留直连配置。

2. Dify/GlobalNews/GameTask 插件初始化改为仅传 scene,不再拼接 backend/provider/url 等旧兼容字段。

3. 清理插件配置冗余:dify/global_news/game_task/douyu 的 config.toml 删除 backend 字段,统一由 scene 映射后端。

4. 后台 system API 调整为严格模式:插件依赖扫描仅采集 scene;scene 保存时必须绑定有效 backend。

5. 后台页面去除拓扑中的配置Backend冗余列,并新增前端校验,禁止提交空场景或未绑定后端。
2026-04-20 14:45:03 +08:00

404 lines
20 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 %}全局配置 - 机器人管理后台{% endblock %}
{% block content %}
<div class="page-shell system-page">
<div class="page-hero">
<div class="page-hero-copy">
<div class="page-eyebrow">LLM Workspace</div>
<h1>全局配置</h1>
<p>集中维护全局 LLM 后端,插件只引用后端名,不再分散配置密钥和地址。</p>
</div>
<div class="page-hero-actions">
<el-button size="mini" plain @click="loadLlmConfig">刷新</el-button>
<el-button size="mini" @click="addBackend">新增后端</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>全局 LLM 配置</h3>
<p>用表单统一管理 `config.yaml` 中的 `llm.backends`,系统和插件共享同一份后端配置。</p>
</div>
<div class="config-meta" v-if="configPath">
<span>配置文件:{% raw %}{{ configPath }}{% endraw %}</span>
<span>后端数量:{% raw %}{{ llmForm.backends.length }}{% endraw %}</span>
</div>
</div>
<el-form label-width="110px" class="llm-form">
<el-form-item label="默认后端">
<el-select v-model="llmForm.default_backend" placeholder="请选择默认后端" filterable clearable style="width: 360px;">
<el-option
v-for="item in backendNameOptions"
:key="item"
:label="item"
:value="item">
</el-option>
</el-select>
</el-form-item>
</el-form>
<el-card class="scene-card" shadow="never">
<div slot="header" class="workspace-header">
<div>
<h3>场景路由Scene Binding</h3>
<p>用业务场景绑定后端。插件优先引用 scene再由 scene 统一路由到 backend。</p>
</div>
<el-button size="mini" @click="addScene">新增场景</el-button>
</div>
<div class="scene-list" v-if="llmForm.scenes.length">
<div class="scene-row" v-for="(scene, index) in llmForm.scenes" :key="scene.uid">
<el-input v-model="scene.name" placeholder="例如chat.main"></el-input>
<el-select v-model="scene.backend" placeholder="绑定后端" filterable>
<el-option v-for="item in backendNameOptions" :key="item" :label="item" :value="item"></el-option>
</el-select>
<el-button type="text" class="danger-text" @click="removeScene(index)">删除</el-button>
</div>
</div>
<el-empty v-else description="暂无场景配置,可先新增 chat.main / summary.daily 等"></el-empty>
</el-card>
<div class="backend-list" v-if="llmForm.backends.length">
<el-card
v-for="(backend, index) in llmForm.backends"
:key="backend.uid"
class="backend-card"
shadow="never">
<div slot="header" class="backend-card-header">
<div>
<strong>{% raw %}{{ backend.name || `后端 ${index + 1}` }}{% endraw %}</strong>
</div>
<el-button type="text" class="danger-text" @click="removeBackend(index)">删除</el-button>
</div>
<el-form label-width="110px" class="backend-form">
<div class="backend-grid">
<el-form-item label="后端名称">
<el-input v-model="backend.name" placeholder="例如dify_workflow_chat"></el-input>
</el-form-item>
<el-form-item label="Provider">
<el-select v-model="backend.provider" placeholder="请选择">
<el-option label="dify" value="dify"></el-option>
<el-option label="openai_compatible" value="openai_compatible"></el-option>
</el-select>
</el-form-item>
<el-form-item label="Mode">
<el-select v-model="backend.mode" placeholder="请选择" clearable>
<el-option label="workflow" value="workflow"></el-option>
<el-option label="chat" value="chat"></el-option>
<el-option label="completion" value="completion"></el-option>
</el-select>
</el-form-item>
<el-form-item label="模型">
<el-input v-model="backend.model" placeholder="例如gpt-5.4"></el-input>
</el-form-item>
<el-form-item label="API Base URL">
<el-input v-model="backend.api_base_url" placeholder="例如http://127.0.0.1:3000/v1"></el-input>
</el-form-item>
<el-form-item label="API URL">
<el-input v-model="backend.api_url" placeholder="完整地址,和 Base URL 二选一"></el-input>
</el-form-item>
<el-form-item label="Endpoint">
<el-input v-model="backend.endpoint" placeholder="例如chat/completions"></el-input>
</el-form-item>
<el-form-item label="API Key">
<el-input v-model="backend.api_key" placeholder="请输入 API Key" show-password></el-input>
</el-form-item>
<el-form-item label="响应模式">
<el-select v-model="backend.response_mode" placeholder="请选择" clearable>
<el-option label="blocking" value="blocking"></el-option>
<el-option label="streaming" value="streaming"></el-option>
</el-select>
</el-form-item>
<el-form-item label="输出字段">
<el-input v-model="backend.workflow_output_key" placeholder="例如text"></el-input>
</el-form-item>
<el-form-item label="超时(秒)">
<el-input-number v-model="backend.timeout_seconds" :min="1" :step="1"></el-input-number>
</el-form-item>
<el-form-item label="请求超时">
<el-input-number v-model="backend.request_timeout" :min="1" :step="1"></el-input-number>
</el-form-item>
<el-form-item label="温度">
<el-input-number v-model="backend.temperature" :min="0" :max="2" :step="0.05"></el-input-number>
</el-form-item>
<el-form-item label="Max Tokens">
<el-input-number v-model="backend.max_tokens" :min="1" :step="1"></el-input-number>
</el-form-item>
<el-form-item label="最大重试">
<el-input-number v-model="backend.max_retries" :min="1" :step="1"></el-input-number>
</el-form-item>
<el-form-item label="重试间隔">
<el-input-number v-model="backend.retry_delay_seconds" :min="0" :step="0.5"></el-input-number>
</el-form-item>
</div>
<div class="backend-switches">
<el-switch v-model="backend.stream" active-text="Stream"></el-switch>
</div>
</el-form>
</el-card>
</div>
<el-empty v-else description="暂无后端配置,先新增一个"></el-empty>
<el-card class="topology-card" shadow="never">
<div slot="header" class="workspace-header">
<div>
<h3>插件依赖拓扑</h3>
<p>展示 插件 -> scene -> backend -> 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 v-if="scope.row.scene" size="mini" :type="scope.row.valid_scene ? 'success' : 'danger'">
{% raw %}{{ scope.row.scene }}{% endraw %}
</el-tag>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column prop="resolved_backend" label="实际Backend" min-width="160">
<template slot-scope="scope">
<el-tag v-if="scope.row.resolved_backend" size="mini" :type="scope.row.valid_backend ? 'success' : 'danger'">
{% raw %}{{ scope.row.resolved_backend }}{% endraw %}
</el-tag>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column prop="provider" label="Provider" min-width="120">
<template slot-scope="scope">
<span>{% raw %}{{ scope.row.provider || '-' }}{% endraw %}</span>
</template>
</el-table-column>
</el-table>
</el-card>
</el-card>
</div>
{% endblock %}
{% block scripts %}
<script>
new Vue({
el: '#app',
mixins: [baseApp],
data() {
return {
currentView: '17',
configPath: '',
topologyRows: [],
llmForm: {
default_backend: '',
backends: [],
scenes: []
}
}
},
computed: {
backendNameOptions() {
return (this.llmForm.backends || [])
.map(item => item.name)
.filter(Boolean);
}
},
mounted() {
this.currentView = '17';
this.loadLlmConfig();
},
methods: {
newBackend() {
// 统一后端对象结构,保证新增/编辑时字段完整,避免后端清洗时丢键。
return {
uid: `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
name: '',
provider: 'dify',
mode: '',
model: '',
api_base_url: '',
api_url: '',
endpoint: '',
api_key: '',
response_mode: '',
workflow_output_key: '',
timeout_seconds: 60,
request_timeout: 60,
temperature: 0.7,
max_tokens: 1024,
max_retries: 3,
retry_delay_seconds: 1.0,
stream: false
};
},
newScene() {
// Scene 是“业务场景 -> 后端”的路由单元,插件建议只依赖 scene。
return {
uid: `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
name: '',
backend: ''
};
},
normalizeBackend(item) {
return {
uid: `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
name: item.name || '',
provider: item.provider || 'dify',
mode: item.mode || '',
model: item.model || '',
api_base_url: item.api_base_url || '',
api_url: item.api_url || '',
endpoint: item.endpoint || '',
api_key: item.api_key || '',
response_mode: item.response_mode || '',
workflow_output_key: item.workflow_output_key || '',
timeout_seconds: item.timeout_seconds ?? 60,
request_timeout: item.request_timeout ?? 60,
temperature: item.temperature ?? 0.7,
max_tokens: item.max_tokens ?? 1024,
max_retries: item.max_retries ?? 3,
retry_delay_seconds: item.retry_delay_seconds ?? 1.0,
stream: !!item.stream
};
},
normalizeScene(item) {
return {
uid: `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
name: item.name || '',
backend: item.backend || ''
};
},
addBackend() {
this.llmForm.backends.push(this.newBackend());
},
addScene() {
this.llmForm.scenes.push(this.newScene());
},
removeBackend(index) {
const removed = this.llmForm.backends[index];
this.llmForm.backends.splice(index, 1);
if (removed && removed.name && this.llmForm.default_backend === removed.name) {
this.llmForm.default_backend = '';
}
// 后端被删除后,自动清理引用它的 scene避免保存时出现无效绑定。
if (removed && removed.name) {
(this.llmForm.scenes || []).forEach(scene => {
if (scene.backend === removed.name) {
scene.backend = '';
}
});
}
},
removeScene(index) {
this.llmForm.scenes.splice(index, 1);
},
async loadLlmConfig() {
try {
const response = await axios.get('/api/system/llm_config');
if (response.data.success) {
const data = response.data.data || {};
this.configPath = data.config_path || '';
this.llmForm.default_backend = data.default_backend || '';
this.llmForm.backends = (data.backends || []).map(item => this.normalizeBackend(item));
this.llmForm.scenes = (data.scenes || []).map(item => this.normalizeScene(item));
this.topologyRows = data.topology_rows || [];
} else {
this.$message.error(response.data.message || '读取全局 LLM 配置失败');
}
} catch (error) {
this.$message.error(error.response?.data?.message || '读取全局 LLM 配置失败');
}
},
async saveLlmConfig() {
// 前端先做一次严格校验,避免把空场景名或未绑定后端的记录提交到后端。
const invalidScene = (this.llmForm.scenes || []).find(item => {
return !String(item.name || '').trim() || !String(item.backend || '').trim();
});
if (invalidScene) {
this.$message.error('场景配置不完整:请确保每一行都填写场景名并绑定后端');
return;
}
const payload = {
default_backend: this.llmForm.default_backend || '',
backends: (this.llmForm.backends || []).map(item => {
const cleaned = { ...item };
delete cleaned.uid;
return cleaned;
}),
scenes: (this.llmForm.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: 24px; }
.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: #6366f1; 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 .el-card__body { display: flex; flex-direction: column; gap: 16px; }
.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; }
.backend-list { display: flex; flex-direction: column; gap: 16px; }
.scene-card, .topology-card { border-radius: 18px; border: 1px solid rgba(148,163,184,0.16); }
.scene-list { display: flex; flex-direction: column; gap: 10px; }
.scene-row {
display: grid;
grid-template-columns: minmax(260px, 1fr) minmax(260px, 1fr) auto;
gap: 10px;
align-items: center;
}
.backend-card { border-radius: 18px; border: 1px solid rgba(148,163,184,0.16); }
.backend-card-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.backend-grid {
display: grid;
grid-template-columns: repeat(2, minmax(260px, 1fr));
gap: 8px 16px;
}
.backend-switches { display: flex; align-items: center; gap: 16px; padding: 0 12px 8px; }
.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; }
.page-hero-actions { flex-wrap: wrap; }
.scene-row { grid-template-columns: 1fr; }
.backend-grid { grid-template-columns: 1fr; }
}
</style>
{% endblock %}