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