Files
abot/admin/dashboard/templates/system_llm.html
2026-04-30 17:59:03 +08:00

895 lines
43 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 绑定三层维护,并结合最近窗口运行分析判断哪条 AI 路由更慢、更容易失败。</p>
</div>
<div class="page-hero-actions">
<el-button size="mini" plain :loading="runtimeAnalyticsLoading" @click="reloadPageData">刷新</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>AI运行分析</h3>
<p>基于统一 LLM 客户端最近窗口埋点做被动观测,不额外发起探活请求。</p>
</div>
<div class="config-meta">
<span>窗口容量:{% raw %}{{ runtimeAnalytics.overview.window_size || 0 }}{% endraw %}</span>
<span>默认场景:{% raw %}{{ runtimeAnalytics.overview.default_scene || '-' }}{% endraw %}</span>
<span>默认目标:{% raw %}{{ runtimeAnalytics.overview.default_backend || '-' }}{% endraw %}</span>
</div>
</div>
<div class="runtime-summary-grid">
<div class="runtime-summary-card">
<div class="summary-label">最近调用</div>
<div class="summary-value">{% raw %}{{ runtimeAnalytics.overview.total_calls || 0 }}{% endraw %}</div>
<div class="summary-hint">成功 {% raw %}{{ runtimeAnalytics.overview.success_calls || 0 }}{% endraw %} / 失败 {% raw %}{{ runtimeAnalytics.overview.failed_calls || 0 }}{% endraw %}</div>
</div>
<div class="runtime-summary-card">
<div class="summary-label">成功率</div>
<div class="summary-value">{% raw %}{{ formatPercent(runtimeAnalytics.overview.success_rate) }}{% endraw %}</div>
<div class="summary-hint">按最近窗口实时汇总</div>
</div>
<div class="runtime-summary-card">
<div class="summary-label">平均耗时</div>
<div class="summary-value">{% raw %}{{ formatLatency(runtimeAnalytics.overview.avg_latency_ms) }}{% endraw %}</div>
<div class="summary-hint">用于快速识别慢场景</div>
</div>
<div class="runtime-summary-card">
<div class="summary-label">路由规模</div>
<div class="summary-value">{% raw %}{{ runtimeAnalytics.overview.scene_count || 0 }}{% endraw %}</div>
<div class="summary-hint">场景数 / 目标数 {% raw %}{{ runtimeAnalytics.overview.target_count || 0 }}{% endraw %}</div>
</div>
</div>
<div class="runtime-overview-panel">
<div class="runtime-status-row">
<el-tag size="mini" :type="statusTagType(runtimeAnalytics.overview.status)">
{% raw %}{{ statusText(runtimeAnalytics.overview.status) }}{% endraw %}
</el-tag>
<span class="runtime-overview-text">{% raw %}{{ runtimeAnalytics.overview.summary || '最近窗口内暂无统一 LLM 调用记录' }}{% endraw %}</span>
</div>
<div class="runtime-overview-meta">
<span>Provider 模板:{% raw %}{{ runtimeAnalytics.overview.provider_count || 0 }}{% endraw %}</span>
<span>最近场景:{% raw %}{{ runtimeAnalytics.overview.last_call.scene || '-' }}{% endraw %}</span>
<span>最近后端:{% raw %}{{ runtimeAnalytics.overview.last_call.backend || '-' }}{% endraw %}</span>
<span>最近模型:{% raw %}{{ runtimeAnalytics.overview.last_call.model || '-' }}{% endraw %}</span>
<span>最近时间:{% raw %}{{ runtimeAnalytics.overview.last_call.timestamp || '-' }}{% endraw %}</span>
</div>
<div class="runtime-error-box" v-if="runtimeAnalytics.overview.last_error">
<strong>最近错误:</strong>
<span>{% raw %}{{ runtimeAnalytics.overview.last_error }}{% endraw %}</span>
</div>
</div>
<div class="runtime-table-grid">
<el-card class="analytics-card" shadow="never">
<div slot="header" class="runtime-table-header">
<div>
<h4>按场景统计</h4>
<p>定位哪个业务场景最常调用、最容易失败。</p>
</div>
</div>
<el-table v-if="runtimeAnalytics.by_scene.length" :data="runtimeAnalytics.by_scene" size="mini" style="width: 100%">
<el-table-column prop="key" label="Scene" min-width="150" show-overflow-tooltip></el-table-column>
<el-table-column prop="total_calls" label="调用数" width="80"></el-table-column>
<el-table-column label="成功率" width="100">
<template slot-scope="scope">
{% raw %}{{ formatPercent(scope.row.success_rate) }}{% endraw %}
</template>
</el-table-column>
<el-table-column label="平均耗时" width="110">
<template slot-scope="scope">
{% raw %}{{ formatLatency(scope.row.avg_latency_ms) }}{% endraw %}
</template>
</el-table-column>
<el-table-column prop="failed_calls" label="失败数" width="80"></el-table-column>
<el-table-column prop="last_call_at" label="最近调用" min-width="150"></el-table-column>
<el-table-column prop="last_error" label="最近错误" min-width="220" show-overflow-tooltip></el-table-column>
</el-table>
<el-empty v-else description="最近窗口内暂无场景调用数据"></el-empty>
</el-card>
<el-card class="analytics-card" shadow="never">
<div slot="header" class="runtime-table-header">
<div>
<h4>按后端统计</h4>
<p>观察 backend 层是否存在集中失败或慢请求。</p>
</div>
</div>
<el-table v-if="runtimeAnalytics.by_backend.length" :data="runtimeAnalytics.by_backend" size="mini" style="width: 100%">
<el-table-column prop="key" label="Backend" min-width="150" show-overflow-tooltip></el-table-column>
<el-table-column prop="total_calls" label="调用数" width="80"></el-table-column>
<el-table-column label="成功率" width="100">
<template slot-scope="scope">
{% raw %}{{ formatPercent(scope.row.success_rate) }}{% endraw %}
</template>
</el-table-column>
<el-table-column label="平均耗时" width="110">
<template slot-scope="scope">
{% raw %}{{ formatLatency(scope.row.avg_latency_ms) }}{% endraw %}
</template>
</el-table-column>
<el-table-column prop="failed_calls" label="失败数" width="80"></el-table-column>
<el-table-column prop="last_call_at" label="最近调用" min-width="150"></el-table-column>
<el-table-column prop="last_error" label="最近错误" min-width="220" show-overflow-tooltip></el-table-column>
</el-table>
<el-empty v-else description="最近窗口内暂无后端调用数据"></el-empty>
</el-card>
<el-card class="analytics-card" shadow="never">
<div slot="header" class="runtime-table-header">
<div>
<h4>按 Provider 统计</h4>
<p>区分 Dify 与 OpenAI Compatible 等不同接入形态的表现。</p>
</div>
</div>
<el-table v-if="runtimeAnalytics.by_provider.length" :data="runtimeAnalytics.by_provider" size="mini" style="width: 100%">
<el-table-column prop="key" label="Provider" min-width="150" show-overflow-tooltip></el-table-column>
<el-table-column prop="total_calls" label="调用数" width="80"></el-table-column>
<el-table-column label="成功率" width="100">
<template slot-scope="scope">
{% raw %}{{ formatPercent(scope.row.success_rate) }}{% endraw %}
</template>
</el-table-column>
<el-table-column label="平均耗时" width="110">
<template slot-scope="scope">
{% raw %}{{ formatLatency(scope.row.avg_latency_ms) }}{% endraw %}
</template>
</el-table-column>
<el-table-column prop="failed_calls" label="失败数" width="80"></el-table-column>
<el-table-column prop="last_call_at" label="最近调用" min-width="150"></el-table-column>
<el-table-column prop="last_error" label="最近错误" min-width="220" show-overflow-tooltip></el-table-column>
</el-table>
<el-empty v-else description="最近窗口内暂无 Provider 调用数据"></el-empty>
</el-card>
<el-card class="analytics-card" shadow="never">
<div slot="header" class="runtime-table-header">
<div>
<h4>按模型统计</h4>
<p>帮助判断是否需要按场景切换模型或做降级策略。</p>
</div>
</div>
<el-table v-if="runtimeAnalytics.by_model.length" :data="runtimeAnalytics.by_model" size="mini" style="width: 100%">
<el-table-column prop="key" label="Model" min-width="150" show-overflow-tooltip></el-table-column>
<el-table-column prop="total_calls" label="调用数" width="80"></el-table-column>
<el-table-column label="成功率" width="100">
<template slot-scope="scope">
{% raw %}{{ formatPercent(scope.row.success_rate) }}{% endraw %}
</template>
</el-table-column>
<el-table-column label="平均耗时" width="110">
<template slot-scope="scope">
{% raw %}{{ formatLatency(scope.row.avg_latency_ms) }}{% endraw %}
</template>
</el-table-column>
<el-table-column prop="failed_calls" label="失败数" width="80"></el-table-column>
<el-table-column prop="last_call_at" label="最近调用" min-width="150"></el-table-column>
<el-table-column prop="last_error" label="最近错误" min-width="220" show-overflow-tooltip></el-table-column>
</el-table>
<el-empty v-else description="最近窗口内暂无模型调用数据"></el-empty>
</el-card>
</div>
</el-card>
<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: [],
runtimeAnalyticsLoading: false,
runtimeAnalytics: {
overview: {
window_size: 0,
total_calls: 0,
success_calls: 0,
failed_calls: 0,
success_rate: 0,
avg_latency_ms: 0,
last_error: '',
status: 'warning',
summary: '',
last_call: {},
provider_count: 0,
scene_count: 0,
target_count: 0,
default_scene: '',
default_backend: ''
},
by_scene: [],
by_backend: [],
by_provider: [],
by_model: []
},
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.reloadPageData();
},
methods: {
newUid() {
return `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
},
// 统一刷新配置与运行分析,避免管理员点一次“刷新”只能看到半套信息。
async reloadPageData() {
await Promise.all([
this.loadLlmConfig(),
this.loadRuntimeAnalytics()
]);
},
statusTagType(status) {
if (status === 'healthy') {
return 'success';
}
if (status === 'danger') {
return 'danger';
}
return 'warning';
},
statusText(status) {
if (status === 'healthy') {
return '运行正常';
}
if (status === 'danger') {
return '需要立即处理';
}
return '需要关注';
},
formatPercent(value) {
const numeric = Number(value || 0);
return `${numeric.toFixed(2)}%`;
},
formatLatency(value) {
const numeric = Number(value || 0);
return `${numeric.toFixed(2)} ms`;
},
// 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 loadRuntimeAnalytics() {
this.runtimeAnalyticsLoading = true;
try {
const response = await axios.get('/api/system/llm_runtime_analytics');
if (!response.data.success) {
this.$message.error(response.data.message || '读取 AI 运行分析失败');
return;
}
const data = response.data.data || {};
const overview = data.overview || {};
// 这里做前端兜底结构归一化,避免后端未来新增字段时影响当前页面渲染。
this.runtimeAnalytics = {
overview: {
window_size: overview.window_size || 0,
total_calls: overview.total_calls || 0,
success_calls: overview.success_calls || 0,
failed_calls: overview.failed_calls || 0,
success_rate: overview.success_rate || 0,
avg_latency_ms: overview.avg_latency_ms || 0,
last_error: overview.last_error || '',
status: overview.status || 'warning',
summary: overview.summary || '',
last_call: overview.last_call || {},
provider_count: overview.provider_count || 0,
scene_count: overview.scene_count || 0,
target_count: overview.target_count || 0,
default_scene: overview.default_scene || '',
default_backend: overview.default_backend || ''
},
by_scene: data.by_scene || [],
by_backend: data.by_backend || [],
by_provider: data.by_provider || [],
by_model: data.by_model || []
};
} catch (error) {
this.$message.error(error.response?.data?.message || '读取 AI 运行分析失败');
} finally {
this.runtimeAnalyticsLoading = false;
}
},
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.reloadPageData();
} 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;
}
.runtime-summary-grid {
display: grid;
grid-template-columns: repeat(4, minmax(180px, 1fr));
gap: 14px;
margin-bottom: 16px;
}
.runtime-summary-card {
padding: 16px 18px;
border-radius: 16px;
border: 1px solid rgba(148,163,184,0.18);
background: linear-gradient(180deg, rgba(255,255,255,0.96), rgba(241,245,249,0.88));
}
.summary-label {
font-size: 12px;
color: #64748b;
margin-bottom: 8px;
}
.summary-value {
font-size: 28px;
line-height: 1;
font-weight: 700;
color: #0f172a;
margin-bottom: 8px;
}
.summary-hint {
font-size: 12px;
color: #475569;
}
.runtime-overview-panel {
padding: 16px 18px;
border-radius: 16px;
background: rgba(15, 23, 42, 0.03);
border: 1px solid rgba(148,163,184,0.14);
margin-bottom: 18px;
}
.runtime-status-row {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 10px;
}
.runtime-overview-text {
color: #0f172a;
font-size: 14px;
}
.runtime-overview-meta {
display: flex;
gap: 12px 18px;
flex-wrap: wrap;
color: #64748b;
font-size: 12px;
}
.runtime-error-box {
margin-top: 12px;
padding: 10px 12px;
border-radius: 10px;
background: rgba(239, 68, 68, 0.08);
color: #991b1b;
font-size: 12px;
line-height: 1.6;
}
.runtime-table-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.analytics-card {
border: 1px solid rgba(148,163,184,0.16);
border-radius: 16px;
}
.runtime-table-header h4 {
font-size: 16px;
margin-bottom: 4px;
color: #0f172a;
}
.runtime-table-header p {
color: #64748b;
font-size: 12px;
}
.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; }
.runtime-summary-grid { grid-template-columns: 1fr; }
.runtime-table-grid { grid-template-columns: 1fr; }
.entry-grid { grid-template-columns: 1fr; }
.scene-row { grid-template-columns: 1fr; }
}
</style>
{% endblock %}