feat: ai agent部分功能
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
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
This commit is contained in:
@@ -68,6 +68,8 @@ const emit = defineEmits<{
|
||||
child_process_rule_id?: number;
|
||||
process_rule_note_id?: number;
|
||||
rule_type?: number;
|
||||
/** P4:本次导入来源的 AI 生成记录 ID,随开方提交回传做采纳率统计 */
|
||||
generation_id?: number;
|
||||
},
|
||||
): void;
|
||||
(e: 'search-drug', keyword: string): void;
|
||||
@@ -91,6 +93,13 @@ const activeId = ref(0);
|
||||
const matchedList = ref<any[]>([]);
|
||||
const unmatchedList = ref<any[]>([]);
|
||||
const conflictMessages = ref<string[]>([]);
|
||||
/**
|
||||
* P2 安全校验提示(后端 safety_flags):
|
||||
* 十八反/孕妇禁忌/剂量超限逐条标红展示,医生自行决断(只提示不拦截)
|
||||
*/
|
||||
const safetyFlags = ref<
|
||||
Array<{ drug?: string; level: string; message: string; ref?: string; type: string }>
|
||||
>([]);
|
||||
const rxDosage = ref(0);
|
||||
const rxDayDosage = ref(0);
|
||||
const rxReason = ref('');
|
||||
@@ -223,6 +232,7 @@ function resetPreview() {
|
||||
matchedList.value = [];
|
||||
unmatchedList.value = [];
|
||||
conflictMessages.value = [];
|
||||
safetyFlags.value = [];
|
||||
rxDosage.value = 0;
|
||||
rxDayDosage.value = 0;
|
||||
rxReason.value = '';
|
||||
@@ -416,6 +426,37 @@ function applyConflict(conflict: any) {
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析后端 safety_flags 结构 { has_risk, flags: [{type, level, drug, message, ref}] }
|
||||
* 有 safety_flags 时优先展示统一安全块(含十八反),老记录没有该字段则回落旧配伍提示条
|
||||
*/
|
||||
function applySafetyFlags(safety: any) {
|
||||
const flags = Array.isArray(safety?.flags) ? safety.flags : [];
|
||||
safetyFlags.value = flags
|
||||
.map((f: any) => ({
|
||||
type: String(f?.type || ''),
|
||||
level: String(f?.level || 'info'),
|
||||
drug: String(f?.drug || ''),
|
||||
message: String(f?.message || '').trim(),
|
||||
ref: String(f?.ref || '').trim(),
|
||||
}))
|
||||
.filter((f: any) => f.message);
|
||||
}
|
||||
|
||||
/** 安全提示分级样式(error红/warning橙/info灰,主题变量适配暗色) */
|
||||
function safetyLevelClass(level: string) {
|
||||
if (level === 'error') return 'text-red-500';
|
||||
if (level === 'warning') return 'text-orange-500';
|
||||
return 'text-muted-foreground';
|
||||
}
|
||||
|
||||
/** 安全提示分级标签文本 */
|
||||
function safetyLevelLabel(level: string) {
|
||||
if (level === 'error') return '禁忌';
|
||||
if (level === 'warning') return '警示';
|
||||
return '参考';
|
||||
}
|
||||
|
||||
function applyMatch(match: any) {
|
||||
const matched = Array.isArray(match?.matched) ? match.matched : [];
|
||||
matchedList.value = matched.map((m: any) => ({
|
||||
@@ -604,6 +645,7 @@ async function runGenerateInDrawer(chief: string) {
|
||||
activeId.value = Number(data?.generation_id || 0);
|
||||
applyMatch(data?.match || null);
|
||||
applyConflict(data?.conflict || null);
|
||||
applySafetyFlags(data?.safety_flags || null);
|
||||
applyRxMeta(data);
|
||||
message.success(
|
||||
durationText ? `已生成(耗时 ${durationText})` : '已生成,请确认导入',
|
||||
@@ -632,6 +674,7 @@ async function onSelectHistory(row: any) {
|
||||
});
|
||||
applyMatch(data);
|
||||
applyConflict(data?.conflict || null);
|
||||
applySafetyFlags(data?.safety_flags || null);
|
||||
applyRxMeta({
|
||||
...data,
|
||||
duration_ms: row.duration_ms,
|
||||
@@ -642,6 +685,7 @@ async function onSelectHistory(row: any) {
|
||||
matchedList.value = [];
|
||||
unmatchedList.value = [];
|
||||
conflictMessages.value = [];
|
||||
safetyFlags.value = [];
|
||||
message.error(e?.message || e?.msg || '对照失败');
|
||||
} finally {
|
||||
matchLoading.value = false;
|
||||
@@ -718,9 +762,14 @@ function onConfirmImport() {
|
||||
process_rule_id?: number;
|
||||
child_process_rule_id?: number;
|
||||
process_rule_note_id?: number;
|
||||
generation_id?: number;
|
||||
} = {
|
||||
rows: drugs,
|
||||
};
|
||||
// P4:带上生成记录 ID,父页开方提交时透传给后端做采纳率/修改率统计
|
||||
if (activeId.value > 0) {
|
||||
importPayload.generation_id = activeId.value;
|
||||
}
|
||||
// 中药导入必须带剂数/每日次数,缺省用处方页默认 7/2
|
||||
if (isTcmRx.value) {
|
||||
importPayload.dosage = rxDosage.value > 0 ? rxDosage.value : 7;
|
||||
@@ -769,8 +818,26 @@ defineExpose({
|
||||
{{ typeLabel }}
|
||||
</div>
|
||||
<AiDisclaimerBanner class="shrink-0" :text="disclaimerText" />
|
||||
<!-- P2 安全校验统一提示块:十八反/孕妇禁忌/剂量超限逐条分级标红(只提示,医生决断) -->
|
||||
<div
|
||||
v-if="conflictMessages.length"
|
||||
v-if="safetyFlags.length"
|
||||
class="shrink-0 rounded-lg border border-red-500/35 bg-red-500/5 px-3 py-2 text-xs"
|
||||
>
|
||||
<div class="mb-1 font-medium text-red-500">安全校验提示(请人工核对)</div>
|
||||
<div v-for="(f, i) in safetyFlags" :key="i" class="flex items-start gap-1.5 leading-5">
|
||||
<span
|
||||
class="shrink-0 font-medium"
|
||||
:class="safetyLevelClass(f.level)"
|
||||
>[{{ safetyLevelLabel(f.level) }}]</span>
|
||||
<span :class="f.level === 'info' ? 'text-muted-foreground' : ''">
|
||||
{{ f.message }}
|
||||
<span v-if="f.ref" class="text-muted-foreground">(依据:{{ f.ref }})</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 老记录(未落 safety_flags 字段)回落旧配伍提示条 -->
|
||||
<div
|
||||
v-else-if="conflictMessages.length"
|
||||
class="shrink-0 rounded-lg border border-orange-500/35 bg-orange-500/10 px-3 py-2 text-xs"
|
||||
>
|
||||
<div class="mb-1 font-medium">配伍禁忌提示</div>
|
||||
|
||||
@@ -871,6 +871,8 @@ const selectPatient = async (patient: Patient, isUpdateTabType = true) => {
|
||||
|
||||
// 存纯数字字符串,避免 JSON.stringify 造成读取歧义
|
||||
localStorage.setItem(`doctorReception-id`, String(patient.id));
|
||||
// 切换患者后 AI 采纳关联失效,重置避免跨患者误关联
|
||||
aiRxGenerationId.value = 0;
|
||||
getCurrentDrugs();
|
||||
};
|
||||
|
||||
@@ -1082,6 +1084,8 @@ const updateLocalStorage = () => {
|
||||
};
|
||||
|
||||
const currentDrugs = ref([]);
|
||||
/** P4:最近一次 AI 导入的生成记录 ID,发处方时回传后端做采纳率/修改率统计(清空/切换/发送后重置) */
|
||||
const aiRxGenerationId = ref(0);
|
||||
/**
|
||||
* 获取缓存的处方信息(按类型分开存储)
|
||||
*/
|
||||
@@ -1205,8 +1209,12 @@ const sendPrescription = () => {
|
||||
salesperson_transfer_prescription_id: salespersonTransferPrescriptionId.value || undefined,
|
||||
price_discount: priceDiscount.value,
|
||||
special_prescription_id: appliedSpecialPrescriptionId.value || 0,
|
||||
// P4:AI 导入来源生成 ID,后端据此统计采纳率/修改率(非 AI 导入时为 undefined 不传)
|
||||
ai_generation_id: aiRxGenerationId.value || undefined,
|
||||
}).then(async (res) => {
|
||||
message.success('处方已发送');
|
||||
// 处方已提交,重置 AI 关联,避免下一张处方误关联
|
||||
aiRxGenerationId.value = 0;
|
||||
// 有 VIP 时同步保存病历(诊断/医嘱已与处方同源)
|
||||
if (canUseMedicalRecord.value) {
|
||||
try {
|
||||
@@ -1551,6 +1559,7 @@ async function handleImportAiPrescription(payload: {
|
||||
process_rule_id?: number;
|
||||
child_process_rule_id?: number;
|
||||
process_rule_note_id?: number;
|
||||
generation_id?: number;
|
||||
}) {
|
||||
if (guardSpecialPrescriptionCartEdit()) return;
|
||||
const list = Array.isArray(payload?.rows) ? payload.rows : [];
|
||||
@@ -1661,6 +1670,8 @@ async function handleImportAiPrescription(payload: {
|
||||
return;
|
||||
}
|
||||
currentDrugs.value = next;
|
||||
// P4:记录 AI 生成 ID(金方导入等无 ID 场景会重置为 0,避免误关联)
|
||||
aiRxGenerationId.value = Number(payload.generation_id || 0);
|
||||
if (activeCategory.value === 1) {
|
||||
// 中药剂数/每日次数:有值用返回值,否则保留处方页默认 7/2
|
||||
dosage.value = Number(payload.dosage) > 0 ? Number(payload.dosage) : 7;
|
||||
@@ -2644,6 +2655,8 @@ watch(
|
||||
*/
|
||||
function clearPrescriptionCart(showToast = true) {
|
||||
currentDrugs.value = [];
|
||||
// 处方清空后 AI 导入内容已不存在,解除采纳关联
|
||||
aiRxGenerationId.value = 0;
|
||||
diagnosis.value = '';
|
||||
medicalAdvice.value = '';
|
||||
packageMethodId.value = 2;
|
||||
|
||||
@@ -143,6 +143,8 @@ const form = reactive(emptyMedicalRecord());
|
||||
const loading = ref(false);
|
||||
/** 加载中不写本地,避免接口回填触发草稿覆盖 */
|
||||
const hydrating = ref(false);
|
||||
/** P4:最近一次导入的 AI 生成记录 ID,保存病历时回传后端做采纳率/修改率统计(换号/清空重置) */
|
||||
const aiMrGenerationId = ref(0);
|
||||
/** 系统配置:常用|我的展示位置,默认标签 */
|
||||
const commonDisplayMode = ref<MrCommonDisplayMode>('tags');
|
||||
const showCommonTags = computed(
|
||||
@@ -471,6 +473,8 @@ async function loadRecord() {
|
||||
watch(
|
||||
() => props.registerId,
|
||||
(id) => {
|
||||
// 换号后 AI 采纳关联失效,重置避免误关联到别的挂号
|
||||
aiMrGenerationId.value = 0;
|
||||
if (id) loadRecord();
|
||||
},
|
||||
{ immediate: true },
|
||||
@@ -725,6 +729,8 @@ async function handleSave() {
|
||||
medicalAdvice: medicalAdviceModel.value,
|
||||
// 后端按此开关做主诉必填拦截
|
||||
validate_required: 1,
|
||||
// P4:AI 导入来源生成 ID,后端据此统计采纳率/修改率
|
||||
ai_generation_id: aiMrGenerationId.value || undefined,
|
||||
});
|
||||
persistLocalDraft();
|
||||
message.success('病历已保存');
|
||||
@@ -767,7 +773,10 @@ function handleImportAiMedicalRecord(payload: {
|
||||
method_id?: number | null;
|
||||
syndrome_id?: number | null;
|
||||
} | null;
|
||||
generation_id?: number;
|
||||
}) {
|
||||
// P4:记录本次导入来源的生成 ID,保存病历时回传做采纳率统计
|
||||
aiMrGenerationId.value = Number(payload?.generation_id || 0);
|
||||
const fields = payload?.fields || {};
|
||||
Object.keys(fields).forEach((k) => {
|
||||
if (k === 'chief_complaint' || k === 'name') return;
|
||||
@@ -845,6 +854,8 @@ async function handleClear() {
|
||||
const keepPatient = effectivePatientId.value;
|
||||
Object.assign(form, emptyMedicalRecord(props.registerId, props.storeId));
|
||||
form.user_patient_id = keepPatient;
|
||||
// 病历清空后 AI 内容已不存在,解除采纳关联
|
||||
aiMrGenerationId.value = 0;
|
||||
emit('update:diagnosis', '');
|
||||
emit('update:medicalAdvice', '');
|
||||
clearMedicalRecordDraft(`${props.storagePrefix || ''}${props.registerId}`);
|
||||
@@ -902,6 +913,8 @@ defineExpose({
|
||||
user_patient_id: effectivePatientId.value,
|
||||
diagnosis: diagnosisModel.value,
|
||||
doctor_order: medicalAdviceModel.value,
|
||||
// P4:随发处方同步保存病历的链路也带上 AI 生成 ID
|
||||
ai_generation_id: aiMrGenerationId.value || undefined,
|
||||
}),
|
||||
/** 外部局部回写(如 AI 出方预览点改) */
|
||||
patchFields,
|
||||
|
||||
@@ -96,6 +96,8 @@ const emit = defineEmits<{
|
||||
method_id?: number | null;
|
||||
syndrome_id?: number | null;
|
||||
} | null;
|
||||
/** P4:本次导入来源的 AI 生成记录 ID,保存病历时回传做采纳率统计 */
|
||||
generation_id?: number;
|
||||
},
|
||||
): void;
|
||||
/** 生成弹窗里改字段时即时回写病历面板(与 AI 出方 sync-medical-record 同协议) */
|
||||
@@ -127,6 +129,33 @@ const previewChief = ref('');
|
||||
const lastDurationMs = ref(0);
|
||||
/** 醒目提示文案(系统配置 / 门闸下发) */
|
||||
const disclaimerText = ref('');
|
||||
/**
|
||||
* P2 病历质量提示(后端 quality_flags):
|
||||
* 必填空缺/主诉-诊断不自洽/辨证链路不完整,规则级提示医生人工核对
|
||||
*/
|
||||
const qualityFlags = ref<
|
||||
Array<{ field?: string; level: string; message: string; type: string }>
|
||||
>([]);
|
||||
|
||||
/** 解析后端 quality_flags 结构(结构异常时静默为空,不影响预览) */
|
||||
function applyQualityFlags(quality: any) {
|
||||
const flags = Array.isArray(quality?.flags) ? quality.flags : [];
|
||||
qualityFlags.value = flags
|
||||
.map((f: any) => ({
|
||||
type: String(f?.type || ''),
|
||||
level: String(f?.level || 'info'),
|
||||
field: String(f?.field || ''),
|
||||
message: String(f?.message || '').trim(),
|
||||
}))
|
||||
.filter((f: any) => f.message);
|
||||
}
|
||||
|
||||
/** 质量提示分级样式(warning橙/info灰,主题变量适配暗色) */
|
||||
function qualityLevelClass(level: string) {
|
||||
if (level === 'error') return 'text-red-500';
|
||||
if (level === 'warning') return 'text-orange-500';
|
||||
return 'text-muted-foreground';
|
||||
}
|
||||
|
||||
const sexLabel = computed(() => {
|
||||
const s = Number(patientSex.value);
|
||||
@@ -419,6 +448,7 @@ async function onSelectHistory(row: any) {
|
||||
const fields = (data?.fields || {}) as Record<string, string>;
|
||||
previewFields.value = { ...fields };
|
||||
previewTcmIds.value = (data?.tcm_ids || null) as typeof previewTcmIds.value;
|
||||
applyQualityFlags(data?.quality_flags || null);
|
||||
const snap = data?.input_snapshot || {};
|
||||
previewChief.value = String(
|
||||
snap.chief_complaint || chiefComplaint.value || '',
|
||||
@@ -428,6 +458,7 @@ async function onSelectHistory(row: any) {
|
||||
previewFields.value = {};
|
||||
previewTcmIds.value = null;
|
||||
previewChief.value = '';
|
||||
qualityFlags.value = [];
|
||||
message.error(e?.message || e?.msg || '加载详情失败');
|
||||
} finally {
|
||||
detailLoading.value = false;
|
||||
@@ -458,6 +489,7 @@ function beginGeneratingPlaceholder() {
|
||||
activeId.value = PENDING_GEN_ID;
|
||||
previewFields.value = {};
|
||||
previewTcmIds.value = null;
|
||||
qualityFlags.value = [];
|
||||
lastDurationMs.value = 0;
|
||||
const rest = (historyList.value || []).filter(
|
||||
(r) => Number(r?.id) !== PENDING_GEN_ID && !r?._pending,
|
||||
@@ -509,6 +541,7 @@ async function runGenerateInDrawer(chief: string) {
|
||||
}
|
||||
previewFields.value = (data?.fields || {}) as Record<string, string>;
|
||||
previewTcmIds.value = (data?.tcm_ids || null) as typeof previewTcmIds.value;
|
||||
applyQualityFlags(data?.quality_flags || null);
|
||||
await loadHistory();
|
||||
activeId.value = Number(data?.generation_id || 0);
|
||||
message.success(
|
||||
@@ -518,6 +551,7 @@ async function runGenerateInDrawer(chief: string) {
|
||||
endGeneratingPlaceholder();
|
||||
previewFields.value = {};
|
||||
previewTcmIds.value = null;
|
||||
qualityFlags.value = [];
|
||||
message.error(e?.message || e?.msg || 'AI写病历失败');
|
||||
} finally {
|
||||
generating.value = false;
|
||||
@@ -533,6 +567,8 @@ function onConfirmImport() {
|
||||
chief_complaint: String(previewChief.value || chiefComplaint.value || '').trim(),
|
||||
fields: { ...previewFields.value },
|
||||
tcm_ids: previewTcmIds.value || null,
|
||||
// P4:带上生成记录 ID,病历保存时透传后端做采纳率/修改率统计
|
||||
generation_id: activeId.value > 0 ? activeId.value : undefined,
|
||||
});
|
||||
drawerApi.close();
|
||||
}
|
||||
@@ -560,6 +596,19 @@ defineExpose({
|
||||
{{ patientName || '—' }} · {{ sexLabel }} · {{ patientAge || '—' }}岁
|
||||
</div>
|
||||
<AiDisclaimerBanner class="shrink-0" :text="disclaimerText" />
|
||||
<!-- P2 病历质量提示:规则级检查(必填空缺/自洽性/辨证链路),医生人工核对 -->
|
||||
<div
|
||||
v-if="qualityFlags.length && !generating"
|
||||
class="shrink-0 rounded-lg border border-orange-500/35 bg-orange-500/5 px-3 py-2 text-xs"
|
||||
>
|
||||
<div class="mb-1 font-medium text-orange-500">质量提示(请人工核对)</div>
|
||||
<div v-for="(f, i) in qualityFlags" :key="i" class="leading-5">
|
||||
<span class="font-medium" :class="qualityLevelClass(f.level)">·</span>
|
||||
<span :class="f.level === 'info' ? 'text-muted-foreground' : ''">
|
||||
{{ f.message }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex min-h-0 flex-1 gap-3 overflow-hidden">
|
||||
<div class="w-44 shrink-0 overflow-y-auto border-r border-border pr-2">
|
||||
<div class="mb-2 text-sm font-medium">历史记录</div>
|
||||
|
||||
70
apps/web-antd/src/views/system/ai-shadow/api/index.ts
Normal file
70
apps/web-antd/src/views/system/ai-shadow/api/index.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
const prefix = 'ai-generation/';
|
||||
|
||||
export interface ShadowCompareRow {
|
||||
id: number;
|
||||
store_id: number;
|
||||
register_id: number;
|
||||
doctor_id: number;
|
||||
scene: string;
|
||||
prescription_type: number;
|
||||
primary_generation_id: number;
|
||||
primary_provider: string;
|
||||
primary_model: string;
|
||||
primary_tokens: number;
|
||||
primary_duration_ms: number;
|
||||
primary_status: number;
|
||||
shadow_generation_id: number;
|
||||
shadow_provider: string;
|
||||
shadow_model: string;
|
||||
shadow_tokens: number;
|
||||
shadow_duration_ms: number;
|
||||
shadow_status: number;
|
||||
content_diff_hash: string;
|
||||
content_similarity: number;
|
||||
quality_score: number;
|
||||
remark: string;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
export interface ShadowListResult {
|
||||
list: ShadowCompareRow[];
|
||||
total: number;
|
||||
page: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface ShadowStats {
|
||||
count: number;
|
||||
avg_primary_tokens: number;
|
||||
avg_shadow_tokens: number;
|
||||
avg_primary_duration_ms: number;
|
||||
avg_shadow_duration_ms: number;
|
||||
primary_success_rate: number;
|
||||
shadow_success_rate: number;
|
||||
avg_content_similarity: number;
|
||||
}
|
||||
|
||||
export interface ShadowListParams {
|
||||
store_id?: number;
|
||||
scene?: string;
|
||||
primary_provider?: string;
|
||||
shadow_provider?: string;
|
||||
date_start?: number;
|
||||
date_end?: number;
|
||||
page?: number;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export async function getShadowList(params: ShadowListParams) {
|
||||
return requestClient.get<ShadowListResult>(`${prefix}shadow-list`, { params });
|
||||
}
|
||||
|
||||
export async function getShadowDetail(id: number) {
|
||||
return requestClient.get<ShadowCompareRow>(`${prefix}shadow-detail`, { params: { id } });
|
||||
}
|
||||
|
||||
export async function getShadowStats(params: Partial<ShadowListParams>) {
|
||||
return requestClient.get<ShadowStats>(`${prefix}shadow-stats`, { params });
|
||||
}
|
||||
23
apps/web-antd/src/views/system/ai-shadow/config/constants.ts
Normal file
23
apps/web-antd/src/views/system/ai-shadow/config/constants.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* 影子流量对比模块常量
|
||||
* 场景 / 供应商选项集中维护,避免在 schema 与列配置中散落字面量
|
||||
*/
|
||||
|
||||
/** 场景选项(与后端 xk_ai_shadow_compare.scene 取值对齐) */
|
||||
export const SHADOW_SCENE_OPTIONS = [
|
||||
{ label: '病历', value: 'medical_record' },
|
||||
{ label: '处方', value: 'prescription' },
|
||||
];
|
||||
|
||||
/** 供应商选项(主链路 / 影子链路共用) */
|
||||
export const SHADOW_PROVIDER_OPTIONS = [
|
||||
{ label: 'DeepSeek', value: 'deepseek' },
|
||||
{ label: '讯飞星火', value: 'spark' },
|
||||
{ label: 'Go Agent', value: 'agent' },
|
||||
];
|
||||
|
||||
/** 场景 code 转中文展示名(列表列用) */
|
||||
export function shadowSceneLabel(scene: string): string {
|
||||
const hit = SHADOW_SCENE_OPTIONS.find((o) => o.value === scene);
|
||||
return hit ? hit.label : scene || '-';
|
||||
}
|
||||
105
apps/web-antd/src/views/system/ai-shadow/config/search.ts
Normal file
105
apps/web-antd/src/views/system/ai-shadow/config/search.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import type { VbenFormProps } from '#/adapter/form';
|
||||
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import { getStoreOption } from '#/views/system/store/api';
|
||||
|
||||
import { SHADOW_PROVIDER_OPTIONS, SHADOW_SCENE_OPTIONS } from './constants';
|
||||
|
||||
/**
|
||||
* 影子流量对比搜索表单
|
||||
* 诊所用 ApiSelect(复用 getStoreOption),时间范围映射为 date_start/date_end 时间戳
|
||||
*/
|
||||
export const formOptions: VbenFormProps = {
|
||||
collapsed: false,
|
||||
schema: [
|
||||
{
|
||||
component: 'ApiSelect',
|
||||
componentProps: {
|
||||
allowClear: true,
|
||||
showSearch: true,
|
||||
filterOption: true,
|
||||
afterFetch: (data: { id: number; name: string }[]) => {
|
||||
return (data || []).map((item: any) => ({
|
||||
label: `${item.name}【${item.id}】`,
|
||||
value: item.id,
|
||||
}));
|
||||
},
|
||||
api: getStoreOption,
|
||||
placeholder: '请选择诊所',
|
||||
},
|
||||
defaultValue: undefined,
|
||||
fieldName: 'store_id',
|
||||
label: '诊所',
|
||||
},
|
||||
{
|
||||
component: 'VbenSelect',
|
||||
componentProps: {
|
||||
placeholder: '全部场景',
|
||||
allowClear: true,
|
||||
options: SHADOW_SCENE_OPTIONS,
|
||||
},
|
||||
defaultValue: undefined,
|
||||
fieldName: 'scene',
|
||||
label: '场景',
|
||||
},
|
||||
{
|
||||
component: 'VbenSelect',
|
||||
componentProps: {
|
||||
placeholder: '全部',
|
||||
allowClear: true,
|
||||
options: SHADOW_PROVIDER_OPTIONS,
|
||||
},
|
||||
defaultValue: undefined,
|
||||
fieldName: 'primary_provider',
|
||||
label: '主链路',
|
||||
},
|
||||
{
|
||||
component: 'VbenSelect',
|
||||
componentProps: {
|
||||
placeholder: '全部',
|
||||
allowClear: true,
|
||||
options: SHADOW_PROVIDER_OPTIONS,
|
||||
},
|
||||
defaultValue: undefined,
|
||||
fieldName: 'shadow_provider',
|
||||
label: '影子链路',
|
||||
},
|
||||
{
|
||||
component: 'RangePicker',
|
||||
componentProps: {
|
||||
format: 'YYYY-MM-DD',
|
||||
valueFormat: 'YYYY-MM-DD',
|
||||
},
|
||||
defaultValue: undefined,
|
||||
fieldName: 'search_time',
|
||||
label: '时间范围',
|
||||
},
|
||||
],
|
||||
showCollapseButton: true,
|
||||
submitButtonOptions: {
|
||||
content: '查询',
|
||||
},
|
||||
submitOnChange: true,
|
||||
submitOnEnter: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* 将表单值转为接口参数:search_time → date_start/date_end(当天起止秒级时间戳)
|
||||
* 后端 AiShadowCompareService::listCompare 按 created_at 时间戳过滤
|
||||
*/
|
||||
export function normalizeShadowFilters(formValues: Record<string, any> = {}) {
|
||||
const params: Record<string, any> = { ...formValues };
|
||||
const range = params.search_time;
|
||||
delete params.search_time;
|
||||
if (Array.isArray(range) && range.length === 2 && range[0] && range[1]) {
|
||||
params.date_start = dayjs(range[0]).startOf('day').unix();
|
||||
params.date_end = dayjs(range[1]).endOf('day').unix();
|
||||
}
|
||||
Object.keys(params).forEach((k) => {
|
||||
if (params[k] === '' || params[k] === undefined || params[k] === null) {
|
||||
delete params[k];
|
||||
}
|
||||
});
|
||||
return params;
|
||||
}
|
||||
114
apps/web-antd/src/views/system/ai-shadow/config/table.ts
Normal file
114
apps/web-antd/src/views/system/ai-shadow/config/table.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import type { VxeGridProps } from '#/adapter/vxe-table';
|
||||
|
||||
import type { ShadowCompareRow } from '../api';
|
||||
|
||||
import { getShadowList } from '../api';
|
||||
import { shadowSceneLabel } from './constants';
|
||||
import { normalizeShadowFilters } from './search';
|
||||
|
||||
/**
|
||||
* 影子流量对比表格配置(只读,无操作列)
|
||||
*
|
||||
* 用工厂函数而不是直接导出 gridOptions:
|
||||
* 顶部统计看板要与列表用同一套筛选条件(诊所/场景/时间范围),
|
||||
* 每次 Grid 发起查询时通过 onQueried 回调把归一化后的参数抛给页面,
|
||||
* 页面据此同步刷新 getShadowStats——单一数据源,避免两套筛选状态漂移
|
||||
*/
|
||||
export function createGridOptions(
|
||||
onQueried?: (params: Record<string, any>) => void,
|
||||
): VxeGridProps<ShadowCompareRow> {
|
||||
return {
|
||||
columnConfig: {
|
||||
useKey: true,
|
||||
},
|
||||
rowConfig: {
|
||||
useKey: true,
|
||||
},
|
||||
columns: [
|
||||
{ field: 'id', align: 'left', title: 'ID', width: 80 },
|
||||
{
|
||||
field: 'scene',
|
||||
align: 'left',
|
||||
title: '场景',
|
||||
width: 90,
|
||||
formatter: ({ cellValue }) => shadowSceneLabel(String(cellValue)),
|
||||
},
|
||||
{ field: 'store_id', align: 'left', title: '门店ID', width: 90 },
|
||||
{
|
||||
title: '主链路(PHP 直连)',
|
||||
align: 'center',
|
||||
children: [
|
||||
{ field: 'primary_provider', align: 'left', title: 'Provider', width: 100 },
|
||||
{ field: 'primary_tokens', align: 'right', title: 'Token', width: 90 },
|
||||
{ field: 'primary_duration_ms', align: 'right', title: '耗时(ms)', width: 100 },
|
||||
{
|
||||
field: 'primary_status',
|
||||
align: 'left',
|
||||
title: '状态',
|
||||
width: 90,
|
||||
slots: { default: 'primary_status' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '影子链路(Go Agent)',
|
||||
align: 'center',
|
||||
children: [
|
||||
{ field: 'shadow_provider', align: 'left', title: 'Provider', width: 100 },
|
||||
{ field: 'shadow_tokens', align: 'right', title: 'Token', width: 90 },
|
||||
{ field: 'shadow_duration_ms', align: 'right', title: '耗时(ms)', width: 100 },
|
||||
{
|
||||
field: 'shadow_status',
|
||||
align: 'left',
|
||||
title: '状态',
|
||||
width: 90,
|
||||
slots: { default: 'shadow_status' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
field: 'content_similarity',
|
||||
align: 'right',
|
||||
title: '内容相似度',
|
||||
width: 110,
|
||||
slots: { default: 'similarity' },
|
||||
},
|
||||
{ field: 'remark', align: 'left', title: '备注', minWidth: 160, showOverflow: true },
|
||||
{ field: 'created_at', align: 'left', title: '对比时间', width: 170 },
|
||||
],
|
||||
keepSource: true,
|
||||
pagerConfig: {},
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({ page }, formValues) => {
|
||||
const filters = normalizeShadowFilters(formValues || {});
|
||||
// 统计看板与列表同步刷新(同一套筛选条件)
|
||||
onQueried?.(filters);
|
||||
const res = await getShadowList({
|
||||
page: page.currentPage,
|
||||
size: page.pageSize,
|
||||
...filters,
|
||||
});
|
||||
// 后端返回 {list,total,page,size},适配器约定 {items,total}
|
||||
return {
|
||||
items: (res as any)?.list ?? [],
|
||||
total: (res as any)?.total ?? 0,
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
height: 'auto',
|
||||
border: false,
|
||||
toolbarConfig: {
|
||||
search: true,
|
||||
refresh: true,
|
||||
print: false,
|
||||
export: false,
|
||||
zoom: true,
|
||||
custom: {
|
||||
icon: 'vxe-icon-menu',
|
||||
},
|
||||
},
|
||||
showOverflow: true,
|
||||
};
|
||||
}
|
||||
150
apps/web-antd/src/views/system/ai-shadow/index.vue
Normal file
150
apps/web-antd/src/views/system/ai-shadow/index.vue
Normal file
@@ -0,0 +1,150 @@
|
||||
<script lang="ts" setup>
|
||||
import type { ShadowStats } from './api';
|
||||
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { Page } from '@vben/common-ui';
|
||||
|
||||
import { Statistic, Tag } from 'ant-design-vue';
|
||||
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
|
||||
import { getShadowStats } from './api';
|
||||
import { formOptions } from './config/search';
|
||||
import { createGridOptions } from './config/table';
|
||||
|
||||
/**
|
||||
* AI 影子流量对比页
|
||||
*
|
||||
* 开启影子流量(ai_shadow_traffic_enabled=1)后,主链路(PHP 直连厂商)正常返回用户,
|
||||
* 同时按比例异步复制请求给 Go Agent;本页展示两条链路的 token/耗时/成功率/内容相似度对比,
|
||||
* 运维据此决定是否放量切换到 Agent 链路。只读页面,无增删改。
|
||||
*/
|
||||
defineOptions({ name: 'AiShadowCompare' });
|
||||
|
||||
/** 顶部聚合统计(与列表共用同一套筛选条件,Grid 每次查询时同步刷新) */
|
||||
const stats = ref<ShadowStats>({
|
||||
count: 0,
|
||||
avg_primary_tokens: 0,
|
||||
avg_shadow_tokens: 0,
|
||||
avg_primary_duration_ms: 0,
|
||||
avg_shadow_duration_ms: 0,
|
||||
primary_success_rate: 0,
|
||||
shadow_success_rate: 0,
|
||||
avg_content_similarity: 0,
|
||||
});
|
||||
|
||||
/** 上次统计请求的筛选条件快照:翻页时条件不变,跳过重复的 shadow-stats 请求 */
|
||||
let lastStatsKey = '';
|
||||
|
||||
/**
|
||||
* 按当前筛选条件刷新聚合统计
|
||||
* 后端 shadowStats 只支持 store_id/scene/date_start/date_end 四个过滤维度;
|
||||
* Grid 每次查询(含翻页)都会回调进来,用条件快照去重,只有筛选变化才真正请求
|
||||
*/
|
||||
async function loadStats(params: Record<string, any>) {
|
||||
const query = {
|
||||
store_id: params.store_id,
|
||||
scene: params.scene,
|
||||
date_start: params.date_start,
|
||||
date_end: params.date_end,
|
||||
};
|
||||
const key = JSON.stringify(query);
|
||||
if (key === lastStatsKey) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await getShadowStats(query);
|
||||
if (res) {
|
||||
stats.value = res as unknown as ShadowStats;
|
||||
// 成功后才记快照:失败时下次查询(如翻页)还有机会重试拉到统计
|
||||
lastStatsKey = key;
|
||||
}
|
||||
} catch {
|
||||
// 统计失败不阻塞列表展示(列表由 Grid proxyConfig 独立请求)
|
||||
}
|
||||
}
|
||||
|
||||
const [Grid] = useVbenVxeGrid({
|
||||
formOptions,
|
||||
gridOptions: createGridOptions(loadStats),
|
||||
});
|
||||
|
||||
/** 状态码转 Tag 颜色与文案:1=成功 2=失败 其余=进行中 */
|
||||
function statusInfo(status: number): { color: string; text: string } {
|
||||
if (status === 1) return { color: 'success', text: '成功' };
|
||||
if (status === 2) return { color: 'error', text: '失败' };
|
||||
return { color: 'processing', text: '进行中' };
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height title="AI 影子流量对比">
|
||||
<!-- 顶部统计看板:颜色走主题变量,亮/暗色自动适配 -->
|
||||
<div
|
||||
class="mb-4 grid grid-cols-2 gap-4 rounded border border-solid border-[hsl(var(--border))] bg-[hsl(var(--card,var(--background)))] p-4 md:grid-cols-4"
|
||||
>
|
||||
<Statistic :value="stats.count" title="对比次数" />
|
||||
<Statistic
|
||||
:value="stats.avg_content_similarity"
|
||||
suffix="%"
|
||||
title="平均内容相似度"
|
||||
/>
|
||||
<Statistic
|
||||
:precision="0"
|
||||
:value="stats.avg_primary_tokens"
|
||||
title="主链路平均 Token"
|
||||
/>
|
||||
<Statistic
|
||||
:precision="0"
|
||||
:value="stats.avg_shadow_tokens"
|
||||
title="影子链路平均 Token"
|
||||
/>
|
||||
<Statistic
|
||||
:value="stats.avg_primary_duration_ms"
|
||||
title="主链路平均耗时(ms)"
|
||||
/>
|
||||
<Statistic
|
||||
:value="stats.avg_shadow_duration_ms"
|
||||
title="影子链路平均耗时(ms)"
|
||||
/>
|
||||
<Statistic
|
||||
:value="stats.primary_success_rate"
|
||||
suffix="%"
|
||||
title="主链路成功率"
|
||||
/>
|
||||
<Statistic
|
||||
:value="stats.shadow_success_rate"
|
||||
suffix="%"
|
||||
title="影子链路成功率"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Grid>
|
||||
<template #primary_status="{ row }">
|
||||
<Tag :color="statusInfo(row.primary_status).color">
|
||||
{{ statusInfo(row.primary_status).text }}
|
||||
</Tag>
|
||||
</template>
|
||||
<template #shadow_status="{ row }">
|
||||
<Tag :color="statusInfo(row.shadow_status).color">
|
||||
{{ statusInfo(row.shadow_status).text }}
|
||||
</Tag>
|
||||
</template>
|
||||
<!-- 相似度:70+ 视为高度一致,50 以下通常意味着 Agent 改写了输出 -->
|
||||
<template #similarity="{ row }">
|
||||
<Tag
|
||||
:color="
|
||||
row.content_similarity >= 70
|
||||
? 'success'
|
||||
: row.content_similarity >= 50
|
||||
? 'warning'
|
||||
: 'error'
|
||||
"
|
||||
>
|
||||
{{ row.content_similarity }}%
|
||||
</Tag>
|
||||
</template>
|
||||
</Grid>
|
||||
</Page>
|
||||
</template>
|
||||
@@ -17,6 +17,10 @@ interface RowType {
|
||||
duration_ms: number;
|
||||
error_msg: string;
|
||||
created_at: string;
|
||||
/** P4:采纳状态(0未采纳/1已采纳)与文案 */
|
||||
is_adopted: number;
|
||||
is_modified_final: number;
|
||||
adopted_txt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -60,6 +64,14 @@ export const gridOptions: VxeGridProps<RowType> = {
|
||||
width: 90,
|
||||
slots: { default: 'duration' },
|
||||
},
|
||||
{
|
||||
// P4:医生采纳情况(未采纳/直接采纳/改后采纳),直接采纳是质量最好的信号
|
||||
field: 'adopted_txt',
|
||||
align: 'left',
|
||||
title: '采纳',
|
||||
width: 96,
|
||||
slots: { default: 'adopted' },
|
||||
},
|
||||
{
|
||||
field: 'error_msg',
|
||||
align: 'left',
|
||||
|
||||
@@ -32,6 +32,13 @@ const stats = reactive({
|
||||
total_count: 0,
|
||||
success_count: 0,
|
||||
fail_count: 0,
|
||||
// P4 质量指标:采纳率/直接采纳率/修改率/药名未匹配率/人均重生成
|
||||
adopted_count: 0,
|
||||
adoption_rate: 0,
|
||||
direct_rate: 0,
|
||||
modified_rate: 0,
|
||||
unmatched_rate: 0,
|
||||
avg_regen: 0,
|
||||
});
|
||||
|
||||
/** 全量模型 option,选平台时按 platform_id 过滤 */
|
||||
@@ -56,6 +63,12 @@ async function refreshStats(formValues?: Record<string, any>) {
|
||||
stats.total_count = Number(data.total_count || 0);
|
||||
stats.success_count = Number(data.success_count || 0);
|
||||
stats.fail_count = Number(data.fail_count || 0);
|
||||
stats.adopted_count = Number(data.adopted_count || 0);
|
||||
stats.adoption_rate = Number(data.adoption_rate || 0);
|
||||
stats.direct_rate = Number(data.direct_rate || 0);
|
||||
stats.modified_rate = Number(data.modified_rate || 0);
|
||||
stats.unmatched_rate = Number(data.unmatched_rate || 0);
|
||||
stats.avg_regen = Number(data.avg_regen || 0);
|
||||
} catch {
|
||||
// 摘要失败不阻断列表
|
||||
}
|
||||
@@ -221,6 +234,61 @@ onMounted(async () => {
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
<!-- P4 质量指标行:度量 AI 生成质量,随筛选联动(如按场景/时间对比) -->
|
||||
<Row :gutter="12" class="mt-3">
|
||||
<Col :xs="12" :sm="8" :md="5">
|
||||
<Card size="small">
|
||||
<div class="text-xs text-[hsl(var(--muted-foreground))]">
|
||||
采纳率
|
||||
<span class="ml-1 opacity-70">已采纳/成功数</span>
|
||||
</div>
|
||||
<div class="mt-1 text-lg font-semibold">
|
||||
{{ stats.adoption_rate }}%
|
||||
<span class="ml-1 text-xs font-normal text-[hsl(var(--muted-foreground))]">
|
||||
{{ stats.adopted_count }}/{{ stats.success_count }}
|
||||
</span>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col :xs="12" :sm="8" :md="5">
|
||||
<Card size="small">
|
||||
<div class="text-xs text-[hsl(var(--muted-foreground))]">
|
||||
直接采纳率
|
||||
<span class="ml-1 opacity-70">未改原稿直接用</span>
|
||||
</div>
|
||||
<div class="mt-1 text-lg font-semibold text-[hsl(var(--primary))]">
|
||||
{{ stats.direct_rate }}%
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col :xs="12" :sm="8" :md="5">
|
||||
<Card size="small">
|
||||
<div class="text-xs text-[hsl(var(--muted-foreground))]">
|
||||
修改率
|
||||
<span class="ml-1 opacity-70">采纳但改过原稿</span>
|
||||
</div>
|
||||
<div class="mt-1 text-lg font-semibold">{{ stats.modified_rate }}%</div>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col :xs="12" :sm="8" :md="5">
|
||||
<Card size="small">
|
||||
<div class="text-xs text-[hsl(var(--muted-foreground))]">
|
||||
药名未匹配率
|
||||
<span class="ml-1 opacity-70">越低越好</span>
|
||||
</div>
|
||||
<div class="mt-1 text-lg font-semibold">{{ stats.unmatched_rate }}%</div>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col :xs="12" :sm="8" :md="4">
|
||||
<Card size="small">
|
||||
<div class="text-xs text-[hsl(var(--muted-foreground))]">
|
||||
人均重生成
|
||||
<span class="ml-1 opacity-70">>1 需关注</span>
|
||||
</div>
|
||||
<div class="mt-1 text-lg font-semibold">{{ stats.avg_regen }}</div>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
<Grid>
|
||||
<template #status="{ row }">
|
||||
@@ -236,6 +304,16 @@ onMounted(async () => {
|
||||
<template #duration="{ row }">
|
||||
{{ formatDuration(Number(row.duration_ms || 0)) }}
|
||||
</template>
|
||||
<template #adopted="{ row }">
|
||||
<!-- P4 采纳状态:直接采纳绿色(质量最好)、改后采纳蓝色、未采纳灰字 -->
|
||||
<Tag
|
||||
v-if="Number(row.is_adopted) === 1"
|
||||
:color="Number(row.is_modified_final) === 1 ? 'processing' : 'success'"
|
||||
>
|
||||
{{ row.adopted_txt }}
|
||||
</Tag>
|
||||
<span v-else class="text-xs text-[hsl(var(--muted-foreground))]">未采纳</span>
|
||||
</template>
|
||||
<template #action="{ row }">
|
||||
<TableAction
|
||||
:actions="[
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
*/
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
|
||||
import { Button, Checkbox, Input, Spin, Tabs, Tag, message } from 'ant-design-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 {
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
getAiPlatformConfigOptions,
|
||||
saveAiFeatureCopy,
|
||||
} from '#/views/system/ai/platform/api';
|
||||
import { saveSystemConfig } from '#/views/system/system-config/api';
|
||||
import { getSystemConfigList, saveSystemConfig } from '#/views/system/system-config/api';
|
||||
|
||||
/** 二级 Tab 持久化 */
|
||||
const SUB_TAB_STORAGE_KEY = 'system_config_ai_sub_tab';
|
||||
@@ -70,6 +70,36 @@ 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),
|
||||
);
|
||||
@@ -171,6 +201,176 @@ 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) {
|
||||
@@ -277,10 +477,13 @@ async function handleSaveCopies() {
|
||||
|
||||
onMounted(() => {
|
||||
const stored = localStorage.getItem(SUB_TAB_STORAGE_KEY);
|
||||
if (stored === 'model' || stored === 'copy' || stored === 'mr') {
|
||||
// 新增 advanced 二级 Tab 后,缓存值合法性扩展
|
||||
if (stored === 'model' || stored === 'copy' || stored === 'mr' || stored === 'advanced') {
|
||||
subTab.value = stored;
|
||||
}
|
||||
load();
|
||||
// 加载 Agent 高级配置(独立接口,失败不阻塞主流程)
|
||||
loadAdvancedCfg();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -518,6 +721,185 @@ onMounted(() => {
|
||||
</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>
|
||||
|
||||
Reference in New Issue
Block a user