支持菜单标题自动填、AI整理预览应用,以及系统配置侧 AI 模型与提示词相关能力。 Co-authored-by: Cursor <cursoragent@cursor.com>
2028 lines
71 KiB
Vue
2028 lines
71 KiB
Vue
<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>
|