feat: 语音开方

This commit is contained in:
李琦
2026-08-31 16:35:57 +08:00
parent c2040c2b15
commit 443e501da5
6 changed files with 914 additions and 86 deletions

View File

@@ -442,10 +442,49 @@ export async function aiGeneratePrescriptionApi(data: {
use_entrusted_process?: 0 | 1 | boolean;
/** 中药委托调剂:制剂要求 idyii_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);
}
/**
* 语音转写multipartaudio 文件)
* 字段名必须为 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 });

View File

@@ -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>

View File

@@ -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"

View File

@@ -0,0 +1,73 @@
/**
* 浏览器录音 → 16k 单声道 WAV讯飞一句话识别 raw/pcm 要求)
* 为什么MediaRecorder 默认 webmASR 不支持,需解码后重编码
*/
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));
}
}

View File

@@ -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' },
];

View File

@@ -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」选)
// 分组 0Agent 接入(独立开关 + 服务地址 + 鉴权密钥)
// aiActiveProviderAdv 不再使用——只保留模型 Tab 里的 activeProvider 作为真实 provider
// ===== Agent 配置(走 getSystemConfigList写入 xk_system_config =====
// 与上方模型配置独立:
// - 模型配置决定 provider/key/model
// - 「Agent 中转」Tab全局直连/中转 + Agent 服务地址
// - 「语音配置Tab:讯飞 ASR + 口述落方 LLM 路由
// - 「Agent 高级」TabKB / 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);
/** 口述落方 LLMinherit | 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': {
// 钳制 15非法回落默认 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([
// 分组 0Agent 接入
// 关键:用 ai_agent_via_agent 独立开关,不再写 ai_active_provider避免污染模型 Tab 的真实 provider
// 这样后台"模型配置 Tab"选什么 provider/key/modelGo 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|medicalmedical 需控制台授权)',
},
{
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: '口述落方 LLMinherit|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 知识库检索',
},
// 分组 1ReAct
// 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),
},
// 分组 2Token 预算
// 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">
<!-- 分组 0Agent 接入配置独立开关 + 服务地址 + 鉴权密钥 -->
<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
直连厂商语音转写与口述落方见语音配置TabReAct /
知识库等见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>
<!-- 分组 1ReAct 多轮推理循环 -->
<!-- ReAct 多轮推理循环 -->
<div
class="rounded border border-solid border-[hsl(var(--border))] p-4"
>
@@ -1095,7 +1339,7 @@ onMounted(() => {
</div>
</div>
<!-- 分组 2Token 预算管理 -->
<!-- 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"