content.go

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