Files
xk-admin/apps/web-antd/src/views/system/system-config/components/ai-model-config-panel.vue
lq a159739fa2
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CI / CI OK (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
feat: ai agent部分功能
2026-08-14 21:28:21 +08:00

984 lines
38 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) 病历配置:勾选 AI 写病历需要生成的字段
* 3) 提醒文案:VIP 关联的醒目提示与知情同意
*/
import { computed, onMounted, ref, watch } from 'vue';
import { Button, Checkbox, Input, InputNumber, Select, Spin, Switch, Tabs, Tag, message } from 'ant-design-vue';
import { CheckCircleFilled, KeyOutlined } from '@ant-design/icons-vue';
import {
getAiFeatureCopyList,
getAiPlatformConfigOptions,
saveAiFeatureCopy,
} from '#/views/system/ai/platform/api';
import { getSystemConfigList, saveSystemConfig } from '#/views/system/system-config/api';
/** 二级 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;
models: {
id: number;
label: string;
value: string;
code: string;
name: string;
description: string;
is_default: number;
}[];
keys: {
id?: number;
key_name?: string;
name?: string;
label?: string;
value: number;
is_default?: 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');
/** 病历配置:可选字段与已勾选编码 */
const mrFieldOptions = ref<{ label: string; value: string }[]>([]);
const mrSelectedFields = ref<string[]>([]);
const mrSaving = ref(false);
// ===== Agent 高级配置(走 getSystemConfigList,写入 xk_system_config) =====
// 这组配置与上方"模型配置"独立:
// - 上方"模型配置"决定用什么 provider/key/model(写入 ai_active_provider/_api_key_id/_model)
// - 这里只决定"是否走 Go Agent 中转",用独立开关 ai_agent_via_agent 控制
// 这样切换 agent 模式时不会污染模型组合(模型永远在「模型配置 Tab」选)
// 分组 0:Agent 接入(独立开关 + 服务地址 + 鉴权密钥)
// aiActiveProviderAdv 不再使用——只保留模型 Tab 里的 activeProvider 作为真实 provider
const aiAgentViaAgent = ref(false);
const aiAgentBaseUrl = ref('http://127.0.0.1:18123');
const aiAgentSecret = ref('');
const aiAgentTesting = 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);
// 高级配置保存状态
const advSaving = ref(false);
const currentPlatform = computed(() =>
platforms.value.find((p) => p.code === activeProvider.value),
);
const keyOptions = computed(() => currentPlatform.value?.keys || []);
const modelOptions = computed(() => currentPlatform.value?.models || []);
/** 密钥展示名:key_name → name → label → 兜底 */
function keyDisplayName(k: {
key_name?: string;
name?: string;
label?: string;
value: number;
id?: 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 || {};
platforms.value = Array.isArray(data.platforms) ? data.platforms : [];
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((c: any) => String(c))
: 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;
}
}
/** 切换平台后:密钥/模型回落到该平台默认或首个 */
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();
});
function onSubTabChange(key: string | number) {
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 numStr(v: number): string {
return String(v);
}
function boolStr(v: boolean): string {
return v ? '1' : '0';
}
/**
* 单独从 getSystemConfigList 加载 Agent 高级配置项
*
* 与上方 load() 走 getAiPlatformConfigOptions 不同:
* - 上方模型配置是 PHP AiRuntimeConfigService 单独提供的结构化接口
* - 这里走通用 key-value 接口,读 xk_system_config 表里的 16 个 ai_* key
* (ai_active_provider / ai_agent_* / 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_agent_via_agent': {
// 是否走 Go Agent 中转:1/true/yes 视为开
aiAgentViaAgent.value = parseBoolAdv(row.config_value);
break;
}
case 'ai_agent_base_url': {
aiAgentBaseUrl.value = String(row.config_value || 'http://127.0.0.1:18123');
break;
}
case 'ai_agent_secret': {
aiAgentSecret.value = String(row.config_value || '');
break;
}
case 'ai_react_enabled': {
aiReactEnabled.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_react_json_repair_enabled': {
aiReactJsonRepairEnabled.value = parseBoolAdv(row.config_value);
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_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_ab_test_enabled': {
aiAbTestEnabled.value = parseBoolAdv(row.config_value);
break;
}
case 'ai_ab_test_experiment_ratio': {
aiAbTestExperimentRatio.value = Number(row.config_value) || 10;
break;
}
default: {
break;
}
}
}
} catch (e) {
// 高级配置加载失败不阻塞主流程,仅控制台告警
console.warn('[AiModelConfigPanel] loadAdvancedCfg failed:', e);
}
}
/** 保存 Agent 高级配置(16 个 key 一次性 POST) */
async function handleSaveAdvanced() {
advSaving.value = true;
try {
await saveSystemConfig([
// 分组 0:Agent 接入
// 关键:用 ai_agent_via_agent 独立开关,不再写 ai_active_provider(避免污染模型 Tab 的真实 provider)
// 这样后台"模型配置 Tab"选什么 provider/key/model,Go Agent 中转就用什么,互不影响
{ 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 },
// 分组 1:ReAct
{ config_key: 'ai_react_enabled', config_value: boolStr(aiReactEnabled.value) },
{ config_key: 'ai_react_max_iterations', config_value: numStr(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: numStr(aiReactReflectionTemperature.value) },
{ config_key: 'ai_react_json_repair_enabled', config_value: boolStr(aiReactJsonRepairEnabled.value) },
// 分组 2:Token 预算
{ config_key: 'ai_token_budget_enabled', config_value: boolStr(aiTokenBudgetEnabled.value) },
{ config_key: 'ai_token_budget_per_request', config_value: numStr(aiTokenBudgetPerRequest.value) },
{ config_key: 'ai_token_max_per_call', config_value: numStr(aiTokenMaxPerCall.value) },
// 分组 3:流量实验
{ config_key: 'ai_shadow_traffic_enabled', config_value: boolStr(aiShadowTrafficEnabled.value) },
{ config_key: 'ai_shadow_traffic_ratio', config_value: numStr(aiShadowTrafficRatio.value) },
{ config_key: 'ai_ab_test_enabled', config_value: boolStr(aiAbTestEnabled.value) },
{ config_key: 'ai_ab_test_experiment_ratio', config_value: numStr(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 (e) {
message.error(`连接失败:${e instanceof Error ? e.message : String(e)}`);
} finally {
aiAgentTesting.value = false;
}
}
/** 保存 AI 写病历输出字段勾选 */
async function handleSaveMrFields() {
if (!mrSelectedFields.value.length) {
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;
}
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) {
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);
// 新增 advanced 二级 Tab 后,缓存值合法性扩展
if (stored === 'model' || stored === 'copy' || stored === 'mr' || stored === 'advanced') {
subTab.value = stored;
}
load();
// 加载 Agent 高级配置(独立接口,失败不阻塞主流程)
loadAdvancedCfg();
});
</script>
<template>
<Spin :spinning="loading">
<div class="ai-config-panel py-2">
<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 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 }}
</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 class="mt-6">
<Button type="primary" :loading="saving" @click="handleSave">
保存 AI 配置
</Button>
</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 class="mt-6">
<Button type="primary" :loading="mrSaving" @click="handleSaveMrFields">
保存病历配置
</Button>
</div>
</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 v-if="featureCopies.length" class="mt-2">
<Button type="primary" :loading="copySaving" @click="handleSaveCopies">
保存 AI 功能文案
</Button>
</div>
</div>
</Tabs.TabPane>
<!-- Agent 高级:Go Agent 接入 / ReAct / Token 预算 / 流量实验 -->
<Tabs.TabPane key="advanced" tab="Agent 高级">
<div class="py-4 space-y-6">
<!-- 分组 0:Agent 接入配置(独立开关 + 服务地址 + 鉴权密钥) -->
<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 调用走 Go Agent 中转(含本地知识库检索 / ReAct 多轮),模型/密钥/平台始终由上方「模型配置」Tab 决定
</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>
<!-- 分组 1: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>
<!-- 分组 2: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>
<!-- 分组 3:流量实验 -->
<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>
<Button type="primary" :loading="advSaving" @click="handleSaveAdvanced">
保存 Agent 高级配置
</Button>
</div>
</div>
</Tabs.TabPane>
</Tabs>
</div>
</Spin>
</template>
<style scoped>
.ai-pick-card {
position: relative;
cursor: pointer;
border: 1px solid hsl(var(--border));
border-radius: 12px;
padding: 14px 16px;
background: hsl(var(--card));
color: hsl(var(--foreground));
outline: none;
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) / 0.45);
box-shadow: 0 4px 14px hsl(var(--foreground) / 0.06);
}
.ai-pick-card--on {
border-color: hsl(var(--primary));
background: linear-gradient(
135deg,
hsl(var(--primary) / 0.14),
hsl(var(--primary) / 0.04)
);
box-shadow:
0 0 0 1px hsl(var(--primary) / 0.35),
0 8px 20px hsl(var(--primary) / 0.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;
height: 44px;
width: 44px;
flex-shrink: 0;
align-items: center;
justify-content: center;
overflow: hidden;
border-radius: 10px;
border: 1px solid hsl(var(--border));
background: hsl(var(--muted) / 0.45);
}
.ai-pick-logo {
padding: 5px;
}
.ai-pick-icon {
font-size: 18px;
color: hsl(var(--primary));
background: hsl(var(--primary) / 0.1);
border-color: hsl(var(--primary) / 0.25);
}
.ai-pick-logo-fallback {
font-size: 15px;
font-weight: 600;
color: hsl(var(--muted-foreground));
}
.ai-copy-card {
border: 1px solid hsl(var(--border));
border-radius: 10px;
padding: 14px 16px;
background: hsl(var(--card));
}
.ai-mr-fields :deep(.ant-checkbox-wrapper) {
margin-inline-start: 0;
color: hsl(var(--foreground));
}
</style>