-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathattachment.go
More file actions
405 lines (350 loc) · 10.6 KB
/
attachment.go
File metadata and controls
405 lines (350 loc) · 10.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
package opik
import (
"bytes"
"context"
"encoding/base64"
"fmt"
"io"
"mime"
"net/http"
"os"
"path/filepath"
"strings"
)
// AttachmentType represents the type of attachment.
type AttachmentType string
const (
AttachmentTypeImage AttachmentType = "image"
AttachmentTypeAudio AttachmentType = "audio"
AttachmentTypeVideo AttachmentType = "video"
AttachmentTypeDocument AttachmentType = "document"
AttachmentTypeText AttachmentType = "text"
AttachmentTypeOther AttachmentType = "other"
)
// Attachment represents a file attachment for a trace or span.
type Attachment struct {
// Name is the display name of the attachment.
Name string
// Type is the attachment type (image, audio, etc.).
Type AttachmentType
// MimeType is the MIME type of the attachment.
MimeType string
// Data is the raw attachment data.
Data []byte
// URL is an optional URL for externally hosted attachments.
URL string
// Base64 is the base64-encoded data (for embedding).
Base64 string
}
// NewAttachmentFromFile creates an attachment from a file path.
func NewAttachmentFromFile(path string) (*Attachment, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("failed to read file: %w", err)
}
name := filepath.Base(path)
mimeType := detectMimeType(path, data)
attachType := mimeTypeToAttachmentType(mimeType)
return &Attachment{
Name: name,
Type: attachType,
MimeType: mimeType,
Data: data,
}, nil
}
// NewAttachmentFromBytes creates an attachment from raw bytes.
func NewAttachmentFromBytes(name string, data []byte, mimeType string) *Attachment {
if mimeType == "" {
mimeType = http.DetectContentType(data)
}
attachType := mimeTypeToAttachmentType(mimeType)
return &Attachment{
Name: name,
Type: attachType,
MimeType: mimeType,
Data: data,
}
}
// NewAttachmentFromReader creates an attachment from an io.Reader.
func NewAttachmentFromReader(name string, r io.Reader, mimeType string) (*Attachment, error) {
data, err := io.ReadAll(r)
if err != nil {
return nil, fmt.Errorf("failed to read data: %w", err)
}
return NewAttachmentFromBytes(name, data, mimeType), nil
}
// NewAttachmentFromURL creates an attachment from a URL.
func NewAttachmentFromURL(name string, url string, attachType AttachmentType) *Attachment {
return &Attachment{
Name: name,
Type: attachType,
URL: url,
}
}
// NewTextAttachment creates a text attachment.
func NewTextAttachment(name, content string) *Attachment {
return &Attachment{
Name: name,
Type: AttachmentTypeText,
MimeType: "text/plain",
Data: []byte(content),
}
}
// NewImageAttachment creates an image attachment from bytes.
func NewImageAttachment(name string, data []byte, mimeType string) *Attachment {
if mimeType == "" {
mimeType = http.DetectContentType(data)
}
return &Attachment{
Name: name,
Type: AttachmentTypeImage,
MimeType: mimeType,
Data: data,
}
}
// ToBase64 returns the base64-encoded attachment data.
func (a *Attachment) ToBase64() string {
if a.Base64 != "" {
return a.Base64
}
if len(a.Data) > 0 {
return base64.StdEncoding.EncodeToString(a.Data)
}
return ""
}
// ToDataURL returns a data URL for the attachment.
func (a *Attachment) ToDataURL() string {
if a.URL != "" {
return a.URL
}
mimeType := a.MimeType
if mimeType == "" {
mimeType = "application/octet-stream"
}
return fmt.Sprintf("data:%s;base64,%s", mimeType, a.ToBase64())
}
// Size returns the size of the attachment in bytes.
func (a *Attachment) Size() int {
return len(a.Data)
}
// IsEmbeddable returns true if the attachment is small enough to embed inline.
func (a *Attachment) IsEmbeddable(maxSize int) bool {
return len(a.Data) <= maxSize
}
// detectMimeType detects the MIME type from file extension and content.
func detectMimeType(path string, data []byte) string {
// Try extension first
ext := filepath.Ext(path)
if ext != "" {
if mimeType := mime.TypeByExtension(ext); mimeType != "" {
return mimeType
}
}
// Fall back to content detection
return http.DetectContentType(data)
}
// mimeTypeToAttachmentType converts a MIME type to an attachment type.
func mimeTypeToAttachmentType(mimeType string) AttachmentType {
switch {
case strings.HasPrefix(mimeType, "image/"):
return AttachmentTypeImage
case strings.HasPrefix(mimeType, "audio/"):
return AttachmentTypeAudio
case strings.HasPrefix(mimeType, "video/"):
return AttachmentTypeVideo
case strings.HasPrefix(mimeType, "text/"):
return AttachmentTypeText
case strings.Contains(mimeType, "pdf"),
strings.Contains(mimeType, "document"),
strings.Contains(mimeType, "word"),
strings.Contains(mimeType, "excel"),
strings.Contains(mimeType, "powerpoint"):
return AttachmentTypeDocument
default:
return AttachmentTypeOther
}
}
// AttachmentUploader handles uploading attachments to storage.
type AttachmentUploader struct {
client *Client
maxEmbedSize int
}
// NewAttachmentUploader creates a new attachment uploader.
func NewAttachmentUploader(client *Client) *AttachmentUploader {
return &AttachmentUploader{
client: client,
maxEmbedSize: 1024 * 1024, // 1MB default
}
}
// SetMaxEmbedSize sets the maximum size for inline embedding.
func (u *AttachmentUploader) SetMaxEmbedSize(size int) {
u.maxEmbedSize = size
}
// Upload uploads an attachment and returns the URL or embedded data.
func (u *AttachmentUploader) Upload(ctx context.Context, attachment *Attachment) (string, error) {
// For small files, embed inline
if attachment.IsEmbeddable(u.maxEmbedSize) {
return attachment.ToDataURL(), nil
}
// For larger files, would upload to S3 or similar
// For now, just embed anyway (server will handle storage)
return attachment.ToDataURL(), nil
}
// UploadMultiple uploads multiple attachments.
func (u *AttachmentUploader) UploadMultiple(ctx context.Context, attachments []*Attachment) ([]string, error) {
urls := make([]string, len(attachments))
for i, a := range attachments {
url, err := u.Upload(ctx, a)
if err != nil {
return nil, fmt.Errorf("failed to upload attachment %s: %w", a.Name, err)
}
urls[i] = url
}
return urls, nil
}
// AttachmentOption is a functional option for attachments.
type AttachmentOption func(*attachmentOptions)
type attachmentOptions struct {
attachments []*Attachment
}
// WithAttachment adds an attachment.
func WithAttachment(attachment *Attachment) AttachmentOption {
return func(o *attachmentOptions) {
o.attachments = append(o.attachments, attachment)
}
}
// WithFileAttachment adds a file attachment.
func WithFileAttachment(path string) AttachmentOption {
return func(o *attachmentOptions) {
if a, err := NewAttachmentFromFile(path); err == nil {
o.attachments = append(o.attachments, a)
}
}
}
// WithTextAttachment adds a text attachment.
func WithTextAttachment(name, content string) AttachmentOption {
return func(o *attachmentOptions) {
o.attachments = append(o.attachments, NewTextAttachment(name, content))
}
}
// WithImageAttachment adds an image attachment.
func WithImageAttachment(name string, data []byte) AttachmentOption {
return func(o *attachmentOptions) {
o.attachments = append(o.attachments, NewImageAttachment(name, data, ""))
}
}
// AttachmentExtractor extracts attachments from LLM responses.
type AttachmentExtractor struct {
enabled bool
}
// NewAttachmentExtractor creates a new attachment extractor.
func NewAttachmentExtractor() *AttachmentExtractor {
return &AttachmentExtractor{enabled: true}
}
// SetEnabled enables or disables attachment extraction.
func (e *AttachmentExtractor) SetEnabled(enabled bool) {
e.enabled = enabled
}
// Extract extracts attachments from content.
func (e *AttachmentExtractor) Extract(content any) []*Attachment {
if !e.enabled {
return nil
}
var attachments []*Attachment
switch v := content.(type) {
case string:
// Look for data URLs
attachments = append(attachments, e.extractDataURLs(v)...)
case map[string]any:
// Look for image URLs or base64 data in response
attachments = append(attachments, e.extractFromMap(v)...)
case []any:
for _, item := range v {
attachments = append(attachments, e.Extract(item)...)
}
}
return attachments
}
func (e *AttachmentExtractor) extractDataURLs(s string) []*Attachment {
var attachments []*Attachment
// Simple data URL extraction
if strings.HasPrefix(s, "data:") {
parts := strings.SplitN(s, ",", 2)
if len(parts) == 2 {
mimeType := strings.TrimPrefix(parts[0], "data:")
mimeType = strings.TrimSuffix(mimeType, ";base64")
data, err := base64.StdEncoding.DecodeString(parts[1])
if err == nil {
attachments = append(attachments, &Attachment{
Name: "extracted",
Type: mimeTypeToAttachmentType(mimeType),
MimeType: mimeType,
Data: data,
})
}
}
}
return attachments
}
func (e *AttachmentExtractor) extractFromMap(m map[string]any) []*Attachment {
var attachments []*Attachment
// Look for common image/file keys
keys := []string{"image", "images", "file", "files", "attachment", "attachments", "url", "data"}
for _, key := range keys {
if val, ok := m[key]; ok {
switch v := val.(type) {
case string:
if strings.HasPrefix(v, "data:") || strings.HasPrefix(v, "http") {
attachments = append(attachments, &Attachment{
Name: key,
URL: v,
Type: AttachmentTypeOther,
})
}
case []byte:
attachments = append(attachments, NewAttachmentFromBytes(key, v, ""))
case map[string]any:
attachments = append(attachments, e.extractFromMap(v)...)
}
}
}
return attachments
}
// ImageFromBase64 creates an Attachment from a base64 string.
func ImageFromBase64(name, base64Data, mimeType string) (*Attachment, error) {
data, err := base64.StdEncoding.DecodeString(base64Data)
if err != nil {
return nil, fmt.Errorf("failed to decode base64: %w", err)
}
return &Attachment{
Name: name,
Type: AttachmentTypeImage,
MimeType: mimeType,
Data: data,
Base64: base64Data,
}, nil
}
// ImageFromURL fetches and creates an Attachment from a URL.
func ImageFromURL(ctx context.Context, name, url string) (*Attachment, error) {
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, err
}
resp, err := http.DefaultClient.Do(req) //nolint:gosec // G704: URL is provided by SDK user for fetching attachments
if err != nil {
return nil, err
}
defer resp.Body.Close()
var buf bytes.Buffer
if _, err := io.Copy(&buf, resp.Body); err != nil {
return nil, err
}
mimeType := resp.Header.Get("Content-Type")
return &Attachment{
Name: name,
Type: AttachmentTypeImage,
MimeType: mimeType,
Data: buf.Bytes(),
URL: url,
}, nil
}