Files
xk-admin/apps/web-antd/src/views/system/system-config/components/ai-model-config-panel.vue
李琦 618abca975 feat: 菜单AI整理与提示词管理,并优化OCR/AI加载体验
支持菜单标题自动填、AI整理预览应用,以及系统配置侧 AI 模型与提示词相关能力。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-20 09:41:34 +08:00

2028 lines
71 KiB
Vue
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.
<script lang="ts" setup>
/**
* 系统配置 - AI管理:二级 Tab
* 1) 模型配置 2) 功能路由 3) 病历配置 4) 提醒文案
* 5) Agent 中转 6) 语音配置 7) Agent 高级
*/
import { computed, onMounted, reactive, ref, watch } from 'vue';
import { CheckCircleFilled, KeyOutlined } from '@ant-design/icons-vue';
import {
Button,
Checkbox,
Input,
InputNumber,
Select,
Spin,
Switch,
Tabs,
Tag,
message,
Radio,
} from 'ant-design-vue';
import {
getAiFeatureCopyList,
getAiPlatformBalance,
getAiPlatformConfigOptions,
saveAiFeatureCopy,
} from '#/views/system/ai/platform/api';
import { updateAiModel } from '#/views/system/ai/platform/api/model';
import {
THINKING_TYPE_OPTIONS,
resolveThinkingType,
supportsThinkingType,
thinkingTypeLabel,
} from '#/views/system/ai/platform/config/provider-capabilities';
import { GlassCard } from '#/components/glass-card';
import { ensureAiPromptDefaults } from '#/views/system/ai/prompt/api';
import {
getSystemConfigList,
saveSystemConfig,
} from '#/views/system/system-config/api';
/** 功能路由可选值 */
const VIA_MODE_OPTIONS = [
{ label: '跟随全局', value: 'inherit' },
{ label: '强制 Agent', value: 'agent' },
{ label: '强制直连', value: 'direct' },
];
/** 各 AI 功能(与 AiGenerationSceneEnum 对齐);icon/tone 供液态玻璃卡展示 */
const FEATURE_VIA_ROWS = [
{
scene: 'medical_record',
label: '写病历',
tip: '',
icon: 'lucide:file-heart',
tone: 'primary',
},
{
scene: 'prescription',
label: '出方',
tip: '',
icon: 'lucide:pill',
tone: 'success',
},
{
scene: 'voice_prescription',
label: '语音口述开方',
tip: '可覆盖语音 Tab 旧键',
icon: 'lucide:mic',
tone: 'warning',
},
{
scene: 'daily_brief',
label: '每日简报',
tip: '',
icon: 'lucide:newspaper',
tone: 'primary',
},
{
scene: 'prescription_image_ocr',
label: '识图开方',
tip: '多模态暂强制直连,改 Agent 不生效',
icon: 'lucide:scan-line',
tone: 'warning',
},
{
scene: 'transfer_image_ocr',
label: '识图传方',
tip: '多模态暂强制直连,改 Agent 不生效',
icon: 'lucide:image',
tone: 'warning',
},
{
scene: 'menu_organize',
label: '菜单层级整理',
tip: '可走 Agent',
icon: 'lucide:folder-tree',
tone: 'success',
},
];
/** 路由取值 → 卡片角标文案 */
function viaModeLabel(mode: string) {
if (mode === 'agent') return 'Agent';
if (mode === 'direct') return '直连';
return '跟随全局';
}
/** 二级 Tab 持久化 */
const SUB_TAB_STORAGE_KEY = 'system_config_ai_sub_tab';
interface PlatformCard {
id: number;
code: string;
name: string;
logo: string;
description: string;
default_model: string;
api_url: string;
/** 1=支持第三方余额查询(如 DeepSeek),0=不支持 */
balance_supported?: number;
/** 展示文案;不支持或未选用密钥时为 -- */
balance_txt?: string;
models: {
code: string;
description: string;
extra_json?: Record<string, any>;
id: number;
is_default: number;
label: string;
name: string;
value: string;
}[];
keys: {
id?: number;
is_default?: number;
key_name?: string;
label?: string;
name?: string;
value: number;
}[];
}
interface FeatureCopyRow {
id: number;
feature_code: string;
feature_name: string;
record_label: string;
disclaimer_text: string;
consent_text: string;
}
const loading = ref(false);
const saving = ref(false);
const copySaving = ref(false);
const platforms = ref<PlatformCard[]>([]);
const activeProvider = ref('');
const activeApiKeyId = ref<number | undefined>(undefined);
const activeModel = ref('');
const featureCopies = ref<FeatureCopyRow[]>([]);
const subTab = ref('model');
/** 首屏 load 完成后再监听密钥切换去刷余额,避免与 config-options 内嵌查询打架 */
const balanceWatchEnabled = ref(false);
/** 余额刷新中(仅当前选用且支持查询的平台) */
const balanceLoading = ref(false);
/** 病历配置:可选字段与已勾选编码 */
const mrFieldOptions = ref<{ label: string; value: string }[]>([]);
const mrSelectedFields = ref<string[]>([]);
const mrSaving = ref(false);
// ===== Agent 配置(走 getSystemConfigList,写入 xk_system_config) =====
// 与上方「模型配置」独立:
// - 模型配置决定 provider/key/model
// - 「Agent 中转」Tab:全局直连/中转 + Agent 服务地址
// - 「语音配置」Tab:讯飞 ASR + 口述落方 LLM 路由
// - 「Agent 高级」Tab:KB / ReAct / Token / 流量实验
const aiAgentViaAgent = ref(false);
const aiAgentBaseUrl = ref('http://127.0.0.1:18123');
const aiAgentSecret = ref('');
const aiAgentTesting = ref(false);
/** 各功能 via:scene -> inherit|agent|direct */
const featureViaModes = reactive<Record<string, string>>({});
const featureViaSaving = ref(false);
const promptSyncing = ref(false);
for (const row of FEATURE_VIA_ROWS) {
featureViaModes[row.scene] = 'inherit';
}
/** 一次 AI 出方生成几份方案(1–5,默认 1;>1 需开启 Agent 中转并由 Go 并行) */
const aiPrescriptionGenerateCount = ref(1);
/** 病历场景是否让 Go Agent 检索知识库(独立于「是否中转」) */
const aiAgentKbEnabledMedicalRecord = ref(false);
/** 处方场景是否让 Go Agent 检索知识库 */
const aiAgentKbEnabledPrescription = ref(false);
// 分组 1:ReAct 多轮循环
const aiReactEnabled = ref(false);
const aiReactMaxIterations = ref(3);
const aiReactPlanningEnabled = ref(false);
const aiReactReflectionEnabled = ref(false);
const aiReactReflectionTemperature = ref(0.2);
const aiReactJsonRepairEnabled = ref(true);
// 分组 2:Token 预算
const aiTokenBudgetEnabled = ref(true);
const aiTokenBudgetPerRequest = ref(8000);
const aiTokenMaxPerCall = ref(2048);
// 分组 3:流量实验
const aiShadowTrafficEnabled = ref(false);
const aiShadowTrafficRatio = ref(0);
const aiAbTestEnabled = ref(false);
const aiAbTestExperimentRatio = ref(10);
/** Agent 中转 Tab 保存中 */
const agentViaSaving = ref(false);
/** 语音配置 Tab 保存中 */
const voiceSaving = ref(false);
/** Agent 高级 Tab 保存中 */
const advSaving = ref(false);
/** 讯飞 ASR(语音转写) */
const asrXunfeiAppId = ref('');
const asrXunfeiApiKey = ref('');
const asrXunfeiApiSecret = ref('');
const asrXunfeiApiUrl = ref('wss://iat-api.xfyun.cn/v2/iat');
const asrXunfeiDomain = ref('iat');
const asrXunfeiAccent = ref('mandarin');
const asrXunfeiTimeout = ref(60);
/** 口述落方 LLM:inherit | agent | direct */
const aiVoiceViaAgentMode = ref<'inherit' | 'agent' | 'direct'>('inherit');
const currentPlatform = computed(() =>
platforms.value.find((p) => p.code === activeProvider.value),
);
const keyOptions = computed(() => currentPlatform.value?.keys || []);
const modelOptions = computed(() => currentPlatform.value?.models || []);
/** 当前选中模型行(用于展示/编辑厂商扩展参数) */
const currentModelRow = computed(() =>
modelOptions.value.find(
(m) => m.value === activeModel.value || m.code === activeModel.value,
),
);
/** 当前厂商是否支持思考开关 */
const showModelThinkingEditor = computed(() =>
supportsThinkingType(activeProvider.value),
);
/** 编辑条里的思考开关(与模型 extra 同步) */
const editThinkingType = ref<'disabled' | 'enabled'>('disabled');
const modelExtraSaving = ref(false);
watch(
[currentModelRow, activeProvider],
() => {
editThinkingType.value = resolveThinkingType(
activeProvider.value,
currentModelRow.value?.extra_json,
);
},
{ immediate: true },
);
/** 密钥展示名:key_name → name → label → 兜底 */
function keyDisplayName(k: {
id?: number;
key_name?: string;
label?: string;
name?: string;
value: number;
}): string {
const candidates = [k.key_name, k.name, k.label];
for (const c of candidates) {
const s = String(c || '').trim();
if (s) return s;
}
return `密钥#${k.id || k.value}`;
}
async function load() {
loading.value = true;
try {
const [optRes, copyRes] = await Promise.all([
getAiPlatformConfigOptions(),
getAiFeatureCopyList(),
]);
const data = optRes?.data || optRes || {};
balanceWatchEnabled.value = false;
platforms.value = (Array.isArray(data.platforms) ? data.platforms : []).map(
(p: PlatformCard) => ({
...p,
balance_supported: Number(p.balance_supported || 0),
balance_txt: String(p.balance_txt || '--'),
}),
);
const active = data.active || {};
activeProvider.value = String(
active.provider || platforms.value[0]?.code || '',
);
activeApiKeyId.value = active.api_key_id
? Number(active.api_key_id)
: undefined;
activeModel.value = String(active.model || '');
syncSelectionForPlatform();
const mr = data.medical_record_fields || {};
mrFieldOptions.value = Array.isArray(mr.options)
? mr.options.map((o: any) => ({
label: String(o.label || o.value || ''),
value: String(o.value || ''),
}))
: [];
mrSelectedFields.value = Array.isArray(mr.selected)
? mr.selected.map(String)
: mrFieldOptions.value.map((o) => o.value);
const copies = copyRes?.data || copyRes || [];
featureCopies.value = (Array.isArray(copies) ? copies : []).map(
(r: any) => ({
id: Number(r.id || 0),
feature_code: String(r.feature_code || ''),
feature_name: String(r.feature_name || r.feature_code || ''),
record_label: String(r.record_label || ''),
disclaimer_text: String(r.disclaimer_text || ''),
consent_text: String(r.consent_text || ''),
}),
);
} finally {
loading.value = false;
// 开启监听:后续切换平台/密钥时按「当前选用密钥」刷新余额
balanceWatchEnabled.value = true;
// syncSelection 可能改过 key,再按页面当前选用刷一次,与卡片展示对齐
void refreshActivePlatformBalance();
}
}
/** 切换平台后:密钥/模型回落到该平台默认或首个 */
function syncSelectionForPlatform() {
const keys = keyOptions.value;
if (
activeApiKeyId.value &&
!keys.some((k) => k.value === activeApiKeyId.value)
) {
const def = keys.find((k) => Number(k.is_default) === 1);
activeApiKeyId.value = def?.value ?? keys[0]?.value;
} else if (!activeApiKeyId.value && keys[0]) {
const def = keys.find((k) => Number(k.is_default) === 1);
activeApiKeyId.value = def?.value ?? keys[0].value;
}
const models = modelOptions.value;
if (activeModel.value && !models.some((m) => m.value === activeModel.value)) {
const def = models.find((m) => Number(m.is_default) === 1);
activeModel.value =
def?.value ||
currentPlatform.value?.default_model ||
models[0]?.value ||
'';
} else if (!activeModel.value) {
const def = models.find((m) => Number(m.is_default) === 1);
activeModel.value =
def?.value ||
currentPlatform.value?.default_model ||
models[0]?.value ||
'';
}
}
watch(activeProvider, () => {
syncSelectionForPlatform();
});
/**
* 按当前选用平台 + 密钥刷新余额展示
* 为什么:不支持查询的平台固定「--」;支持的平台只用当前选用密钥查,切换密钥要即时更新
*/
async function refreshActivePlatformBalance() {
const code = String(activeProvider.value || '');
// 非当前选用的支持平台也先回落到 --,避免残留上一次查询结果
platforms.value.forEach((p) => {
if (!p.balance_supported || p.code !== code) {
p.balance_txt = '--';
}
});
const plat = platforms.value.find((p) => p.code === code);
if (!plat || !plat.balance_supported) {
return;
}
if (!activeApiKeyId.value) {
plat.balance_txt = '--';
return;
}
balanceLoading.value = true;
try {
const res = await getAiPlatformBalance({
platform_code: code,
api_key_id: Number(activeApiKeyId.value),
});
const data = res?.data || res || {};
plat.balance_txt = String(data.balance_txt || '--');
} catch {
plat.balance_txt = '--';
} finally {
balanceLoading.value = false;
}
}
watch([activeProvider, activeApiKeyId], () => {
if (!balanceWatchEnabled.value) return;
void refreshActivePlatformBalance();
});
function onSubTabChange(key: number | string) {
subTab.value = String(key);
localStorage.setItem(SUB_TAB_STORAGE_KEY, String(key));
}
/** 病历字段全选 / 清空 */
function selectAllMrFields() {
mrSelectedFields.value = mrFieldOptions.value.map((o) => o.value);
}
function clearMrFields() {
mrSelectedFields.value = [];
}
// ===== Agent 高级配置的辅助函数 =====
// 与 PHP SystemConfigService::castValue bool 分支对齐:1/true/yes 视为真
function parseBoolAdv(v: unknown): boolean {
return v === '1' || v === 1 || v === 'true' || v === true;
}
function boolStr(v: boolean): string {
return v ? '1' : '0';
}
/**
* 单独从 getSystemConfigList 加载 Agent 高级配置项
*
* 与上方 load() 走 getAiPlatformConfigOptions 不同:
* - 上方模型配置是 PHP AiRuntimeConfigService 单独提供的结构化接口
* - 这里走通用 key-value 接口,读 xk_system_config 表里的 ai_* key
* (ai_agent_* / ai_agent_kb_enabled_* / ai_react_* / ai_token_* / ai_shadow_* / ai_ab_test_*)
*
* 不在 load() 里直接合并是为了避免 getAiPlatformConfigOptions 失败时牵连这组配置加载
*/
async function loadAdvancedCfg() {
try {
const list = await getSystemConfigList();
const rows = Array.isArray(list) ? list : list?.data || [];
for (const row of rows) {
switch (row.config_key) {
case 'ai_ab_test_enabled': {
aiAbTestEnabled.value = parseBoolAdv(row.config_value);
break;
}
case 'ai_ab_test_experiment_ratio': {
aiAbTestExperimentRatio.value = Number(row.config_value) || 10;
break;
}
case 'ai_agent_base_url': {
aiAgentBaseUrl.value = String(
row.config_value || 'http://127.0.0.1:18123',
);
break;
}
case 'ai_agent_kb_enabled_medical_record': {
aiAgentKbEnabledMedicalRecord.value = parseBoolAdv(row.config_value);
break;
}
case 'ai_agent_kb_enabled_prescription': {
aiAgentKbEnabledPrescription.value = parseBoolAdv(row.config_value);
break;
}
case 'ai_agent_secret': {
aiAgentSecret.value = String(row.config_value || '');
break;
}
case 'ai_agent_via_agent': {
// 是否走 Go Agent 中转:1/true/yes 视为开
aiAgentViaAgent.value = parseBoolAdv(row.config_value);
break;
}
case 'ai_asr_xunfei_app_id': {
asrXunfeiAppId.value = String(row.config_value || '');
break;
}
case 'ai_asr_xunfei_api_key': {
asrXunfeiApiKey.value = String(row.config_value || '');
break;
}
case 'ai_asr_xunfei_api_secret': {
asrXunfeiApiSecret.value = String(row.config_value || '');
break;
}
case 'ai_asr_xunfei_api_url': {
asrXunfeiApiUrl.value = String(
row.config_value || 'wss://iat-api.xfyun.cn/v2/iat',
);
break;
}
case 'ai_asr_xunfei_domain': {
asrXunfeiDomain.value = String(row.config_value || 'iat');
break;
}
case 'ai_asr_xunfei_accent': {
asrXunfeiAccent.value = String(row.config_value || 'mandarin');
break;
}
case 'ai_asr_xunfei_timeout': {
asrXunfeiTimeout.value = Number(row.config_value) || 60;
break;
}
case 'ai_voice_via_agent_mode': {
const m = String(row.config_value || 'inherit').toLowerCase();
aiVoiceViaAgentMode.value =
m === 'agent' || m === 'direct' ? m : 'inherit';
break;
}
case 'ai_prescription_generate_count': {
// 钳制 1–5,非法回落默认 1
const n = Number(row.config_value);
aiPrescriptionGenerateCount.value =
Number.isFinite(n) && n >= 1 ? Math.min(5, Math.floor(n)) : 1;
break;
}
case 'ai_react_enabled': {
aiReactEnabled.value = parseBoolAdv(row.config_value);
break;
}
case 'ai_react_json_repair_enabled': {
aiReactJsonRepairEnabled.value = parseBoolAdv(row.config_value);
break;
}
case 'ai_react_max_iterations': {
aiReactMaxIterations.value = Number(row.config_value) || 3;
break;
}
case 'ai_react_planning_enabled': {
aiReactPlanningEnabled.value = parseBoolAdv(row.config_value);
break;
}
case 'ai_react_reflection_enabled': {
aiReactReflectionEnabled.value = parseBoolAdv(row.config_value);
break;
}
case 'ai_react_reflection_temperature': {
aiReactReflectionTemperature.value = Number(row.config_value) || 0.2;
break;
}
case 'ai_shadow_traffic_enabled': {
aiShadowTrafficEnabled.value = parseBoolAdv(row.config_value);
break;
}
case 'ai_shadow_traffic_ratio': {
aiShadowTrafficRatio.value = Number(row.config_value) || 0;
break;
}
case 'ai_token_budget_enabled': {
aiTokenBudgetEnabled.value = parseBoolAdv(row.config_value);
break;
}
case 'ai_token_budget_per_request': {
aiTokenBudgetPerRequest.value = Number(row.config_value) || 8000;
break;
}
case 'ai_token_max_per_call': {
aiTokenMaxPerCall.value = Number(row.config_value) || 2048;
break;
}
case 'ai_feature_via_modes': {
let parsed: Record<string, string> = {};
try {
const raw = row.config_value;
parsed =
typeof raw === 'string'
? JSON.parse(raw || '{}')
: raw && typeof raw === 'object'
? (raw as Record<string, string>)
: {};
} catch {
parsed = {};
}
for (const fr of FEATURE_VIA_ROWS) {
const m = String(parsed[fr.scene] || 'inherit').toLowerCase();
featureViaModes[fr.scene] = ['inherit', 'agent', 'direct'].includes(
m,
)
? m
: 'inherit';
}
break;
}
default: {
break;
}
}
}
} catch (error) {
// 高级配置加载失败不阻塞主流程,仅控制台告警
console.warn('[AiModelConfigPanel] loadAdvancedCfg failed:', error);
}
}
/** 保存各 AI 功能直连/Agent 路由 */
async function handleSaveFeatureVia() {
featureViaSaving.value = true;
try {
const payload: Record<string, string> = {};
for (const row of FEATURE_VIA_ROWS) {
payload[row.scene] = featureViaModes[row.scene] || 'inherit';
}
await saveSystemConfig([
{
config_key: 'ai_feature_via_modes',
config_value: JSON.stringify(payload),
value_type: 'json',
config_group: 'ai',
description: '各 AI 功能调用路径 inherit|agent|direct',
},
]);
message.success('功能路由已保存');
} finally {
featureViaSaving.value = false;
}
}
/** 把代码种子写入 xk_ai_prompt(不覆盖已有);Agent 后台即可看到 */
async function handleSyncPrompts() {
promptSyncing.value = true;
try {
const res = await ensureAiPromptDefaults();
const inserted = Number(res?.inserted ?? 0);
const skipped = Number(res?.skipped ?? 0);
message.success(`提示词同步完成:新增 ${inserted},跳过 ${skipped}`);
} catch (e) {
console.error(e);
} finally {
promptSyncing.value = false;
}
}
/**
* 保存「Agent 中转」Tab:直连/中转开关 + 服务地址 + 密钥 + 出方份数
* 为什么单独存:运维高频改中转路径,与 ReAct/流量实验解耦,避免误改高级项
*/
async function handleSaveAgentVia() {
agentViaSaving.value = true;
try {
await saveSystemConfig([
{
config_key: 'ai_agent_via_agent',
config_value: boolStr(aiAgentViaAgent.value),
},
{ config_key: 'ai_agent_base_url', config_value: aiAgentBaseUrl.value },
{ config_key: 'ai_agent_secret', config_value: aiAgentSecret.value },
{
config_key: 'ai_prescription_generate_count',
config_value: String(
Math.min(
5,
Math.max(
1,
Math.floor(Number(aiPrescriptionGenerateCount.value) || 1),
),
),
),
},
]);
message.success('Agent 中转配置已保存');
} finally {
agentViaSaving.value = false;
}
}
/**
* 保存「语音配置」Tab:讯飞 ASR + 口述落方 LLM 路由
*/
async function handleSaveVoiceConfig() {
voiceSaving.value = true;
try {
await saveSystemConfig([
{
config_key: 'ai_asr_xunfei_app_id',
config_value: asrXunfeiAppId.value.trim(),
config_group: 'ai',
description: '讯飞 ASR AppID(空则回落 env XUNFEI_ASR_APP_ID)',
},
{
config_key: 'ai_asr_xunfei_api_key',
config_value: asrXunfeiApiKey.value.trim(),
config_group: 'ai',
description: '讯飞 ASR APIKey',
},
{
config_key: 'ai_asr_xunfei_api_secret',
config_value: asrXunfeiApiSecret.value.trim(),
config_group: 'ai',
description: '讯飞 ASR APISecret',
},
{
config_key: 'ai_asr_xunfei_api_url',
config_value:
asrXunfeiApiUrl.value.trim() || 'wss://iat-api.xfyun.cn/v2/iat',
config_group: 'ai',
description: '讯飞流式听写 WebSocket 地址',
},
{
config_key: 'ai_asr_xunfei_domain',
config_value: asrXunfeiDomain.value.trim() || 'iat',
config_group: 'ai',
description: '听写领域 iat|medical(medical 需控制台授权)',
},
{
config_key: 'ai_asr_xunfei_accent',
config_value: asrXunfeiAccent.value.trim() || 'mandarin',
config_group: 'ai',
description: '口音 mandarin 等',
},
{
config_key: 'ai_asr_xunfei_timeout',
config_value: String(
Math.max(10, Number(asrXunfeiTimeout.value) || 60),
),
config_group: 'ai',
description: 'ASR 超时秒数',
},
{
config_key: 'ai_voice_via_agent_mode',
config_value: aiVoiceViaAgentMode.value,
config_group: 'ai',
description: '口述落方 LLM:inherit|agent|direct',
},
]);
message.success('语音配置已保存');
} finally {
voiceSaving.value = false;
}
}
/** 保存 Agent 高级配置(知识库 / ReAct / Token / 流量实验) */
async function handleSaveAdvanced() {
advSaving.value = true;
try {
await saveSystemConfig([
// 知识库检索(与「是否中转」独立;必须 value_type=bool,否则 PHP (bool)"0" 会当成 true)
{
config_key: 'ai_agent_kb_enabled_medical_record',
config_value: boolStr(aiAgentKbEnabledMedicalRecord.value),
value_type: 'bool',
config_group: 'ai',
description: '病历场景是否启用 Go Agent 知识库检索',
},
{
config_key: 'ai_agent_kb_enabled_prescription',
config_value: boolStr(aiAgentKbEnabledPrescription.value),
value_type: 'bool',
config_group: 'ai',
description: '处方场景是否启用 Go Agent 知识库检索',
},
// ReAct
{
config_key: 'ai_react_enabled',
config_value: boolStr(aiReactEnabled.value),
},
{
config_key: 'ai_react_max_iterations',
config_value: String(aiReactMaxIterations.value),
},
{
config_key: 'ai_react_planning_enabled',
config_value: boolStr(aiReactPlanningEnabled.value),
},
{
config_key: 'ai_react_reflection_enabled',
config_value: boolStr(aiReactReflectionEnabled.value),
},
{
config_key: 'ai_react_reflection_temperature',
config_value: String(aiReactReflectionTemperature.value),
},
{
config_key: 'ai_react_json_repair_enabled',
config_value: boolStr(aiReactJsonRepairEnabled.value),
},
// Token 预算
{
config_key: 'ai_token_budget_enabled',
config_value: boolStr(aiTokenBudgetEnabled.value),
},
{
config_key: 'ai_token_budget_per_request',
config_value: String(aiTokenBudgetPerRequest.value),
},
{
config_key: 'ai_token_max_per_call',
config_value: String(aiTokenMaxPerCall.value),
},
// 流量实验
{
config_key: 'ai_shadow_traffic_enabled',
config_value: boolStr(aiShadowTrafficEnabled.value),
},
{
config_key: 'ai_shadow_traffic_ratio',
config_value: String(aiShadowTrafficRatio.value),
},
{
config_key: 'ai_ab_test_enabled',
config_value: boolStr(aiAbTestEnabled.value),
},
{
config_key: 'ai_ab_test_experiment_ratio',
config_value: String(aiAbTestExperimentRatio.value),
},
]);
message.success('Agent 高级配置已保存');
} finally {
advSaving.value = false;
}
}
/**
* 测试 Agent 服务可达性(GET {base_url}/health)
*
* 不入库,仅 UI 反馈:
* - Go Agent 健康检查路径 /health 已在 middleware.Auth 白名单内,无需鉴权
* - 仅校验"地址可达 + 服务存活",不验证 secret(secret 错误时业务调用会 401)
*/
async function testAgentConnection() {
const url = String(aiAgentBaseUrl.value || '').trim();
if (!url) {
message.warning('请先填写 Agent 服务地址');
return;
}
aiAgentTesting.value = true;
try {
const resp = await fetch(`${url.replace(/\/$/, '')}/health`, {
method: 'GET',
headers: { Accept: 'application/json' },
signal: AbortSignal.timeout(5000),
});
if (resp.ok) {
message.success(`Agent 服务可达(${resp.status})`);
} else {
message.error(`Agent 响应异常:HTTP ${resp.status}`);
}
} catch (error) {
message.error(
`连接失败:${error instanceof Error ? error.message : String(error)}`,
);
} finally {
aiAgentTesting.value = false;
}
}
/** 保存 AI 写病历输出字段勾选 */
async function handleSaveMrFields() {
if (mrSelectedFields.value.length === 0) {
message.warning('请至少勾选一个病历字段');
return;
}
// 按 options 顺序保存,保证提示词字段顺序稳定
const selectedSet = new Set(mrSelectedFields.value);
const ordered = mrFieldOptions.value
.map((o) => o.value)
.filter((v) => selectedSet.has(v));
mrSaving.value = true;
try {
await saveSystemConfig([
{
config_key: 'ai_medical_record_fields',
config_value: JSON.stringify(ordered),
value_type: 'json',
config_group: 'ai',
description: 'AI写病历需生成的字段编码列表(JSON数组,不含主诉)',
sort: 410,
} as any,
]);
mrSelectedFields.value = ordered;
message.success('病历字段配置已保存');
} finally {
mrSaving.value = false;
}
}
function selectProvider(code: string) {
activeProvider.value = code;
}
function selectKey(id: number) {
activeApiKeyId.value = id;
}
function selectModel(code: string) {
activeModel.value = code;
}
/**
* 保存当前选中模型的厂商扩展参数(写回 xk_ai_model.extra_json,与平台详情同源)
* 为什么不另建系统配置 key:参数挂在模型行上,换模型即换配置,避免串台
*/
async function saveCurrentModelExtra() {
const row = currentModelRow.value;
if (!row?.id) {
message.warning('请先选择模型');
return;
}
if (!showModelThinkingEditor.value) return;
modelExtraSaving.value = true;
try {
await updateAiModel({
id: row.id,
platform_id: currentPlatform.value?.id,
extra_json: { thinking_type: editThinkingType.value },
thinking_type: editThinkingType.value,
});
// 本地同步,避免整页 reload 闪烁
row.extra_json = {
...(row.extra_json || {}),
thinking_type: editThinkingType.value,
};
message.success('模型参数已保存');
} finally {
modelExtraSaving.value = false;
}
}
async function handleSave() {
if (!activeProvider.value) {
message.warning('请选择 AI 平台');
return;
}
saving.value = true;
try {
await saveSystemConfig([
{
config_key: 'ai_active_provider',
config_value: activeProvider.value,
value_type: 'string',
config_group: 'ai',
description: '当前 AI 平台编码(spark/deepseek)',
sort: 400,
},
{
config_key: 'ai_active_api_key_id',
config_value: String(activeApiKeyId.value || 0),
value_type: 'string',
config_group: 'ai',
description: '当前选用的 AI API Key ID',
sort: 401,
},
{
config_key: 'ai_active_model',
config_value: String(activeModel.value || ''),
value_type: 'string',
config_group: 'ai',
description: '当前 AI 模型名(空则用平台默认)',
sort: 402,
},
]);
message.success('AI 配置已保存');
} finally {
saving.value = false;
}
}
/** 保存 VIP 关联的 AI 醒目提示 / 知情同意文案 */
async function handleSaveCopies() {
if (featureCopies.value.length === 0) {
message.warning('暂无文案可保存,请先执行 SQL 初始化');
return;
}
copySaving.value = true;
try {
await saveAiFeatureCopy({
items: featureCopies.value.map((r) => ({
id: r.id,
feature_code: r.feature_code,
record_label: r.record_label,
disclaimer_text: r.disclaimer_text,
consent_text: r.consent_text,
})),
});
message.success('AI 功能文案已保存');
} finally {
copySaving.value = false;
}
}
onMounted(() => {
const stored = localStorage.getItem(SUB_TAB_STORAGE_KEY);
// 缓存值合法性:含 Agent 中转 / 语音配置 / Agent 高级
if (
stored === 'model' ||
stored === 'feature' ||
stored === 'copy' ||
stored === 'mr' ||
stored === 'agent' ||
stored === 'voice' ||
stored === 'advanced'
) {
subTab.value = stored;
}
load();
// 加载 Agent / 语音 / 高级配置(独立接口,失败不阻塞主流程)
loadAdvancedCfg();
});
</script>
<template>
<div class="cfg-panel">
<div class="cfg-panel-body !pt-2">
<Spin :spinning="loading">
<div class="ai-config-panel">
<Tabs :active-key="subTab" @change="onSubTabChange">
<Tabs.TabPane key="model" tab="模型配置">
<div class="py-2">
<div class="mb-2 font-medium text-[hsl(var(--foreground))]">
选用 AI 平台
</div>
<div class="mb-3 text-sm text-[hsl(var(--muted-foreground))]">
平台 / 模型 /
密钥请在「AI平台管理」维护;此处点选当前运行时组合。
</div>
<div
class="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3"
>
<div
v-for="p in platforms"
:key="p.code"
class="ai-pick-card"
:class="{ 'ai-pick-card--on': activeProvider === p.code }"
role="button"
tabindex="0"
@click="selectProvider(p.code)"
@keydown.enter="selectProvider(p.code)"
>
<CheckCircleFilled
v-if="activeProvider === p.code"
class="ai-pick-check"
/>
<div class="flex items-start gap-3">
<div class="ai-pick-logo">
<img
v-if="p.logo"
:src="p.logo"
:alt="p.name"
class="h-full w-full object-contain"
/>
<span v-else class="ai-pick-logo-fallback">
{{ (p.name || p.code || '?').slice(0, 1) }}
</span>
</div>
<div class="min-w-0 flex-1 pr-5">
<div class="font-medium text-[hsl(var(--foreground))]">
{{ p.name }}
</div>
<div
class="mt-0.5 text-xs text-[hsl(var(--muted-foreground))]"
>
{{ p.code }} · {{ p.default_model || '—' }}
</div>
<div
class="mt-1 text-xs text-[hsl(var(--muted-foreground))]"
>
余额
<span
class="ml-1 font-medium text-[hsl(var(--foreground))]"
>
{{
balanceLoading && activeProvider === p.code
? '查询中…'
: p.balance_txt || '--'
}}
</span>
</div>
<div
class="mt-1 line-clamp-2 text-xs text-[hsl(var(--muted-foreground))]"
>
{{ p.description || p.api_url }}
</div>
</div>
</div>
</div>
</div>
<div
class="mt-6 mb-2 font-medium text-[hsl(var(--foreground))]"
>
API Key
</div>
<div
class="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3"
>
<div
v-for="k in keyOptions"
:key="k.value"
class="ai-pick-card"
:class="{ 'ai-pick-card--on': activeApiKeyId === k.value }"
role="button"
tabindex="0"
@click="selectKey(k.value)"
@keydown.enter="selectKey(k.value)"
>
<CheckCircleFilled
v-if="activeApiKeyId === k.value"
class="ai-pick-check"
/>
<div class="flex items-start gap-3">
<div class="ai-pick-icon">
<KeyOutlined />
</div>
<div class="min-w-0 flex-1 pr-5">
<div class="flex flex-wrap items-center gap-2">
<span
class="font-medium text-[hsl(var(--foreground))]"
>
{{ keyDisplayName(k) }}
</span>
<Tag v-if="k.is_default === 1" color="gold">默认</Tag>
</div>
<div
class="mt-1 text-xs text-[hsl(var(--muted-foreground))]"
>
ID {{ k.value }}
</div>
</div>
</div>
</div>
</div>
<div
v-if="!keyOptions.length"
class="mt-1 text-xs text-amber-600 dark:text-amber-400"
>
当前平台暂无启用密钥,请先到「AI平台管理」添加
</div>
<div
class="mt-6 mb-2 font-medium text-[hsl(var(--foreground))]"
>
模型
</div>
<div
class="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3"
>
<div
v-for="m in modelOptions"
:key="m.value"
class="ai-pick-card"
:class="{ 'ai-pick-card--on': activeModel === m.value }"
role="button"
tabindex="0"
@click="selectModel(m.value)"
@keydown.enter="selectModel(m.value)"
>
<CheckCircleFilled
v-if="activeModel === m.value"
class="ai-pick-check"
/>
<div class="min-w-0 pr-5">
<div class="flex flex-wrap items-center gap-2">
<span class="font-medium text-[hsl(var(--foreground))]">
{{ m.name || m.label || m.code }}
</span>
<Tag v-if="m.is_default === 1" color="gold">默认</Tag>
</div>
<div
class="mt-1 text-xs text-[hsl(var(--muted-foreground))]"
>
{{ m.code }}
<template v-if="supportsThinkingType(activeProvider)">
·
{{
thinkingTypeLabel(
resolveThinkingType(activeProvider, m.extra_json),
)
}}
</template>
</div>
<div
v-if="m.description"
class="mt-1 line-clamp-2 text-xs text-[hsl(var(--muted-foreground))]"
>
{{ m.description }}
</div>
</div>
</div>
</div>
<div
v-if="!modelOptions.length"
class="mt-1 text-xs text-amber-600 dark:text-amber-400"
>
当前平台暂无启用模型,请先到「AI平台管理」添加
</div>
<!-- 按厂商能力编辑当前模型参数(与平台详情模型弹窗同源) -->
<div
v-if="showModelThinkingEditor && currentModelRow"
class="mt-4 rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--muted)/0.2)] p-3"
>
<div class="mb-2 font-medium text-[hsl(var(--foreground))]">
当前模型参数 ·
{{ currentModelRow.name || currentModelRow.code }}
</div>
<div class="mb-2 text-xs text-[hsl(var(--muted-foreground))]">
仅当前厂商支持的参数可改;保存后写回该模型,调用时立即按此生效(走
Agent 需重启后缓存刷新)。
</div>
<div class="flex flex-wrap items-center gap-3">
<span class="text-sm text-[hsl(var(--foreground))]"
>思考模式</span
>
<Radio.Group
v-model:value="editThinkingType"
button-style="solid"
size="small"
>
<Radio.Button
v-for="opt in THINKING_TYPE_OPTIONS"
:key="opt.value"
:value="opt.value"
>
{{ opt.value === 'disabled' ? '关闭' : '开启' }}
</Radio.Button>
</Radio.Group>
<Button
type="primary"
size="small"
:loading="modelExtraSaving"
@click="saveCurrentModelExtra"
>
保存模型参数
</Button>
</div>
</div>
</div>
</Tabs.TabPane>
<!-- 功能路由:各场景一张液态玻璃卡 -->
<Tabs.TabPane key="feature" tab="功能路由">
<div class="space-y-4 py-4">
<GlassCard
title="AI 功能调用路径"
subtitle="跟随全局 = Agent 中转总开关;强制 Agent/直连覆盖该功能。识图多模态暂强制直连。提示词在 Agent 后台「提示词管理」编辑。"
icon="lucide:route"
tone="primary"
padding="md"
>
<div class="flex flex-wrap gap-3">
<Button
type="primary"
:loading="featureViaSaving"
@click="handleSaveFeatureVia"
>
保存功能路由
</Button>
<Button
:loading="promptSyncing"
@click="handleSyncPrompts"
>
同步默认提示词到库
</Button>
</div>
</GlassCard>
<div
class="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3"
>
<GlassCard
v-for="row in FEATURE_VIA_ROWS"
:key="row.scene"
:title="row.label"
:subtitle="row.scene"
:icon="row.icon"
:tone="row.tone"
hoverable
padding="md"
>
<template #extra>
<Tag
:color="
featureViaModes[row.scene] === 'agent'
? 'processing'
: featureViaModes[row.scene] === 'direct'
? 'default'
: 'success'
"
>
{{ viaModeLabel(featureViaModes[row.scene]) }}
</Tag>
</template>
<div
v-if="row.tip"
class="mb-3 text-xs text-[hsl(var(--muted-foreground))]"
>
{{ row.tip }}
</div>
<div class="text-xs text-[hsl(var(--muted-foreground))] mb-1.5">
调用路径
</div>
<Select
v-model:value="featureViaModes[row.scene]"
:options="VIA_MODE_OPTIONS"
class="w-full"
/>
</GlassCard>
</div>
</div>
</Tabs.TabPane>
<Tabs.TabPane key="mr" tab="病历配置">
<div class="py-2">
<div class="mb-3 text-sm text-[hsl(var(--muted-foreground))]">
勾选后,AI
写病历只会返回这些字段(主诉仍由医生填写,不在此配置)。未勾选的字段不会生成。
</div>
<div class="mb-3 flex flex-wrap gap-2">
<Button size="small" @click="selectAllMrFields">全选</Button>
<Button size="small" @click="clearMrFields">清空</Button>
<span
class="text-xs text-[hsl(var(--muted-foreground))] self-center"
>
已选 {{ mrSelectedFields.length }} /
{{ mrFieldOptions.length }}
</span>
</div>
<Checkbox.Group
v-model:value="mrSelectedFields"
class="ai-mr-fields grid grid-cols-2 gap-x-4 gap-y-2 md:grid-cols-3 xl:grid-cols-4"
>
<Checkbox
v-for="opt in mrFieldOptions"
:key="opt.value"
:value="opt.value"
>
{{ opt.label }}
</Checkbox>
</Checkbox.Group>
</div>
</Tabs.TabPane>
<Tabs.TabPane key="copy" tab="提醒文案">
<div class="py-2">
<div class="mb-3 text-sm text-[hsl(var(--muted-foreground))]">
按 VIP
功能码维护醒目提示与首次知情同意全文;医生签署后仅该功能无需再次确认。
</div>
<div
v-if="!featureCopies.length"
class="rounded-lg border border-dashed border-[hsl(var(--border))] p-4 text-sm text-[hsl(var(--muted-foreground))]"
>
暂无文案数据,请执行 sql/20260806/xk_ai_disclaimer_consent.sql
</div>
<div
v-for="row in featureCopies"
:key="row.feature_code"
class="ai-copy-card mb-4"
>
<div class="mb-2 flex flex-wrap items-center gap-2">
<span class="font-medium text-[hsl(var(--foreground))]">
{{ row.feature_name || row.feature_code }}
</span>
<span class="text-xs text-[hsl(var(--muted-foreground))]">
{{ row.feature_code }}
</span>
</div>
<div class="mb-1 text-xs text-[hsl(var(--muted-foreground))]">
记录称谓(用于「本{称谓}由 AI…」)
</div>
<Input
v-model:value="row.record_label"
class="mb-3 max-w-xs"
placeholder="病历 / 处方"
/>
<div class="mb-1 text-xs text-[hsl(var(--muted-foreground))]">
醒目提示(生成记录 / 模态框顶部)
</div>
<Input.TextArea
v-model:value="row.disclaimer_text"
:rows="4"
class="mb-3"
placeholder="本病历/处方由 AI…"
/>
<div class="mb-1 text-xs text-[hsl(var(--muted-foreground))]">
首次知情同意全文
</div>
<Input.TextArea
v-model:value="row.consent_text"
:rows="6"
placeholder="我已知悉:…"
/>
</div>
</div>
</Tabs.TabPane>
<!-- Agent 中转:直连/中转开关 + 服务地址(独立 Tab,运维高频) -->
<Tabs.TabPane key="agent" tab="Agent 中转">
<div class="py-4 space-y-6">
<div
class="rounded border border-solid border-blue-200 bg-blue-50/30 p-4 dark:border-blue-900 dark:bg-blue-950/20"
>
<div class="mb-3 flex items-center justify-between">
<div>
<div class="font-medium text-[hsl(var(--foreground))]">
直连 / Agent 中转
</div>
<div class="text-xs text-[hsl(var(--muted-foreground))]">
开启后 AI 写病历、AI 辅助辨证出方等经 AiAgentFactory
的调用走 Go Agent 中转。关闭则为 PHP
直连厂商。语音转写与口述落方见「语音配置」Tab;ReAct /
知识库等见「Agent
高级」。中转开启时须保证下方服务地址可达
</div>
</div>
<Switch
v-model:checked="aiAgentViaAgent"
checked-children="中转"
un-checked-children="直连"
/>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<div class="mb-1 text-sm text-[hsl(var(--foreground))]">
Agent 服务地址
<Tag
v-if="aiAgentViaAgent"
color="processing"
class="ml-1"
>
必填
</Tag>
</div>
<Input
v-model:value="aiAgentBaseUrl"
placeholder="http://127.0.0.1:18123"
:disabled="!aiAgentViaAgent"
/>
<div
class="mt-1 text-xs text-[hsl(var(--muted-foreground))]"
>
仅当上方开关开启时生效;与 Go 端 config.yaml 的
server.port 对应
</div>
</div>
<div>
<div class="mb-1 text-sm text-[hsl(var(--foreground))]">
当前生效的模型组合
<Tag color="blue" class="ml-1">
{{ activeProvider || '未选择' }}
</Tag>
</div>
<div class="text-xs text-[hsl(var(--muted-foreground))]">
模型/密钥/平台由「模型配置」Tab 决定,Go Agent
中转会原样使用, 切换模型请前往<a
class="ml-1 underline"
@click="subTab = 'model'"
>模型配置 Tab</a
>
</div>
</div>
</div>
<div class="mt-3">
<div class="mb-1 text-sm text-[hsl(var(--foreground))]">
Agent 鉴权密钥
<span
class="ml-1 text-xs text-[hsl(var(--muted-foreground))]"
>
(与 Go 端 config.Agent.SharedSecret 对应;留空则不发
Authorization)
</span>
</div>
<Input.Password
v-model:value="aiAgentSecret"
placeholder="如 qiqi991012"
:disabled="!aiAgentViaAgent"
autocomplete="new-password"
/>
</div>
<div class="mt-3 flex flex-wrap items-center gap-3">
<Button
:loading="aiAgentTesting"
:disabled="!aiAgentViaAgent"
@click="testAgentConnection"
>
测试连接
</Button>
<span class="text-xs text-[hsl(var(--muted-foreground))]">
仅校验服务可达(GET
/health),不验证密钥;密钥错误会在实际业务调用时报 401
</span>
</div>
<div class="mt-4 max-w-xs">
<div class="mb-1 text-sm text-[hsl(var(--foreground))]">
一次生成处方份数
</div>
<InputNumber
v-model:value="aiPrescriptionGenerateCount"
:min="1"
:max="5"
style="width: 100%"
/>
<div
class="mt-1 text-xs text-[hsl(var(--muted-foreground))]"
>
默认 1;设为 2–5 时由 Go Agent
并行生成多套方案供医生对比(需开启上方中转;单路失败不影响其它成功方案)
</div>
</div>
</div>
</div>
</Tabs.TabPane>
<!-- 语音配置:ASR 转写 + 口述落方 LLM(与 Agent 中转分离) -->
<Tabs.TabPane key="voice" tab="语音配置">
<div class="py-4 space-y-6">
<div
class="rounded border border-solid border-[hsl(var(--primary)/30%)] p-4 shadow-[0_0_6px_hsl(var(--primary)/18%)]"
>
<div class="mb-3">
<div class="font-medium text-[hsl(var(--foreground))]">
语音转写(讯飞 ASR)
</div>
<div class="text-xs text-[hsl(var(--muted-foreground))]">
接诊「语音开方」录音转文字使用;字段留空时回落 .env 的
XUNFEI_ASR_*。须开通控制台「语音听写(流式版)」v2
WebSocket
</div>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<div class="mb-1 text-sm text-[hsl(var(--foreground))]">
AppID
</div>
<Input
v-model:value="asrXunfeiAppId"
placeholder="讯飞控制台 AppID"
/>
</div>
<div>
<div class="mb-1 text-sm text-[hsl(var(--foreground))]">
APIKey
</div>
<Input
v-model:value="asrXunfeiApiKey"
placeholder="APIKey"
/>
</div>
<div class="md:col-span-2">
<div class="mb-1 text-sm text-[hsl(var(--foreground))]">
APISecret
</div>
<Input.Password
v-model:value="asrXunfeiApiSecret"
placeholder="APISecret"
autocomplete="new-password"
/>
</div>
<div class="md:col-span-2">
<div class="mb-1 text-sm text-[hsl(var(--foreground))]">
WebSocket 地址
</div>
<Input
v-model:value="asrXunfeiApiUrl"
placeholder="wss://iat-api.xfyun.cn/v2/iat"
/>
</div>
<div>
<div class="mb-1 text-sm text-[hsl(var(--foreground))]">
领域 domain
</div>
<Input
v-model:value="asrXunfeiDomain"
placeholder="iat 或 medical"
/>
</div>
<div>
<div class="mb-1 text-sm text-[hsl(var(--foreground))]">
口音 accent
</div>
<Input
v-model:value="asrXunfeiAccent"
placeholder="mandarin"
/>
</div>
<div>
<div class="mb-1 text-sm text-[hsl(var(--foreground))]">
超时(秒)
</div>
<InputNumber
v-model:value="asrXunfeiTimeout"
:min="10"
:max="120"
style="width: 100%"
/>
</div>
</div>
</div>
<div
class="rounded border border-solid border-[hsl(var(--border))] p-4"
>
<div class="mb-3">
<div class="font-medium text-[hsl(var(--foreground))]">
口述落方 LLM 路由
</div>
<div class="text-xs text-[hsl(var(--muted-foreground))]">
仅「口述落方」解析药味时生效;辨证出方(语音选
A)仍走「Agent 中转」Tab 的全局设置。Agent
宕机时可设为「强制直连」而不影响其它 AI
</div>
</div>
<Radio.Group v-model:value="aiVoiceViaAgentMode">
<div class="flex flex-col gap-2">
<Radio value="inherit">
跟随 Agent 中转 Tab(当前全局:{{
aiAgentViaAgent ? '中转' : '直连'
}})
</Radio>
<Radio value="agent">强制 Go Agent 中转</Radio>
<Radio value="direct">强制 PHP 直连厂商</Radio>
</div>
</Radio.Group>
</div>
</div>
</Tabs.TabPane>
<!-- Agent 高级:知识库 / ReAct / Token / 流量实验(不含中转开关) -->
<Tabs.TabPane key="advanced" tab="Agent 高级">
<div class="py-4 space-y-6">
<!-- 知识库检索(与「是否中转」独立;关掉时 Go 仍会记一条跳过步骤) -->
<div
class="rounded border border-solid border-[hsl(var(--border))] p-4"
>
<div class="mb-3">
<div class="font-medium text-[hsl(var(--foreground))]">
知识库检索
</div>
<div class="text-xs text-[hsl(var(--muted-foreground))]">
与「Agent 中转」Tab 独立:中转只决定走 Go,本开关才决定 Go
是否检索本地知识库 / MaxKB。需先在「Agent
中转」开启中转才生效;生成记录时间轴会出现「检索知识库」步骤
</div>
</div>
<div class="flex flex-wrap items-center gap-6">
<div class="flex items-center gap-2">
<span class="text-sm text-[hsl(var(--foreground))]"
>病历场景</span
>
<Switch
v-model:checked="aiAgentKbEnabledMedicalRecord"
:disabled="!aiAgentViaAgent"
checked-children="开"
un-checked-children="关"
/>
</div>
<div class="flex items-center gap-2">
<span class="text-sm text-[hsl(var(--foreground))]"
>处方场景</span
>
<Switch
v-model:checked="aiAgentKbEnabledPrescription"
:disabled="!aiAgentViaAgent"
checked-children="开"
un-checked-children="关"
/>
</div>
</div>
</div>
<!-- ReAct 多轮推理循环 -->
<div
class="rounded border border-solid border-[hsl(var(--border))] p-4"
>
<div class="mb-3 flex items-center justify-between">
<div>
<div class="font-medium text-[hsl(var(--foreground))]">
ReAct 多轮推理循环
</div>
<div class="text-xs text-[hsl(var(--muted-foreground))]">
开启后单次任务会走 Think-Act-Observe 多轮(含 Planning /
Reflection / JSON 修复),质量更好但延迟与 token
成本更高
</div>
</div>
<Switch
v-model:checked="aiReactEnabled"
checked-children="开"
un-checked-children="关"
/>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<div class="mb-1 text-sm text-[hsl(var(--foreground))]">
单任务最大轮数
</div>
<InputNumber
v-model:value="aiReactMaxIterations"
:min="1"
:max="10"
style="width: 100%"
/>
<div
class="mt-1 text-xs text-[hsl(var(--muted-foreground))]"
>
建议 2-5,过大会显著拉高延迟和成本
</div>
</div>
<div>
<div class="mb-1 text-sm text-[hsl(var(--foreground))]">
反思步骤温度
</div>
<InputNumber
v-model:value="aiReactReflectionTemperature"
:min="0"
:max="1"
:step="0.05"
style="width: 100%"
/>
<div
class="mt-1 text-xs text-[hsl(var(--muted-foreground))]"
>
低于生成温度(0.3),保证审核结果稳定
</div>
</div>
</div>
<div class="mt-3 flex flex-wrap items-center gap-6">
<div class="flex items-center gap-2">
<span class="text-sm text-[hsl(var(--foreground))]"
>Planning 规划阶段</span
>
<Switch
v-model:checked="aiReactPlanningEnabled"
checked-children="开"
un-checked-children="关"
/>
</div>
<div class="flex items-center gap-2">
<span class="text-sm text-[hsl(var(--foreground))]"
>Reflection 反思自检</span
>
<Switch
v-model:checked="aiReactReflectionEnabled"
checked-children="开"
un-checked-children="关"
/>
</div>
<div class="flex items-center gap-2">
<span class="text-sm text-[hsl(var(--foreground))]"
>JSON 自动修复</span
>
<Switch
v-model:checked="aiReactJsonRepairEnabled"
checked-children="开"
un-checked-children="关"
/>
</div>
</div>
</div>
<!-- Token 预算管理 -->
<div
class="rounded border border-solid border-[hsl(var(--border))] p-4"
>
<div class="mb-3 flex items-center justify-between">
<div>
<div class="font-medium text-[hsl(var(--foreground))]">
Token 预算管理
</div>
<div class="text-xs text-[hsl(var(--muted-foreground))]">
开启后单次任务累计 token 超限即中止,防 Agent
死循环烧钱(双层控制:单任务总预算 + 单次调用上限)
</div>
</div>
<Switch
v-model:checked="aiTokenBudgetEnabled"
checked-children="开"
un-checked-children="关"
/>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<div class="mb-1 text-sm text-[hsl(var(--foreground))]">
单任务总 token 上限
</div>
<InputNumber
v-model:value="aiTokenBudgetPerRequest"
:min="1000"
:step="1000"
style="width: 100%"
/>
<div
class="mt-1 text-xs text-[hsl(var(--muted-foreground))]"
>
prompt + completion 累加;8000 约等于 1-2 万汉字
</div>
</div>
<div>
<div class="mb-1 text-sm text-[hsl(var(--foreground))]">
单次 LLM 调用输出上限
</div>
<InputNumber
v-model:value="aiTokenMaxPerCall"
:min="256"
:step="256"
style="width: 100%"
/>
<div
class="mt-1 text-xs text-[hsl(var(--muted-foreground))]"
>
透传给厂商的 max_tokens 字段,防止单次响应爆炸
</div>
</div>
</div>
</div>
<!-- 流量实验 -->
<div
class="rounded border border-solid border-[hsl(var(--border))] p-4"
>
<div class="mb-3 font-medium text-[hsl(var(--foreground))]">
流量实验
</div>
<div class="mb-4 flex items-center justify-between">
<div>
<div class="text-sm text-[hsl(var(--foreground))]">
影子流量(Shadow Traffic)
</div>
<div class="text-xs text-[hsl(var(--muted-foreground))]">
开启后按比例异步复制请求到 Go
Agent,结果不返回用户只做对比;运维用对比报表决定是否放量
</div>
</div>
<Switch
v-model:checked="aiShadowTrafficEnabled"
checked-children="开"
un-checked-children="关"
/>
</div>
<div class="mb-4 pl-2">
<div class="mb-1 text-sm text-[hsl(var(--foreground))]">
影子流量百分比(0-100)
</div>
<InputNumber
v-model:value="aiShadowTrafficRatio"
:min="0"
:max="100"
style="width: 200px"
/>
<div
class="mt-1 text-xs text-[hsl(var(--muted-foreground))]"
>
0=关闭;建议先用 10% 跑 1 周再考虑放量
</div>
</div>
<div class="mb-2 flex items-center justify-between">
<div>
<div class="text-sm text-[hsl(var(--foreground))]">
A/B Test(实验组直接返回 Agent 结果)
</div>
<div class="text-xs text-[hsl(var(--muted-foreground))]">
开启后按比例把请求 provider 切换为
agent,实验组用户直接看到 Agent 输出
</div>
</div>
<Switch
v-model:checked="aiAbTestEnabled"
checked-children="开"
un-checked-children="关"
/>
</div>
<div class="pl-2">
<div class="mb-1 text-sm text-[hsl(var(--foreground))]">
实验组流量百分比(0-100)
</div>
<InputNumber
v-model:value="aiAbTestExperimentRatio"
:min="0"
:max="100"
style="width: 200px"
/>
<div
class="mt-1 text-xs text-[hsl(var(--muted-foreground))]"
>
10 表示 10% 用户走 Agent 实验组
</div>
</div>
</div>
</div>
</Tabs.TabPane>
</Tabs>
</div>
</Spin>
</div>
<div class="cfg-panel-footer">
<Button
v-if="subTab === 'model'"
type="primary"
:loading="saving"
@click="handleSave"
>
保存 AI 配置
</Button>
<Button
v-else-if="subTab === 'mr'"
type="primary"
:loading="mrSaving"
@click="handleSaveMrFields"
>
保存病历配置
</Button>
<Button
v-else-if="subTab === 'copy'"
type="primary"
:loading="copySaving"
@click="handleSaveCopies"
>
保存 AI 功能文案
</Button>
<Button
v-else-if="subTab === 'agent'"
type="primary"
:loading="agentViaSaving"
@click="handleSaveAgentVia"
>
保存 Agent 中转配置
</Button>
<Button
v-else-if="subTab === 'voice'"
type="primary"
:loading="voiceSaving"
@click="handleSaveVoiceConfig"
>
保存语音配置
</Button>
<Button
v-else
type="primary"
:loading="advSaving"
@click="handleSaveAdvanced"
>
保存 Agent 高级配置
</Button>
</div>
</div>
</template>
<style scoped>
.ai-pick-card {
position: relative;
padding: 14px 16px;
color: hsl(var(--foreground));
cursor: pointer;
outline: none;
background: hsl(var(--card));
border: 1px solid hsl(var(--border));
border-radius: 12px;
transition:
border-color 0.15s ease,
box-shadow 0.15s ease,
background-color 0.15s ease,
transform 0.15s ease;
}
.ai-pick-card:hover {
border-color: hsl(var(--primary) / 45%);
box-shadow: 0 4px 14px hsl(var(--foreground) / 6%);
}
.ai-pick-card--on {
background: linear-gradient(
135deg,
hsl(var(--primary) / 14%),
hsl(var(--primary) / 4%)
);
border-color: hsl(var(--primary));
box-shadow:
0 0 0 1px hsl(var(--primary) / 35%),
0 8px 20px hsl(var(--primary) / 12%);
}
.ai-pick-check {
position: absolute;
top: 10px;
right: 10px;
font-size: 18px;
color: hsl(var(--primary));
}
.ai-pick-logo,
.ai-pick-icon {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
overflow: hidden;
background: hsl(var(--muted) / 45%);
border: 1px solid hsl(var(--border));
border-radius: 10px;
}
.ai-pick-logo {
padding: 5px;
}
.ai-pick-icon {
font-size: 18px;
color: hsl(var(--primary));
background: hsl(var(--primary) / 10%);
border-color: hsl(var(--primary) / 25%);
}
.ai-pick-logo-fallback {
font-size: 15px;
font-weight: 600;
color: hsl(var(--muted-foreground));
}
.ai-copy-card {
padding: 14px 16px;
background: hsl(var(--card));
border: 1px solid hsl(var(--border));
border-radius: 10px;
}
.ai-mr-fields :deep(.ant-checkbox-wrapper) {
margin-inline-start: 0;
color: hsl(var(--foreground));
}
</style>