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

@@ -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」选)
// 分组 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"