Skip to content

Commit 4ee93e8

Browse files
Copilotdgageot
andcommitted
Implement TUI attachment support with /attach command
Co-authored-by: dgageot <153495+dgageot@users.noreply.github.com>
1 parent 53f8794 commit 4ee93e8

7 files changed

Lines changed: 322 additions & 146 deletions

File tree

cmd/root/run.go

Lines changed: 3 additions & 139 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import (
44
"bufio"
55
"bytes"
66
"context"
7-
"encoding/base64"
87
"fmt"
98
"io"
109
"log/slog"
@@ -21,7 +20,7 @@ import (
2120
"go.opentelemetry.io/otel"
2221

2322
"github.com/docker/cagent/pkg/app"
24-
"github.com/docker/cagent/pkg/chat"
23+
"github.com/docker/cagent/pkg/attachment"
2524
"github.com/docker/cagent/pkg/config"
2625
"github.com/docker/cagent/pkg/content"
2726
"github.com/docker/cagent/pkg/evaluation"
@@ -412,15 +411,15 @@ func runWithoutTUI(ctx context.Context, agentFilename string, rt runtime.Runtime
412411
defer signal.Stop(sigCh)
413412

414413
// Parse for /attach commands in the message
415-
messageText, attachPath := parseAttachCommand(userInput)
414+
messageText, attachPath := attachment.ParseAttachCommand(userInput)
416415

417416
// Use either the per-message attachment or the global one
418417
finalAttachPath := attachPath
419418
if finalAttachPath == "" {
420419
finalAttachPath = attachmentPath
421420
}
422421

423-
sess.AddMessage(createUserMessageWithAttachment(agentFilename, messageText, finalAttachPath))
422+
sess.AddMessage(attachment.CreateUserMessageWithAttachment(agentFilename, messageText, finalAttachPath))
424423

425424
firstLoop := true
426425
lastAgent := rt.CurrentAgent().Name()
@@ -654,59 +653,7 @@ func runUserCommand(userInput string, sess *session.Session, rt runtime.Runtime,
654653
return false, nil
655654
}
656655

657-
// parseAttachCommand parses user input for /attach commands
658-
// Returns the message text (with /attach commands removed) and the attachment path
659-
func parseAttachCommand(input string) (messageText, attachPath string) {
660-
lines := strings.Split(input, "\n")
661-
var messageLines []string
662-
663-
for _, line := range lines {
664-
// Look for /attach anywhere in the line
665-
attachIndex := strings.Index(line, "/attach ")
666-
if attachIndex != -1 {
667-
// Extract the part before /attach
668-
beforeAttach := line[:attachIndex]
669-
670-
// Extract the part after /attach (starting after "/attach ")
671-
afterAttachStart := attachIndex + 8 // Length of "/attach "
672-
if afterAttachStart < len(line) {
673-
afterAttach := line[afterAttachStart:]
674-
675-
// Split on spaces to get the file path (first token) and any remaining text
676-
tokens := strings.Fields(afterAttach)
677-
if len(tokens) > 0 {
678-
attachPath = tokens[0]
679-
680-
// Reconstruct the line with /attach and file path removed
681-
var remainingText string
682-
if len(tokens) > 1 {
683-
remainingText = strings.Join(tokens[1:], " ")
684-
}
685656

686-
// Combine the text before /attach and any text after the file path
687-
var parts []string
688-
if strings.TrimSpace(beforeAttach) != "" {
689-
parts = append(parts, strings.TrimSpace(beforeAttach))
690-
}
691-
if remainingText != "" {
692-
parts = append(parts, remainingText)
693-
}
694-
reconstructedLine := strings.Join(parts, " ")
695-
if reconstructedLine != "" {
696-
messageLines = append(messageLines, reconstructedLine)
697-
}
698-
}
699-
}
700-
} else {
701-
// Keep lines without /attach commands
702-
messageLines = append(messageLines, line)
703-
}
704-
}
705-
706-
// Join the message lines back together
707-
messageText = strings.TrimSpace(strings.Join(messageLines, "\n"))
708-
return messageText, attachPath
709-
}
710657

711658
func fileExists(path string) bool {
712659
_, err := os.Stat(path)
@@ -746,92 +693,9 @@ func fromStore(reference string) (string, error) {
746693
return buf.String(), nil
747694
}
748695

749-
// createUserMessageWithAttachment creates a user message with optional image attachment
750-
func createUserMessageWithAttachment(agentFilename, userContent, attachmentPath string) *session.Message {
751-
if attachmentPath == "" {
752-
return session.UserMessage(agentFilename, userContent)
753-
}
754696

755-
// Convert file to data URL
756-
dataURL, err := fileToDataURL(attachmentPath)
757-
if err != nil {
758-
fmt.Printf("Warning: Failed to attach file %s: %v\n", attachmentPath, err)
759-
return session.UserMessage(agentFilename, userContent)
760-
}
761697

762-
// Ensure we have some text content when attaching a file
763-
textContent := userContent
764-
if strings.TrimSpace(textContent) == "" {
765-
textContent = "Please analyze this attached file."
766-
}
767698

768-
// Create message with multi-content including text and image
769-
multiContent := []chat.MessagePart{
770-
{
771-
Type: chat.MessagePartTypeText,
772-
Text: textContent,
773-
},
774-
{
775-
Type: chat.MessagePartTypeImageURL,
776-
ImageURL: &chat.MessageImageURL{
777-
URL: dataURL,
778-
Detail: chat.ImageURLDetailAuto,
779-
},
780-
},
781-
}
782-
783-
return &session.Message{
784-
AgentFilename: agentFilename,
785-
AgentName: "",
786-
Message: chat.Message{
787-
Role: chat.MessageRoleUser,
788-
MultiContent: multiContent,
789-
CreatedAt: time.Now().Format(time.RFC3339),
790-
},
791-
}
792-
}
793-
794-
// fileToDataURL converts a file to a data URL
795-
func fileToDataURL(filePath string) (string, error) {
796-
// Check if file exists
797-
if _, err := os.Stat(filePath); os.IsNotExist(err) {
798-
return "", fmt.Errorf("file does not exist: %s", filePath)
799-
}
800-
801-
// Read file content
802-
fileBytes, err := os.ReadFile(filePath)
803-
if err != nil {
804-
return "", fmt.Errorf("failed to read file: %w", err)
805-
}
806-
807-
// Determine MIME type based on file extension
808-
ext := strings.ToLower(filepath.Ext(filePath))
809-
var mimeType string
810-
switch ext {
811-
case ".jpg", ".jpeg":
812-
mimeType = "image/jpeg"
813-
case ".png":
814-
mimeType = "image/png"
815-
case ".gif":
816-
mimeType = "image/gif"
817-
case ".webp":
818-
mimeType = "image/webp"
819-
case ".bmp":
820-
mimeType = "image/bmp"
821-
case ".svg":
822-
mimeType = "image/svg+xml"
823-
default:
824-
return "", fmt.Errorf("unsupported image format: %s", ext)
825-
}
826-
827-
// Encode to base64
828-
encoded := base64.StdEncoding.EncodeToString(fileBytes)
829-
830-
// Create data URL
831-
dataURL := fmt.Sprintf("data:%s;base64,%s", mimeType, encoded)
832-
833-
return dataURL, nil
834-
}
835699

836700
// getCommandsForAgent returns the commands map for the selected agent,
837701
// loading from the in-memory team for local runs or from the YAML file for remote runs.

pkg/app/app.go

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88

99
tea "github.com/charmbracelet/bubbletea/v2"
1010

11+
"github.com/docker/cagent/pkg/attachment"
1112
"github.com/docker/cagent/pkg/runtime"
1213
"github.com/docker/cagent/pkg/session"
1314
"github.com/docker/cagent/pkg/team"
@@ -50,8 +51,17 @@ func (a *App) Title() string {
5051
return a.title
5152
}
5253

54+
func (a *App) ConfigFilename() string {
55+
return a.agentFilename
56+
}
57+
5358
// Run one agent loop
5459
func (a *App) Run(ctx context.Context, cancel context.CancelFunc, message string) {
60+
a.RunWithAttachment(ctx, cancel, message, "")
61+
}
62+
63+
// RunWithAttachment runs one agent loop with optional attachment support
64+
func (a *App) RunWithAttachment(ctx context.Context, cancel context.CancelFunc, message, attachmentPath string) {
5565
a.cancel = cancel
5666
go func() {
5767
// Special shell command
@@ -61,8 +71,9 @@ func (a *App) Run(ctx context.Context, cancel context.CancelFunc, message string
6171
return
6272
}
6373

64-
// User message
65-
a.session.AddMessage(session.UserMessage(a.agentFilename, message))
74+
// User message with optional attachment
75+
userMessage := attachment.CreateUserMessageWithAttachment(a.agentFilename, message, attachmentPath)
76+
a.session.AddMessage(userMessage)
6677
for event := range a.runtime.RunStream(ctx, a.session) {
6778
if ctx.Err() != nil {
6879
return

pkg/attachment/attachment.go

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
package attachment
2+
3+
import (
4+
"encoding/base64"
5+
"fmt"
6+
"os"
7+
"path/filepath"
8+
"strings"
9+
"time"
10+
11+
"github.com/docker/cagent/pkg/chat"
12+
"github.com/docker/cagent/pkg/session"
13+
)
14+
15+
// ParseAttachCommand parses user input for /attach commands
16+
// Returns the message text (with /attach commands removed) and the attachment path
17+
func ParseAttachCommand(input string) (messageText, attachPath string) {
18+
lines := strings.Split(input, "\n")
19+
var messageLines []string
20+
21+
for _, line := range lines {
22+
// Look for /attach anywhere in the line
23+
attachIndex := strings.Index(line, "/attach ")
24+
if attachIndex != -1 {
25+
// Extract the part before /attach
26+
beforeAttach := line[:attachIndex]
27+
28+
// Extract the part after /attach (starting after "/attach ")
29+
afterAttachStart := attachIndex + 8 // Length of "/attach "
30+
if afterAttachStart < len(line) {
31+
afterAttach := line[afterAttachStart:]
32+
// Parse the file path (first token)
33+
tokens := strings.Fields(afterAttach)
34+
if len(tokens) > 0 {
35+
attachPath = tokens[0]
36+
37+
// Reconstruct the line with /attach and file path removed
38+
var remainingText string
39+
if len(tokens) > 1 {
40+
remainingText = strings.Join(tokens[1:], " ")
41+
}
42+
43+
// Combine the text before /attach and any text after the file path
44+
var parts []string
45+
if strings.TrimSpace(beforeAttach) != "" {
46+
parts = append(parts, strings.TrimSpace(beforeAttach))
47+
}
48+
if remainingText != "" {
49+
parts = append(parts, remainingText)
50+
}
51+
reconstructedLine := strings.Join(parts, " ")
52+
if reconstructedLine != "" {
53+
messageLines = append(messageLines, reconstructedLine)
54+
}
55+
}
56+
}
57+
} else {
58+
// Keep lines without /attach commands
59+
messageLines = append(messageLines, line)
60+
}
61+
}
62+
63+
// Join the message lines back together
64+
messageText = strings.TrimSpace(strings.Join(messageLines, "\n"))
65+
return messageText, attachPath
66+
}
67+
68+
// CreateUserMessageWithAttachment creates a user message with optional image attachment
69+
func CreateUserMessageWithAttachment(agentFilename, userContent, attachmentPath string) *session.Message {
70+
if attachmentPath == "" {
71+
return session.UserMessage(agentFilename, userContent)
72+
}
73+
74+
// Convert file to data URL
75+
dataURL, err := FileToDataURL(attachmentPath)
76+
if err != nil {
77+
// Return a regular message with error info in content instead of failing silently
78+
errorContent := userContent
79+
if errorContent == "" {
80+
errorContent = fmt.Sprintf("Failed to attach file %s: %v", attachmentPath, err)
81+
} else {
82+
errorContent = fmt.Sprintf("%s\n\n[Attachment Error: Failed to attach file %s: %v]", userContent, attachmentPath, err)
83+
}
84+
return session.UserMessage(agentFilename, errorContent)
85+
}
86+
87+
// Ensure we have some text content when attaching a file
88+
textContent := userContent
89+
if strings.TrimSpace(textContent) == "" {
90+
textContent = "Please analyze this attached file."
91+
}
92+
93+
// Create message with multi-content including text and image
94+
multiContent := []chat.MessagePart{
95+
{
96+
Type: chat.MessagePartTypeText,
97+
Text: textContent,
98+
},
99+
{
100+
Type: chat.MessagePartTypeImageURL,
101+
ImageURL: &chat.MessageImageURL{
102+
URL: dataURL,
103+
Detail: chat.ImageURLDetailAuto,
104+
},
105+
},
106+
}
107+
108+
return &session.Message{
109+
AgentFilename: agentFilename,
110+
AgentName: "",
111+
Message: chat.Message{
112+
Role: chat.MessageRoleUser,
113+
MultiContent: multiContent,
114+
CreatedAt: time.Now().Format(time.RFC3339),
115+
},
116+
}
117+
}
118+
119+
// FileToDataURL converts a file to a data URL
120+
func FileToDataURL(filePath string) (string, error) {
121+
// Check if file exists
122+
if _, err := os.Stat(filePath); os.IsNotExist(err) {
123+
return "", fmt.Errorf("file does not exist: %s", filePath)
124+
}
125+
126+
// Read file content
127+
fileBytes, err := os.ReadFile(filePath)
128+
if err != nil {
129+
return "", fmt.Errorf("failed to read file: %w", err)
130+
}
131+
132+
// Determine MIME type based on file extension
133+
ext := strings.ToLower(filepath.Ext(filePath))
134+
var mimeType string
135+
switch ext {
136+
case ".jpg", ".jpeg":
137+
mimeType = "image/jpeg"
138+
case ".png":
139+
mimeType = "image/png"
140+
case ".gif":
141+
mimeType = "image/gif"
142+
case ".webp":
143+
mimeType = "image/webp"
144+
case ".bmp":
145+
mimeType = "image/bmp"
146+
case ".svg":
147+
mimeType = "image/svg+xml"
148+
default:
149+
return "", fmt.Errorf("unsupported image format: %s", ext)
150+
}
151+
152+
// Encode to base64
153+
encoded := base64.StdEncoding.EncodeToString(fileBytes)
154+
155+
// Create data URL
156+
dataURL := fmt.Sprintf("data:%s;base64,%s", mimeType, encoded)
157+
158+
return dataURL, nil
159+
}

0 commit comments

Comments
 (0)