content.go

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