content.go

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