会议转写通常分批到达。用户刚结束产品评审,系统已经基于前 120 个片段生成摘要;几秒后最后 8 个片段补齐,又触发第二次整理。如果第一次请求更慢,它可能在第二次之后返回,把新摘要覆盖成缺少结论的旧版本。
AI 会议纪要舱的场景页已有三条行动项:需求范围确认、原型交付时间和灰度策略复核。界面还显示负责人、优先级和确认状态。我围绕“晚到的结果不能覆盖当前会议版本”补齐控制器,让本地演示数据也具备真实工作台需要的状态边界。
转写片段使用稳定序号
单纯按到达顺序拼接会造成句子错位。每个片段带有服务端序号、开始时间和版本;重复上报时保留更新版本。
exportinterfaceTranscriptSegment{id:stringsequence:numberspeakerId:stringstartedAt:numberendedAt:numbertext:stringrevision:numberfinal:boolean}exportinterfaceMeetingState{meetingId:stringphase:'idle'|'recording'|'summarizing'|'ready'|'error'transcriptVersion:numbersummaryRequestId:numbersegments:TranscriptSegment[]summary:MeetingSummary|undefinedmessage:string}exportinterfaceMeetingSummary{basedOnVersion:numberabstract:stringdecisions:string[]actions:ActionItem[]}exportinterfaceActionItem{id:stringtitle:stringowner:stringdueAt?:numberpriority:'high'|'normal'confirmed:boolean}transcriptVersion只在转写内容真正变化时递增。页面选中某条行动项不会改变它,避免纯界面操作触发重复摘要。
合并批次时按片段 ID 去重
exportfunctionmergeTranscript(current:TranscriptSegment[],batch:TranscriptSegment[]):TranscriptSegment[]{constmap=newMap<string,TranscriptSegment>()current.forEach((item:TranscriptSegment)=>map.set(item.id,item))batch.forEach((item:TranscriptSegment)=>{constprevious=map.get(item.id)if(!previous||item.revision>=previous.revision){map.set(item.id,{...item})}})returnArray.from(map.values()).sort((a:TranscriptSegment,b:TranscriptSegment)=>a.sequence-b.sequence||a.startedAt-b.startedAt)}排序优先使用稳定序号,时间戳作为同序号的兜底。临时片段升级为最终片段时,ID 不变、revision增加,列表不会出现两句几乎相同的转写。
每次摘要请求都携带版本
控制器生成递增请求 ID,同时记录请求对应的转写版本。回调到达时必须同时满足两个条件:请求 ID 仍是最新,转写版本也没有变化。
exportinterfaceSummaryTicket{requestId:numbertranscriptVersion:numbertext:string}exportclassMeetingController{state:MeetingStateconstructor(meetingId:string){this.state={meetingId,phase:'idle',transcriptVersion:0,summaryRequestId:0,segments:[],summary:undefined,message:''}}acceptBatch(batch:TranscriptSegment[]):void{constmerged=mergeTranscript(this.state.segments,batch)constchanged=JSON.stringify(merged)!==JSON.stringify(this.state.segments)if(!changed){return}this.state.segments=mergedthis.state.transcriptVersion+=1this.state.phase='recording'this.state.message=`已接收${merged.length}个片段`}createSummaryTicket():SummaryTicket{constrequestId=this.state.summaryRequestId+1this.state.summaryRequestId=requestIdthis.state.phase='summarizing'this.state.message='正在整理会议内容'return{requestId,transcriptVersion:this.state.transcriptVersion,text:this.state.segments.filter((item:TranscriptSegment)=>item.final).map((item:TranscriptSegment)=>`${item.speakerId}:${item.text}`).join('\n')}}applySummary(ticket:SummaryTicket,summary:MeetingSummary):boolean{if(ticket.requestId!==this.state.summaryRequestId||ticket.transcriptVersion!==this.state.transcriptVersion){returnfalse}this.state.summary={...summary,basedOnVersion:ticket.transcriptVersion}this.state.phase='ready'this.state.message='会议纪要已更新'returntrue}}示例里用序列化比较内容变化,便于说明规则。数据量较大时可以在批次合并过程中维护changed标志,避免每次序列化整份转写。
行动项确认不重新生成摘要
AI 提取的负责人可能不准确,所以行动项先进入待确认状态。用户修改负责人或截止时间时,只更新当前行动项,不触发新的 AI 请求。
exportfunctionconfirmAction(summary:MeetingSummary,actionId:string,owner:string,dueAt?:number):MeetingSummary{return{...summary,actions:summary.actions.map((item:ActionItem)=>item.id===actionId?{...item,owner:owner.trim(),dueAt,confirmed:true}:item)}}页面用行动项 ID 作为ForEach键。调整优先级或过滤“待确认”时,选中状态不会因为数组位置改变而跳到别的任务。
ForEach(this.summary.actions,(item:ActionItem,index:number)=>{Row({space:14}){Text(`${index+1}`).width(54).height(54).textAlign(TextAlign.Center).backgroundColor('#8B7CFF')Column({space:5}){Text(item.title).fontSize(17).fontWeight(FontWeight.Bold)Text(`负责人${item.owner}`).fontSize(12).fontColor('#8F9AAD')}.layoutWeight(1).alignItems(HorizontalAlign.Start)Text(item.confirmed?'已确认':'待确认')}.onClick(()=>{this.selectedActionId=item.id})},(item:ActionItem)=>item.id)错误状态保留上一版可用结果
摘要请求失败时不应清空已经确认的行动项。控制器把阶段设为error并保留state.summary,页面继续展示旧结果,同时给出“基于较早转写版本”的提示。用户可以重试,新的请求 ID 会自然淘汰更早的回调。
当前工程界面与数据均为本地演示,尚未接入真实语音转写或大模型服务。接入时只需让转写适配器产生TranscriptSegment,让摘要适配器消费SummaryTicket;请求版本、行动项确认和错误恢复仍由本地控制器管理。这样最后几句迟到,也不会把刚整理好的会议结论覆盖掉。