@@ -9,23 +9,36 @@ import type {
99 OrchestrationThreadStreamItem ,
1010 ThreadMessageSentPayload ,
1111} from "@t3tools/contracts" ;
12+ import {
13+ deriveVoiceTranscriptionsByAttachmentId ,
14+ readVoiceTranscriptionActivity ,
15+ readVoiceTranscriptionAttachmentId ,
16+ type VoiceTranscriptionState ,
17+ } from "@t3tools/shared/voiceTranscription" ;
1218
1319const LEGACY_VOICE_NOTE_PLACEHOLDER = "[Voice note]" ;
20+ const LEGACY_VOICE_NOTE_FAILED_PLACEHOLDER = "[Voice note — transcription failed]" ;
1421
1522function supportsAudioAttachments (
1623 capabilities : OrchestrationClientCapabilities | undefined ,
1724) : boolean {
1825 return capabilities ?. audioAttachments === true ;
1926}
2027
21- function legacyVoiceNoteText ( attachment : Extract < ChatAttachment , { type : "audio" } > ) : string {
22- const transcript = attachment . transcript ?. trim ( ) ;
28+ function legacyVoiceNoteText (
29+ attachment : Extract < ChatAttachment , { type : "audio" } > ,
30+ transcription ?: VoiceTranscriptionState ,
31+ ) : string {
32+ const transcript = ( transcription ?. transcript ?? attachment . transcript ) ?. trim ( ) ;
33+ const status = transcription ?. status ?? attachment . transcriptionStatus ;
34+ if ( status === "failed" ) return LEGACY_VOICE_NOTE_FAILED_PLACEHOLDER ;
2335 return transcript ? `[Voice note transcript]\n${ transcript } ` : LEGACY_VOICE_NOTE_PLACEHOLDER ;
2436}
2537
2638function projectMessageFields ( input : {
2739 readonly text : string ;
2840 readonly attachments ?: ReadonlyArray < ChatAttachment > | undefined ;
41+ readonly transcriptions ?: ReadonlyMap < string , VoiceTranscriptionState > | undefined ;
2942} ) : {
3043 readonly changed : boolean ;
3144 readonly text : string ;
@@ -45,7 +58,9 @@ function projectMessageFields(input: {
4558 }
4659
4760 const supportedAttachments = attachments . filter ( ( attachment ) => attachment . type !== "audio" ) ;
48- const voiceNoteText = audioAttachments . map ( legacyVoiceNoteText ) . join ( "\n\n" ) ;
61+ const voiceNoteText = audioAttachments
62+ . map ( ( attachment ) => legacyVoiceNoteText ( attachment , input . transcriptions ?. get ( attachment . id ) ) )
63+ . join ( "\n\n" ) ;
4964 const text = input . text . trim ( ) . length > 0 ? `${ input . text } \n\n${ voiceNoteText } ` : voiceNoteText ;
5065 return {
5166 changed : true ,
@@ -57,11 +72,12 @@ function projectMessageFields(input: {
5772export function projectOrchestrationMessageForClient (
5873 message : OrchestrationMessage ,
5974 capabilities : OrchestrationClientCapabilities | undefined ,
75+ transcriptions ?: ReadonlyMap < string , VoiceTranscriptionState > ,
6076) : OrchestrationMessage {
6177 if ( supportsAudioAttachments ( capabilities ) ) {
6278 return message ;
6379 }
64- const projected = projectMessageFields ( message ) ;
80+ const projected = projectMessageFields ( { ... message , transcriptions } ) ;
6581 if ( ! projected . changed ) {
6682 return message ;
6783 }
@@ -75,8 +91,9 @@ export function projectOrchestrationMessageForClient(
7591
7692function projectThreadMessageEventForClient (
7793 payload : ThreadMessageSentPayload ,
94+ transcriptions ?: ReadonlyMap < string , VoiceTranscriptionState > ,
7895) : ThreadMessageSentPayload {
79- const projected = projectMessageFields ( payload ) ;
96+ const projected = projectMessageFields ( { ... payload , transcriptions } ) ;
8097 if ( ! projected . changed ) {
8198 return payload ;
8299 }
@@ -95,10 +112,14 @@ export function projectOrchestrationThreadForClient(
95112 if ( supportsAudioAttachments ( capabilities ) ) {
96113 return thread ;
97114 }
115+ const transcriptions = deriveVoiceTranscriptionsByAttachmentId (
116+ thread . messages ,
117+ thread . activities ,
118+ ) ;
98119 return {
99120 ...thread ,
100121 messages : thread . messages . map ( ( message ) =>
101- projectOrchestrationMessageForClient ( message , capabilities ) ,
122+ projectOrchestrationMessageForClient ( message , capabilities , transcriptions ) ,
102123 ) ,
103124 } ;
104125}
@@ -166,3 +187,123 @@ export function projectOrchestrationThreadStreamItemForClient(
166187 return item ;
167188 }
168189}
190+
191+ interface VoiceMessageState {
192+ readonly payload : ThreadMessageSentPayload ;
193+ readonly attachmentIds : ReadonlyArray < string > ;
194+ }
195+
196+ export function makeOrchestrationThreadStreamProjectorForClient (
197+ capabilities : OrchestrationClientCapabilities | undefined ,
198+ seedThread ?: OrchestrationThread ,
199+ ) : ( item : OrchestrationThreadStreamItem ) => OrchestrationThreadStreamItem {
200+ if ( supportsAudioAttachments ( capabilities ) ) {
201+ return ( item ) => item ;
202+ }
203+
204+ const messageById = new Map < string , VoiceMessageState > ( ) ;
205+ const messageIdByAttachmentId = new Map < string , string > ( ) ;
206+ const transcriptions = new Map < string , VoiceTranscriptionState > ( ) ;
207+
208+ const registerMessage = ( payload : ThreadMessageSentPayload ) : void => {
209+ const attachmentIds = ( payload . attachments ?? [ ] )
210+ . filter ( ( attachment ) => attachment . type === "audio" )
211+ . map ( ( attachment ) => attachment . id ) ;
212+ if ( attachmentIds . length === 0 ) return ;
213+ const previous = messageById . get ( payload . messageId ) ;
214+ for ( const attachmentId of previous ?. attachmentIds ?? [ ] ) {
215+ messageIdByAttachmentId . delete ( attachmentId ) ;
216+ }
217+ messageById . set ( payload . messageId , { payload, attachmentIds } ) ;
218+ for ( const attachmentId of attachmentIds ) {
219+ messageIdByAttachmentId . set ( attachmentId , payload . messageId ) ;
220+ }
221+ } ;
222+
223+ const seed = ( thread : OrchestrationThread ) : void => {
224+ for ( const [ attachmentId , transcription ] of deriveVoiceTranscriptionsByAttachmentId (
225+ thread . messages ,
226+ thread . activities ,
227+ ) ) {
228+ transcriptions . set ( attachmentId , transcription ) ;
229+ }
230+ for ( const message of thread . messages ) {
231+ registerMessage ( {
232+ threadId : thread . id ,
233+ messageId : message . id ,
234+ role : message . role ,
235+ text : message . text ,
236+ ...( message . attachments ? { attachments : [ ...message . attachments ] } : { } ) ,
237+ turnId : message . turnId ,
238+ streaming : message . streaming ,
239+ createdAt : message . createdAt ,
240+ updatedAt : message . updatedAt ,
241+ } ) ;
242+ }
243+ } ;
244+
245+ if ( seedThread ) seed ( seedThread ) ;
246+
247+ return ( item ) => {
248+ if ( item . kind === "synchronized" ) return item ;
249+ if ( item . kind === "snapshot" ) {
250+ seed ( item . snapshot . thread ) ;
251+ return {
252+ ...item ,
253+ snapshot : projectOrchestrationThreadSnapshotForClient ( item . snapshot , capabilities ) ,
254+ } ;
255+ }
256+
257+ const event = item . event ;
258+ if ( event . type === "thread.message-sent" ) {
259+ registerMessage ( event . payload ) ;
260+ return {
261+ ...item ,
262+ event : projectOrchestrationEventForClient ( event , capabilities ) ,
263+ } ;
264+ }
265+ if ( event . type !== "thread.activity-appended" ) {
266+ return {
267+ ...item ,
268+ event : projectOrchestrationEventForClient ( event , capabilities ) ,
269+ } ;
270+ }
271+
272+ const transcription = readVoiceTranscriptionActivity ( event . payload . activity ) ;
273+ const explicitAttachmentId = readVoiceTranscriptionAttachmentId ( event . payload . activity ) ;
274+ const fallbackAttachmentId = deriveVoiceTranscriptionsByAttachmentId (
275+ [ ...messageById . values ( ) ] . map ( ( { payload } ) => payload ) ,
276+ [ event . payload . activity ] ,
277+ )
278+ . keys ( )
279+ . next ( ) . value ;
280+ const attachmentId =
281+ explicitAttachmentId && messageIdByAttachmentId . has ( explicitAttachmentId )
282+ ? explicitAttachmentId
283+ : fallbackAttachmentId ;
284+ if ( ! transcription || ! attachmentId ) return item ;
285+ transcriptions . set ( attachmentId , transcription ) ;
286+ const messageId = messageIdByAttachmentId . get ( attachmentId ) ;
287+ const message = messageId ? messageById . get ( messageId ) : undefined ;
288+ if ( ! message ) return item ;
289+
290+ return {
291+ kind : "event" ,
292+ event : {
293+ ...event ,
294+ type : "thread.message-sent" ,
295+ payload : {
296+ ...projectThreadMessageEventForClient (
297+ {
298+ ...message . payload ,
299+ updatedAt : event . payload . activity . createdAt ,
300+ replaceText : true ,
301+ } ,
302+ transcriptions ,
303+ ) ,
304+ replaceText : true ,
305+ } ,
306+ } ,
307+ } ;
308+ } ;
309+ }
0 commit comments