11import type * as schema from '@agentclientprotocol/sdk/dist/schema/index.js'
2- import type { ChatMessage , ModelConfig } from '@shared/presenter'
2+ import type { ChatMessage } from '@shared/presenter'
33
4- interface NormalizedContent {
5- type : 'text' | 'resource_link'
6- value : string
4+ interface FormatOptions {
5+ promptCapabilities ?: schema . PromptCapabilities
6+ includeSystemPrompt ?: boolean
77}
88
9+ interface FormatResult {
10+ blocks : schema . ContentBlock [ ]
11+ includedSystemPrompt : boolean
12+ }
13+
14+ type NormalizedContent =
15+ | { type : 'text' ; text : string }
16+ | { type : 'image' ; data : string ; mimeType : string ; uri ?: string }
17+ | { type : 'audio' ; data : string ; mimeType : string }
18+ | { type : 'resource_link' ; uri : string ; name ?: string ; mimeType ?: string }
19+ | { type : 'resource' ; uri : string ; text : string ; mimeType ?: string }
20+
21+ const DATA_URL_PATTERN = / ^ d a t a : ( [ ^ ; , ] + ) ; b a s e 6 4 , ( .* ) $ / s
22+
923export class AcpMessageFormatter {
10- format ( messages : ChatMessage [ ] , modelConfig : ModelConfig ) : schema . ContentBlock [ ] {
24+ format ( messages : ChatMessage [ ] , options : FormatOptions = { } ) : FormatResult {
1125 const blocks : schema . ContentBlock [ ] = [ ]
12- const configLine = this . buildConfigLine ( modelConfig )
13- if ( configLine ) {
14- blocks . push ( { type : 'text' , text : configLine } )
26+ const systemPrompt = options . includeSystemPrompt ? this . extractSystemPrompt ( messages ) : null
27+ if ( systemPrompt ) {
28+ blocks . push ( {
29+ type : 'text' ,
30+ text : `System instructions:\n${ systemPrompt } `
31+ } )
1532 }
1633
17- messages . forEach ( ( message ) => {
18- const prefix = ( message . role || 'unknown' ) . toUpperCase ( )
19- const normalized = this . normalizeContent ( message )
20- if ( normalized . length === 0 ) {
21- blocks . push ( { type : 'text' , text : `${ prefix } :` } )
22- return
23- }
24-
25- normalized . forEach ( ( item , index ) => {
26- if ( item . type === 'text' ) {
27- const label = index === 0 ? `${ prefix } : ` : ''
28- blocks . push ( { type : 'text' , text : `${ label } ${ item . value } ` } )
29- } else if ( item . type === 'resource_link' ) {
30- blocks . push ( { type : 'resource_link' , uri : item . value , name : prefix } )
31- }
34+ const userMessage = this . findLastUserMessage ( messages )
35+ if ( userMessage ) {
36+ this . normalizeContent ( userMessage ) . forEach ( ( item ) => {
37+ blocks . push ( this . toContentBlock ( item , options . promptCapabilities ) )
3238 } )
39+ }
3340
34- if ( message . tool_calls && message . tool_calls . length > 0 ) {
35- message . tool_calls . forEach ( ( toolCall ) => {
36- blocks . push ( {
37- type : 'text' ,
38- text : `${ prefix } TOOL CALL ${ toolCall . id || '' } : ${ toolCall . function ?. name || 'unknown' } ${ toolCall . function ?. arguments || '' } `
39- } )
40- } )
41- }
41+ if ( ! blocks . length ) {
42+ blocks . push ( { type : 'text' , text : '' } )
43+ }
4244
43- if ( message . role === 'tool' && typeof message . content === 'string' ) {
44- blocks . push ( {
45- type : 'text' ,
46- text : `TOOL RESPONSE${ message . tool_call_id ? ` (${ message . tool_call_id } )` : '' } : ${ message . content } `
47- } )
48- }
49- } )
45+ return {
46+ blocks,
47+ includedSystemPrompt : Boolean ( systemPrompt )
48+ }
49+ }
5050
51- return blocks
51+ private findLastUserMessage ( messages : ChatMessage [ ] ) : ChatMessage | null {
52+ for ( let index = messages . length - 1 ; index >= 0 ; index -= 1 ) {
53+ if ( messages [ index ] ?. role === 'user' ) {
54+ return messages [ index ]
55+ }
56+ }
57+ return null
5258 }
5359
54- private buildConfigLine ( modelConfig : ModelConfig ) : string {
55- const temperature = modelConfig . temperature ?? 0.6
56- const maxTokens = modelConfig . maxTokens ?? modelConfig . maxCompletionTokens ?? 4096
57- return `temperature=${ temperature } , maxTokens=${ maxTokens } `
60+ private extractSystemPrompt ( messages : ChatMessage [ ] ) : string | null {
61+ const systemMessage = messages . find ( ( message ) => message . role === 'system' )
62+ if ( ! systemMessage ) return null
63+
64+ const text = this . normalizeContent ( systemMessage )
65+ . filter ( ( item ) : item is Extract < NormalizedContent , { type : 'text' } > => item . type === 'text' )
66+ . map ( ( item ) => item . text . trim ( ) )
67+ . filter ( Boolean )
68+ . join ( '\n' )
69+
70+ return text || null
5871 }
5972
6073 private normalizeContent ( message : ChatMessage ) : NormalizedContent [ ] {
@@ -63,31 +76,121 @@ export class AcpMessageFormatter {
6376
6477 if ( typeof content === 'string' ) {
6578 if ( content . trim ( ) . length > 0 ) {
66- normalized . push ( { type : 'text' , value : content } )
79+ normalized . push ( { type : 'text' , text : content } )
6780 }
68- } else if ( Array . isArray ( content ) ) {
69- content . forEach ( ( rawPart ) => {
70- const part = rawPart as Record < string , unknown >
71- const type = typeof part . type === 'string' ? part . type : undefined
72-
73- if ( ( type === 'text' || type === 'input_text' ) && typeof part . text === 'string' ) {
74- normalized . push ( { type : 'text' , value : part . text } )
75- } else if ( type === 'image_url' ) {
76- const imageUrl = part [ 'image_url' ] as { url ?: string } | undefined
77- if ( imageUrl ?. url ) {
78- normalized . push ( { type : 'resource_link' , value : imageUrl . url } )
81+ return normalized
82+ }
83+
84+ if ( ! Array . isArray ( content ) ) {
85+ return normalized
86+ }
87+
88+ content . forEach ( ( rawPart ) => {
89+ const part = rawPart as Record < string , unknown >
90+ const type = typeof part . type === 'string' ? part . type : undefined
91+
92+ if ( ( type === 'text' || type === 'input_text' ) && typeof part . text === 'string' ) {
93+ normalized . push ( { type : 'text' , text : part . text } )
94+ return
95+ }
96+
97+ if ( type === 'image_url' || type === 'input_image' ) {
98+ const imageUrl = part . image_url as { url ?: string } | undefined
99+ const url = imageUrl ?. url
100+ if ( ! url ) return
101+ const image = this . parseDataUrl ( url )
102+ if ( image ) {
103+ normalized . push ( { type : 'image' , data : image . data , mimeType : image . mimeType , uri : url } )
104+ } else {
105+ normalized . push ( { type : 'resource_link' , uri : url , name : 'image' } )
106+ }
107+ return
108+ }
109+
110+ if ( type === 'input_audio' || type === 'audio' ) {
111+ const data = typeof part . data === 'string' ? part . data : undefined
112+ const mimeType =
113+ typeof part . mimeType === 'string'
114+ ? part . mimeType
115+ : typeof part . mime_type === 'string'
116+ ? part . mime_type
117+ : 'audio/mpeg'
118+ if ( data ) {
119+ normalized . push ( { type : 'audio' , data, mimeType } )
120+ }
121+ return
122+ }
123+
124+ if ( type === 'resource_link' && typeof part . uri === 'string' ) {
125+ normalized . push ( {
126+ type : 'resource_link' ,
127+ uri : part . uri ,
128+ name : typeof part . name === 'string' ? part . name : undefined ,
129+ mimeType : typeof part . mimeType === 'string' ? part . mimeType : undefined
130+ } )
131+ return
132+ }
133+
134+ if ( typeof part . text === 'string' ) {
135+ normalized . push ( { type : 'text' , text : part . text } )
136+ }
137+ } )
138+
139+ return normalized
140+ }
141+
142+ private toContentBlock (
143+ item : NormalizedContent ,
144+ capabilities ?: schema . PromptCapabilities
145+ ) : schema . ContentBlock {
146+ switch ( item . type ) {
147+ case 'text' :
148+ return { type : 'text' , text : item . text }
149+ case 'image' :
150+ if ( capabilities ?. image ) {
151+ return {
152+ type : 'image' ,
153+ data : item . data ,
154+ mimeType : item . mimeType ,
155+ ...( item . uri ? { uri : item . uri } : { } )
79156 }
80- } else if ( type === 'input_image' ) {
81- const imageUrl = part [ 'image_url' ] as { url ?: string } | undefined
82- if ( imageUrl ?. url ) {
83- normalized . push ( { type : 'resource_link' , value : imageUrl . url } )
157+ }
158+ return item . uri
159+ ? { type : 'resource_link' , uri : item . uri , name : 'image' , mimeType : item . mimeType }
160+ : { type : 'text' , text : `[image ${ item . mimeType } ]` }
161+ case 'audio' :
162+ if ( capabilities ?. audio ) {
163+ return { type : 'audio' , data : item . data , mimeType : item . mimeType }
164+ }
165+ return { type : 'text' , text : `[audio ${ item . mimeType } ]` }
166+ case 'resource' :
167+ if ( capabilities ?. embeddedContext ) {
168+ return {
169+ type : 'resource' ,
170+ resource : {
171+ uri : item . uri ,
172+ text : item . text ,
173+ ...( item . mimeType ? { mimeType : item . mimeType } : { } )
174+ }
84175 }
85- } else if ( typeof part . text === 'string' ) {
86- normalized . push ( { type : 'text' , value : part . text } )
87176 }
88- } )
177+ return { type : 'text' , text : item . text }
178+ case 'resource_link' :
179+ return {
180+ type : 'resource_link' ,
181+ uri : item . uri ,
182+ name : item . name ?? item . uri ,
183+ ...( item . mimeType ? { mimeType : item . mimeType } : { } )
184+ }
89185 }
186+ }
90187
91- return normalized
188+ private parseDataUrl ( value : string ) : { mimeType : string ; data : string } | null {
189+ const match = DATA_URL_PATTERN . exec ( value )
190+ if ( ! match ) return null
191+ return {
192+ mimeType : match [ 1 ] ,
193+ data : match [ 2 ]
194+ }
92195 }
93196}
0 commit comments