feat: 语音开方
This commit is contained in:
@@ -442,10 +442,49 @@ export async function aiGeneratePrescriptionApi(data: {
|
||||
use_entrusted_process?: 0 | 1 | boolean;
|
||||
/** 中药委托调剂:制剂要求 id(yii_process_rule pid=0) */
|
||||
process_rule_id?: number;
|
||||
/** 语音辨证:dialectic */
|
||||
voice_mode?: string;
|
||||
transcript?: string;
|
||||
asr_provider?: string;
|
||||
asr_duration_ms?: number;
|
||||
return_dosage_process_detail?: 0 | 1 | boolean;
|
||||
}) {
|
||||
return requestClient.post<any>(`${prefix}ai-generate-prescription`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 语音转写(multipart:audio 文件)
|
||||
* 字段名必须为 audio,与后端 request()->file('audio') 对齐
|
||||
*/
|
||||
export async function aiVoiceAsrApi(file: File, durationMs = 0) {
|
||||
const fd = new FormData();
|
||||
fd.append('audio', file);
|
||||
if (durationMs > 0) {
|
||||
fd.append('duration_ms', String(durationMs));
|
||||
}
|
||||
return requestClient.post<any>(`${prefix}ai-voice-asr`, fd, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
}
|
||||
|
||||
/** 语音口述落方(只抽药名剂量) */
|
||||
export async function aiGenerateVoicePrescriptionApi(data: {
|
||||
register_id: number;
|
||||
store_id?: number;
|
||||
prescription_type: number;
|
||||
transcript: string;
|
||||
asr_provider?: string;
|
||||
asr_duration_ms?: number;
|
||||
use_entrusted_process?: 0 | 1 | boolean;
|
||||
process_rule_id?: number;
|
||||
return_dosage_process_detail?: 0 | 1 | boolean;
|
||||
}) {
|
||||
return requestClient.post<any>(
|
||||
`${prefix}ai-generate-voice-prescription`,
|
||||
data,
|
||||
);
|
||||
}
|
||||
|
||||
/** AI 功能门闸(醒目提示 + 是否已签署知情同意) */
|
||||
export async function aiFeatureGateApi(data: { feature_code: string }) {
|
||||
return requestClient.get<any>(`${prefix}ai-feature-gate`, { params: data });
|
||||
|
||||
@@ -11,12 +11,15 @@ import { Button, Checkbox, Descriptions, Input, Radio, Spin, Switch, Tabs, Tag,
|
||||
|
||||
import {
|
||||
aiGeneratePrescriptionApi,
|
||||
aiGenerateVoicePrescriptionApi,
|
||||
aiListGenerationsApi,
|
||||
aiMarkGenerationReadApi,
|
||||
aiMatchPrescriptionDrugsApi,
|
||||
aiSavePrescriptionDrugSelectionApi,
|
||||
aiVoiceAsrApi,
|
||||
getProcessRuleList,
|
||||
} from '../api';
|
||||
import { encodeBlobToWav16k } from '../utils/encodeWav';
|
||||
import AiDisclaimerBanner from '#/views/doctor/components/AiDisclaimerBanner.vue';
|
||||
import { ensureAiFeatureConsent } from '#/views/doctor/utils/aiFeatureGate';
|
||||
import PrescriptionLoading from '#/components/loading/PrescriptionLoading.vue';
|
||||
@@ -137,6 +140,19 @@ const editingKey = ref('');
|
||||
/** 醒目提示文案(系统配置 / 门闸下发) */
|
||||
const disclaimerText = ref('');
|
||||
|
||||
/** 语音开方:录音 / 转写 / A·B 模式 */
|
||||
const voiceRecording = ref(false);
|
||||
const voiceAsrLoading = ref(false);
|
||||
const voiceTranscript = ref('');
|
||||
const voiceAsrProvider = ref('');
|
||||
const voiceAsrDurationMs = ref(0);
|
||||
/** dialectic | dictation;默认口述落方(医生口述药味更常见) */
|
||||
const voiceMode = ref<'dialectic' | 'dictation'>('dictation');
|
||||
let voiceMediaRecorder: MediaRecorder | null = null;
|
||||
let voiceAudioChunks: Blob[] = [];
|
||||
let voiceStream: MediaStream | null = null;
|
||||
let voiceRecordStartedAt = 0;
|
||||
|
||||
const sexLabel = computed(() => {
|
||||
const s = Number(patientSex.value);
|
||||
if (s === 1) return '男';
|
||||
@@ -193,6 +209,8 @@ const [Drawer, drawerApi] = useVbenDrawer({
|
||||
patientAge?: number;
|
||||
chiefComplaint?: string;
|
||||
medicalRecord?: Record<string, any>;
|
||||
/** assist=携带病历辅助出方;voice=工具栏语音入口,同意后自动开语音弹窗 */
|
||||
entry?: 'assist' | 'voice';
|
||||
}>();
|
||||
registerId.value = Number(data?.registerId || 0);
|
||||
storeId.value = Number(data?.storeId || 0);
|
||||
@@ -205,6 +223,7 @@ const [Drawer, drawerApi] = useVbenDrawer({
|
||||
data?.medicalRecord && typeof data.medicalRecord === 'object'
|
||||
? { ...data.medicalRecord }
|
||||
: {};
|
||||
const entryMode = data?.entry === 'voice' ? 'voice' : 'assist';
|
||||
resetPreview();
|
||||
// 首次使用对应 VIP 功能需签署知情同意;拒绝则关闭抽屉
|
||||
try {
|
||||
@@ -223,6 +242,14 @@ const [Drawer, drawerApi] = useVbenDrawer({
|
||||
return;
|
||||
}
|
||||
void bootstrapHistory();
|
||||
// 工具栏「语音开方」:同意后直接进入语音弹窗,默认口述落方
|
||||
if (entryMode === 'voice') {
|
||||
voiceMode.value = 'dictation';
|
||||
voiceTranscript.value = '';
|
||||
voiceAsrProvider.value = '';
|
||||
voiceAsrDurationMs.value = 0;
|
||||
voiceModalApi.open();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -235,6 +262,28 @@ const [GenModal, genModalApi] = useVbenModal({
|
||||
onConfirm: async () => confirmGenerateAndClose(),
|
||||
});
|
||||
|
||||
/**
|
||||
* 语音开方弹窗:录音转写 → 卡片选 A/B → 确认生成
|
||||
* 未选模式时确认按钮禁用(由 onConfirm 内校验兜底)
|
||||
*/
|
||||
const [VoiceModal, voiceModalApi] = useVbenModal({
|
||||
title: '语音开方',
|
||||
class: 'w-[640px]',
|
||||
confirmText: '确认生成',
|
||||
cancelText: '取消',
|
||||
onConfirm: async () => confirmVoiceGenerate(),
|
||||
onOpenChange(isOpen: boolean) {
|
||||
if (!isOpen) {
|
||||
stopVoiceRecording(true);
|
||||
voiceTranscript.value = '';
|
||||
voiceMode.value = 'dictation';
|
||||
voiceAsrProvider.value = '';
|
||||
voiceAsrDurationMs.value = 0;
|
||||
voiceAsrLoading.value = false;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
function resetPreview() {
|
||||
matchedList.value = [];
|
||||
unmatchedList.value = [];
|
||||
@@ -268,8 +317,12 @@ function resetState() {
|
||||
disclaimerText.value = '';
|
||||
genProcessRuleId.value = undefined;
|
||||
processRuleOptions.value = [];
|
||||
stopVoiceRecording(true);
|
||||
voiceTranscript.value = '';
|
||||
voiceMode.value = 'dictation';
|
||||
resetPreview();
|
||||
genModalApi.close();
|
||||
voiceModalApi.close();
|
||||
}
|
||||
|
||||
function formatTime(ts: number) {
|
||||
@@ -617,6 +670,217 @@ async function confirmGenerateAndClose(): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开语音开方弹窗(录音 → 转写 → 选 A/B)
|
||||
*/
|
||||
async function openVoiceModal() {
|
||||
if (generating.value) return;
|
||||
if (!prescriptionType.value) {
|
||||
message.warning('请先选择处方类型');
|
||||
return;
|
||||
}
|
||||
if (!registerId.value) {
|
||||
message.warning('挂号无效');
|
||||
return;
|
||||
}
|
||||
voiceTranscript.value = '';
|
||||
voiceMode.value = 'dictation';
|
||||
voiceAsrProvider.value = '';
|
||||
voiceAsrDurationMs.value = 0;
|
||||
// 与生成弹窗一致:恢复委托调剂本地记忆并拉制剂要求
|
||||
if (isTcmRx.value && useEntrustedProcess.value && !processRuleOptions.value.length) {
|
||||
void loadTopProcessRules();
|
||||
}
|
||||
voiceModalApi.open();
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始 / 停止录音(点击切换)
|
||||
*/
|
||||
async function toggleVoiceRecording() {
|
||||
if (voiceAsrLoading.value) return;
|
||||
if (voiceRecording.value) {
|
||||
stopVoiceRecording(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
voiceStream = stream;
|
||||
voiceAudioChunks = [];
|
||||
voiceRecordStartedAt = Date.now();
|
||||
const mime = MediaRecorder.isTypeSupported('audio/webm')
|
||||
? 'audio/webm'
|
||||
: '';
|
||||
voiceMediaRecorder = mime
|
||||
? new MediaRecorder(stream, { mimeType: mime })
|
||||
: new MediaRecorder(stream);
|
||||
voiceMediaRecorder.ondataavailable = (ev) => {
|
||||
if (ev.data && ev.data.size > 0) voiceAudioChunks.push(ev.data);
|
||||
};
|
||||
voiceMediaRecorder.onstop = () => {
|
||||
void handleVoiceRecordStop();
|
||||
};
|
||||
voiceMediaRecorder.start();
|
||||
voiceRecording.value = true;
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '无法访问麦克风');
|
||||
voiceRecording.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止录音;discard=true 时丢弃数据不转写
|
||||
*/
|
||||
function stopVoiceRecording(discard = false) {
|
||||
if (!voiceRecording.value && !voiceMediaRecorder) {
|
||||
cleanupVoiceStream();
|
||||
return;
|
||||
}
|
||||
voiceRecording.value = false;
|
||||
if (discard) {
|
||||
if (voiceMediaRecorder && voiceMediaRecorder.state !== 'inactive') {
|
||||
voiceMediaRecorder.ondataavailable = null;
|
||||
voiceMediaRecorder.onstop = () => cleanupVoiceStream();
|
||||
try {
|
||||
voiceMediaRecorder.stop();
|
||||
} catch {
|
||||
cleanupVoiceStream();
|
||||
}
|
||||
} else {
|
||||
cleanupVoiceStream();
|
||||
}
|
||||
voiceAudioChunks = [];
|
||||
voiceMediaRecorder = null;
|
||||
return;
|
||||
}
|
||||
if (voiceMediaRecorder && voiceMediaRecorder.state !== 'inactive') {
|
||||
try {
|
||||
voiceMediaRecorder.stop();
|
||||
} catch {
|
||||
cleanupVoiceStream();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function cleanupVoiceStream() {
|
||||
if (voiceStream) {
|
||||
voiceStream.getTracks().forEach((t) => t.stop());
|
||||
voiceStream = null;
|
||||
}
|
||||
voiceMediaRecorder = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 录音结束 → 转 wav → ASR
|
||||
*/
|
||||
async function handleVoiceRecordStop() {
|
||||
const chunks = voiceAudioChunks.slice();
|
||||
const started = voiceRecordStartedAt;
|
||||
cleanupVoiceStream();
|
||||
voiceAudioChunks = [];
|
||||
if (!chunks.length) {
|
||||
message.warning('未录到有效音频');
|
||||
return;
|
||||
}
|
||||
const rawBlob = new Blob(chunks, { type: chunks[0]?.type || 'audio/webm' });
|
||||
const durationMs = Math.max(0, Date.now() - started);
|
||||
voiceAsrLoading.value = true;
|
||||
try {
|
||||
const wavBlob = await encodeBlobToWav16k(rawBlob);
|
||||
const file = new File([wavBlob], `voice_${Date.now()}.wav`, {
|
||||
type: 'audio/wav',
|
||||
});
|
||||
const data = await aiVoiceAsrApi(file, durationMs);
|
||||
const text = String(data?.text || '').trim();
|
||||
if (!text) {
|
||||
message.warning('未识别到有效语音');
|
||||
return;
|
||||
}
|
||||
voiceTranscript.value = text;
|
||||
voiceAsrProvider.value = String(data?.provider || 'xunfei');
|
||||
voiceAsrDurationMs.value = Number(data?.duration_ms || durationMs);
|
||||
message.success('转写完成,请选择开方方式');
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || e?.msg || '语音转写失败');
|
||||
} finally {
|
||||
voiceAsrLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 语音弹窗确认:按 A/B 调对应生成接口
|
||||
*/
|
||||
async function confirmVoiceGenerate() {
|
||||
const text = voiceTranscript.value.trim();
|
||||
if (!text) {
|
||||
message.warning('请先完成语音转写或填写文本');
|
||||
return;
|
||||
}
|
||||
if (!voiceMode.value) {
|
||||
message.warning('请选择辨证出方或口述落方');
|
||||
return;
|
||||
}
|
||||
if (isTcmRx.value && useEntrustedProcess.value && !genProcessRuleId.value) {
|
||||
message.warning('请选择制剂要求');
|
||||
return;
|
||||
}
|
||||
const mode = voiceMode.value;
|
||||
const transcript = text;
|
||||
const asrProvider = voiceAsrProvider.value;
|
||||
const asrDuration = voiceAsrDurationMs.value;
|
||||
voiceModalApi.close();
|
||||
if (mode === 'dialectic') {
|
||||
// A:转写当主诉,走现有辨证出方
|
||||
chiefComplaint.value = transcript;
|
||||
void runGenerateInDrawer(transcript, {
|
||||
voice_mode: 'dialectic',
|
||||
transcript,
|
||||
asr_provider: asrProvider,
|
||||
asr_duration_ms: asrDuration,
|
||||
});
|
||||
return;
|
||||
}
|
||||
// B:口述落方
|
||||
void runVoiceDictationGenerate(transcript, asrProvider, asrDuration);
|
||||
}
|
||||
|
||||
/**
|
||||
* B 口述落方生成
|
||||
*/
|
||||
async function runVoiceDictationGenerate(
|
||||
transcript: string,
|
||||
asrProvider: string,
|
||||
asrDuration: number,
|
||||
) {
|
||||
beginGeneratingPlaceholder();
|
||||
generating.value = true;
|
||||
multiPrescriptions.value = [];
|
||||
multiActiveIndex.value = 0;
|
||||
try {
|
||||
const req: Record<string, any> = {
|
||||
register_id: registerId.value,
|
||||
store_id: storeId.value || undefined,
|
||||
prescription_type: prescriptionType.value,
|
||||
transcript,
|
||||
asr_provider: asrProvider || undefined,
|
||||
asr_duration_ms: asrDuration || undefined,
|
||||
};
|
||||
if (isTcmRx.value && useEntrustedProcess.value && genProcessRuleId.value) {
|
||||
req.use_entrusted_process = 1;
|
||||
req.process_rule_id = genProcessRuleId.value;
|
||||
req.return_dosage_process_detail = returnDosageProcessDetail.value ? 1 : 0;
|
||||
}
|
||||
const data = await aiGenerateVoicePrescriptionApi(req);
|
||||
await applyGenerateResponse(data, '口述识别');
|
||||
} catch (e: any) {
|
||||
endGeneratingPlaceholder();
|
||||
resetPreview();
|
||||
message.error(e?.message || e?.msg || '口述识别失败');
|
||||
} finally {
|
||||
generating.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 本地把某条历史标为已读(同步 multi / historyList)
|
||||
*/
|
||||
@@ -682,7 +946,15 @@ function onMultiTabChange(key: string | number) {
|
||||
applyMultiSlot(slot, idx);
|
||||
}
|
||||
|
||||
async function runGenerateInDrawer(chief: string) {
|
||||
async function runGenerateInDrawer(
|
||||
chief: string,
|
||||
voiceMeta?: {
|
||||
voice_mode?: string;
|
||||
transcript?: string;
|
||||
asr_provider?: string;
|
||||
asr_duration_ms?: number;
|
||||
},
|
||||
) {
|
||||
beginGeneratingPlaceholder();
|
||||
generating.value = true;
|
||||
multiPrescriptions.value = [];
|
||||
@@ -704,50 +976,16 @@ async function runGenerateInDrawer(chief: string) {
|
||||
req.process_rule_id = genProcessRuleId.value;
|
||||
req.return_dosage_process_detail = returnDosageProcessDetail.value ? 1 : 0;
|
||||
}
|
||||
if (voiceMeta?.voice_mode) {
|
||||
req.voice_mode = voiceMeta.voice_mode;
|
||||
req.transcript = voiceMeta.transcript || chief;
|
||||
if (voiceMeta.asr_provider) req.asr_provider = voiceMeta.asr_provider;
|
||||
if (voiceMeta.asr_duration_ms) {
|
||||
req.asr_duration_ms = voiceMeta.asr_duration_ms;
|
||||
}
|
||||
}
|
||||
const data = await aiGeneratePrescriptionApi(req);
|
||||
const durationText = formatDurationMs(data?.duration_ms);
|
||||
const list = Array.isArray(data?.prescriptions) ? data.prescriptions : [];
|
||||
// 业务软失败:HTTP 成功但 ok=false,警告提示而非 error
|
||||
if (data?.ok === false) {
|
||||
endGeneratingPlaceholder();
|
||||
resetPreview();
|
||||
if (list.length > 1) {
|
||||
multiPrescriptions.value = list;
|
||||
const firstOkIdx = list.findIndex((p: any) => p && p.ok !== false);
|
||||
const useIdx = firstOkIdx >= 0 ? firstOkIdx : 0;
|
||||
applyMultiSlot(list[useIdx], useIdx);
|
||||
}
|
||||
if (data?.generation_id) {
|
||||
await loadHistory();
|
||||
activeId.value = Number(data.generation_id);
|
||||
}
|
||||
message.warning(String(data?.message || '生成未成功'));
|
||||
return;
|
||||
}
|
||||
await loadHistory();
|
||||
if (list.length > 1) {
|
||||
multiPrescriptions.value = list;
|
||||
const firstOkIdx = list.findIndex((p: any) => p && p.ok !== false);
|
||||
const useIdx = firstOkIdx >= 0 ? firstOkIdx : 0;
|
||||
applyMultiSlot(list[useIdx], useIdx);
|
||||
const okN = list.filter((p: any) => p && p.ok !== false).length;
|
||||
const failN = list.length - okN;
|
||||
const baseMsg =
|
||||
failN > 0
|
||||
? `已生成 ${okN} 套可用方案(${failN} 套失败),可切换对比`
|
||||
: `已生成 ${okN} 套方案,可切换对比`;
|
||||
message.success(durationText ? `${baseMsg}(耗时 ${durationText})` : baseMsg);
|
||||
return;
|
||||
}
|
||||
activeId.value = Number(data?.generation_id || 0);
|
||||
applyMatch(data?.match || null);
|
||||
applyConflict(data?.conflict || null);
|
||||
applySafetyFlags(data?.safety_flags || null);
|
||||
applyRxMeta(data);
|
||||
void markReadIfNeeded(activeId.value, 0);
|
||||
message.success(
|
||||
durationText ? `已生成(耗时 ${durationText})` : '已生成,请确认导入',
|
||||
);
|
||||
await applyGenerateResponse(data, '生成');
|
||||
} catch (e: any) {
|
||||
endGeneratingPlaceholder();
|
||||
resetPreview();
|
||||
@@ -757,6 +995,62 @@ async function runGenerateInDrawer(chief: string) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一处理生成/口述接口返回(多方案 / 单方案 / 软失败)
|
||||
*/
|
||||
async function applyGenerateResponse(data: any, actionLabel: string) {
|
||||
const durationText = formatDurationMs(data?.duration_ms);
|
||||
const list = Array.isArray(data?.prescriptions) ? data.prescriptions : [];
|
||||
if (data?.ok === false) {
|
||||
endGeneratingPlaceholder();
|
||||
resetPreview();
|
||||
if (list.length > 1) {
|
||||
multiPrescriptions.value = list;
|
||||
const firstOkIdx = list.findIndex((p: any) => p && p.ok !== false);
|
||||
const useIdx = firstOkIdx >= 0 ? firstOkIdx : 0;
|
||||
applyMultiSlot(list[useIdx], useIdx);
|
||||
}
|
||||
if (data?.generation_id) {
|
||||
await loadHistory();
|
||||
activeId.value = Number(data.generation_id);
|
||||
}
|
||||
message.warning(String(data?.message || `${actionLabel}未成功`));
|
||||
return;
|
||||
}
|
||||
await loadHistory();
|
||||
if (list.length > 1) {
|
||||
multiPrescriptions.value = list;
|
||||
const firstOkIdx = list.findIndex((p: any) => p && p.ok !== false);
|
||||
const useIdx = firstOkIdx >= 0 ? firstOkIdx : 0;
|
||||
applyMultiSlot(list[useIdx], useIdx);
|
||||
const okN = list.filter((p: any) => p && p.ok !== false).length;
|
||||
const failN = list.length - okN;
|
||||
const baseMsg =
|
||||
failN > 0
|
||||
? `已生成 ${okN} 套可用方案(${failN} 套失败),可切换对比`
|
||||
: `已生成 ${okN} 套方案,可切换对比`;
|
||||
message.success(durationText ? `${baseMsg}(耗时 ${durationText})` : baseMsg);
|
||||
return;
|
||||
}
|
||||
activeId.value = Number(data?.generation_id || 0);
|
||||
applyMatch(data?.match || null);
|
||||
applyConflict(data?.conflict || null);
|
||||
applySafetyFlags(data?.safety_flags || null);
|
||||
applyRxMeta(data);
|
||||
void markReadIfNeeded(activeId.value, 0);
|
||||
const unmatchedN = Array.isArray(data?.match?.unmatched)
|
||||
? data.match.unmatched.length
|
||||
: unmatchedList.value.length;
|
||||
const okMsg = durationText
|
||||
? `已${actionLabel}(耗时 ${durationText})`
|
||||
: `已${actionLabel},请确认导入`;
|
||||
if (unmatchedN > 0) {
|
||||
message.warning(`${okMsg};${unmatchedN} 味未在药库找到,已标红`);
|
||||
} else {
|
||||
message.success(okMsg);
|
||||
}
|
||||
}
|
||||
|
||||
async function onSelectHistory(row: any) {
|
||||
if (!row?.id || Number(row.id) === PENDING_GEN_ID || row._pending) return;
|
||||
// 点历史时退出「本次多方案」对比态,避免 Tab 与历史选中互相干扰
|
||||
@@ -866,6 +1160,10 @@ function onConfirmImport() {
|
||||
message.warning('请先勾选已对照药品');
|
||||
return;
|
||||
}
|
||||
const skipN = unmatchedList.value.length;
|
||||
if (skipN > 0) {
|
||||
message.warning(`已跳过 ${skipN} 味未对照药品(标红项不会导入)`);
|
||||
}
|
||||
const importPayload: {
|
||||
rows: typeof drugs;
|
||||
dosage?: number;
|
||||
@@ -914,8 +1212,13 @@ defineExpose({
|
||||
patientAge?: number;
|
||||
chiefComplaint?: string;
|
||||
medicalRecord?: Record<string, any>;
|
||||
/** assist 默认;voice=工具栏语音入口 */
|
||||
entry?: 'assist' | 'voice';
|
||||
}) {
|
||||
drawerApi.setData(payload);
|
||||
drawerApi.setData({
|
||||
...payload,
|
||||
entry: payload.entry === 'voice' ? 'voice' : 'assist',
|
||||
});
|
||||
drawerApi.open();
|
||||
},
|
||||
});
|
||||
@@ -1175,16 +1478,23 @@ defineExpose({
|
||||
</div>
|
||||
<div
|
||||
v-if="unmatchedList.length"
|
||||
class="mb-1.5 mt-3 text-xs font-medium text-muted-foreground"
|
||||
class="mb-1.5 mt-3 text-xs font-medium text-red-500"
|
||||
>
|
||||
未对照({{ unmatchedList.length }})
|
||||
未对照({{ unmatchedList.length }})— 药库未找到,已标红,不会导入
|
||||
</div>
|
||||
<div
|
||||
v-for="(u, ui) in unmatchedList"
|
||||
:key="ui"
|
||||
class="mb-1.5 rounded border border-orange-500/30 bg-orange-500/10 px-2 py-1.5 text-sm"
|
||||
class="mb-1.5 rounded border border-red-500/40 px-2 py-1.5 text-sm shadow-[0_0_6px_hsl(0_84%_60%/0.18)]"
|
||||
>
|
||||
<div class="font-medium">{{ u.ai_name }}</div>
|
||||
<div class="font-medium text-red-500">{{ u.ai_name }}</div>
|
||||
<div
|
||||
v-if="u.dose || u.unit"
|
||||
class="text-xs text-muted-foreground"
|
||||
>
|
||||
{{ u.dose }}{{ u.unit }}
|
||||
<span v-if="u.usage"> · {{ u.usage }}</span>
|
||||
</div>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
@@ -1369,6 +1679,106 @@ defineExpose({
|
||||
</div>
|
||||
</div>
|
||||
</GenModal>
|
||||
|
||||
<!-- 语音开方:录音转写 + A/B 模式卡片 -->
|
||||
<VoiceModal>
|
||||
<div class="space-y-3 text-sm text-foreground">
|
||||
<AiDisclaimerBanner :text="disclaimerText" />
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
:type="voiceRecording ? 'primary' : 'default'"
|
||||
:danger="voiceRecording"
|
||||
:loading="voiceAsrLoading"
|
||||
@click="toggleVoiceRecording"
|
||||
>
|
||||
{{
|
||||
voiceAsrLoading
|
||||
? '转写中…'
|
||||
: voiceRecording
|
||||
? '点击结束录音'
|
||||
: '点击开始录音'
|
||||
}}
|
||||
</Button>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
说完后点结束,系统会转写成文字供修改
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<div class="mb-1 text-xs text-muted-foreground">转写文本(可改)</div>
|
||||
<Input.TextArea
|
||||
v-model:value="voiceTranscript"
|
||||
:rows="4"
|
||||
placeholder="录音转写结果会显示在这里,也可直接粘贴/修改"
|
||||
:disabled="voiceRecording || voiceAsrLoading"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="voiceTranscript.trim()" class="space-y-2">
|
||||
<div class="text-xs font-medium text-muted-foreground">
|
||||
请选择开方方式
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg border p-3 text-left transition-colors"
|
||||
:class="
|
||||
voiceMode === 'dialectic'
|
||||
? 'border-primary/30 shadow-[0_0_6px_hsl(var(--primary)/0.18)] bg-primary/5'
|
||||
: 'border-border hover:border-primary/20'
|
||||
"
|
||||
@click="voiceMode = 'dialectic'"
|
||||
>
|
||||
<div class="font-medium">辨证出方</div>
|
||||
<div class="mt-1 text-xs text-muted-foreground">
|
||||
根据口述病情,AI 辅助生成处方
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg border p-3 text-left transition-colors"
|
||||
:class="
|
||||
voiceMode === 'dictation'
|
||||
? 'border-primary/30 shadow-[0_0_6px_hsl(var(--primary)/0.18)] bg-primary/5'
|
||||
: 'border-border hover:border-primary/20'
|
||||
"
|
||||
@click="voiceMode = 'dictation'"
|
||||
>
|
||||
<div class="font-medium">口述落方</div>
|
||||
<div class="mt-1 text-xs text-muted-foreground">
|
||||
识别口述药名剂量,对照药库后导入
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="isTcmRx"
|
||||
class="rounded-lg border border-border bg-muted/30 p-3"
|
||||
>
|
||||
<Checkbox
|
||||
v-model:checked="useEntrustedProcess"
|
||||
@update:checked="onEntrustedToggle"
|
||||
>
|
||||
委托调剂
|
||||
</Checkbox>
|
||||
<div v-if="useEntrustedProcess" class="mt-2 space-y-3">
|
||||
<div>
|
||||
<div class="mb-1 text-xs text-muted-foreground">制剂要求</div>
|
||||
<div v-if="processRuleLoading" class="text-xs text-muted-foreground">
|
||||
加载中…
|
||||
</div>
|
||||
<div v-else class="ai-process-pill-group">
|
||||
<span
|
||||
v-for="opt in processRuleOptions"
|
||||
:key="opt.value"
|
||||
class="ai-process-pill"
|
||||
:class="{ active: genProcessRuleId === opt.value }"
|
||||
@click="onProcessRuleTagClick(opt.value)"
|
||||
>{{ opt.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</VoiceModal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
BankOutlined,
|
||||
ReloadOutlined,
|
||||
RobotOutlined,
|
||||
AudioOutlined,
|
||||
UserAddOutlined,
|
||||
SaveOutlined,
|
||||
StopOutlined,
|
||||
@@ -1665,7 +1666,7 @@ function handleImportGoldenFormula(payload: { rows?: any[]; formulaName?: string
|
||||
|
||||
const aiPrescriptionDrawerRef = ref<InstanceType<typeof AiPrescriptionDrawer> | null>(null);
|
||||
|
||||
/** 打开 AI 给处方抽屉:先确认患者与主诉,再生成预览 */
|
||||
/** 打开 AI 给处方抽屉:先确认患者与主诉,再生成预览(携带病历) */
|
||||
function openAiPrescriptionDrawer() {
|
||||
if (!canUseAiPrescription.value) {
|
||||
message.warning('当前门店未开通AI辅助出方VIP功能');
|
||||
@@ -1702,6 +1703,41 @@ function openAiPrescriptionDrawer() {
|
||||
patientAge: Number(activePatient.value?.age || 0),
|
||||
chiefComplaint: String(payload.chief_complaint || ''),
|
||||
medicalRecord: { ...payload },
|
||||
entry: 'assist',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 工具栏「语音开方」:打开同一抽屉后自动进语音弹窗,默认口述落方
|
||||
* 为什么不强依赖病历:口述落方按转写解析药味,患者上下文由服务端按 register 拉取
|
||||
*/
|
||||
function openVoicePrescriptionDrawer() {
|
||||
if (!canUseAiPrescription.value) {
|
||||
message.warning('当前门店未开通AI辅助出方VIP功能');
|
||||
return;
|
||||
}
|
||||
if (guardSpecialPrescriptionCartEdit()) return;
|
||||
const registerId = Number.parseInt(
|
||||
localStorage.getItem(`doctorReception-id`) || '0',
|
||||
);
|
||||
if (!registerId) {
|
||||
message.warning('请先选择患者');
|
||||
return;
|
||||
}
|
||||
if (!activeCategory.value) {
|
||||
message.warning('请先选择处方类型');
|
||||
return;
|
||||
}
|
||||
aiPrescriptionDrawerRef.value?.open({
|
||||
registerId,
|
||||
storeId: Number(myStoreId.value || 0) || undefined,
|
||||
prescriptionType: Number(activeCategory.value),
|
||||
patientName: String(activePatient.value?.name || ''),
|
||||
patientSex: Number(activePatient.value?.sex || 0),
|
||||
patientAge: Number(activePatient.value?.age || 0),
|
||||
chiefComplaint: '',
|
||||
medicalRecord: {},
|
||||
entry: 'voice',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3736,6 +3772,15 @@ function onStoreSelectOpenChange(open: boolean) {
|
||||
<RobotOutlined />
|
||||
AI辅助出方
|
||||
</Button>
|
||||
<Button
|
||||
v-if="!isSpecialPrescriptionCartLocked && canUseAiPrescription"
|
||||
type="link"
|
||||
size="small"
|
||||
@click="openVoicePrescriptionDrawer"
|
||||
>
|
||||
<AudioOutlined />
|
||||
语音开方
|
||||
</Button>
|
||||
<Button
|
||||
v-if="canUseCommonPrescription"
|
||||
type="link"
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* 浏览器录音 → 16k 单声道 WAV(讯飞一句话识别 raw/pcm 要求)
|
||||
* 为什么:MediaRecorder 默认 webm,ASR 不支持,需解码后重编码
|
||||
*/
|
||||
export async function encodeBlobToWav16k(blob: Blob): Promise<Blob> {
|
||||
const arrayBuffer = await blob.arrayBuffer();
|
||||
const audioCtx = new AudioContext();
|
||||
let decoded: AudioBuffer;
|
||||
try {
|
||||
decoded = await audioCtx.decodeAudioData(arrayBuffer.slice(0));
|
||||
} finally {
|
||||
await audioCtx.close().catch(() => undefined);
|
||||
}
|
||||
const targetRate = 16000;
|
||||
const offline = new OfflineAudioContext(
|
||||
1,
|
||||
Math.ceil(decoded.duration * targetRate),
|
||||
targetRate,
|
||||
);
|
||||
const source = offline.createBufferSource();
|
||||
// 混成单声道
|
||||
const mono = offline.createBuffer(1, decoded.length, decoded.sampleRate);
|
||||
const ch0 = mono.getChannelData(0);
|
||||
const channels = decoded.numberOfChannels;
|
||||
for (let i = 0; i < decoded.length; i++) {
|
||||
let sum = 0;
|
||||
for (let c = 0; c < channels; c++) {
|
||||
sum += decoded.getChannelData(c)[i] || 0;
|
||||
}
|
||||
ch0[i] = sum / channels;
|
||||
}
|
||||
source.buffer = mono;
|
||||
source.connect(offline.destination);
|
||||
source.start(0);
|
||||
const rendered = await offline.startRendering();
|
||||
const pcm = rendered.getChannelData(0);
|
||||
return pcmToWavBlob(pcm, targetRate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Float32 PCM → 16bit LE WAV Blob
|
||||
*/
|
||||
function pcmToWavBlob(samples: Float32Array, sampleRate: number): Blob {
|
||||
const dataLength = samples.length * 2;
|
||||
const buffer = new ArrayBuffer(44 + dataLength);
|
||||
const view = new DataView(buffer);
|
||||
writeString(view, 0, 'RIFF');
|
||||
view.setUint32(4, 36 + dataLength, true);
|
||||
writeString(view, 8, 'WAVE');
|
||||
writeString(view, 12, 'fmt ');
|
||||
view.setUint32(16, 16, true);
|
||||
view.setUint16(20, 1, true);
|
||||
view.setUint16(22, 1, true);
|
||||
view.setUint32(24, sampleRate, true);
|
||||
view.setUint32(28, sampleRate * 2, true);
|
||||
view.setUint16(32, 2, true);
|
||||
view.setUint16(34, 16, true);
|
||||
writeString(view, 36, 'data');
|
||||
view.setUint32(40, dataLength, true);
|
||||
let offset = 44;
|
||||
for (let i = 0; i < samples.length; i++) {
|
||||
const s = Math.max(-1, Math.min(1, samples[i] || 0));
|
||||
view.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7fff, true);
|
||||
offset += 2;
|
||||
}
|
||||
return new Blob([buffer], { type: 'audio/wav' });
|
||||
}
|
||||
|
||||
function writeString(view: DataView, offset: number, str: string) {
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
view.setUint8(offset + i, str.charCodeAt(i));
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ export const AI_GENERATION_STATUS_OPTIONS = [
|
||||
export const AI_GENERATION_SCENE_OPTIONS = [
|
||||
{ label: '写病历', value: 'medical_record' },
|
||||
{ label: '出方', value: 'prescription' },
|
||||
{ label: '语音口述开方', value: 'voice_prescription' },
|
||||
{ label: '每日简报', value: 'daily_brief' },
|
||||
];
|
||||
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 系统配置 - AI:二级 Tab
|
||||
* 1) 模型配置:选用平台 / 密钥 / 模型
|
||||
* 2) 病历配置:勾选 AI 写病历需要生成的字段
|
||||
* 3) 提醒文案:VIP 关联的醒目提示与知情同意
|
||||
* 1) 模型配置 2) 病历配置 3) 提醒文案
|
||||
* 4) Agent 中转 5) 语音配置 6) Agent 高级
|
||||
*/
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
|
||||
@@ -14,6 +13,7 @@ import {
|
||||
Input,
|
||||
InputNumber,
|
||||
message,
|
||||
Radio,
|
||||
Spin,
|
||||
Switch,
|
||||
Tabs,
|
||||
@@ -83,13 +83,12 @@ 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
|
||||
// ===== 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('');
|
||||
@@ -116,9 +115,24 @@ 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),
|
||||
);
|
||||
@@ -291,6 +305,42 @@ async function loadAdvancedCfg() {
|
||||
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);
|
||||
@@ -353,14 +403,14 @@ async function loadAdvancedCfg() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 保存 Agent 高级配置(接入 / 知识库 / ReAct / Token / 流量实验)一次性 POST */
|
||||
async function handleSaveAdvanced() {
|
||||
advSaving.value = true;
|
||||
/**
|
||||
* 保存「Agent 中转」Tab:直连/中转开关 + 服务地址 + 密钥 + 出方份数
|
||||
* 为什么单独存:运维高频改中转路径,与 ReAct/流量实验解耦,避免误改高级项
|
||||
*/
|
||||
async function handleSaveAgentVia() {
|
||||
agentViaSaving.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),
|
||||
@@ -379,7 +429,82 @@ async function handleSaveAdvanced() {
|
||||
),
|
||||
),
|
||||
},
|
||||
// 分组 0.5:知识库检索(与「是否中转」独立;必须 value_type=bool,否则 PHP (bool)"0" 会当成 true)
|
||||
]);
|
||||
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),
|
||||
@@ -394,7 +519,7 @@ async function handleSaveAdvanced() {
|
||||
config_group: 'ai',
|
||||
description: '处方场景是否启用 Go Agent 知识库检索',
|
||||
},
|
||||
// 分组 1:ReAct
|
||||
// ReAct
|
||||
{
|
||||
config_key: 'ai_react_enabled',
|
||||
config_value: boolStr(aiReactEnabled.value),
|
||||
@@ -419,7 +544,7 @@ async function handleSaveAdvanced() {
|
||||
config_key: 'ai_react_json_repair_enabled',
|
||||
config_value: boolStr(aiReactJsonRepairEnabled.value),
|
||||
},
|
||||
// 分组 2:Token 预算
|
||||
// Token 预算
|
||||
{
|
||||
config_key: 'ai_token_budget_enabled',
|
||||
config_value: boolStr(aiTokenBudgetEnabled.value),
|
||||
@@ -432,7 +557,7 @@ async function handleSaveAdvanced() {
|
||||
config_key: 'ai_token_max_per_call',
|
||||
config_value: String(aiTokenMaxPerCall.value),
|
||||
},
|
||||
// 分组 3:流量实验
|
||||
// 流量实验
|
||||
{
|
||||
config_key: 'ai_shadow_traffic_enabled',
|
||||
config_value: boolStr(aiShadowTrafficEnabled.value),
|
||||
@@ -596,17 +721,19 @@ async function handleSaveCopies() {
|
||||
|
||||
onMounted(() => {
|
||||
const stored = localStorage.getItem(SUB_TAB_STORAGE_KEY);
|
||||
// 新增 advanced 二级 Tab 后,缓存值合法性扩展
|
||||
// 缓存值合法性:含 Agent 中转 / 语音配置 / Agent 高级
|
||||
if (
|
||||
stored === 'model' ||
|
||||
stored === 'copy' ||
|
||||
stored === 'mr' ||
|
||||
stored === 'agent' ||
|
||||
stored === 'voice' ||
|
||||
stored === 'advanced'
|
||||
) {
|
||||
subTab.value = stored;
|
||||
}
|
||||
load();
|
||||
// 加载 Agent 高级配置(独立接口,失败不阻塞主流程)
|
||||
// 加载 Agent / 语音 / 高级配置(独立接口,失败不阻塞主流程)
|
||||
loadAdvancedCfg();
|
||||
});
|
||||
</script>
|
||||
@@ -862,22 +989,22 @@ onMounted(() => {
|
||||
</div>
|
||||
</Tabs.TabPane>
|
||||
|
||||
<!-- Agent 高级:Go Agent 接入 / ReAct / Token 预算 / 流量实验 -->
|
||||
<Tabs.TabPane key="advanced" tab="Agent 高级">
|
||||
<!-- Agent 中转:直连/中转开关 + 服务地址(独立 Tab,运维高频) -->
|
||||
<Tabs.TabPane key="agent" 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 接入配置
|
||||
直连 / Agent 中转
|
||||
</div>
|
||||
<div class="text-xs text-[hsl(var(--muted-foreground))]">
|
||||
独立开关:开启后所有 AI 调用走 Go Agent 中转(ReAct
|
||||
多轮由下方单独控制;知识库检索也是下方单独开关),模型/密钥/平台始终由上方「模型配置」Tab
|
||||
决定
|
||||
开启后 AI 写病历、AI 辅助辨证出方等经 AiAgentFactory
|
||||
的调用走 Go Agent 中转。关闭则为 PHP
|
||||
直连厂商。语音转写与口述落方见「语音配置」Tab;ReAct /
|
||||
知识库等见「Agent 高级」。中转开启时须保证下方服务地址可达
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
@@ -974,8 +1101,125 @@ onMounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.TabPane>
|
||||
|
||||
<!-- 分组 0.5:知识库检索(与「是否中转」独立;关掉时 Go 仍会记一条跳过步骤,生成记录时间轴可见) -->
|
||||
<!-- 语音配置: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"
|
||||
>
|
||||
@@ -984,9 +1228,9 @@ onMounted(() => {
|
||||
知识库检索
|
||||
</div>
|
||||
<div class="text-xs text-[hsl(var(--muted-foreground))]">
|
||||
与上方「Agent 中转」独立:中转只决定走 Go,本开关才决定 Go
|
||||
与「Agent 中转」Tab 独立:中转只决定走 Go,本开关才决定 Go
|
||||
是否检索本地知识库 /
|
||||
MaxKB。需先开启中转才生效;生成记录时间轴会出现「检索知识库」步骤
|
||||
MaxKB。需先在「Agent 中转」开启中转才生效;生成记录时间轴会出现「检索知识库」步骤
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-6">
|
||||
@@ -1011,7 +1255,7 @@ onMounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分组 1:ReAct 多轮推理循环 -->
|
||||
<!-- ReAct 多轮推理循环 -->
|
||||
<div
|
||||
class="rounded border border-solid border-[hsl(var(--border))] p-4"
|
||||
>
|
||||
@@ -1095,7 +1339,7 @@ onMounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分组 2:Token 预算管理 -->
|
||||
<!-- Token 预算管理 -->
|
||||
<div
|
||||
class="rounded border border-solid border-[hsl(var(--border))] p-4"
|
||||
>
|
||||
@@ -1151,7 +1395,7 @@ onMounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分组 3:流量实验 -->
|
||||
<!-- 流量实验 -->
|
||||
<div
|
||||
class="rounded border border-solid border-[hsl(var(--border))] p-4"
|
||||
>
|
||||
@@ -1254,6 +1498,22 @@ onMounted(() => {
|
||||
>
|
||||
保存 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"
|
||||
|
||||
Reference in New Issue
Block a user