on main help sign in
feat: paste long content as an attachment (#1634)
b3f7321Carlos Alexandro Becker
internal/agent/agent.go+5−2
@@ -200,7 +200,7 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
200 var currentAssistant *message.Message
200 var currentAssistant *message.Message
201 var shouldSummarize bool
201 var shouldSummarize bool
202 result, err := agent.Stream(genCtx, fantasy.AgentStreamCall{
202 result, err := agent.Stream(genCtx, fantasy.AgentStreamCall{
203− Prompt: call.Prompt,
203+ Prompt: message.PromptWithTextAttachments(call.Prompt, call.Attachments),
204 Files: files,
204 Files: files,
205 Messages: history,
205 Messages: history,
206 ProviderOptions: call.ProviderOptions,
206 ProviderOptions: call.ProviderOptions,
@@ -649,11 +649,11 @@ func (a *sessionAgent) getCacheControlOptions() fantasy.ProviderOptions {
649}
649}
650
650
651func (a *sessionAgent) createUserMessage(ctx context.Context, call SessionAgentCall) (message.Message, error) {
651func (a *sessionAgent) createUserMessage(ctx context.Context, call SessionAgentCall) (message.Message, error) {
652+ parts := []message.ContentPart{message.TextContent{Text: call.Prompt}}
652 var attachmentParts []message.ContentPart
653 var attachmentParts []message.ContentPart
653 for _, attachment := range call.Attachments {
654 for _, attachment := range call.Attachments {
654 attachmentParts = append(attachmentParts, message.BinaryContent{Path: attachment.FilePath, MIMEType: attachment.MimeType, Data: attachment.Content})
655 attachmentParts = append(attachmentParts, message.BinaryContent{Path: attachment.FilePath, MIMEType: attachment.MimeType, Data: attachment.Content})
655 }
656 }
656− parts := []message.ContentPart{message.TextContent{Text: call.Prompt}}
657 parts = append(parts, attachmentParts...)
657 parts = append(parts, attachmentParts...)
658 msg, err := a.messages.Create(ctx, call.SessionID, message.CreateMessageParams{
658 msg, err := a.messages.Create(ctx, call.SessionID, message.CreateMessageParams{
659 Role: message.User,
659 Role: message.User,
@@ -690,6 +690,9 @@ If not, please feel free to ignore. Again do not mention this message to the use
690
690
691 var files []fantasy.FilePart
691 var files []fantasy.FilePart
692 for _, attachment := range attachments {
692 for _, attachment := range attachments {
693+ if attachment.IsText() {
694+ continue
695+ }
693 files = append(files, fantasy.FilePart{
696 files = append(files, fantasy.FilePart{
694 Filename: attachment.FileName,
697 Filename: attachment.FileName,
695 Data: attachment.Content,
698 Data: attachment.Content,
internal/agent/coordinator.go+8−1
@@ -123,7 +123,14 @@ func (c *coordinator) Run(ctx context.Context, sessionID string, prompt string,
123 }
123 }
124
124
125 if !model.CatwalkCfg.SupportsImages && attachments != nil {
125 if !model.CatwalkCfg.SupportsImages && attachments != nil {
126+ // filter out image attachments
127+ filteredAttachments := make([]message.Attachment, 0, len(attachments))
126− attachments = nil
128+ for _, att := range attachments {
129+ if att.IsText() {
130+ filteredAttachments = append(filteredAttachments, att)
131+ }
132+ }
133+ attachments = filteredAttachments
127 }
134 }
128
135
129 providerCfg, ok := c.cfg.Providers.Get(model.ModelCfg.Provider)
136 providerCfg, ok := c.cfg.Providers.Get(model.ModelCfg.Provider)
internal/message/attachment.go+5
@@ -1,8 +1,13 @@
1package message
1package message
2
2
3+import "strings"
4+
3type Attachment struct {
5type Attachment struct {
4 FilePath string
6 FilePath string
5 FileName string
7 FileName string
6 MimeType string
8 MimeType string
7 Content []byte
9 Content []byte
8}
10}
11+
12+func (a Attachment) IsText() bool { return strings.HasPrefix(a.MimeType, "text/") }
13+func (a Attachment) IsImage() bool { return strings.HasPrefix(a.MimeType, "image/") }
internal/message/content.go+37
@@ -3,6 +3,7 @@ package message
3import (
3import (
4 "encoding/base64"
4 "encoding/base64"
5 "errors"
5 "errors"
6+ "fmt"
6 "slices"
7 "slices"
7 "strings"
8 "strings"
8 "time"
9 "time"
@@ -435,16 +436,52 @@ func (m *Message) AddBinary(mimeType string, data []byte) {
435 m.Parts = append(m.Parts, BinaryContent{MIMEType: mimeType, Data: data})
436 m.Parts = append(m.Parts, BinaryContent{MIMEType: mimeType, Data: data})
436}
437}
437
438
439+func PromptWithTextAttachments(prompt string, attachments []Attachment) string {
440+ addedAttachments := false
441+ for _, content := range attachments {
442+ if !content.IsText() {
443+ continue
444+ }
445+ if !addedAttachments {
446+ prompt += "\n<system_info>The files below have been attached by the user, consider them in your response</system_info>\n"
447+ addedAttachments = true
448+ }
449+ tag := `<file>\n`
450+ if content.FilePath != "" {
451+ tag = fmt.Sprintf("<file path='%s'>\n", content.FilePath)
452+ }
453+ prompt += tag
454+ prompt += "\n" + string(content.Content) + "\n</file>\n"
455+ }
456+ return prompt
457+}
458+
438func (m *Message) ToAIMessage() []fantasy.Message {
459func (m *Message) ToAIMessage() []fantasy.Message {
439 var messages []fantasy.Message
460 var messages []fantasy.Message
440 switch m.Role {
461 switch m.Role {
441 case User:
462 case User:
442 var parts []fantasy.MessagePart
463 var parts []fantasy.MessagePart
443 text := strings.TrimSpace(m.Content().Text)
464 text := strings.TrimSpace(m.Content().Text)
465+ var textAttachments []Attachment
466+ for _, content := range m.BinaryContent() {
467+ if !strings.HasPrefix(content.MIMEType, "text/") {
468+ continue
469+ }
470+ textAttachments = append(textAttachments, Attachment{
471+ FilePath: content.Path,
472+ MimeType: content.MIMEType,
473+ Content: content.Data,
474+ })
475+ }
476+ text = PromptWithTextAttachments(text, textAttachments)
444 if text != "" {
477 if text != "" {
445 parts = append(parts, fantasy.TextPart{Text: text})
478 parts = append(parts, fantasy.TextPart{Text: text})
446 }
479 }
447 for _, content := range m.BinaryContent() {
480 for _, content := range m.BinaryContent() {
481+ // skip text attachements
482+ if strings.HasPrefix(content.MIMEType, "text/") {
483+ continue
484+ }
448 parts = append(parts, fantasy.FilePart{
485 parts = append(parts, fantasy.FilePart{
449 Filename: content.Path,
486 Filename: content.Path,
450 Data: content.Data,
487 Data: content.Data,
internal/tui/components/chat/editor/editor.go+118−52
@@ -2,6 +2,7 @@ package editor
2
2
3import (
3import (
4 "context"
4 "context"
5+ "errors"
5 "fmt"
6 "fmt"
6 "math/rand"
7 "math/rand"
7 "net/http"
8 "net/http"
@@ -29,6 +30,7 @@ import (
29 "github.com/charmbracelet/crush/internal/tui/components/dialogs/quit"
30 "github.com/charmbracelet/crush/internal/tui/components/dialogs/quit"
30 "github.com/charmbracelet/crush/internal/tui/styles"
31 "github.com/charmbracelet/crush/internal/tui/styles"
31 "github.com/charmbracelet/crush/internal/tui/util"
32 "github.com/charmbracelet/crush/internal/tui/util"
33+ "github.com/charmbracelet/x/ansi"
32)
34)
33
35
34type Editor interface {
36type Editor interface {
@@ -84,10 +86,7 @@ var DeleteKeyMaps = DeleteAttachmentKeyMaps{
84 ),
86 ),
85}
87}
86
88
87−const (
88− maxAttachments = 5
89− maxFileResults = 25
89+const maxFileResults = 25
90−)
91
90
92type OpenEditorMsg struct {
91type OpenEditorMsg struct {
93 Text string
92 Text string
@@ -145,14 +144,14 @@ func (m *editorCmp) send() tea.Cmd {
145 return util.CmdHandler(dialogs.OpenDialogMsg{Model: quit.NewQuitDialog()})
144 return util.CmdHandler(dialogs.OpenDialogMsg{Model: quit.NewQuitDialog()})
146 }
145 }
147
146
148− m.textarea.Reset()
149 attachments := m.attachments
147 attachments := m.attachments
150
148
151− m.attachments = nil
152 if value == "" {
149 if value == "" {
153 return nil
150 return nil
154 }
151 }
155
152
153+ m.textarea.Reset()
154+ m.attachments = nil
156 // Change the placeholder when sending a new message.
155 // Change the placeholder when sending a new message.
157 m.randomizePlaceholders()
156 m.randomizePlaceholders()
158
157
@@ -176,9 +175,6 @@ func (m *editorCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
176 case tea.WindowSizeMsg:
175 case tea.WindowSizeMsg:
177 return m, m.repositionCompletions
176 return m, m.repositionCompletions
178 case filepicker.FilePickedMsg:
177 case filepicker.FilePickedMsg:
179− if len(m.attachments) >= maxAttachments {
180− return m, util.ReportError(fmt.Errorf("cannot add more than %d images", maxAttachments))
181− }
182 m.attachments = append(m.attachments, msg.Attachment)
178 m.attachments = append(m.attachments, msg.Attachment)
183 return m, nil
179 return m, nil
184 case completions.CompletionsOpenedMsg:
180 case completions.CompletionsOpenedMsg:
@@ -206,6 +202,17 @@ func (m *editorCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
206 m.currentQuery = ""
202 m.currentQuery = ""
207 m.completionsStartIndex = 0
203 m.completionsStartIndex = 0
208 }
204 }
205+ content, err := os.ReadFile(item.Path)
206+ if err != nil {
207+ // if it fails, let the LLM handle it later.
208+ return m, nil
209+ }
210+ m.attachments = append(m.attachments, message.Attachment{
211+ FilePath: item.Path,
212+ FileName: filepath.Base(item.Path),
213+ MimeType: mimeOf(content),
214+ Content: content,
215+ })
209 }
216 }
210
217
211 case commands.OpenExternalEditorMsg:
218 case commands.OpenExternalEditorMsg:
@@ -217,39 +224,30 @@ func (m *editorCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
217 m.textarea.SetValue(msg.Text)
224 m.textarea.SetValue(msg.Text)
218 m.textarea.MoveToEnd()
225 m.textarea.MoveToEnd()
219 case tea.PasteMsg:
226 case tea.PasteMsg:
220− path := strings.ReplaceAll(msg.Content, "\\ ", " ")
227+ content, path, err := pasteToFile(msg)
221− // try to get an image
228+ if errors.Is(err, errNotAFile) {
222− path, err := filepath.Abs(strings.TrimSpace(path))
223− if err != nil {
224 m.textarea, cmd = m.textarea.Update(msg)
229 m.textarea, cmd = m.textarea.Update(msg)
225 return m, cmd
230 return m, cmd
226 }
231 }
227− isAllowedType := false
232+ if err != nil {
228− for _, ext := range filepicker.AllowedTypes {
229− if strings.HasSuffix(path, ext) {
230− isAllowedType = true
231− break
232− }
233− }
234− if !isAllowedType {
235− m.textarea, cmd = m.textarea.Update(msg)
236− return m, cmd
233+ return m, util.ReportError(err)
237 }
234 }
238− tooBig, _ := filepicker.IsFileTooBig(path, filepicker.MaxAttachmentSize)
235+
239− if tooBig {
236+ if len(content) > maxAttachmentSize {
240− m.textarea, cmd = m.textarea.Update(msg)
237+ return m, util.ReportWarn("File is too big (>5mb)")
241− return m, cmd
242 }
238 }
243
239
244− content, err := os.ReadFile(path)
240+ mimeType := mimeOf(content)
245− if err != nil {
241+ attachment := message.Attachment{
246− m.textarea, cmd = m.textarea.Update(msg)
242+ FilePath: path,
247− return m, cmd
243+ FileName: filepath.Base(path),
244+ MimeType: mimeType,
245+ Content: content,
248 }
246 }
249− mimeBufferSize := min(512, len(content))
247+ if !attachment.IsText() && !attachment.IsImage() {
250− mimeType := http.DetectContentType(content[:mimeBufferSize])
248+ return m, util.ReportWarn("Invalid file content type: " + mimeType)
251− fileName := filepath.Base(path)
249+ }
252− attachment := message.Attachment{FilePath: path, FileName: fileName, MimeType: mimeType, Content: content}
250+ m.textarea.InsertString(attachment.FileName)
253 return m, util.CmdHandler(filepicker.FilePickedMsg{
251 return m, util.CmdHandler(filepicker.FilePickedMsg{
254 Attachment: attachment,
252 Attachment: attachment,
255 })
253 })
@@ -427,18 +425,17 @@ func (m *editorCmp) View() string {
427 m.textarea.Placeholder = "Yolo mode!"
425 m.textarea.Placeholder = "Yolo mode!"
428 }
426 }
429 if len(m.attachments) == 0 {
427 if len(m.attachments) == 0 {
430− content := t.S().Base.Padding(1).Render(
428+ return t.S().Base.Padding(1).Render(
431 m.textarea.View(),
429 m.textarea.View(),
432 )
430 )
433− return content
434 }
431 }
435− content := t.S().Base.Padding(0, 1, 1, 1).Render(
432+ return t.S().Base.Padding(0, 1, 1, 1).Render(
436− lipgloss.JoinVertical(lipgloss.Top,
433+ lipgloss.JoinVertical(
434+ lipgloss.Top,
437 m.attachmentsContent(),
435 m.attachmentsContent(),
438 m.textarea.View(),
436 m.textarea.View(),
439 ),
437 ),
440 )
438 )
441− return content
442}
439}
443
440
444func (m *editorCmp) SetSize(width, height int) tea.Cmd {
441func (m *editorCmp) SetSize(width, height int) tea.Cmd {
@@ -456,24 +453,45 @@ func (m *editorCmp) GetSize() (int, int) {
456func (m *editorCmp) attachmentsContent() string {
453func (m *editorCmp) attachmentsContent() string {
457 var styledAttachments []string
454 var styledAttachments []string
458 t := styles.CurrentTheme()
455 t := styles.CurrentTheme()
459− attachmentStyles := t.S().Base.
456+ attachmentStyle := t.S().Base.
460− MarginLeft(1).
457+ Padding(0, 1).
458+ MarginRight(1).
461 Background(t.FgMuted).
459 Background(t.FgMuted).
462− Foreground(t.FgBase)
460+ Foreground(t.FgBase).
461+ Render
462+ iconStyle := t.S().Base.
463+ Foreground(t.BgSubtle).
464+ Background(t.Green).
465+ Padding(0, 1).
466+ Bold(true).
467+ Render
468+ rmStyle := t.S().Base.
469+ Padding(0, 1).
470+ Bold(true).
471+ Background(t.Red).
472+ Foreground(t.FgBase).
473+ Render
463 for i, attachment := range m.attachments {
474 for i, attachment := range m.attachments {
464− var filename string
465− if len(attachment.FileName) > 10 {
475+ filename := ansi.Truncate(filepath.Base(attachment.FileName), 10, "...")
466− filename = fmt.Sprintf(" %s %s...", styles.DocumentIcon, attachment.FileName[0:7])
476+ icon := styles.ImageIcon
467− } else {
477+ if attachment.IsText() {
468− filename = fmt.Sprintf(" %s %s", styles.DocumentIcon, attachment.FileName)
478+ icon = styles.TextIcon
469 }
479 }
470 if m.deleteMode {
480 if m.deleteMode {
481+ styledAttachments = append(
482+ styledAttachments,
471− filename = fmt.Sprintf("%d%s", i, filename)
483+ rmStyle(fmt.Sprintf("%d", i)),
484+ attachmentStyle(filename),
485+ )
486+ continue
472 }
487 }
473− styledAttachments = append(styledAttachments, attachmentStyles.Render(filename))
488+ styledAttachments = append(
489+ styledAttachments,
490+ iconStyle(icon),
491+ attachmentStyle(filename),
492+ )
474 }
493 }
475− content := lipgloss.JoinHorizontal(lipgloss.Left, styledAttachments...)
494+ return lipgloss.JoinHorizontal(lipgloss.Left, styledAttachments...)
476− return content
477}
495}
478
496
479func (m *editorCmp) SetPosition(x, y int) tea.Cmd {
497func (m *editorCmp) SetPosition(x, y int) tea.Cmd {
@@ -597,3 +615,51 @@ func New(app *app.App) Editor {
597
615
598 return e
616 return e
599}
617}
618+
619+var maxAttachmentSize = 5 * 1024 * 1024 // 5MB
620+
621+var errNotAFile = errors.New("not a file")
622+
623+func pasteToFile(msg tea.PasteMsg) ([]byte, string, error) {
624+ content, path, err := filepathToFile(msg.Content)
625+ if err == nil {
626+ return content, path, err
627+ }
628+
629+ if strings.Count(msg.Content, "\n") > 2 {
630+ return contentToFile([]byte(msg.Content))
631+ }
632+
633+ return nil, "", errNotAFile
634+}
635+
636+func contentToFile(content []byte) ([]byte, string, error) {
637+ f, err := os.CreateTemp("", "paste_*.txt")
638+ if err != nil {
639+ return nil, "", err
640+ }
641+ if _, err := f.Write(content); err != nil {
642+ return nil, "", err
643+ }
644+ if err := f.Close(); err != nil {
645+ return nil, "", err
646+ }
647+ return content, f.Name(), nil
648+}
649+
650+func filepathToFile(name string) ([]byte, string, error) {
651+ path, err := filepath.Abs(strings.TrimSpace(strings.ReplaceAll(name, "\\", "")))
652+ if err != nil {
653+ return nil, "", err
654+ }
655+ content, err := os.ReadFile(path)
656+ if err != nil {
657+ return nil, "", err
658+ }
659+ return content, path, nil
660+}
661+
662+func mimeOf(content []byte) string {
663+ mimeBufferSize := min(512, len(content))
664+ return http.DetectContentType(content[:mimeBufferSize])
665+}
internal/tui/components/chat/messages/messages.go+22−9
@@ -227,19 +227,32 @@ func (m *messageCmp) renderUserMessage() string {
227 m.toMarkdown(m.message.Content().String()),
227 m.toMarkdown(m.message.Content().String()),
228 }
228 }
229
229
230− attachmentStyles := t.S().Text.
230+ attachmentStyle := t.S().Base.
231− MarginLeft(1).
231+ Padding(0, 1).
232+ MarginRight(1).
232− Background(t.BgSubtle)
233+ Background(t.FgMuted).
234+ Foreground(t.FgBase).
235+ Render
236+ iconStyle := t.S().Base.
237+ Foreground(t.BgSubtle).
238+ Background(t.Green).
239+ Padding(0, 1).
240+ Bold(true).
241+ Render
233
242
234 attachments := make([]string, len(m.message.BinaryContent()))
243 attachments := make([]string, len(m.message.BinaryContent()))
235 for i, attachment := range m.message.BinaryContent() {
244 for i, attachment := range m.message.BinaryContent() {
236 const maxFilenameWidth = 10
245 const maxFilenameWidth = 10
237− filename := filepath.Base(attachment.Path)
246+ filename := ansi.Truncate(filepath.Base(attachment.Path), 10, "...")
238− attachments[i] = attachmentStyles.Render(fmt.Sprintf(
247+ icon := styles.ImageIcon
239− " %s %s ",
248+ if strings.HasPrefix(attachment.MIMEType, "text/") {
240− styles.DocumentIcon,
249+ icon = styles.TextIcon
241− ansi.Truncate(filename, maxFilenameWidth, "..."),
250+ }
251+ attachments[i] = lipgloss.JoinHorizontal(
252+ lipgloss.Left,
253+ iconStyle(icon),
254+ attachmentStyle(filename),
242− ))
255+ )
243 }
256 }
244
257
245 if len(attachments) > 0 {
258 if len(attachments) > 0 {
internal/tui/page/chat/chat.go+5−2
@@ -613,8 +613,11 @@ func (p *chatPage) View() string {
613 pillsArea = pillsRow
613 pillsArea = pillsRow
614 }
614 }
615
615
616+ pillsArea = t.S().Base.
617+ MaxWidth(p.width).
618+ MarginTop(1).
616− style := t.S().Base.MarginTop(1).PaddingLeft(3)
619+ PaddingLeft(3).
617− pillsArea = style.Render(pillsArea)
620+ Render(pillsArea)
618 }
621 }
619
622
620 if p.compact {
623 if p.compact {
internal/tui/styles/icons.go+2−1
@@ -10,7 +10,8 @@ const (
10 ArrowRightIcon string = "→"
10 ArrowRightIcon string = "→"
11 CenterSpinnerIcon string = "⋯"
11 CenterSpinnerIcon string = "⋯"
12 LoadingIcon string = "⟳"
12 LoadingIcon string = "⟳"
13− DocumentIcon string = "🖼"
13+ ImageIcon string = "■"
14+ TextIcon string = "☰"
14 ModelIcon string = "◇"
15 ModelIcon string = "◇"
15
16
16 // Tool call icons
17 // Tool call icons