feat: fix attachment handling — display in DMs, enrich in MCP, allow all file types

- Show attachment previews on DM messages (was missing, only channels had it)
- Add file upload button to DM compose bar with paperclip icon
- Enrich messages with attachment data in all MCP bridge functions
  (read_inbox, claim_messages, search, channel_messages, list_by_state)
- Remove file type restrictions — allow any file type, keep 50MB size limit
- Rebuild web dist

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Algis Dumbris
2026-03-24 13:00:28 +02:00
co-authored by Claude Opus 4.6
parent 8134a7eef5
commit 0e28c0b45e
9 changed files with 141 additions and 66 deletions
+3 -21
View File
@@ -119,26 +119,8 @@ func IsImageType(mimeType string) bool {
return imageTypes[mimeType]
}
// IsAllowedType returns true if the MIME type is allowed for upload.
// Allowed: image/*, application/pdf, text/*.
// IsAllowedType returns true for all MIME types. Any file type is allowed;
// only size is restricted (50 MB max).
func IsAllowedType(mimeType string) bool {
// Normalize: strip parameters like "; charset=utf-8".
base := mimeType
if idx := strings.Index(mimeType, ";"); idx >= 0 {
base = strings.TrimSpace(mimeType[:idx])
}
if strings.HasPrefix(base, "image/") {
return true
}
if base == "application/pdf" {
return true
}
if strings.HasPrefix(base, "text/") {
return true
}
// Also allow JSON and XML which may be detected as application/*
if base == "application/json" || base == "application/xml" {
return true
}
return false
return true
}
+4 -4
View File
@@ -167,10 +167,10 @@ func TestIsAllowedType(t *testing.T) {
{"text/csv", true},
{"text/plain; charset=utf-8", true},
{"application/json", true},
{"application/octet-stream", false},
{"application/zip", false},
{"application/x-executable", false},
{"video/mp4", false},
{"application/octet-stream", true},
{"application/zip", true},
{"application/x-executable", true},
{"video/mp4", true},
}
for _, tt := range tests {
-5
View File
@@ -60,11 +60,6 @@ func (s *Service) Upload(ctx context.Context, req UploadRequest) (*UploadResult,
mimeType = DetectMIMEType(sniffBuf, req.Filename)
}
// Validate file type against allowlist.
if !IsAllowedType(mimeType) {
return nil, ErrUnsupportedType
}
// Assign default filename if missing.
filename := req.Filename
if filename == "" {
+4 -4
View File
@@ -236,18 +236,18 @@ func TestService_Upload_FileTypeValidation(t *testing.T) {
wantErr: nil,
},
{
name: "invalid type zip rejected",
name: "zip upload allowed",
content: []byte("not real zip content"),
filename: "archive.zip",
mimeType: "application/zip",
wantErr: ErrUnsupportedType,
wantErr: nil,
},
{
name: "invalid type executable rejected",
name: "executable upload allowed",
content: []byte{0x7f, 0x45, 0x4c, 0x46},
filename: "program.exe",
mimeType: "application/x-executable",
wantErr: ErrUnsupportedType,
wantErr: nil,
},
}
+38 -13
View File
@@ -216,6 +216,8 @@ func (b *ServiceBridge) callReadInbox(ctx context.Context, args map[string]any)
return nil, err
}
b.msgService.EnrichMessages(ctx, page.Messages)
return map[string]any{
"messages": page.Messages,
"count": len(page.Messages),
@@ -233,6 +235,8 @@ func (b *ServiceBridge) callClaimMessages(ctx context.Context, args map[string]a
return nil, err
}
b.msgService.EnrichMessages(ctx, messages)
return map[string]any{
"messages": messages,
"count": len(messages),
@@ -287,6 +291,15 @@ func (b *ServiceBridge) callSearchMessages(ctx context.Context, args map[string]
return nil, err
}
// Enrich messages with attachments
searchMsgs := make([]*messaging.Message, 0, len(resp.Results))
for _, r := range resp.Results {
if r.Message != nil {
searchMsgs = append(searchMsgs, r.Message)
}
}
b.msgService.EnrichMessages(ctx, searchMsgs)
resultMsgs := make([]map[string]any, len(resp.Results))
for i, r := range resp.Results {
entry := map[string]any{
@@ -326,6 +339,8 @@ func (b *ServiceBridge) callSearchMessages(ctx context.Context, args map[string]
return nil, err
}
b.msgService.EnrichMessages(ctx, page.Messages)
return map[string]any{
"messages": page.Messages,
"count": len(page.Messages),
@@ -553,15 +568,18 @@ func (b *ServiceBridge) callGetChannelMessages(ctx context.Context, args map[str
return nil, err
}
b.msgService.EnrichMessages(ctx, page.Messages)
result := make([]map[string]any, len(page.Messages))
for i, msg := range page.Messages {
result[i] = map[string]any{
"id": msg.ID,
"from": msg.FromAgent,
"body": msg.Body,
"priority": msg.Priority,
"status": msg.Status,
"created_at": msg.CreatedAt,
"id": msg.ID,
"from": msg.FromAgent,
"body": msg.Body,
"priority": msg.Priority,
"status": msg.Status,
"created_at": msg.CreatedAt,
"attachments": msg.Attachments,
}
if len(msg.Metadata) > 0 {
result[i]["metadata"] = msg.Metadata
@@ -1119,23 +1137,30 @@ func (b *ServiceBridge) callListByState(ctx context.Context, args map[string]any
if maxBodyLen <= 0 {
maxBodyLen = 500
}
var messages []map[string]any
var msgSlice []*messaging.Message
for _, id := range pageIDs {
msg, err := b.msgService.GetMessageByID(ctx, id)
if err != nil {
continue
}
msgSlice = append(msgSlice, msg)
}
b.msgService.EnrichMessages(ctx, msgSlice)
var messages []map[string]any
for _, msg := range msgSlice {
body := msg.Body
if len(body) > maxBodyLen {
body = body[:maxBodyLen] + "..."
}
messages = append(messages, map[string]any{
"id": msg.ID,
"from_agent": msg.FromAgent,
"body": body,
"priority": msg.Priority,
"created_at": msg.CreatedAt,
"reply_to": msg.ReplyTo,
"id": msg.ID,
"from_agent": msg.FromAgent,
"body": body,
"priority": msg.Priority,
"created_at": msg.CreatedAt,
"reply_to": msg.ReplyTo,
"attachments": msg.Attachments,
})
}
resp["messages"] = messages
+11 -11
View File
@@ -11,30 +11,30 @@
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=Instrument+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link href="/_app/immutable/entry/start.j4rAzk5U.js" rel="modulepreload">
<link href="/_app/immutable/chunks/CQAbBV_c.js" rel="modulepreload">
<link href="/_app/immutable/entry/start.DB7lMQq7.js" rel="modulepreload">
<link href="/_app/immutable/chunks/CWvhbc19.js" rel="modulepreload">
<link href="/_app/immutable/chunks/BjgrqnN-.js" rel="modulepreload">
<link href="/_app/immutable/chunks/BD7A0KAH.js" rel="modulepreload">
<link href="/_app/immutable/chunks/BGmOlpfg.js" rel="modulepreload">
<link href="/_app/immutable/chunks/CYwkL9H5.js" rel="modulepreload">
<link href="/_app/immutable/chunks/BF2TNE-m.js" rel="modulepreload">
<link href="/_app/immutable/chunks/Bvt7CkU7.js" rel="modulepreload">
<link href="/_app/immutable/entry/app.DKddxDn6.js" rel="modulepreload">
<link href="/_app/immutable/chunks/DUR6aSWt.js" rel="modulepreload">
<link href="/_app/immutable/chunks/CgiIxzzM.js" rel="modulepreload">
<link href="/_app/immutable/chunks/CNG0tgnP.js" rel="modulepreload">
<link href="/_app/immutable/chunks/anqNGRhz.js" rel="modulepreload">
<link href="/_app/immutable/chunks/BWyv3yYM.js" rel="modulepreload">
<link href="/_app/immutable/entry/app.B90d0BZx.js" rel="modulepreload">
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">
<script>
{
__sveltekit_31bf05 = {
__sveltekit_1vssdz = {
base: ""
};
const element = document.currentScript.parentElement;
Promise.all([
import("/_app/immutable/entry/start.j4rAzk5U.js"),
import("/_app/immutable/entry/app.DKddxDn6.js")
import("/_app/immutable/entry/start.DB7lMQq7.js"),
import("/_app/immutable/entry/app.B90d0BZx.js")
]).then(([kit, app]) => {
kit.start(app, element);
});
@@ -19,8 +19,6 @@
let uploadError = $state('');
let fileInputEl: HTMLInputElement | undefined = $state(undefined);
const ACCEPTED_FILES = '.jpg,.jpeg,.png,.gif,.webp,.svg,.pdf,.txt,.md,.csv,.json,.xml,.yaml,.yml,.log';
function formatFileSize(bytes: number): string {
if (bytes < 1024 * 1024) {
return (bytes / 1024).toFixed(1) + ' KB';
@@ -261,7 +259,6 @@
<input
bind:this={fileInputEl}
type="file"
accept={ACCEPTED_FILES}
class="hidden"
onchange={handleFileSelected}
/>
@@ -382,7 +382,6 @@
<input
type="file"
class="hidden"
accept=".jpg,.jpeg,.png,.gif,.webp,.svg,.pdf,.txt,.md,.csv,.json,.xml,.yaml,.yml,.log"
bind:this={fileInputEl}
onchange={handleFileSelected}
/>
+81 -4
View File
@@ -1,9 +1,10 @@
<script lang="ts">
import { page } from '$app/stores';
import { agents as agentsApi, messages as messagesApi } from '$lib/api/client';
import { agents as agentsApi, messages as messagesApi, attachments as attachmentsApi } from '$lib/api/client';
import { openThread, closeThread } from '$lib/stores/thread';
import { notifications } from '$lib/stores/notifications';
import MessageBody from '$lib/components/MessageBody.svelte';
import AttachmentPreview from '$lib/components/AttachmentPreview.svelte';
import WorkflowBadge from '$lib/components/WorkflowBadge.svelte';
import ReactionPills from '$lib/components/ReactionPills.svelte';
@@ -19,6 +20,13 @@
let sending = $state(false);
let sendError = $state('');
// Attachment state
type UploadedAttachment = { hash: string; original_filename: string; size: number; mime_type: string };
let uploadedAttachments = $state<UploadedAttachment[]>([]);
let uploading = $state(false);
let uploadError = $state('');
let fileInputEl: HTMLInputElement | undefined = $state(undefined);
// Mark-as-read timer
let markReadTimer: ReturnType<typeof setTimeout> | null = null;
@@ -96,15 +104,18 @@
});
async function handleSend() {
if (!body.trim()) return;
if (!body.trim() && uploadedAttachments.length === 0) return;
sending = true;
sendError = '';
try {
await messagesApi.send({
to: peerAgent,
body: body.trim()
body: body.trim(),
attachments: uploadedAttachments.length > 0 ? uploadedAttachments.map(a => a.hash) : undefined
});
body = '';
uploadedAttachments = [];
uploadError = '';
await loadMessages();
} catch (err: any) {
sendError = err.message || 'Failed to send message';
@@ -148,6 +159,32 @@
return d.toLocaleDateString([], { month: 'short', day: 'numeric' }) + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
function formatFileSize(bytes: number): string {
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
}
async function handleFileSelected(e: Event) {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
uploading = true;
uploadError = '';
try {
const result = await attachmentsApi.upload(file);
uploadedAttachments = [...uploadedAttachments, result];
} catch (err: any) {
uploadError = err.message || 'Upload failed';
} finally {
uploading = false;
if (fileInputEl) fileInputEl.value = '';
}
}
function removeAttachment(hash: string) {
uploadedAttachments = uploadedAttachments.filter(a => a.hash !== hash);
}
let isOwnAgent = $derived(ownAgents.some(a => a.name === peerAgent));
</script>
@@ -248,6 +285,13 @@
{/if}
</div>
<div class="text-sm text-text-primary/90 leading-relaxed"><MessageBody body={msg.body} /></div>
{#if msg.attachments?.length > 0}
<div class="flex flex-wrap gap-2 mt-1.5">
{#each msg.attachments as att (att.hash)}
<AttachmentPreview attachment={att} />
{/each}
</div>
{/if}
{#if msg.workflow_state}
<WorkflowBadge state={msg.workflow_state} />
{/if}
@@ -286,11 +330,34 @@
{#if sendError}
<div class="mb-2 px-3 py-1.5 bg-accent-red/10 rounded text-xs text-accent-red">{sendError}</div>
{/if}
{#if uploadError}
<div class="mb-2 px-3 py-1.5 bg-accent-red/10 rounded text-xs text-accent-red">{uploadError}</div>
{/if}
{#if ownAgents.length === 0}
<div class="px-3 py-2 bg-bg-tertiary rounded text-xs text-text-secondary text-center">
Register an agent to send messages
</div>
{:else}
<!-- Hidden file input -->
<input bind:this={fileInputEl} type="file" class="hidden" onchange={handleFileSelected} />
{#if uploadedAttachments.length > 0}
<div class="flex flex-wrap gap-1.5 px-3 pt-2">
{#each uploadedAttachments as att (att.hash)}
<span class="inline-flex items-center gap-1 px-2 py-1 bg-bg-secondary border border-border rounded text-xs text-text-primary">
<svg class="w-3 h-3 text-text-secondary" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M18.375 12.739l-7.693 7.693a4.5 4.5 0 01-6.364-6.364l10.94-10.94A3 3 0 1119.5 7.372L8.552 18.32m.009-.01l-.01.01m5.699-9.941l-7.81 7.81a1.5 1.5 0 002.112 2.13" /></svg>
{att.original_filename}
<span class="text-text-secondary">({formatFileSize(att.size)})</span>
<button class="ml-0.5 text-text-secondary hover:text-accent-red" onclick={() => removeAttachment(att.hash)} title="Remove">&times;</button>
</span>
{/each}
</div>
{/if}
{#if uploading}
<div class="flex items-center gap-2 px-3 pt-1 text-xs text-text-secondary">
<svg class="w-3.5 h-3.5 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg>
Uploading...
</div>
{/if}
<div class="flex items-end gap-2 bg-bg-tertiary rounded-lg border border-border focus-within:border-border-active transition-colors">
<textarea
placeholder="Message {peer?.display_name || peerAgent}..."
@@ -299,9 +366,19 @@
rows="1"
onkeydown={handleKeydown}
></textarea>
<button
class="p-2 mr-0.5 mb-1 rounded-md text-text-secondary hover:text-text-primary hover:bg-bg-secondary transition-colors disabled:opacity-40"
disabled={uploading}
onclick={() => fileInputEl?.click()}
title="Attach file"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M18.375 12.739l-7.693 7.693a4.5 4.5 0 01-6.364-6.364l10.94-10.94A3 3 0 1119.5 7.372L8.552 18.32m.009-.01l-.01.01m5.699-9.941l-7.81 7.81a1.5 1.5 0 002.112 2.13" />
</svg>
</button>
<button
class="p-2 mr-1 mb-1 rounded-md bg-accent-green text-white hover:brightness-110 transition-all disabled:opacity-40 disabled:cursor-not-allowed"
disabled={sending || !body.trim()}
disabled={sending || (!body.trim() && uploadedAttachments.length === 0)}
onclick={handleSend}
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">