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	var sb strings.Builder
441	sb.WriteString(prompt)
442	addedAttachments := false
443	for _, content := range attachments {
444		if !content.IsText() {
445			continue
446		}
447		if !addedAttachments {
448			sb.WriteString("\n<system_info>The files below have been attached by the user, consider them in your response</system_info>\n")
449			addedAttachments = true
450		}
451		if content.FilePath != "" {
452			fmt.Fprintf(&sb, "<file path='%s'>\n", content.FilePath)
453		} else {
454			sb.WriteString("<file>\n")
455		}
456		sb.WriteString("\n")
457		sb.Write(content.Content)
458		sb.WriteString("\n</file>\n")
459	}
460	return sb.String()
461}
462
463func (m *Message) ToAIMessage() []fantasy.Message {
464	var messages []fantasy.Message
465	switch m.Role {
466	case User:
467		var parts []fantasy.MessagePart
468		text := strings.TrimSpace(m.Content().Text)
469		var textAttachments []Attachment
470		for _, content := range m.BinaryContent() {
471			if !strings.HasPrefix(content.MIMEType, "text/") {
472				continue
473			}
474			textAttachments = append(textAttachments, Attachment{
475				FilePath: content.Path,
476				MimeType: content.MIMEType,
477				Content:  content.Data,
478			})
479		}
480		text = PromptWithTextAttachments(text, textAttachments)
481		if text != "" {
482			parts = append(parts, fantasy.TextPart{Text: text})
483		}
484		for _, content := range m.BinaryContent() {
485			// skip text attachements
486			if strings.HasPrefix(content.MIMEType, "text/") {
487				continue
488			}
489			parts = append(parts, fantasy.FilePart{
490				Filename:  content.Path,
491				Data:      content.Data,
492				MediaType: content.MIMEType,
493			})
494		}
495		messages = append(messages, fantasy.Message{
496			Role:    fantasy.MessageRoleUser,
497			Content: parts,
498		})
499	case Assistant:
500		var parts []fantasy.MessagePart
501		text := strings.TrimSpace(m.Content().Text)
502		if text != "" {
503			parts = append(parts, fantasy.TextPart{Text: text})
504		}
505		reasoning := m.ReasoningContent()
506		if reasoning.Thinking != "" {
507			reasoningPart := fantasy.ReasoningPart{Text: reasoning.Thinking, ProviderOptions: fantasy.ProviderOptions{}}
508			if reasoning.Signature != "" {
509				reasoningPart.ProviderOptions[anthropic.Name] = &anthropic.ReasoningOptionMetadata{
510					Signature: reasoning.Signature,
511				}
512			}
513			if reasoning.ResponsesData != nil {
514				reasoningPart.ProviderOptions[openai.Name] = reasoning.ResponsesData
515			}
516			if reasoning.ThoughtSignature != "" {
517				reasoningPart.ProviderOptions[google.Name] = &google.ReasoningMetadata{
518					Signature: reasoning.ThoughtSignature,
519					ToolID:    reasoning.ToolID,
520				}
521			}
522			parts = append(parts, reasoningPart)
523		}
524		for _, call := range m.ToolCalls() {
525			parts = append(parts, fantasy.ToolCallPart{
526				ToolCallID:       call.ID,
527				ToolName:         call.Name,
528				Input:            call.Input,
529				ProviderExecuted: call.ProviderExecuted,
530			})
531		}
532		messages = append(messages, fantasy.Message{
533			Role:    fantasy.MessageRoleAssistant,
534			Content: parts,
535		})
536	case Tool:
537		var parts []fantasy.MessagePart
538		for _, result := range m.ToolResults() {
539			var content fantasy.ToolResultOutputContent
540			if result.IsError {
541				content = fantasy.ToolResultOutputContentError{
542					Error: errors.New(result.Content),
543				}
544			} else if result.Data != "" {
545				content = fantasy.ToolResultOutputContentMedia{
546					Data:      result.Data,
547					MediaType: result.MIMEType,
548				}
549			} else {
550				content = fantasy.ToolResultOutputContentText{
551					Text: result.Content,
552				}
553			}
554			parts = append(parts, fantasy.ToolResultPart{
555				ToolCallID: result.ToolCallID,
556				Output:     content,
557			})
558		}
559		messages = append(messages, fantasy.Message{
560			Role:    fantasy.MessageRoleTool,
561			Content: parts,
562		})
563	}
564	return messages
565}