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:
co-authored by
Claude Opus 4.6
parent
8134a7eef5
commit
0e28c0b45e
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Vendored
+11
-11
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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">×</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">
|
||||
|
||||
Reference in New Issue
Block a user