Browse Source

语音纪要:录音语言自动识别、转写不翻译、翻译只作用于总结;可选总结模型;详情加 PPT 页签(仅界面)

客户端
- 首页标题「语音记录助手」→「语音纪要」,去掉顶栏右侧的算力摘要
- 「生成」弹窗:去掉录音语言(传空,交给识别服务自动判断);「目标语言」改为「翻译」,
  默认「默认」= 不翻译、总结跟随录音语言,选了语言 = 总结/概览用该语言输出;
  新增「总结模型」一行(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) <noreply@anthropic.com>
main
Rodger-Wang 2 weeks ago
parent
commit
c12651e79c
  1. 56
      apps/admin/app/pages/meetconfig.vue
  2. 26
      apps/client/lib/data/services/meeting/meeting_task_service.dart
  3. 1
      apps/client/lib/data/services/meeting/meeting_upload_service.dart
  4. 10
      apps/client/lib/data/services/network/api.dart
  5. 14
      apps/client/lib/modules/meeting/controllers/meeting_details_controller.dart
  6. 20
      apps/client/lib/modules/meeting/controllers/meeting_home_controller.dart
  7. 39
      apps/client/lib/modules/meeting/module.dart
  8. 182
      apps/client/lib/modules/meeting/views/bottomSheet/generate_bottom_sheet.dart
  9. 10
      apps/client/lib/modules/meeting/views/bottomSheet/record_language_bottom_sheet.dart
  10. 13
      apps/client/lib/modules/meeting/views/meeting_details_view.dart
  11. 21
      apps/client/lib/modules/meeting/views/meeting_home_view.dart
  12. 149
      apps/client/lib/modules/meeting/views/tabs/ppt_tab.dart
  13. 53
      apps/services/modules/console/api_echomeet_orch.go
  14. 15
      apps/services/modules/echomeet/CLAUDE.md
  15. 20
      apps/services/modules/echomeet/api_starttask.go
  16. 38
      apps/services/modules/echomeet/api_summary.go
  17. 49
      apps/services/modules/echomeet/core.go
  18. 16
      apps/services/modules/echomeet/model_cache.go
  19. 33
      apps/services/modules/echomeet/summary_lang_test.go
  20. 73
      apps/services/modules/echomeet/tasks.go
  21. 43
      apps/services/sys/microsoft/speech/speech.go

56
apps/admin/app/pages/meetconfig.vue

@ -87,8 +87,8 @@
<section class="panel p-5">
<div class="text-sm font-semibold text-slate-700">会议总结(大模型)</div>
<div class="text-xs text-slate-400 mt-1 max-w-3xl">
从<b>第三方服务配置</b>里已配好的「文本大模型 / 多媒体大模型」类服务中选一个,用于生成纪要总结。
这一段<b>没有兜底</b>:选中的服务不可用时总结直接失败。
从<b>第三方服务配置</b>里已配好的「文本大模型 / 多媒体大模型」类服务中选一个<b>默认模型</b>,用于生成纪要总结;
还可以再勾选几个<b>供用户在 App 里自选</b>。这一段<b>没有兜底</b>:选中的服务不可用时总结直接失败。
</div>
<div v-if="loading" class="py-14 text-center"><span class="loading loading-spinner loading-sm"></span></div>
@ -104,16 +104,26 @@
</select>
</div>
<div v-if="llmPick && llmChoiceCandidates.length" class="form-control max-w-xl mt-4">
<label class="label">
<span class="label-text font-medium">供用户选择的模型</span>
<span class="label-text-alt text-slate-400">App「生成」弹窗里的「总结模型」;都不勾 = 只用默认模型</span>
</label>
<div class="flex flex-col gap-2">
<label v-for="s in llmChoiceCandidates" :key="s.id" class="flex items-center gap-2 cursor-pointer">
<input v-model="llmChoices" type="checkbox" class="checkbox checkbox-sm" :value="s.id">
<span class="text-sm">{{ s.name }}<span class="text-slate-400">({{ s.id }})</span></span>
</label>
</div>
</div>
<div v-if="!svcOf('llm').length" class="empty mt-4">
当前应用下没有可选的大模型服务,请先到<NuxtLink class="link" to="/serviceconfig">第三方服务配置</NuxtLink>添加并启用。
</div>
<div v-if="extraLlm.length" class="alert alert-warning py-2 text-xs mt-4">
<span>
该应用还另有 {{ extraLlm.length }} 个已配置的总结服务({{ extraLlm.join('、') }})。
保存后只保留上面选中的这一个,其余会从编排里移除(不影响第三方服务配置里的服务本身)。
<template v-if="clientChoice">
⚠️ 该应用开着「客户端可指定总结模型」,这些服务现在可能正被 App 端选用,移除后客户端只剩这一个可选。
</template>
该应用还另有 {{ extraLlm.length }} 个已配置、但没标为「供用户选择」的总结服务({{ extraLlm.join('、') }})。
保存后会从编排里移除(不影响第三方服务配置里的服务本身);要保留就在上面勾上。
</span>
</div>
@ -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<SvcView[]>([])
const kindCats = ref<Record<string, number[]>>({ '1': [8], '3': [5, 9] })
const regionRows = ref(0)
// 作用域开关:允许客户端自己指定总结模型。本页不编辑它(没有后台入口,需要时改库),
// 但要读出来——开着的时候「只保留一个总结服务」这件事对 App 端是有感知的。
// 作用域开关:允许客户端自己指定总结模型。不单独给开关:保存会议总结时按
// 「有没有勾供用户选择的模型」由后端一并置位(勾了=开,一个没勾=关)。
const clientChoice = ref(false)
// 库里当前的样子,用于「有没有改动」与「放弃修改」;编辑态是 asrPick / llmPick。
const asrSaved = ref<string[]>([])
@ -226,6 +237,10 @@ const llmSaved = ref('')
const extraLlm = ref<string[]>([])
const asrPick = ref<string[]>([])
const llmPick = ref('')
// 供用户在 App 里选择的备选总结模型(不含默认那个)
const llmChoicesSaved = ref<string[]>([])
const llmChoices = ref<string[]>([])
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) {

26
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<bool> 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<void> refreshTask(int id, int templateid, String tid, String tolanguage) async {
/// 重新总结。[tolanguage] 为空 = 不翻译(用录音本身的语言),传给服务端的是 `auto`:
/// 这个接口的空串历来表示「沿用记录上次的目标语言」,不能拿它表达「改回不翻译」。
Future<void> 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,

1
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;

10
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,

14
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();

20
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<void> 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<int> selectedIds = <int>[].obs;
bool get isAllSelected =>
@ -131,7 +112,6 @@ class MeetingHomeController extends GetxController {
@override
void onInit() {
super.onInit();
refreshCompute();
_requestStoragePermission();
_initGetDataList();
addMeetingAudio();

39
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,

182
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<GenerateBottomSheet> {
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<Map<String, String>> 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<Map<String, String>> _modelOptions = [];
String _summarySvcId = '';
String _summarySvcName = '';
final Map _icons = {
'auto_stories': Icons.auto_stories,
@ -58,6 +89,42 @@ class _GenerateBottomSheetState extends State<GenerateBottomSheet> {
void initState() {
super.initState();
_getTemplate();
_loadSummaryServices();
}
Future<void> _loadSummaryServices() async {
try {
final resp = await Api.getSummaryServicesEchomeet();
if (resp is! Map) return;
final list = (resp['services'] as List?) ?? const [];
final opts = <Map<String, String>>[];
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<GenerateBottomSheet> {
),
),
),
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<GenerateBottomSheet> {
_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<GenerateBottomSheet> {
);
}
/// 底部弹一个单选列表(翻译语言 / 总结模型共用)
void _pick(
bool isDarkMode, {
required String title,
required String current,
required List<Map<String, String>> options,
required void Function(Map<String, String>) 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<String, String>.from(value)));
}
});
}
Widget _item(Map data) {
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
return Container(

10
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<Map<String, String>>? options;
const RecordLanguageBottomSheet(this.language,
{super.key, this.title, this.options});
@override
State<RecordLanguageBottomSheet> createState() =>
@ -14,7 +18,9 @@ class RecordLanguageBottomSheet extends StatefulWidget {
}
class _RecordLanguageBottomSheetState extends State<RecordLanguageBottomSheet> {
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'}, // 对应中文:四川话

13
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<MeetingDetailsController> {
isDarkMode), // 对应中文:总结
_iconItem(Icons.lan, 'mindMap'.tr, 3,
isDarkMode), // 对应中文:思维导图
_iconItem(Icons.slideshow, 'meetingPpt'.tr, 4,
isDarkMode), // PPT(目前只有界面)
],
),
),
@ -392,6 +395,16 @@ class MeetingDetailsView extends GetView<MeetingDetailsController> {
? '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)),
],
),
),

21
apps/client/lib/modules/meeting/views/meeting_home_view.dart

@ -358,7 +358,7 @@ class _MeetingHomeViewState extends State<MeetingHomeView>
onPressed: () => Get.back(),
),
title: Text(
'语音记录助手',
'agentMeeting'.tr, // 语音纪要
style: TextStyle(
fontSize: 17.sp,
fontWeight: FontWeight.w600,
@ -366,25 +366,6 @@ class _MeetingHomeViewState extends State<MeetingHomeView>
),
),
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,
),
),
);
}),
),
);
}

149
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)),
),
),
],
),
);
}
}

53
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})
}

15
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`(用户创建时填的备注)非空,用语言中立的结构化标签包裹:`<remark>…</remark>\n\n<transcript>\n…\n</transcript>`——**不要硬编码某语言的提示词**,避免污染多语言总结。模板 `template.Template`/`template.Outline` 作为 system prompt,分别产出 `summary`/`overview`(两路并发)。

20
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" &&

38
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,

49
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)
}

16
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)

33
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)
}
}

73
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},
})
}()

43
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,

Loading…
Cancel
Save