From c12651e79c8c9522562aaee1e135a7cec294b566 Mon Sep 17 00:00:00 2001 From: Rodger-Wang <1367893453@qq.com> Date: Sat, 26 Sep 2026 09:36:57 +0800 Subject: [PATCH] =?UTF-8?q?=E8=AF=AD=E9=9F=B3=E7=BA=AA=E8=A6=81=EF=BC=9A?= =?UTF-8?q?=E5=BD=95=E9=9F=B3=E8=AF=AD=E8=A8=80=E8=87=AA=E5=8A=A8=E8=AF=86?= =?UTF-8?q?=E5=88=AB=E3=80=81=E8=BD=AC=E5=86=99=E4=B8=8D=E7=BF=BB=E8=AF=91?= =?UTF-8?q?=E3=80=81=E7=BF=BB=E8=AF=91=E5=8F=AA=E4=BD=9C=E7=94=A8=E4=BA=8E?= =?UTF-8?q?=E6=80=BB=E7=BB=93=EF=BC=9B=E5=8F=AF=E9=80=89=E6=80=BB=E7=BB=93?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=EF=BC=9B=E8=AF=A6=E6=83=85=E5=8A=A0=20PPT=20?= =?UTF-8?q?=E9=A1=B5=E7=AD=BE=EF=BC=88=E4=BB=85=E7=95=8C=E9=9D=A2=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 客户端 - 首页标题「语音记录助手」→「语音纪要」,去掉顶栏右侧的算力摘要 - 「生成」弹窗:去掉录音语言(传空,交给识别服务自动判断);「目标语言」改为「翻译」, 默认「默认」= 不翻译、总结跟随录音语言,选了语言 = 总结/概览用该语言输出; 新增「总结模型」一行(echomeet_getsummaryservices 拉列表,记住上次选择,summary_svc_id 带进 starttask/summary) - 重新总结选「默认」时传 tolanguage=auto(该接口空串历来表示沿用上次语言) - 纪要详情在思维导图右边加「PPT」页签:示意图 + 「生成 PPT」按钮,点了提示即将上线 服务端(echomeet) - 转写不再翻译:TranslateProcess 只做 Translate=Original;StartTask 不再选翻译服务 - 总结输出语言由 summaryLanguageInstruction 追加在模板 system prompt 末尾(空=跟随转写语言) - 模板解析 ResolveSummaryTemplate:有目标语言先找该语种版本,否则用客户端选中的那条 - formlanguage / tolanguage 的 "auto" 与空串同义;Azure 批量转写不指定语言时带候选语种自动识别,被拒退回 en-US - console「会议配置 → 会议总结」:默认模型之外可勾选「供用户选择的模型」,勾了即打开应用级「允许客户端选择」 ⚠️ 行为变化:服务端部署后老版本 App 也不再翻译转写(原「英文录音→中文」现在是英文转写 + 中文总结)。 验证:dart analyze 无 error;flutter test 224 全过;go build/vet 通过,echomeet/console 测试通过; admin npm run build 通过。已部署测试服(app/console/admin),release 包装到 ALN-AL80。 Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/admin/app/pages/meetconfig.vue | 56 ++++-- .../meeting/meeting_task_service.dart | 26 ++- .../meeting/meeting_upload_service.dart | 1 + .../client/lib/data/services/network/api.dart | 10 + .../meeting_details_controller.dart | 14 +- .../controllers/meeting_home_controller.dart | 20 -- apps/client/lib/modules/meeting/module.dart | 39 ++++ .../bottomSheet/generate_bottom_sheet.dart | 182 +++++++++++++----- .../record_language_bottom_sheet.dart | 10 +- .../meeting/views/meeting_details_view.dart | 13 ++ .../meeting/views/meeting_home_view.dart | 21 +- .../modules/meeting/views/tabs/ppt_tab.dart | 149 ++++++++++++++ .../modules/console/api_echomeet_orch.go | 53 ++++- apps/services/modules/echomeet/CLAUDE.md | 15 ++ .../modules/echomeet/api_starttask.go | 20 +- apps/services/modules/echomeet/api_summary.go | 38 ++-- apps/services/modules/echomeet/core.go | 49 +++++ apps/services/modules/echomeet/model_cache.go | 16 ++ .../modules/echomeet/summary_lang_test.go | 33 ++++ apps/services/modules/echomeet/tasks.go | 73 ++----- apps/services/sys/microsoft/speech/speech.go | 43 ++++- 21 files changed, 657 insertions(+), 224 deletions(-) create mode 100644 apps/client/lib/modules/meeting/views/tabs/ppt_tab.dart create mode 100644 apps/services/modules/echomeet/summary_lang_test.go diff --git a/apps/admin/app/pages/meetconfig.vue b/apps/admin/app/pages/meetconfig.vue index 0eb60ffc..3f3d1f89 100644 --- a/apps/admin/app/pages/meetconfig.vue +++ b/apps/admin/app/pages/meetconfig.vue @@ -87,8 +87,8 @@
会议总结(大模型)
- 从第三方服务配置里已配好的「文本大模型 / 多媒体大模型」类服务中选一个,用于生成纪要总结。 - 这一段没有兜底:选中的服务不可用时总结直接失败。 + 从第三方服务配置里已配好的「文本大模型 / 多媒体大模型」类服务中选一个默认模型,用于生成纪要总结; + 还可以再勾选几个供用户在 App 里自选。这一段没有兜底:选中的服务不可用时总结直接失败。
@@ -104,16 +104,26 @@ +
+ +
+ +
+
+
当前应用下没有可选的大模型服务,请先到第三方服务配置添加并启用。
- 该应用还另有 {{ extraLlm.length }} 个已配置的总结服务({{ extraLlm.join('、') }})。 - 保存后只保留上面选中的这一个,其余会从编排里移除(不影响第三方服务配置里的服务本身)。 - + 该应用还另有 {{ extraLlm.length }} 个已配置、但没标为「供用户选择」的总结服务({{ extraLlm.join('、') }})。 + 保存后会从编排里移除(不影响第三方服务配置里的服务本身);要保留就在上面勾上。
@@ -185,6 +195,7 @@ interface Orch { svc_id: string priority: number is_default: boolean + client_visible?: boolean enabled: boolean } interface SvcView { id: string; name: string; provider: string; categories: string; languages: string } @@ -217,8 +228,8 @@ async function loadScopeApps() { const available = ref([]) const kindCats = ref>({ '1': [8], '3': [5, 9] }) const regionRows = ref(0) -// 作用域开关:允许客户端自己指定总结模型。本页不编辑它(没有后台入口,需要时改库), -// 但要读出来——开着的时候「只保留一个总结服务」这件事对 App 端是有感知的。 +// 作用域开关:允许客户端自己指定总结模型。不单独给开关:保存会议总结时按 +// 「有没有勾供用户选择的模型」由后端一并置位(勾了=开,一个没勾=关)。 const clientChoice = ref(false) // 库里当前的样子,用于「有没有改动」与「放弃修改」;编辑态是 asrPick / llmPick。 const asrSaved = ref([]) @@ -226,6 +237,10 @@ const llmSaved = ref('') const extraLlm = ref([]) const asrPick = ref([]) const llmPick = ref('') +// 供用户在 App 里选择的备选总结模型(不含默认那个) +const llmChoicesSaved = ref([]) +const llmChoices = ref([]) +const llmChoiceCandidates = computed(() => svcOf('llm').filter((s) => s.id !== llmPick.value)) const addOpen = ref(false) const addSvc = ref('') @@ -251,13 +266,17 @@ function svcOf(seg: 'asr' | 'llm') { const addableAsr = computed(() => svcOf('asr').filter((s) => !asrPick.value.includes(s.id))) const asrDirty = computed(() => asrPick.value.join(',') !== asrSaved.value.join(',')) -const llmDirty = computed(() => llmPick.value !== llmSaved.value) +const sameSet = (a: string[], b: string[]) => a.length === b.length && a.every((x) => b.includes(x)) +const llmDirty = computed(() => llmPick.value !== llmSaved.value || !sameSet(llmChoices.value, llmChoicesSaved.value)) // 下拉没动过、但库里还躺着多余的总结行时也允许保存——保存正是为了把那些清掉。 // 这种情况不算「未保存的改动」(用户什么都没改),所以只放开按钮,不打那条提示。 const llmSavable = computed(() => llmDirty.value || extraLlm.value.length > 0) function resetAsr() { asrPick.value = asrSaved.value.slice() } -function resetLlm() { llmPick.value = llmSaved.value } +function resetLlm() { + llmPick.value = llmSaved.value + llmChoices.value = llmChoicesSaved.value.slice() +} function openAdd() { addSvc.value = '' @@ -298,7 +317,13 @@ async function loadOrch() { const llms = items.filter((o) => o.kind === KIND_LLM).sort((a, b) => a.priority - b.priority) const def = llms.find((o) => o.is_default) ?? llms[0] llmSaved.value = def?.svc_id ?? '' - extraLlm.value = llms.filter((o) => o.svc_id !== llmSaved.value).map((o) => o.svc_id) + // 备选只在应用级开关开着时才算数(关着时运行时不会把它们下发给 App) + llmChoicesSaved.value = clientChoice.value + ? llms.filter((o) => o.svc_id !== llmSaved.value && o.client_visible).map((o) => o.svc_id) + : [] + extraLlm.value = llms + .filter((o) => o.svc_id !== llmSaved.value && !llmChoicesSaved.value.includes(o.svc_id)) + .map((o) => o.svc_id) resetAsr() resetLlm() } catch (e: any) { @@ -325,7 +350,12 @@ async function saveAsr() { async function saveLlm() { saving.value = true try { - await webApi('saveechoorch', { app_name: scopeApp.value, llm: llmPick.value }) + await webApi('saveechoorch', { + app_name: scopeApp.value, + llm: llmPick.value, + // 默认模型不在备选里重复;没选默认时备选没有意义 + llm_choices: llmPick.value ? llmChoices.value.filter((id) => id !== llmPick.value) : [], + }) success('会议总结已保存') await loadOrch() } catch (e: any) { diff --git a/apps/client/lib/data/services/meeting/meeting_task_service.dart b/apps/client/lib/data/services/meeting/meeting_task_service.dart index 29829fb3..ec80cdfc 100644 --- a/apps/client/lib/data/services/meeting/meeting_task_service.dart +++ b/apps/client/lib/data/services/meeting/meeting_task_service.dart @@ -66,8 +66,9 @@ class MeetingTaskService extends GetxService { String tolanguage, bool isdistinguishspeaker, int templateid, - String tid, - ) async { + String tid, { + String summarySvcId = '', + }) async { if (audiourl.isEmpty) { Map uploadData = _uploadService.uploadList.firstWhere( (element) => element['id'] == id, @@ -84,6 +85,7 @@ class MeetingTaskService extends GetxService { uploadData['isdistinguishspeaker'] = isdistinguishspeaker; uploadData['templateid'] = templateid; uploadData['tid'] = tid; + uploadData['summarySvcId'] = summarySvcId; return true; // 上传完成时由 MeetingUploadService 提交 } return submitTask( @@ -93,6 +95,7 @@ class MeetingTaskService extends GetxService { isdistinguishspeaker, templateid, tid, + summarySvcId: summarySvcId, ); } return submitTask( @@ -102,6 +105,7 @@ class MeetingTaskService extends GetxService { isdistinguishspeaker, templateid, tid, + summarySvcId: summarySvcId, ); } @@ -135,14 +139,19 @@ class MeetingTaskService extends GetxService { /// (无会员 5302、模板不存在…)时把 data 置 null 后 resolve,原来这里不判、 /// 照样把任务塞进 _taskList 并把本地状态写成 1,列表就永远显示「等待转写中」, /// 而服务端那条记录其实还停在 0。 + /// + /// [formlanguage] 为空 = 录音语言交给识别服务自动判断;[tolanguage] 为空 = 不翻译, + /// 总结用录音本身的语言写。转写内容永远不翻译,[tolanguage] 只作用于总结。 + /// [summarySvcId] 为空 = 用后台默认的总结模型。 Future submitTask( int id, String formlanguage, String tolanguage, bool isdistinguishspeaker, int templateid, - String tid, - ) async { + String tid, { + String summarySvcId = '', + }) async { final resp = await Api.startTaskEchomeet({ 'id': id, 'formlanguage': formlanguage, @@ -150,6 +159,7 @@ class MeetingTaskService extends GetxService { 'isdistinguishspeaker': isdistinguishspeaker, 'templateid': templateid, 'tid': tid, + if (summarySvcId.isNotEmpty) 'summary_svc_id': summarySvcId, }); if (resp == null) { Logger.w('MeetingTask', @@ -168,13 +178,17 @@ class MeetingTaskService extends GetxService { return true; } - Future refreshTask(int id, int templateid, String tid, String tolanguage) async { + /// 重新总结。[tolanguage] 为空 = 不翻译(用录音本身的语言),传给服务端的是 `auto`: + /// 这个接口的空串历来表示「沿用记录上次的目标语言」,不能拿它表达「改回不翻译」。 + Future refreshTask(int id, int templateid, String tid, String tolanguage, + {String summarySvcId = ''}) async { tasktype.value = 3; await Api.refreshTaskEchomeet({ 'id': id, 'templateid': templateid, 'tid': tid, - 'tolanguage': tolanguage, + 'tolanguage': tolanguage.isEmpty ? 'auto' : tolanguage, + if (summarySvcId.isNotEmpty) 'summary_svc_id': summarySvcId, }); _taskList.add({ 'id': id, diff --git a/apps/client/lib/data/services/meeting/meeting_upload_service.dart b/apps/client/lib/data/services/meeting/meeting_upload_service.dart index b3f3f6f6..f46c62e7 100644 --- a/apps/client/lib/data/services/meeting/meeting_upload_service.dart +++ b/apps/client/lib/data/services/meeting/meeting_upload_service.dart @@ -148,6 +148,7 @@ class MeetingUploadService extends GetxService { element['isdistinguishspeaker'], element['templateid'], element['tid'] ?? '', + summarySvcId: element['summarySvcId'] ?? '', ); } element['audiourl'] = audiourl; diff --git a/apps/client/lib/data/services/network/api.dart b/apps/client/lib/data/services/network/api.dart index ae48cc99..85bdcd72 100644 --- a/apps/client/lib/data/services/network/api.dart +++ b/apps/client/lib/data/services/network/api.dart @@ -502,6 +502,16 @@ class Api { ); } + /// 会议纪要可选的总结大模型:`{client_choice, services:[{svc_id,name,is_default}]}`。 + /// client_choice=false 时后台不允许客户端指定,services 只含默认那一个。 + static getSummaryServicesEchomeet() { + return DioManager().request( + NWMethod.post, + '/api/home/echomeet_getsummaryservices', + params: {}, + ); + } + static refreshTaskEchomeet([params]) { return DioManager().request( NWMethod.post, diff --git a/apps/client/lib/modules/meeting/controllers/meeting_details_controller.dart b/apps/client/lib/modules/meeting/controllers/meeting_details_controller.dart index ba93dc61..39052576 100644 --- a/apps/client/lib/modules/meeting/controllers/meeting_details_controller.dart +++ b/apps/client/lib/modules/meeting/controllers/meeting_details_controller.dart @@ -81,7 +81,7 @@ class MeetingDetailsController extends GetxController @override void onInit() { super.onInit(); - tabController = TabController(length: 4, vsync: this); + tabController = TabController(length: 5, vsync: this); tabController.addListener(() { if (currentIndex.value == 2 && tabController.index != 2) { FocusScope.of(Get.context!).unfocus(); @@ -564,8 +564,9 @@ class MeetingDetailsController extends GetxController String tolanguage, bool isdistinguishspeaker, int templateid, - String tid, - ) async { + String tid, { + String summarySvcId = '', + }) async { EasyLoading.show(); final String audiourl = meetingData.value.audiourl; final bool accepted = await _meetingTaskService.addTask( @@ -576,6 +577,7 @@ class MeetingDetailsController extends GetxController isdistinguishspeaker, templateid, tid, + summarySvcId: summarySvcId, ); EasyLoading.dismiss(); // ⚠️ 没受理(服务端拒绝 / 上传队列里找不到这条)就别把页面切成「等待转写」: @@ -591,9 +593,11 @@ class MeetingDetailsController extends GetxController _syncProgressPolling(); } - void refreshSummary(int templateid, String tid, String tolanguage) async { + void refreshSummary(int templateid, String tid, String tolanguage, + {String summarySvcId = ''}) async { EasyLoading.show(); - await _meetingTaskService.refreshTask(id, templateid, tid, tolanguage); + await _meetingTaskService.refreshTask(id, templateid, tid, tolanguage, + summarySvcId: summarySvcId); meetingDetails.value.tasktype = 3; meetingDetails.refresh(); _syncProgressPolling(); diff --git a/apps/client/lib/modules/meeting/controllers/meeting_home_controller.dart b/apps/client/lib/modules/meeting/controllers/meeting_home_controller.dart index 233d665f..0c964363 100644 --- a/apps/client/lib/modules/meeting/controllers/meeting_home_controller.dart +++ b/apps/client/lib/modules/meeting/controllers/meeting_home_controller.dart @@ -50,25 +50,6 @@ class MeetingHomeController extends GetxController { // 多选删除模式状态 final RxBool isSelectionMode = false.obs; - /// 顶栏右侧的算力摘要(「算力:设备 X · 账户 Y」,user_getcompute)。 - /// - /// 2026-09-25 以前这里显示的是旧「会议额度」meetintegral 的剩余时长 + 进度条——那个桶已随会员与算力改造停用、 - /// 新账号恒为 0,于是永远显示 00:00:00。现在语音纪要按算力计量,就显示算力。拿不到就不显示(null)。 - final RxnString computeText = RxnString(); - - Future refreshCompute() async { - try { - final resp = await Api.getCompute(); - if (resp is! Map) return; - final c = resp['compute']; - if (c is! Map) return; - final dev = (c['deviceleft'] as num?)?.toInt() ?? 0; - final usr = (c['userbalance'] as num?)?.toInt() ?? 0; - computeText.value = 'computeSummary'.trParams({'device': '$dev', 'user': '$usr'}); - } catch (_) { - // 展示项,失败不影响页面 - } - } final RxList selectedIds = [].obs; bool get isAllSelected => @@ -131,7 +112,6 @@ class MeetingHomeController extends GetxController { @override void onInit() { super.onInit(); - refreshCompute(); _requestStoragePermission(); _initGetDataList(); addMeetingAudio(); diff --git a/apps/client/lib/modules/meeting/module.dart b/apps/client/lib/modules/meeting/module.dart index f7dc1776..5b4dd465 100644 --- a/apps/client/lib/modules/meeting/module.dart +++ b/apps/client/lib/modules/meeting/module.dart @@ -21,6 +21,45 @@ final AgentModuleDescriptor meetingModule = AgentModuleDescriptor( icon: Icons.groups_2, gradient: const [Color(0xFFAB47BC), Color(0xFFEC407A)], onLaunch: () => HomeController.to.openMeet(), + // 本模块新增的文案(2026-09-26)。其余语种回落到英文。 + translations: const { + 'zh_CN': { + 'meetingTranslateDefault': '默认', + 'meetingSummaryModel': '总结模型', + 'meetingPpt': 'PPT', + 'meetingPptTitle': '一键生成演示文稿', + 'meetingPptDesc': '根据这份纪要的总结内容,自动整理成可演示的 PPT', + 'meetingPptGenerate': '生成 PPT', + 'meetingPptComingSoon': 'PPT 生成功能即将上线,敬请期待', + }, + 'zh_TW': { + 'meetingTranslateDefault': '預設', + 'meetingSummaryModel': '總結模型', + 'meetingPpt': 'PPT', + 'meetingPptTitle': '一鍵生成簡報', + 'meetingPptDesc': '根據這份紀要的總結內容,自動整理成可演示的 PPT', + 'meetingPptGenerate': '生成 PPT', + 'meetingPptComingSoon': 'PPT 生成功能即將上線,敬請期待', + }, + 'zh_HK': { + 'meetingTranslateDefault': '預設', + 'meetingSummaryModel': '總結模型', + 'meetingPpt': 'PPT', + 'meetingPptTitle': '一鍵生成簡報', + 'meetingPptDesc': '根據這份紀要的總結內容,自動整理成可演示的 PPT', + 'meetingPptGenerate': '生成 PPT', + 'meetingPptComingSoon': 'PPT 生成功能即將上線,敬請期待', + }, + 'en_US': { + 'meetingTranslateDefault': 'Default', + 'meetingSummaryModel': 'Summary model', + 'meetingPpt': 'PPT', + 'meetingPptTitle': 'Generate slides in one tap', + 'meetingPptDesc': 'Turn this summary into a presentation-ready deck', + 'meetingPptGenerate': 'Generate PPT', + 'meetingPptComingSoon': 'PPT generation is coming soon', + }, + }, pages: [ GetPage( name: Routes.meeting, diff --git a/apps/client/lib/modules/meeting/views/bottomSheet/generate_bottom_sheet.dart b/apps/client/lib/modules/meeting/views/bottomSheet/generate_bottom_sheet.dart index f53e2fcc..b8abc5b2 100644 --- a/apps/client/lib/modules/meeting/views/bottomSheet/generate_bottom_sheet.dart +++ b/apps/client/lib/modules/meeting/views/bottomSheet/generate_bottom_sheet.dart @@ -1,4 +1,6 @@ +import '../../../../core/utils/logger.dart'; import '../../../../data/services/db/sqflite_api.dart'; +import '../../../../data/services/network/api.dart'; import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:get/get.dart'; @@ -24,10 +26,39 @@ class _GenerateBottomSheetState extends State { final GetStorage _storage = GetStorage(); bool _isSpeaker = true; - String _formlanguageText = 'chineseSimplified'.tr; // 对应中文:中文普通话(简体) - String _formlanguage = 'zh-CN'; - String _tolanguageText = 'chineseSimplified'.tr; // 对应中文:中文普通话(简体) - String _tolanguage = 'zh-CN'; + + // 录音语言不再让用户选(2026-09-26):一律传空串,由识别服务自动判断是什么语言。 + + /// 翻译:空串 = 默认(不翻译,录音是什么语言就用什么语言总结); + /// 选了语言 = 总结输出成该语言。**只作用于总结**,转写内容始终是原文。 + String _tolanguage = ''; + String _tolanguageText = 'meetingTranslateDefault'.tr; + + /// 总结可选的语言。值用 App 界面语言的 BCP-47 码:服务端按它找同语种的模板版本, + /// 找不到也会退回模板本身、只靠提示词指定输出语言。 + static List> get _translateOptions => [ + {'name': 'meetingTranslateDefault'.tr, 'value': ''}, + {'name': 'chinese'.tr, 'value': 'zh-CN'}, + {'name': 'english'.tr, 'value': 'en-US'}, + {'name': 'japanese'.tr, 'value': 'ja-JP'}, + {'name': 'korean'.tr, 'value': 'ko-KR'}, + {'name': 'french'.tr, 'value': 'fr-FR'}, + {'name': 'german'.tr, 'value': 'de-DE'}, + {'name': 'spanish'.tr, 'value': 'es-ES'}, + {'name': 'portuguese'.tr, 'value': 'pt-PT'}, + {'name': 'italian'.tr, 'value': 'it-IT'}, + {'name': 'russian'.tr, 'value': 'ru-RU'}, + {'name': 'arabic'.tr, 'value': 'ar-SA'}, + {'name': 'thai'.tr, 'value': 'th-TH'}, + {'name': 'vietnamese'.tr, 'value': 'vi-VN'}, + {'name': 'indonesian'.tr, 'value': 'id-ID'}, + ]; + + /// 总结模型:服务端下发的可选列表(后台「会议配置 → 会议总结」+「允许客户端选择」开关)。 + /// 空 = 没拉到,整行不显示,走后台默认模型。 + List> _modelOptions = []; + String _summarySvcId = ''; + String _summarySvcName = ''; final Map _icons = { 'auto_stories': Icons.auto_stories, @@ -58,6 +89,42 @@ class _GenerateBottomSheetState extends State { void initState() { super.initState(); _getTemplate(); + _loadSummaryServices(); + } + + Future _loadSummaryServices() async { + try { + final resp = await Api.getSummaryServicesEchomeet(); + if (resp is! Map) return; + final list = (resp['services'] as List?) ?? const []; + final opts = >[]; + String defId = '', defName = ''; + for (final e in list) { + if (e is! Map) continue; + final id = '${e['svc_id'] ?? ''}'; + if (id.isEmpty) continue; + final name = '${e['name'] ?? ''}'.isEmpty ? id : '${e['name']}'; + opts.add({'name': name, 'value': id}); + if (e['is_default'] == true && defId.isEmpty) { + defId = id; + defName = name; + } + } + if (opts.isEmpty || !mounted) return; + final String last = _storage.read('meeting_last_summary_svc') ?? ''; + final hit = opts.firstWhere((o) => o['value'] == last, + orElse: () => defId.isNotEmpty + ? {'name': defName, 'value': defId} + : opts.first); + setState(() { + _modelOptions = opts; + _summarySvcId = hit['value']!; + _summarySvcName = hit['name']!; + }); + } catch (e) { + // 拉不到就不显示这一行,照旧用后台默认模型 + Logger.w('GenerateSheet', '拉总结模型列表失败: $e'); + } } void _getTemplate() async { @@ -278,58 +345,43 @@ class _GenerateBottomSheetState extends State { ), ), ), - if (!widget.isSummarizeAgain) - _row( - 'recordingLanguage'.tr, // 对应中文:录音语言 - value: _formlanguageText, - isDarkMode: isDarkMode, - onTap: () { - Get.bottomSheet( - RecordLanguageBottomSheet(_formlanguage), - isScrollControlled: true, - backgroundColor: - isDarkMode ? Colors.grey[850] : Colors.grey[100], - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.vertical( - top: Radius.circular(12.r), - ), - ), - ).then((value) { - if (value != null) { - setState(() { - _formlanguageText = value['name']; - _formlanguage = value['value']; - }); - } - }); - }, - ), _row( - 'targetLanguage'.tr, // 对应中文:目标语言 + 'translate'.tr, // 翻译:只翻译总结,转写保持原文 value: _tolanguageText, - isBorder: false, + isBorder: _modelOptions.isNotEmpty, isDarkMode: isDarkMode, - onTap: () { - Get.bottomSheet( - RecordLanguageBottomSheet(_tolanguage, title: 'targetLanguage'.tr), - isScrollControlled: true, - backgroundColor: - isDarkMode ? Colors.grey[850] : Colors.grey[100], - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.vertical( - top: Radius.circular(12.r), - ), - ), - ).then((value) { - if (value != null) { - setState(() { - _tolanguageText = value['name']; - _tolanguage = value['value']; - }); - } - }); - }, + onTap: () => _pick( + isDarkMode, + title: 'translate'.tr, + current: _tolanguage, + options: _translateOptions, + onPicked: (v) { + _tolanguage = v['value']!; + _tolanguageText = v['name']!; + }, + ), ), + if (_modelOptions.isNotEmpty) + _row( + 'meetingSummaryModel'.tr, // 总结模型 + value: _summarySvcName, + isBorder: false, + isDarkMode: isDarkMode, + onTap: _modelOptions.length < 2 + ? null + : () => _pick( + isDarkMode, + title: 'meetingSummaryModel'.tr, + current: _summarySvcId, + options: _modelOptions, + onPicked: (v) { + _summarySvcId = v['value']!; + _summarySvcName = v['name']!; + _storage.write( + 'meeting_last_summary_svc', _summarySvcId); + }, + ), + ), ], ), ), @@ -357,14 +409,16 @@ class _GenerateBottomSheetState extends State { _templateData['id'], _templateData['tid'] ?? '', _tolanguage, + summarySvcId: _summarySvcId, ); } else { _controller.summaryCreate( - _formlanguage, + '', // 录音语言:自动识别 _tolanguage, _isSpeaker, _templateData['id'], _templateData['tid'] ?? '', + summarySvcId: _summarySvcId, ); } }, @@ -390,6 +444,28 @@ class _GenerateBottomSheetState extends State { ); } + /// 底部弹一个单选列表(翻译语言 / 总结模型共用) + void _pick( + bool isDarkMode, { + required String title, + required String current, + required List> options, + required void Function(Map) onPicked, + }) { + Get.bottomSheet( + RecordLanguageBottomSheet(current, title: title, options: options), + isScrollControlled: true, + backgroundColor: isDarkMode ? Colors.grey[850] : Colors.grey[100], + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(12.r)), + ), + ).then((value) { + if (value is Map && mounted) { + setState(() => onPicked(Map.from(value))); + } + }); + } + Widget _item(Map data) { final isDarkMode = Theme.of(context).brightness == Brightness.dark; return Container( diff --git a/apps/client/lib/modules/meeting/views/bottomSheet/record_language_bottom_sheet.dart b/apps/client/lib/modules/meeting/views/bottomSheet/record_language_bottom_sheet.dart index e2b663e1..ce28ba88 100644 --- a/apps/client/lib/modules/meeting/views/bottomSheet/record_language_bottom_sheet.dart +++ b/apps/client/lib/modules/meeting/views/bottomSheet/record_language_bottom_sheet.dart @@ -6,7 +6,11 @@ class RecordLanguageBottomSheet extends StatefulWidget { final String language; final String? title; - const RecordLanguageBottomSheet(this.language, {super.key, this.title}); + /// 自定义选项(`{'name','value'}` 列表);不传用下面那张录音语言表 + final List>? options; + + const RecordLanguageBottomSheet(this.language, + {super.key, this.title, this.options}); @override State createState() => @@ -14,7 +18,9 @@ class RecordLanguageBottomSheet extends StatefulWidget { } class _RecordLanguageBottomSheetState extends State { - final List _languages = [ + late final List _languages = widget.options ?? _recordLanguages; + + final List _recordLanguages = [ {'name': 'chinese'.tr, 'value': 'zh-CN'}, // 对应中文:中文普通话(简体) {'name': 'cantonese'.tr, 'value': 'cant'}, // 对应中文:粤语 {'name': 'sichuan'.tr, 'value': 'sc'}, // 对应中文:四川话 diff --git a/apps/client/lib/modules/meeting/views/meeting_details_view.dart b/apps/client/lib/modules/meeting/views/meeting_details_view.dart index 063d3d38..ba8625f8 100644 --- a/apps/client/lib/modules/meeting/views/meeting_details_view.dart +++ b/apps/client/lib/modules/meeting/views/meeting_details_view.dart @@ -12,6 +12,7 @@ import '../utils/meeting_ui_utils.dart'; import 'tabs/mind_map_tab.dart'; import 'bottomSheet/more_bottom_sheet.dart'; import 'tabs/overview_tab.dart'; +import 'tabs/ppt_tab.dart'; import 'bottomSheet/share_bottom_sheet.dart'; import 'tabs/speech_tab.dart'; import 'tabs/summary_tab.dart'; @@ -335,6 +336,8 @@ class MeetingDetailsView extends GetView { isDarkMode), // 对应中文:总结 _iconItem(Icons.lan, 'mindMap'.tr, 3, isDarkMode), // 对应中文:思维导图 + _iconItem(Icons.slideshow, 'meetingPpt'.tr, 4, + isDarkMode), // PPT(目前只有界面) ], ), ), @@ -392,6 +395,16 @@ class MeetingDetailsView extends GetView { ? 'transcribing'.tr // 对应中文:正在转写... : 'analyzing'.tr) // 对应中文:正在分析... ), + // PPT 基于总结生成,所以和思维导图一样要等总结完成 + Obx(() => controller.meetingDetails.value.tasktype >= 5 + ? const PptTab() + : controller.meetingDetails.value.tasktype == 0 + ? const SizedBox() + : TranscribingPlaceholder( + onRefresh: controller.refreshFromServer, + controller.meetingDetails.value.tasktype < 3 + ? 'transcribing'.tr + : 'analyzing'.tr)), ], ), ), diff --git a/apps/client/lib/modules/meeting/views/meeting_home_view.dart b/apps/client/lib/modules/meeting/views/meeting_home_view.dart index 16157b66..c3e3eeea 100644 --- a/apps/client/lib/modules/meeting/views/meeting_home_view.dart +++ b/apps/client/lib/modules/meeting/views/meeting_home_view.dart @@ -358,7 +358,7 @@ class _MeetingHomeViewState extends State onPressed: () => Get.back(), ), title: Text( - '语音记录助手', + 'agentMeeting'.tr, // 语音纪要 style: TextStyle( fontSize: 17.sp, fontWeight: FontWeight.w600, @@ -366,25 +366,6 @@ class _MeetingHomeViewState extends State ), ), centerTitle: true, - // 顶栏右侧:算力摘要(原来是旧「会议额度」的剩余时长 + 进度条,那个桶已停用、新账号恒为 0) - bottom: PreferredSize( - preferredSize: Size.fromHeight(22.w), - child: Obx(() { - final text = _controller.computeText.value; - if (text == null) return const SizedBox.shrink(); - return Container( - padding: EdgeInsets.only(right: 12.w, bottom: 6.w), - alignment: Alignment.centerRight, - child: Text( - text, - style: TextStyle( - fontSize: 12.sp, - color: isDarkMode ? Colors.white70 : Colors.black54, - ), - ), - ); - }), - ), ); } diff --git a/apps/client/lib/modules/meeting/views/tabs/ppt_tab.dart b/apps/client/lib/modules/meeting/views/tabs/ppt_tab.dart new file mode 100644 index 00000000..dbe79fe5 --- /dev/null +++ b/apps/client/lib/modules/meeting/views/tabs/ppt_tab.dart @@ -0,0 +1,149 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:get/get.dart'; + +/// 纪要详情的「PPT」页签(2026-09-26 先上界面)。 +/// +/// 目前只有展示:一张示意图 + 「生成 PPT」按钮,点了提示功能即将上线。 +/// 真正接生成服务时,把 [_onGenerate] 换成调接口、再按状态显示进度/预览即可,页签位置不用动。 +class PptTab extends StatelessWidget { + const PptTab({super.key}); + + void _onGenerate() { + Get.snackbar( + 'meetingPpt'.tr, + 'meetingPptComingSoon'.tr, + snackPosition: SnackPosition.TOP, + duration: const Duration(seconds: 2), + ); + } + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final Color sub = isDark ? Colors.grey[400]! : Colors.grey[600]!; + return Center( + child: SingleChildScrollView( + padding: EdgeInsets.symmetric(horizontal: 24.w, vertical: 24.w), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _preview(isDark), + 24.verticalSpace, + Text( + 'meetingPptTitle'.tr, + style: TextStyle( + fontSize: 17.sp, + fontWeight: FontWeight.w600, + color: isDark ? Colors.white : Colors.black87, + ), + ), + 8.verticalSpace, + Text( + 'meetingPptDesc'.tr, + textAlign: TextAlign.center, + style: TextStyle(fontSize: 13.sp, color: sub, height: 1.5), + ), + 28.verticalSpace, + GestureDetector( + onTap: _onGenerate, + child: Container( + height: 46.w, + padding: EdgeInsets.symmetric(horizontal: 40.w), + alignment: Alignment.center, + decoration: BoxDecoration( + color: isDark ? Colors.white : Colors.black, + borderRadius: BorderRadius.circular(12.r), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.auto_awesome, + size: 18.sp, + color: isDark ? Colors.black : Colors.white), + 6.horizontalSpace, + Text( + 'meetingPptGenerate'.tr, + style: TextStyle( + fontSize: 16.sp, + color: isDark ? Colors.black : Colors.white, + ), + ), + ], + ), + ), + ), + ], + ), + ), + ); + } + + /// 示意图:一张 16:9 的「幻灯片」,标题条 + 几行要点占位 + Widget _preview(bool isDark) { + final Color card = isDark ? const Color(0xFF2D2D2D) : Colors.white; + final Color bar = isDark ? Colors.grey[700]! : Colors.grey[200]!; + return Container( + width: 240.w, + height: 135.w, + padding: EdgeInsets.all(14.w), + decoration: BoxDecoration( + color: card, + borderRadius: BorderRadius.circular(12.r), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: isDark ? 0.3 : 0.08), + blurRadius: 16, + offset: const Offset(0, 6), + ), + ], + ), + child: Row( + children: [ + Expanded( + flex: 3, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 90.w, + height: 12.w, + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFFAB47BC), Color(0xFFEC407A)], + ), + borderRadius: BorderRadius.circular(4.r), + ), + ), + 14.verticalSpace, + for (final w in [110.0, 90.0, 100.0, 70.0]) ...[ + Container( + width: w.w, + height: 7.w, + decoration: BoxDecoration( + color: bar, + borderRadius: BorderRadius.circular(3.r), + ), + ), + 8.verticalSpace, + ], + ], + ), + ), + Expanded( + flex: 2, + child: Container( + height: double.infinity, + decoration: BoxDecoration( + color: const Color(0xFFEC407A).withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(8.r), + ), + child: Icon(Icons.slideshow, + size: 36.sp, color: const Color(0xFFEC407A)), + ), + ), + ], + ), + ); + } +} diff --git a/apps/services/modules/console/api_echomeet_orch.go b/apps/services/modules/console/api_echomeet_orch.go index 4d2e501e..9320c9e6 100644 --- a/apps/services/modules/console/api_echomeet_orch.go +++ b/apps/services/modules/console/api_echomeet_orch.go @@ -167,7 +167,10 @@ func (this *serverComp) saveEchoOrch(c *gin.Context) { var req struct { AppName string `json:"app_name"` ASR *[]string `json:"asr"` // 转写:按顺序,第 1 个主用,其余依次兜底 - LLM *string `json:"llm"` // 总结:单选,空串 = 不配总结 + LLM *string `json:"llm"` // 总结:默认模型,空串 = 不配总结 + // 总结:除默认模型外,再给 App 用户挑选的模型(2026-09-26)。nil = 本次不动这些行的「对用户可见」, + // 也不动应用级开关;非 nil 时它就是完整的可选清单,并据此把「允许客户端选择」开关置为 len>0。 + LLMChoices *[]string `json:"llm_choices"` } if err := c.ShouldBindJSON(&req); err != nil { writeErr(c, pb.ErrorCode_ReqParameterError, err.Error()) @@ -208,6 +211,25 @@ func (this *serverComp) saveEchoOrch(c *gin.Context) { } want[echoKindLLM] = []string{llm} } + if req.LLMChoices != nil { + seen := map[string]bool{llm: true} + for _, id := range *req.LLMChoices { + id = strings.TrimSpace(id) + if id == "" || seen[id] { + continue + } + if llm == "" { + writeErr(c, pb.ErrorCode_ReqParameterError, "先选默认总结模型,再勾选供用户选择的模型") + return + } + if ok, msg := this.echoOrchSvcAllowed(req.AppName, id, echoKindLLM); !ok { + writeErr(c, pb.ErrorCode_ReqParameterError, msg) + return + } + seen[id] = true + want[echoKindLLM] = append(want[echoKindLLM], id) + } + } kinds = append(kinds, echoKindLLM) } if len(kinds) == 0 { @@ -236,11 +258,17 @@ func (this *serverComp) saveEchoOrch(c *gin.Context) { row := oldBy[kind][id] priority := int32((i + 1) * 10) isDefault := kind == echoKindLLM && i == 0 // 总结取 is_default;转写不用这个标记 + // 总结段第 2 个起是「供用户选择」的备选;默认模型本来就会下发,不需要这个标记。 + setVisible := kind == echoKindLLM && req.LLMChoices != nil + visible := i > 0 if row != nil { - // 已有行:只动顺序/启用/默认,cost_hint、client_visible 等运营填过的字段原样保留。 + // 已有行:只动顺序/启用/默认(和本次明确给了的「对用户可见」),cost_hint 等运营填过的字段原样保留。 row.Priority = priority row.Enabled = true row.IsDefault = isDefault + if setVisible { + row.ClientVisible = visible + } row.Updatetime = now if err := postgres.Save(comm.TableEchomeetOrch, row); err != nil { writeErr(c, pb.ErrorCode_DBError, err.Error()) @@ -251,6 +279,7 @@ func (this *serverComp) saveEchoOrch(c *gin.Context) { add := &EchoOrchestration{ AppName: req.AppName, Region: echoOrchEditRegion, Kind: kind, SvcId: id, Priority: priority, IsDefault: isDefault, Enabled: true, + ClientVisible: setVisible && visible, Createtime: now, Updatetime: now, } if err := postgres.Insert(comm.TableEchomeetOrch, add); err != nil { @@ -269,6 +298,26 @@ func (this *serverComp) saveEchoOrch(c *gin.Context) { } } + // 勾了备选模型 = 允许 App 用户挑;一个都没勾就关掉,客户端只看得到默认那一个。 + if req.LLM != nil && req.LLMChoices != nil { + choice := len(want[echoKindLLM]) > 1 + setting := &EchoOrchSetting{} + if err := postgres.FindOne(comm.TableEchomeetOrchSetting, setting, "app_name=? AND region=?", req.AppName, echoOrchEditRegion); err != nil { + setting = &EchoOrchSetting{AppName: req.AppName, Region: echoOrchEditRegion, LLMClientChoice: choice, Updatetime: now} + if err := postgres.Insert(comm.TableEchomeetOrchSetting, setting); err != nil { + writeErr(c, pb.ErrorCode_DBError, err.Error()) + return + } + } else if setting.LLMClientChoice != choice { + setting.LLMClientChoice = choice + setting.Updatetime = now + if err := postgres.Save(comm.TableEchomeetOrchSetting, setting); err != nil { + writeErr(c, pb.ErrorCode_DBError, err.Error()) + return + } + } + } + broadcastConfigChanged(comm.ConfigKindEchomeetOrch, "update", echoOrchEditRegion) // 通知 echomeet 运行时重载编排 writeOK(c, gin.H{"saved": kinds}) } diff --git a/apps/services/modules/echomeet/CLAUDE.md b/apps/services/modules/echomeet/CLAUDE.md index c99c8a8c..e37aa202 100644 --- a/apps/services/modules/echomeet/CLAUDE.md +++ b/apps/services/modules/echomeet/CLAUDE.md @@ -60,6 +60,21 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co 输入统一是客户端 BCP-47(`zh-CN`/`en-US`…)。每个平台格式不同,core.go 逐家维护 map。**铁律:未识别语言一律返回原值,绝不 fallback 成 `zh`**——否则「未知语言→中文」会藏在调用栈深处。翻译时在 `TranslateProcess` 直接传 BCP-47,由各 provider 内部转换,不要提前转(会被二次转换误判)。 +## 语言口径:录音自动识别、转写不翻译、翻译只作用于总结(2026-09-26) + +- **录音语言**:客户端不再让用户选,`formlanguage` 传空串(`"auto"` 同义,见 `normalizeAutoLang`)。 + `PickASR("")` 不按语言过滤;阿里 paraformer 不带 `language_hints` 即自动识别;Azure 带 + `languageIdentification.candidateLocales`(被拒时退回 en-US 重建,见 `sys/microsoft/speech`);字节空语言即自动。 +- **转写永不翻译**:`TranslateProcess` 只做 `Translate = Original`(字段仍要填,客户端「转写」页与分享页读它)。 + StartTask 不再选翻译服务(`MtSvcId` 新记录恒空)。 +- **翻译 = 总结的输出语言**:`tolanguage` 空 = 跟随转写语言;有值 = 总结/概览用该语言写。 + 由 `summaryLanguageInstruction` 追加在模板 system prompt 末尾(英文元指令,与模板语种无关)。 + 模板按 `ResolveSummaryTemplate`:有目标语言先找该语种版本,找不到或不翻译就用客户端选中的那条本身。 +- ⚠️ `echomeet_summary`(重新总结)里 `tolanguage` 空串 = **沿用记录上次的**(老客户端语义), + 要「改回不翻译」得传 `"auto"`。StartTask 里空串与 `"auto"` 都是不翻译。 +- 总结模型:客户端从 `echomeet_getsummaryservices` 拉列表、把 `summary_svc_id` 带进 starttask/summary; + 后台「会议配置 → 会议总结」的「允许客户端选择」关着时服务端忽略它、只下发默认那一个。 + ## AI 总结输入约定(含图片) `AIProcess`(tasks.go)拼 `originalText`:把 `translate`(已翻译的分句)按 `[说话人]:内容` 拼接;若 `record.Remark`(用户创建时填的备注)非空,用语言中立的结构化标签包裹:`…\n\n\n…\n`——**不要硬编码某语言的提示词**,避免污染多语言总结。模板 `template.Template`/`template.Outline` 作为 system prompt,分别产出 `summary`/`overview`(两路并发)。 diff --git a/apps/services/modules/echomeet/api_starttask.go b/apps/services/modules/echomeet/api_starttask.go index c5573a95..0d5670f4 100644 --- a/apps/services/modules/echomeet/api_starttask.go +++ b/apps/services/modules/echomeet/api_starttask.go @@ -117,7 +117,11 @@ func (this *apiComp) StartTask(session comm.IUserSession, req *pb.EchomeetStartT } _ = this.module.model.addUseLog(userlog) - tmpl, err := this.module.cache.ResolveTemplate(req.Tid, req.Templateid, req.Tolanguage) + // 2026-09-26 起:录音语言空 = 交给识别服务自动判断;目标语言空 = 不翻译(总结用录音本身的语言)。 + // 老客户端两个都传真实语种,照旧能用。"auto" 与空串同义(重新总结接口里空串另有含义)。 + req.Formlanguage = normalizeAutoLang(req.Formlanguage) + req.Tolanguage = normalizeAutoLang(req.Tolanguage) + tmpl, err := this.module.cache.ResolveSummaryTemplate(req.Tid, req.Templateid, req.Tolanguage) if err != nil { errdata = &pb.ErrorData{ Code: pb.ErrorCode_TemplateNotFound, @@ -143,19 +147,13 @@ func (this *apiComp) StartTask(session comm.IUserSession, req *pb.EchomeetStartT return } model.LlmSvcId = llmSvcId - // 翻译:源=目标语言时无需翻译,选路失败不阻断(后续改语言重译时再补选)。 - if mtSvcId, _, merr := this.module.providers.PickMT(model.Formlanguage); merr == nil { - model.MtSvcId = mtSvcId - } else if model.Formlanguage != model.Tolanguage { - errdata = &pb.ErrorData{Code: pb.ErrorCode_SystemError, Message: merr.Error()} - return - } + // 转写内容不再翻译(目标语言只作用于总结,由大模型直接按目标语言输出),不需要翻译选路。 if model.Rtype == "VOICETRANSLAT" { // 语音翻译:已有转写文本,只走翻译+总结,无需识别选路。 this.module.model.saverecord(model) chargeSaved = true - this.module.Infof("StartTask id:%d → 语音翻译 mt:%s llm:%s", model.Id, model.MtSvcId, model.LlmSvcId) + this.module.Infof("StartTask id:%d → 语音翻译 llm:%s", model.Id, model.LlmSvcId) var contexts []*pb.ContextStruct if err = json.Unmarshal([]byte(model.Original), &contexts); err != nil { this.module.Errorf("Summary id:%d 解析 Original 失败 err:%v", model.Id, err) @@ -187,9 +185,9 @@ func (this *apiComp) StartTask(session comm.IUserSession, req *pb.EchomeetStartT this.module.model.saverecord(model) chargeSaved = true - this.module.Infof("StartTask id:%d uid:%s formlang:%s tolang:%s seconds:%d size:%d distinguishSpeaker:%v asr:%s mt:%s llm:%s", + this.module.Infof("StartTask id:%d uid:%s formlang:%q tolang:%q seconds:%d size:%d distinguishSpeaker:%v asr:%s llm:%s", model.Id, model.Uid, model.Formlanguage, model.Tolanguage, model.Seconds, model.Size, model.Isdistinguishspeaker, - model.AsrSvcId, model.MtSvcId, model.LlmSvcId) + model.AsrSvcId, model.LlmSvcId) // 字节短音频走专用限流队列(flash 同步通道);其余统一走通用异步提交。 if this.module.providers.ASRProvider(asrSvcId) == "bytedance" && diff --git a/apps/services/modules/echomeet/api_summary.go b/apps/services/modules/echomeet/api_summary.go index aa126329..1fa73aa8 100644 --- a/apps/services/modules/echomeet/api_summary.go +++ b/apps/services/modules/echomeet/api_summary.go @@ -3,11 +3,10 @@ package echomeet import ( "yunyan/comm" "yunyan/pb" - "encoding/json" ) // @Summary 会议记录智能总结 -// @Description 按指定模板和语言对已有会议记录进行翻译与 AI 总结 +// @Description 按指定模板和语言对已有会议记录重新 AI 总结(转写内容不翻译,语言只作用于总结) // @Tags Echomeet // @Accept json // @Produce json @@ -28,10 +27,11 @@ func (this *apiComp) Summary(session comm.IUserSession, req *pb.EchomeetSummaryR errdata = recordErr(err) return } - // 确定目标语言 + // 确定总结语言:空串 = 沿用记录上次的(老客户端的语义,保留); + // "auto" = 改回不翻译(跟随录音语言);其余 = 用这个语言写总结。 language := model.Tolanguage if req.Tolanguage != "" { - language = req.Tolanguage + language = normalizeAutoLang(req.Tolanguage) } // 确定模板:tid 优先,其次 req.Templateid,最后沿用记录中的 templateid tid := req.Tid @@ -47,7 +47,7 @@ func (this *apiComp) Summary(session comm.IUserSession, req *pb.EchomeetSummaryR tid = base.Tid } } - tmpl, err := this.module.cache.ResolveTemplate(tid, templateid, language) + tmpl, err := this.module.cache.ResolveSummaryTemplate(tid, templateid, language) if err != nil { errdata = &pb.ErrorData{ Code: pb.ErrorCode_TemplateNotFound, @@ -66,28 +66,14 @@ func (this *apiComp) Summary(session comm.IUserSession, req *pb.EchomeetSummaryR model.LlmSvcId = llmSvcId } this.module.Infof("Summary id:%d → llm:%s", model.Id, model.LlmSvcId) - // 是否需要重新翻译: - // - 客户端显式传入 tolanguage(重新总结操作,强制重译以保证 Translate 与 Tolanguage 一致);或 - // - 目标语言变化;或 - // - 当前 Translate 为空(上次翻译失败/缺失,需要补做)。 - // 强制重译的代价是多一次翻译调用,换取避免「Tolanguage 已改但 Translate 仍是旧语种」的状态污染。 - needTranslate := req.Tolanguage != "" || language != model.Tolanguage || model.Translate == "" - this.module.Infof("Summary id:%d → language:%s (model.Tolanguage:%s, Translate空=%v) needTranslate=%v", - model.Id, language, model.Tolanguage, model.Translate == "", needTranslate) - if needTranslate { - var contexts []*pb.ContextStruct - if err = json.Unmarshal([]byte(model.Original), &contexts); err != nil { - this.module.Errorf("Summary id:%d 解析 Original 失败 err:%v", model.Id, err) - } else { - prevTo := model.Tolanguage - model.Tolanguage = language - if terr := this.module.tasks.TranslateProcess(model, contexts); terr != nil { - // 翻译失败时回滚 Tolanguage,避免下次因 language==Tolanguage 而跳过重试 - model.Tolanguage = prevTo - } - } - this.module.model.saverecord(model) + // 转写内容不再翻译(2026-09-26),换总结语言只需改 Tolanguage 再总结一次, + // 由 AIProcess 按它给大模型下输出语言指令。 + model.Tolanguage = language + if model.Translate == "" { + model.Translate = model.Original } + this.module.Infof("Summary id:%d → 总结语言:%q", model.Id, language) + this.module.model.saverecord(model) _ = this.module.tasks.SubmitAITask(model) resp = &pb.EchomeetAddRecordResp{ Record: model, diff --git a/apps/services/modules/echomeet/core.go b/apps/services/modules/echomeet/core.go index 50750379..afa5f132 100644 --- a/apps/services/modules/echomeet/core.go +++ b/apps/services/modules/echomeet/core.go @@ -1,5 +1,10 @@ package echomeet +import ( + "fmt" + "strings" +) + const ( MaxShortAudioProcess = 10 MaxLongAudioProcess = 10 @@ -201,3 +206,47 @@ func baseLang(bcp47 string) string { } return bcp47 } + +// ============== 会议纪要:自动语种 / 总结输出语言 ============== + +// normalizeAutoLang 客户端表达「自动 / 不翻译」的两种写法(空串、"auto")统一成空串。 +func normalizeAutoLang(lang string) string { + lang = strings.TrimSpace(lang) + if strings.EqualFold(lang, "auto") { + return "" + } + return lang +} + +// summaryLangNames 总结输出语言的英文名,拼进给大模型的指令里(模型对语种名比对 BCP-47 码更稳)。 +// 不在表里的按原码写,模型一般也认得。 +var summaryLangNames = map[string]string{ + "zh-CN": "Simplified Chinese", "zh-TW": "Traditional Chinese", "zh-HK": "Traditional Chinese", + "en-US": "English", "en-GB": "English", + "ja-JP": "Japanese", "ko-KR": "Korean", + "fr-FR": "French", "de-DE": "German", + "es-ES": "Spanish", "es-MX": "Spanish", + "pt-PT": "Portuguese", "pt-BR": "Portuguese", + "it-IT": "Italian", "ru-RU": "Russian", "ar-SA": "Arabic", + "th-TH": "Thai", "vi-VN": "Vietnamese", "id-ID": "Indonesian", + "ms-MY": "Malay", "tr-TR": "Turkish", "nl-NL": "Dutch", "pl-PL": "Polish", + "hi-IN": "Hindi", "uk-UA": "Ukrainian", +} + +// summaryLanguageInstruction 追加在总结/概览模板 system prompt 末尾的输出语言要求。 +// +// 用英文写是刻意的:它是给模型的元指令,不是展示给用户的文字,与模板本身的语种无关; +// 写成某一种自然语言的「请用中文回答」反而会和模板语言打架。 +// - to 为空 = 不翻译:跟随转写内容的语言(录音是英文就出英文纪要); +// - to 有值 = 翻译:整篇用目标语言输出,转写原文是什么语言都一样。 +func summaryLanguageInstruction(to string) string { + to = normalizeAutoLang(to) + if to == "" { + return "\n\n[Output language] Write the entire output in the same language as the transcript. Do not translate it into another language." + } + name := summaryLangNames[to] + if name == "" { + name = to + } + return fmt.Sprintf("\n\n[Output language] Write the entire output in %s (%s), regardless of the language used in the transcript.", name, to) +} diff --git a/apps/services/modules/echomeet/model_cache.go b/apps/services/modules/echomeet/model_cache.go index d09861bf..c57c94fb 100644 --- a/apps/services/modules/echomeet/model_cache.go +++ b/apps/services/modules/echomeet/model_cache.go @@ -167,6 +167,22 @@ func (this *modelCacheComp) GetTemplateForTid(tid string, language string) (temp // ResolveTemplate 解析模板(与 model.resolvetemplate 行为一致): // - 私有模板(templateid>=起点):单条、无多语言版本,按主键直取,忽略 tid/language; // - 公共模板:tid 非空按 tid+language 取对应语言版本,否则按主键。 +// ResolveSummaryTemplate 总结用的模板。目标语言有值时先找该语种的版本(格式、标题更地道); +// 没有该语种版本或目标语言为空(= 不翻译)时退回客户端选中的那条模板本身—— +// 输出语言由 AIProcess 追加的指令决定,不再依赖模板是哪种语言写的。 +func (this *modelCacheComp) ResolveSummaryTemplate(tid string, templateid uint64, language string) (*pb.DBEchoMeetTemplate, error) { + if language != "" { + if t, err := this.ResolveTemplate(tid, templateid, language); err == nil { + return t, nil + } + } + if templateid != 0 { + return this.GetTemplateForId(templateid) + } + // 只给了 tid、没有主键:按英文版兜底(公共模板 41 个语种都有英文版) + return this.GetTemplateForTid(tid, "en-US") +} + func (this *modelCacheComp) ResolveTemplate(tid string, templateid uint64, language string) (template *pb.DBEchoMeetTemplate, err error) { if templateid >= templatePrivateIdStart { return this.GetTemplateForId(templateid) diff --git a/apps/services/modules/echomeet/summary_lang_test.go b/apps/services/modules/echomeet/summary_lang_test.go new file mode 100644 index 00000000..1bc6c871 --- /dev/null +++ b/apps/services/modules/echomeet/summary_lang_test.go @@ -0,0 +1,33 @@ +package echomeet + +import ( + "strings" + "testing" +) + +// 客户端「自动 / 不翻译」的两种写法都要归一成空串 +func TestNormalizeAutoLang(t *testing.T) { + for in, want := range map[string]string{"": "", "auto": "", " AUTO ": "", "en-US": "en-US"} { + if got := normalizeAutoLang(in); got != want { + t.Errorf("normalizeAutoLang(%q)=%q want %q", in, got, want) + } + } +} + +// 不翻译 → 跟随转写语言;翻译 → 点名目标语种。两者绝不能混: +// 不翻译时若写出了某个具体语种,英文录音会被总结成中文。 +func TestSummaryLanguageInstruction(t *testing.T) { + for _, to := range []string{"", "auto"} { + got := summaryLanguageInstruction(to) + if !strings.Contains(got, "same language as the transcript") { + t.Errorf("to=%q 应跟随转写语言,得到 %q", to, got) + } + } + got := summaryLanguageInstruction("ja-JP") + if !strings.Contains(got, "Japanese (ja-JP)") { + t.Errorf("ja-JP 应点名日语,得到 %q", got) + } + if got := summaryLanguageInstruction("xx-YY"); !strings.Contains(got, "xx-YY") { + t.Errorf("表外语种应原样写码,得到 %q", got) + } +} diff --git a/apps/services/modules/echomeet/tasks.go b/apps/services/modules/echomeet/tasks.go index 2cd20a36..e47c3f2d 100644 --- a/apps/services/modules/echomeet/tasks.go +++ b/apps/services/modules/echomeet/tasks.go @@ -504,61 +504,18 @@ func (this *tasksComp) finishSyncTranscribe(rec *pb.DBEchoMeetRecord, contexts [ _ = this.submitAITaskOnce(rec) } -// 翻译处理流 -func (this *tasksComp) TranslateProcess(rec *pb.DBEchoMeetRecord, contexts []*pb.ContextStruct) error { - var ( - tests []string - results []string - ) - if rec.Formlanguage == rec.Tolanguage { - this.module.Infof("TranslateProcess id:%d → 源语言与目标语言相同(%s),跳过翻译", rec.Id, rec.Formlanguage) - rec.Translate = rec.Original - return nil - } - this.module.Infof("TranslateProcess id:%d → 开始翻译 %s→%s 共%d句", rec.Id, rec.Formlanguage, rec.Tolanguage, len(contexts)) - tests = make([]string, 0, len(contexts)) - for _, item := range contexts { - tests = append(tests, item.Content) - } - // 翻译服务:优先用记录已选路的 MtSvcId;为空(如创建时源=目标语言未选路,后来改了目标语言) - // 则现场按编排补选并回填记录。 - if rec.MtSvcId == "" { - svcId, _, perr := this.module.providers.PickMT(rec.Formlanguage) - if perr != nil { - this.module.Errorf("TranslateProcess id:%d 翻译选路失败 err:%v", rec.Id, perr) - return perr - } - rec.MtSvcId = svcId - } - translator, err := this.module.providers.GetTranslator(rec.MtSvcId) - if err != nil { - this.module.Errorf("TranslateProcess id:%d 取 translator 失败 err:%v", rec.Id, err) - return err - } - // 这里直接传 BCP-47,由各 provider 自行转换为其平台的语言码。 - // 不要在这里调用 toTranslateLang —— 否则 provider 内部再调一次会把 "en" 当未知 fallback 成 "zh",最终发出去的是 zh→zh。 - results, err = translator.Translate(context.Background(), rec.Formlanguage, rec.Tolanguage, tests) - // 判定「结果是否可用」只认长度对齐,不能只看 results==nil: - // 有的 provider(如字节)出错时用具名返回值裸 return,会带回一个长度不足的非 nil 切片。 - if err != nil && len(results) != len(tests) { - this.module.Errorf("TranslateProcess id:%d 翻译失败 svc:%s got=%d want=%d err:%v", - rec.Id, rec.MtSvcId, len(results), len(tests), err) - return err - } - if err != nil { - // 部分失败但结果完整(provider 已用原文占位):记日志后继续,不让整篇译文作废。 - this.module.Warnf("TranslateProcess id:%d 部分句子翻译失败(已用原文占位) svc:%s err:%v", - rec.Id, rec.MtSvcId, err) - } - for i, item := range contexts { - if i >= len(results) { - this.module.Warnf("TranslateProcess id:%d 翻译结果数量不足 期望:%d 实际:%d", rec.Id, len(contexts), len(results)) - return fmt.Errorf("translate result length mismatch: want=%d got=%d", len(contexts), len(results)) - } - item.Content = results[i] - } - rec.Translate = utils.ToString(contexts) - this.module.Infof("TranslateProcess id:%d → 翻译完成", rec.Id) +// 翻译处理流 —— 2026-09-26 起**转写内容不再翻译**。 +// +// 以前这里把整篇转写按 Formlanguage→Tolanguage 逐句机翻,总结再拿译文去做。现在口径是: +// 转写永远保持原文(用户听到什么就看到什么),「翻译」只作用于总结,由大模型直接按 +// 目标语言输出(见 AIProcess 里的 summaryLanguageInstruction),省掉一整轮机翻调用, +// 也避免逐句机翻把上下文切碎后总结质量变差。 +// +// Translate 字段仍然要填:客户端「转写」页和分享页读的是它,老数据里它也一直是那份展示文本。 +// contexts 参数保留是为了不动调用点。 +func (this *tasksComp) TranslateProcess(rec *pb.DBEchoMeetRecord, _ []*pb.ContextStruct) error { + rec.Translate = rec.Original + this.module.Infof("TranslateProcess id:%d → 转写保持原文,不翻译(总结语言:%q)", rec.Id, rec.Tolanguage) return nil } @@ -664,18 +621,20 @@ func (this *tasksComp) AIProcess(task interface{}) { this.module.Warnf("AIProcess id:%d 解析 imageurls 失败已忽略 err:%v", rec.Id, err) } } + // 输出语言写进 system prompt:选了翻译就用目标语言写,没选就跟随录音本身的语言。 + langRule := summaryLanguageInstruction(rec.Tolanguage) wg.Add(2) go func() { defer wg.Done() summary, err1 = summarizer.Chat(context.Background(), []ChatMessage{ - {Role: "system", Content: template.Template}, + {Role: "system", Content: template.Template + langRule}, {Role: "user", Content: originalText, Images: userImages}, }) }() go func() { defer wg.Done() overview, err2 = summarizer.Chat(context.Background(), []ChatMessage{ - {Role: "system", Content: template.Outline}, + {Role: "system", Content: template.Outline + langRule}, {Role: "user", Content: originalText, Images: userImages}, }) }() diff --git a/apps/services/sys/microsoft/speech/speech.go b/apps/services/sys/microsoft/speech/speech.go index 2830ba40..af42424c 100644 --- a/apps/services/sys/microsoft/speech/speech.go +++ b/apps/services/sys/microsoft/speech/speech.go @@ -44,18 +44,30 @@ func newSys(options Options) (sys *Speech, err error) { // ==================== 创建任务 ==================== type createTaskRequest struct { - DisplayName string `json:"displayName"` - Locale string `json:"locale"` - ContentUrls []string `json:"contentUrls"` - Properties createTaskProperties `json:"properties"` + DisplayName string `json:"displayName"` + Locale string `json:"locale"` + ContentUrls []string `json:"contentUrls"` + Properties createTaskProperties `json:"properties"` } type createTaskProperties struct { - DiarizationEnabled bool `json:"diarizationEnabled"` - WordLevelTimestampsEnabled bool `json:"wordLevelTimestampsEnabled"` - DisplayFormWordLevelTimestampsEnabled bool `json:"displayFormWordLevelTimestampsEnabled"` - PunctuationMode string `json:"punctuationMode,omitempty"` - ProfanityFilterMode string `json:"profanityFilterMode,omitempty"` + // 语种自动识别:请求不指定语言时带上候选语种,让 Azure 自己判断(locale 仍必填,作兜底)。 + LanguageIdentification *languageIdentification `json:"languageIdentification,omitempty"` + DiarizationEnabled bool `json:"diarizationEnabled"` + WordLevelTimestampsEnabled bool `json:"wordLevelTimestampsEnabled"` + DisplayFormWordLevelTimestampsEnabled bool `json:"displayFormWordLevelTimestampsEnabled"` + PunctuationMode string `json:"punctuationMode,omitempty"` + ProfanityFilterMode string `json:"profanityFilterMode,omitempty"` +} + +type languageIdentification struct { + CandidateLocales []string `json:"candidateLocales"` +} + +// autoDetectLocales 不指定语言时交给 Azure 自动识别的候选语种。Azure 批量转写最多接受 10 个。 +var autoDetectLocales = []string{ + "zh-CN", "en-US", "ja-JP", "ko-KR", "fr-FR", + "de-DE", "es-ES", "pt-BR", "ru-RU", "it-IT", } type createTaskResponse struct { @@ -69,15 +81,28 @@ func (this *Speech) CreateTask(audioURL, language string, enableSpeaker bool, ca if audioURL == "" { return "", fmt.Errorf("audioURL is empty") } + // 空语言 = 自动识别(语音纪要 2026-09-26 起不再让用户选录音语言) + var lid *languageIdentification if language == "" { language = "en-US" + lid = &languageIdentification{CandidateLocales: autoDetectLocales} } + taskID, err = this.createTask(audioURL, language, enableSpeaker, lid) + if err != nil && lid != nil { + // 自动识别的参数组合被拒时退回固定语种,宁可识别质量差也别让整个任务失败 + this.options.Log.Warnf("azure speech 自动识别语种建任务失败,退回 %s: %v", language, err) + taskID, err = this.createTask(audioURL, language, enableSpeaker, nil) + } + return +} +func (this *Speech) createTask(audioURL, language string, enableSpeaker bool, lid *languageIdentification) (taskID string, err error) { reqBody := createTaskRequest{ DisplayName: "echomeet-" + time.Now().Format("20060102150405"), Locale: language, ContentUrls: []string{audioURL}, Properties: createTaskProperties{ + LanguageIdentification: lid, DiarizationEnabled: enableSpeaker, WordLevelTimestampsEnabled: true, DisplayFormWordLevelTimestampsEnabled: true,