1package message
  2
  3import (
  4	"encoding/base64"
  5	"errors"
  6	"slices"
  7	"strings"
  8	"time"
  9
 10	"charm.land/fantasy"
 11	"charm.land/fantasy/providers/anthropic"
 12	"charm.land/fantasy/providers/openai"
 13	"github.com/charmbracelet/catwalk/pkg/catwalk"
 14)
 15
 16type MessageRole string
 17
 18const (
 19	Assistant MessageRole = "assistant"
 20	User      MessageRole = "user"
 21	System    MessageRole = "system"
 22	Tool      MessageRole = "tool"
 23)
 24
 25type FinishReason string
 26
 27const (
 28	FinishReasonEndTurn          FinishReason = "end_turn"
 29	FinishReasonMaxTokens        FinishReason = "max_tokens"
 30	FinishReasonToolUse          FinishReason = "tool_use"
 31	FinishReasonCanceled         FinishReason = "canceled"
 32	FinishReasonError            FinishReason = "error"
 33	FinishReasonPermissionDenied FinishReason = "permission_denied"
 34
 35	// Should never happen
 36	FinishReasonUnknown FinishReason = "unknown"
 37)
 38
 39type ContentPart interface {
 40	isPart()
 41}
 42
 43type ReasoningContent struct {
 44	Thinking      string                             `json:"thinking"`
 45	Signature     string                             `json:"signature"`
 46	ResponsesData *openai.ResponsesReasoningMetadata `json:"responses_data"`
 47	StartedAt     int64                              `json:"started_at,omitempty"`
 48	FinishedAt    int64                              `json:"finished_at,omitempty"`
 49}
 50
 51func (tc ReasoningContent) String() string {
 52	return tc.Thinking
 53}
 54func (ReasoningContent) isPart() {}
 55
 56type TextContent struct {
 57	Text string `json:"text"`
 58}
 59
 60func (tc TextContent) String() string {
 61	return tc.Text
 62}
 63
 64func (TextContent) isPart() {}
 65
 66type ImageURLContent struct {
 67	URL    string `json:"url"`
 68	Detail string `json:"detail,omitempty"`
 69}
 70
 71func (iuc ImageURLContent) String() string {
 72	return iuc.URL
 73}
 74
 75func (ImageURLContent) isPart() {}
 76
 77type BinaryContent struct {
 78	Path     string
 79	MIMEType string
 80	Data     []byte
 81}
 82
 83func (bc BinaryContent) String(p catwalk.InferenceProvider) string {
 84	base64Encoded := base64.StdEncoding.EncodeToString(bc.Data)
 85	if p == catwalk.InferenceProviderOpenAI {
 86		return "data:" + bc.MIMEType + ";base64," + base64Encoded
 87	}
 88	return base64Encoded
 89}
 90
 91func (BinaryContent) isPart() {}
 92
 93type ToolCall struct {
 94	ID               string `json:"id"`
 95	Name             string `json:"name"`
 96	Input            string `json:"input"`
 97	ProviderExecuted bool   `json:"provider_executed"`
 98	Finished         bool   `json:"finished"`
 99}
100
101func (ToolCall) isPart() {}
102
103type ToolResult struct {
104	ToolCallID string `json:"tool_call_id"`
105	Name       string `json:"name"`
106	Content    string `json:"content"`
107	Data       string `json:"data"`
108	MIMEType   string `json:"mime_type"`
109	Metadata   string `json:"metadata"`
110	IsError    bool   `json:"is_error"`
111}
112
113func (ToolResult) isPart() {}
114
115type Finish struct {
116	Reason  FinishReason `json:"reason"`
117	Time    int64        `json:"time"`
118	Message string       `json:"message,omitempty"`
119	Details string       `json:"details,omitempty"`
120}
121
122func (Finish) isPart() {}
123
124type Message struct {
125	ID               string
126	Role             MessageRole
127	SessionID        string
128	Parts            []ContentPart
129	Model            string
130	Provider         string
131	CreatedAt        int64
132	UpdatedAt        int64
133	IsSummaryMessage bool
134}
135
136func (m *Message) Content() TextContent {
137	for _, part := range m.Parts {
138		if c, ok := part.(TextContent); ok {
139			return c
140		}
141	}
142	return TextContent{}
143}
144
145func (m *Message) ReasoningContent() ReasoningContent {
146	for _, part := range m.Parts {
147		if c, ok := part.(ReasoningContent); ok {
148			return c
149		}
150	}
151	return ReasoningContent{}
152}
153
154func (m *Message) ImageURLContent() []ImageURLContent {
155	imageURLContents := make([]ImageURLContent, 0)
156	for _, part := range m.Parts {
157		if c, ok := part.(ImageURLContent); ok {
158			imageURLContents = append(imageURLContents, c)
159		}
160	}
161	return imageURLContents
162}
163
164func (m *Message) BinaryContent() []BinaryContent {
165	binaryContents := make([]BinaryContent, 0)
166	for _, part := range m.Parts {
167		if c, ok := part.(BinaryContent); ok {
168			binaryContents = append(binaryContents, c)
169		}
170	}
171	return binaryContents
172}
173
174func (m *Message) ToolCalls() []ToolCall {
175	toolCalls := make([]ToolCall, 0)
176	for _, part := range m.Parts {
177		if c, ok := part.(ToolCall); ok {
178			toolCalls = append(toolCalls, c)
179		}
180	}
181	return toolCalls
182}
183
184func (m *Message) ToolResults() []ToolResult {
185	toolResults := make([]ToolResult, 0)
186	for _, part := range m.Parts {
187		if c, ok := part.(ToolResult); ok {
188			toolResults = append(toolResults, c)
189		}
190	}
191	return toolResults
192}
193
194func (m *Message) IsFinished() bool {
195	for _, part := range m.Parts {
196		if _, ok := part.(Finish); ok {
197			return true
198		}
199	}
200	return false
201}
202
203func (m *Message) FinishPart() *Finish {
204	for _, part := range m.Parts {
205		if c, ok := part.(Finish); ok {
206			return &c
207		}
208	}
209	return nil
210}
211
212func (m *Message) FinishReason() FinishReason {
213	for _, part := range m.Parts {
214		if c, ok := part.(Finish); ok {
215			return c.Reason
216		}
217	}
218	return ""
219}
220
221func (m *Message) IsThinking() bool {
222	if m.ReasoningContent().Thinking != "" && m.Content().Text == "" && !m.IsFinished() {
223		return true
224	}
225	return false
226}
227
228func (m *Message) AppendContent(delta string) {
229	found := false
230	for i, part := range m.Parts {
231		if c, ok := part.(TextContent); ok {
232			m.Parts[i] = TextContent{Text: c.Text + delta}
233			found = true
234		}
235	}
236	if !found {
237		m.Parts = append(m.Parts, TextContent{Text: delta})
238	}
239}
240
241func (m *Message) AppendReasoningContent(delta string) {
242	found := false
243	for i, part := range m.Parts {
244		if c, ok := part.(ReasoningContent); ok {
245			m.Parts[i] = ReasoningContent{
246				Thinking:   c.Thinking + delta,
247				Signature:  c.Signature,
248				StartedAt:  c.StartedAt,
249				FinishedAt: c.FinishedAt,
250			}
251			found = true
252		}
253	}
254	if !found {
255		m.Parts = append(m.Parts, ReasoningContent{
256			Thinking:  delta,
257			StartedAt: time.Now().Unix(),
258		})
259	}
260}
261
262func (m *Message) AppendReasoningSignature(signature string) {
263	for i, part := range m.Parts {
264		if c, ok := part.(ReasoningContent); ok {
265			m.Parts[i] = ReasoningContent{
266				Thinking:   c.Thinking,
267				Signature:  c.Signature + signature,
268				StartedAt:  c.StartedAt,
269				FinishedAt: c.FinishedAt,
270			}
271			return
272		}
273	}
274	m.Parts = append(m.Parts, ReasoningContent{Signature: signature})
275}
276
277func (m *Message) SetReasoningResponsesData(data *openai.ResponsesReasoningMetadata) {
278	for i, part := range m.Parts {
279		if c, ok := part.(ReasoningContent); ok {
280			m.Parts[i] = ReasoningContent{
281				Thinking:      c.Thinking,
282				ResponsesData: data,
283				StartedAt:     c.StartedAt,
284				FinishedAt:    c.FinishedAt,
285			}
286			return
287		}
288	}
289}
290
291func (m *Message) FinishThinking() {
292	for i, part := range m.Parts {
293		if c, ok := part.(ReasoningContent); ok {
294			if c.FinishedAt == 0 {
295				m.Parts[i] = ReasoningContent{
296					Thinking:   c.Thinking,
297					Signature:  c.Signature,
298					StartedAt:  c.StartedAt,
299					FinishedAt: time.Now().Unix(),
300				}
301			}
302			return
303		}
304	}
305}
306
307func (m *Message) ThinkingDuration() time.Duration {
308	reasoning := m.ReasoningContent()
309	if reasoning.StartedAt == 0 {
310		return 0
311	}
312
313	endTime := reasoning.FinishedAt
314	if endTime == 0 {
315		endTime = time.Now().Unix()
316	}
317
318	return time.Duration(endTime-reasoning.StartedAt) * time.Second
319}
320
321func (m *Message) FinishToolCall(toolCallID string) {
322	for i, part := range m.Parts {
323		if c, ok := part.(ToolCall); ok {
324			if c.ID == toolCallID {
325				m.Parts[i] = ToolCall{
326					ID:       c.ID,
327					Name:     c.Name,
328					Input:    c.Input,
329					Finished: true,
330				}
331				return
332			}
333		}
334	}
335}
336
337func (m *Message) AppendToolCallInput(toolCallID string, inputDelta string) {
338	for i, part := range m.Parts {
339		if c, ok := part.(ToolCall); ok {
340			if c.ID == toolCallID {
341				m.Parts[i] = ToolCall{
342					ID:       c.ID,
343					Name:     c.Name,
344					Input:    c.Input + inputDelta,
345					Finished: c.Finished,
346				}
347				return
348			}
349		}
350	}
351}
352
353func (m *Message) AddToolCall(tc ToolCall) {
354	for i, part := range m.Parts {
355		if c, ok := part.(ToolCall); ok {
356			if c.ID == tc.ID {
357				m.Parts[i] = tc
358				return
359			}
360		}
361	}
362	m.Parts = append(m.Parts, tc)
363}
364
365func (m *Message) SetToolCalls(tc []ToolCall) {
366	// remove any existing tool call part it could have multiple
367	parts := make([]ContentPart, 0)
368	for _, part := range m.Parts {
369		if _, ok := part.(ToolCall); ok {
370			continue
371		}
372		parts = append(parts, part)
373	}
374	m.Parts = parts
375	for _, toolCall := range tc {
376		m.Parts = append(m.Parts, toolCall)
377	}
378}
379
380func (m *Message) AddToolResult(tr ToolResult) {
381	m.Parts = append(m.Parts, tr)
382}
383
384func (m *Message) SetToolResults(tr []ToolResult) {
385	for _, toolResult := range tr {
386		m.Parts = append(m.Parts, toolResult)
387	}
388}
389
390func (m *Message) AddFinish(reason FinishReason, message, details string) {
391	// remove any existing finish part
392	for i, part := range m.Parts {
393		if _, ok := part.(Finish); ok {
394			m.Parts = slices.Delete(m.Parts, i, i+1)
395			break
396		}
397	}
398	m.Parts = append(m.Parts, Finish{Reason: reason, Time: time.Now().Unix(), Message: message, Details: details})
399}
400
401func (m *Message) AddImageURL(url, detail string) {
402	m.Parts = append(m.Parts, ImageURLContent{URL: url, Detail: detail})
403}
404
405func (m *Message) AddBinary(mimeType string, data []byte) {
406	m.Parts = append(m.Parts, BinaryContent{MIMEType: mimeType, Data: data})
407}
408
409func (m *Message) ToAIMessage() []fantasy.Message {
410	var messages []fantasy.Message
411	switch m.Role {
412	case User:
413		var parts []fantasy.MessagePart
414		text := strings.TrimSpace(m.Content().Text)
415		if text != "" {
416			parts = append(parts, fantasy.TextPart{Text: text})
417		}
418		for _, content := range m.BinaryContent() {
419			parts = append(parts, fantasy.FilePart{
420				Filename:  content.Path,
421				Data:      content.Data,
422				MediaType: content.MIMEType,
423			})
424		}
425		messages = append(messages, fantasy.Message{
426			Role:    fantasy.MessageRoleUser,
427			Content: parts,
428		})
429	case Assistant:
430		var parts []fantasy.MessagePart
431		text := strings.TrimSpace(m.Content().Text)
432		if text != "" {
433			parts = append(parts, fantasy.TextPart{Text: text})
434		}
435		reasoning := m.ReasoningContent()
436		if reasoning.Thinking != "" {
437			reasoningPart := fantasy.ReasoningPart{Text: reasoning.Thinking, ProviderOptions: fantasy.ProviderOptions{}}
438			if reasoning.Signature != "" {
439				reasoningPart.ProviderOptions[anthropic.Name] = &anthropic.ReasoningOptionMetadata{
440					Signature: reasoning.Signature,
441				}
442			}
443			if reasoning.ResponsesData != nil {
444				reasoningPart.ProviderOptions[openai.Name] = reasoning.ResponsesData
445			}
446			parts = append(parts, reasoningPart)
447		}
448		for _, call := range m.ToolCalls() {
449			parts = append(parts, fantasy.ToolCallPart{
450				ToolCallID:       call.ID,
451				ToolName:         call.Name,
452				Input:            call.Input,
453				ProviderExecuted: call.ProviderExecuted,
454			})
455		}
456		messages = append(messages, fantasy.Message{
457			Role:    fantasy.MessageRoleAssistant,
458			Content: parts,
459		})
460	case Tool:
461		var parts []fantasy.MessagePart
462		for _, result := range m.ToolResults() {
463			var content fantasy.ToolResultOutputContent
464			if result.IsError {
465				content = fantasy.ToolResultOutputContentError{
466					Error: errors.New(result.Content),
467				}
468			} else if result.Data != "" {
469				content = fantasy.ToolResultOutputContentMedia{
470					Data:      result.Data,
471					MediaType: result.MIMEType,
472				}
473			} else {
474				content = fantasy.ToolResultOutputContentText{
475					Text: result.Content,
476				}
477			}
478			parts = append(parts, fantasy.ToolResultPart{
479				ToolCallID: result.ToolCallID,
480				Output:     content,
481			})
482		}
483		messages = append(messages, fantasy.Message{
484			Role:    fantasy.MessageRoleTool,
485			Content: parts,
486		})
487	}
488	return messages
489}