mirror of
https://github.com/coder/coder.git
synced 2026-06-03 21:18:24 +00:00
ef6969dd70
Agents can already see workspace files and take screenshots, but users could not download those artifacts from chat. This PR adds durable chat attachments to chatd. `attach_file`, explicit `computer` screenshot actions (not the automatic post-action screenshots), and `propose_plan` now fetch bytes over the agent connection, store them in `chat_files`, link them to the chat, and carry attachment metadata in tool responses so `buildAssistantPartsForPersist` can materialize ordinary `type:"file"` assistant parts that the chat file APIs serve. The same storage helpers are reused for other artifact-producing paths. `wait_agent` recordings and thumbnails are stored as chat files and linked back to the parent chat, with best-effort relinking so parent chats retain those artifacts without leaving orphaned rows when chat-file caps reject links. `storeChatAttachment` wraps insert + link in one transaction, files are capped at 10 MB each and 20 per chat, and serving defaults to `Content-Disposition: attachment` with an explicit inline-safe allowlist. This PR also consolidates chat-file media policy in `coderd/chatfiles`. Uploads and tool-generated attachments share byte-based MIME detection, SVG blocking, inline-safety rules, and compatible `text/plain` refinement for JSON, CSV, and Markdown. Prompt construction still only inlines synthetic pasted text for model consumption; assistant-created attachments are persisted for the user and intentionally not replayed into later LLM turns. UI follow-up lives in #24281. Relates to CODAGT-91
172 lines
4.9 KiB
Go
172 lines
4.9 KiB
Go
package chattool
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"io"
|
|
"strings"
|
|
|
|
"charm.land/fantasy"
|
|
"github.com/google/uuid"
|
|
"golang.org/x/xerrors"
|
|
|
|
"github.com/coder/coder/v2/codersdk/workspacesdk"
|
|
)
|
|
|
|
const maxAttachmentSize = 10 << 20 // 10 MiB
|
|
|
|
// StoreFileFunc persists a chat attachment after classifying it for durable
|
|
// storage and returns the stored attachment metadata.
|
|
type StoreFileFunc func(ctx context.Context, name string, detectName string, data []byte) (AttachmentMetadata, error)
|
|
|
|
// AttachmentMetadata identifies a durable chat attachment that should be
|
|
// promoted into a standard file message part for the user.
|
|
type AttachmentMetadata struct {
|
|
FileID uuid.UUID `json:"file_id"`
|
|
MediaType string `json:"media_type"`
|
|
Name string `json:"name,omitempty"`
|
|
}
|
|
|
|
type attachmentResponseMetadata struct {
|
|
Attachments []AttachmentMetadata `json:"attachments,omitempty"`
|
|
}
|
|
|
|
func storeAttachmentData(
|
|
ctx context.Context,
|
|
storeFile StoreFileFunc,
|
|
name string,
|
|
detectName string,
|
|
data []byte,
|
|
) (AttachmentMetadata, error) {
|
|
if storeFile == nil {
|
|
return AttachmentMetadata{}, xerrors.New("file storage is not configured")
|
|
}
|
|
if len(data) == 0 {
|
|
return AttachmentMetadata{}, xerrors.New("attachment is empty")
|
|
}
|
|
if len(data) > maxAttachmentSize {
|
|
return AttachmentMetadata{}, xerrors.Errorf("attachment exceeds %d MiB size limit", maxAttachmentSize>>20)
|
|
}
|
|
|
|
name = strings.TrimSpace(name)
|
|
if name == "" {
|
|
return AttachmentMetadata{}, xerrors.New("attachment name is required")
|
|
}
|
|
if strings.TrimSpace(detectName) == "" {
|
|
detectName = name
|
|
}
|
|
|
|
attachment, err := storeFile(ctx, name, detectName, data)
|
|
if err != nil {
|
|
return AttachmentMetadata{}, err
|
|
}
|
|
if attachment.FileID == uuid.Nil {
|
|
return AttachmentMetadata{}, xerrors.New("stored attachment is missing file ID")
|
|
}
|
|
if attachment.MediaType == "" {
|
|
return AttachmentMetadata{}, xerrors.New("stored attachment is missing media type")
|
|
}
|
|
if attachment.Name == "" {
|
|
attachment.Name = name
|
|
}
|
|
return attachment, nil
|
|
}
|
|
|
|
func storeWorkspaceAttachment(
|
|
ctx context.Context,
|
|
conn workspacesdk.AgentConn,
|
|
path string,
|
|
name string,
|
|
storeFile StoreFileFunc,
|
|
) (AttachmentMetadata, int, error) {
|
|
if conn == nil {
|
|
return AttachmentMetadata{}, 0, xerrors.New("workspace connection is not configured")
|
|
}
|
|
if strings.TrimSpace(path) == "" {
|
|
return AttachmentMetadata{}, 0, xerrors.New("path is required")
|
|
}
|
|
reader, _, err := conn.ReadFile(ctx, path, 0, maxAttachmentSize+1)
|
|
if err != nil {
|
|
return AttachmentMetadata{}, 0, err
|
|
}
|
|
defer reader.Close()
|
|
|
|
data, err := io.ReadAll(io.LimitReader(reader, maxAttachmentSize+1))
|
|
if err != nil {
|
|
return AttachmentMetadata{}, 0, err
|
|
}
|
|
if strings.TrimSpace(name) == "" {
|
|
path = strings.TrimRight(path, "/\\")
|
|
if idx := strings.LastIndexAny(path, "/\\"); idx >= 0 {
|
|
name = path[idx+1:]
|
|
} else {
|
|
name = path
|
|
}
|
|
}
|
|
attachment, err := storeAttachmentData(ctx, storeFile, name, path, data)
|
|
if err != nil {
|
|
return AttachmentMetadata{}, 0, err
|
|
}
|
|
return attachment, len(data), nil
|
|
}
|
|
|
|
func storeScreenshotAttachment(
|
|
ctx context.Context,
|
|
storeFile StoreFileFunc,
|
|
name string,
|
|
encodedPNG string,
|
|
) (AttachmentMetadata, error) {
|
|
if strings.TrimSpace(encodedPNG) == "" {
|
|
return AttachmentMetadata{}, xerrors.New("screenshot data is empty")
|
|
}
|
|
decoder := base64.NewDecoder(base64.StdEncoding, strings.NewReader(encodedPNG))
|
|
data, err := io.ReadAll(io.LimitReader(decoder, maxAttachmentSize+1))
|
|
if err != nil {
|
|
return AttachmentMetadata{}, xerrors.Errorf("decode screenshot: %w", err)
|
|
}
|
|
if strings.TrimSpace(name) == "" {
|
|
name = "screenshot.png"
|
|
}
|
|
return storeAttachmentData(ctx, storeFile, name, name, data)
|
|
}
|
|
|
|
// WithAttachments stores durable attachment metadata on a tool response so the
|
|
// persistence layer can promote the files into assistant chat attachments.
|
|
func WithAttachments(
|
|
response fantasy.ToolResponse,
|
|
attachments ...AttachmentMetadata,
|
|
) fantasy.ToolResponse {
|
|
if len(attachments) == 0 {
|
|
return response
|
|
}
|
|
return fantasy.WithResponseMetadata(response, attachmentResponseMetadata{
|
|
Attachments: attachments,
|
|
})
|
|
}
|
|
|
|
// AttachmentsFromMetadata decodes durable attachment metadata from a tool
|
|
// response so the persistence layer can promote them into assistant file parts.
|
|
func AttachmentsFromMetadata(metadata string) ([]AttachmentMetadata, error) {
|
|
if strings.TrimSpace(metadata) == "" {
|
|
return nil, nil
|
|
}
|
|
|
|
var decoded attachmentResponseMetadata
|
|
if err := json.Unmarshal([]byte(metadata), &decoded); err != nil {
|
|
return nil, xerrors.Errorf("unmarshal attachment metadata: %w", err)
|
|
}
|
|
|
|
attachments := make([]AttachmentMetadata, 0, len(decoded.Attachments))
|
|
for i, attachment := range decoded.Attachments {
|
|
if attachment.FileID == uuid.Nil {
|
|
return nil, xerrors.Errorf("attachment %d is missing file_id", i)
|
|
}
|
|
if attachment.MediaType == "" {
|
|
return nil, xerrors.Errorf("attachment %d is missing media_type", i)
|
|
}
|
|
attachments = append(attachments, attachment)
|
|
}
|
|
return attachments, nil
|
|
}
|