Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions packages/agent-chat/src/AgentChatShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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],
Expand Down Expand Up @@ -507,6 +510,12 @@ export function AgentChatShell(props: AgentChatShellProps) {
}}
>
{m.content}
<MailboxThumbs
chatId={m.chat_id || chat?.chat_id || ""}
attachments={m.attachments}
gatewayBaseUrl={gatewayBaseUrl}
getAccessToken={getAccessToken}
/>
</div>
</div>
);
Expand Down Expand Up @@ -819,6 +828,12 @@ export function AgentChatShell(props: AgentChatShellProps) {
{m.sender_type}:{m.sender_id.slice(0, 24)}
</div>
{m.content}
<MailboxThumbs
chatId={m.chat_id || chat?.chat_id || ""}
attachments={m.attachments}
gatewayBaseUrl={gatewayBaseUrl}
getAccessToken={getAccessToken}
/>
</div>
))}
</div>
Expand Down
99 changes: 99 additions & 0 deletions packages/agent-chat/src/MailboxThumbs.tsx
Original file line number Diff line number Diff line change
@@ -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<string | null>;
}) {
const ids = mailboxIdsFromAttachments(parseMessageAttachments(attachments ?? []));
const [files, setFiles] = useState<FileBlob[]>([]);

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 (
<div
style={{
display: "flex",
flexDirection: "column",
gap: 8,
marginTop: 8,
maxWidth: "100%",
}}
>
{files.map((f) =>
f.contentType.startsWith("video/") ? (
<video
key={f.url}
src={f.url}
controls
playsInline
style={{ maxWidth: "100%", borderRadius: 8, display: "block" }}
/>
) : (
<img
key={f.url}
src={f.url}
alt=""
style={{ maxWidth: "100%", borderRadius: 8, display: "block" }}
/>
),
)}
</div>
);
}
9 changes: 7 additions & 2 deletions packages/agent-chat/src/gateway.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { ChatMessage, ChatParticipant, ChatSummary, ThreadSummary } from "./types";
import { normalizeChatMessage } from "./mailbox";

export class ChatGatewayError extends Error {
constructor(
Expand Down Expand Up @@ -1107,8 +1108,12 @@ export function createGatewayClient(
`/api/chat/agent-create-jobs/${encodeURIComponent(jobId)}/retry-bind`,
{ method: "POST", body: "{}" },
),
listMessages: (chatId) =>
request<ChatMessage[]>(`/api/chats/${encodeURIComponent(chatId)}/messages?limit=50`),
listMessages: async (chatId) => {
const rows = await request<ChatMessage[]>(
`/api/chats/${encodeURIComponent(chatId)}/messages?limit=50`,
);
return rows.map((row) => normalizeChatMessage(row));
},
listParticipants: (chatId) =>
request<ChatParticipant[]>(`/api/chats/${encodeURIComponent(chatId)}/participants`),
sendMessage: (chatId, content, mentions, threadId, opts) =>
Expand Down
38 changes: 38 additions & 0 deletions packages/agent-chat/src/mailbox.ts
Original file line number Diff line number Diff line change
@@ -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<T extends { attachments?: unknown }>(
row: T,
): T & { attachments: string[] } {
return { ...row, attachments: parseMessageAttachments(row.attachments) };
}
9 changes: 9 additions & 0 deletions packages/agent-chat/src/ranch-shell/RanchChatShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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];
Expand Down Expand Up @@ -4485,6 +4488,12 @@ export function RanchChatShell(props: RanchChatShellProps) {
}}
>
{m.content}
<MailboxThumbs
chatId={m.chat_id || active?.chat_id || ""}
attachments={m.attachments}
gatewayBaseUrl={gatewayBaseUrl}
getAccessToken={getAccessToken}
/>
</div>
{isUser && (delivery || deliveryByAgent) ? (
<DeliveryStatusFooter
Expand Down
2 changes: 2 additions & 0 deletions packages/agent-chat/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,8 @@ export type ChatMessage = {
sender_id: string;
content: string | null;
created_at: string;
/** Mailbox refs (``mbx:{id}``). Parsed from API JSON string or WS array. */
attachments?: string[] | null;
/** Topic/thread id when the message belongs to a Topic. */
thread_id?: string | null;
/** Topic title for badges (when provided by Gateway). */
Expand Down
Loading