From 5c728ed1de6a3515d7b17175f6f29be61e808394 Mon Sep 17 00:00:00 2001 From: NeilJo-GY <43027886+NeilJo-GY@users.noreply.github.com> Date: Fri, 11 Sep 2026 14:02:29 +0800 Subject: [PATCH] feat(chat): render conversation image and video from mailbox refs Hunter bubbles fetch this chat's files with JWT instead of hotlinking. Co-authored-by: Cursor --- packages/agent-chat/src/AgentChatShell.tsx | 15 +++ packages/agent-chat/src/MailboxThumbs.tsx | 99 +++++++++++++++++++ packages/agent-chat/src/gateway.ts | 9 +- packages/agent-chat/src/mailbox.ts | 38 +++++++ .../src/ranch-shell/RanchChatShell.tsx | 9 ++ packages/agent-chat/src/types.ts | 2 + 6 files changed, 170 insertions(+), 2 deletions(-) create mode 100644 packages/agent-chat/src/MailboxThumbs.tsx create mode 100644 packages/agent-chat/src/mailbox.ts diff --git a/packages/agent-chat/src/AgentChatShell.tsx b/packages/agent-chat/src/AgentChatShell.tsx index 82d65f3..4bf0821 100644 --- a/packages/agent-chat/src/AgentChatShell.tsx +++ b/packages/agent-chat/src/AgentChatShell.tsx @@ -11,6 +11,8 @@ import type { } from "./types"; import { CHAT_OPEN_EVENT } from "./types"; import { connectChatSocket, type ChatSocket } from "./ws"; +import { MailboxThumbs } from "./MailboxThumbs"; +import { parseMessageAttachments } from "./mailbox"; const DEFAULT_ACCENT = "#10B981"; const ZINC_800 = "#27272a"; @@ -279,6 +281,7 @@ export function AgentChatShell(props: AgentChatShellProps) { content: typeof d.content === "string" ? d.content : null, created_at: typeof d.created_at === "string" ? d.created_at : new Date().toISOString(), + attachments: parseMessageAttachments(d.attachments), }; setMessages((prev) => prev.some((x) => x.message_id === m.message_id) ? prev : [...prev, m], @@ -507,6 +510,12 @@ export function AgentChatShell(props: AgentChatShellProps) { }} > {m.content} + ); @@ -819,6 +828,12 @@ export function AgentChatShell(props: AgentChatShellProps) { {m.sender_type}:{m.sender_id.slice(0, 24)} {m.content} + ))} diff --git a/packages/agent-chat/src/MailboxThumbs.tsx b/packages/agent-chat/src/MailboxThumbs.tsx new file mode 100644 index 0000000..bf73663 --- /dev/null +++ b/packages/agent-chat/src/MailboxThumbs.tsx @@ -0,0 +1,99 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { mailboxIdsFromAttachments, parseMessageAttachments } from "./mailbox"; + +function joinUrl(base: string, path: string): string { + const b = base.replace(/\/+$/, ""); + const p = path.startsWith("/") ? path : `/${path}`; + return `${b}${p}`; +} + +type FileBlob = { url: string; contentType: string }; + +export function MailboxThumbs({ + chatId, + attachments, + gatewayBaseUrl, + getAccessToken, +}: { + chatId: string; + attachments?: string[] | string | null; + gatewayBaseUrl: string; + getAccessToken: () => Promise; +}) { + const ids = mailboxIdsFromAttachments(parseMessageAttachments(attachments ?? [])); + const [files, setFiles] = useState([]); + + useEffect(() => { + if (ids.length === 0 || !chatId) { + setFiles([]); + return; + } + let cancelled = false; + const created: string[] = []; + (async () => { + const token = await getAccessToken(); + if (!token || cancelled) return; + const next: FileBlob[] = []; + for (const id of ids) { + try { + const res = await fetch( + joinUrl( + gatewayBaseUrl, + `/api/chats/${encodeURIComponent(chatId)}/files/${encodeURIComponent(id)}`, + ), + { headers: { Authorization: `Bearer ${token}` } }, + ); + if (!res.ok) continue; + const blob = await res.blob(); + const url = URL.createObjectURL(blob); + created.push(url); + next.push({ + url, + contentType: blob.type || res.headers.get("content-type") || "", + }); + } catch { + /* skip broken blob */ + } + } + if (!cancelled) setFiles(next); + })(); + return () => { + cancelled = true; + for (const u of created) URL.revokeObjectURL(u); + }; + }, [chatId, gatewayBaseUrl, getAccessToken, ids.join("|")]); + + if (files.length === 0) return null; + return ( +
+ {files.map((f) => + f.contentType.startsWith("video/") ? ( +
+ ); +} diff --git a/packages/agent-chat/src/gateway.ts b/packages/agent-chat/src/gateway.ts index 950ea64..666425c 100644 --- a/packages/agent-chat/src/gateway.ts +++ b/packages/agent-chat/src/gateway.ts @@ -1,4 +1,5 @@ import type { ChatMessage, ChatParticipant, ChatSummary, ThreadSummary } from "./types"; +import { normalizeChatMessage } from "./mailbox"; export class ChatGatewayError extends Error { constructor( @@ -1107,8 +1108,12 @@ export function createGatewayClient( `/api/chat/agent-create-jobs/${encodeURIComponent(jobId)}/retry-bind`, { method: "POST", body: "{}" }, ), - listMessages: (chatId) => - request(`/api/chats/${encodeURIComponent(chatId)}/messages?limit=50`), + listMessages: async (chatId) => { + const rows = await request( + `/api/chats/${encodeURIComponent(chatId)}/messages?limit=50`, + ); + return rows.map((row) => normalizeChatMessage(row)); + }, listParticipants: (chatId) => request(`/api/chats/${encodeURIComponent(chatId)}/participants`), sendMessage: (chatId, content, mentions, threadId, opts) => diff --git a/packages/agent-chat/src/mailbox.ts b/packages/agent-chat/src/mailbox.ts new file mode 100644 index 0000000..fcb51c0 --- /dev/null +++ b/packages/agent-chat/src/mailbox.ts @@ -0,0 +1,38 @@ +/** Chat mailbox refs: only ``mbx:{id}`` is renderable. Hotlinks never become img src. */ + +export const MAILBOX_PREFIX = "mbx:"; + +export function parseMessageAttachments(raw: unknown): string[] { + if (Array.isArray(raw)) { + return raw.filter((x): x is string => typeof x === "string" && x.trim() !== ""); + } + if (typeof raw === "string" && raw.trim()) { + try { + const parsed = JSON.parse(raw) as unknown; + if (Array.isArray(parsed)) { + return parsed.filter((x): x is string => typeof x === "string" && x.trim() !== ""); + } + } catch { + /* ignore */ + } + } + return []; +} + +export function mailboxIdsFromAttachments(refs: string[]): string[] { + const ids: string[] = []; + for (const ref of refs) { + const t = ref.trim(); + if (!t.startsWith(MAILBOX_PREFIX)) continue; + const id = t.slice(MAILBOX_PREFIX.length).trim(); + if (!id || id.includes("/") || id.includes("..")) continue; + ids.push(id); + } + return ids; +} + +export function normalizeChatMessage( + row: T, +): T & { attachments: string[] } { + return { ...row, attachments: parseMessageAttachments(row.attachments) }; +} diff --git a/packages/agent-chat/src/ranch-shell/RanchChatShell.tsx b/packages/agent-chat/src/ranch-shell/RanchChatShell.tsx index af81a27..4a5052f 100644 --- a/packages/agent-chat/src/ranch-shell/RanchChatShell.tsx +++ b/packages/agent-chat/src/ranch-shell/RanchChatShell.tsx @@ -26,6 +26,8 @@ import type { ThreadSummary, } from "../types"; import { connectChatSocket, type ChatSocket } from "../ws"; +import { MailboxThumbs } from "../MailboxThumbs"; +import { parseMessageAttachments } from "../mailbox"; import { AgentOwnerSettings, deliveryLabel, @@ -2611,6 +2613,7 @@ export function RanchChatShell(props: RanchChatShellProps) { created_at: typeof d.created_at === "string" ? d.created_at : new Date().toISOString(), metadata: parseMessageMetadata(d.metadata), + attachments: parseMessageAttachments(d.attachments), }; setMessages((prev) => { const next = prev.some((x) => x.message_id === m.message_id) ? prev : [...prev, m]; @@ -4485,6 +4488,12 @@ export function RanchChatShell(props: RanchChatShellProps) { }} > {m.content} + {isUser && (delivery || deliveryByAgent) ? (