From 6fe59faa0caf3fcf226322b7713c8205b6b465b1 Mon Sep 17 00:00:00 2001 From: viettranx Date: Fri, 20 Mar 2026 21:46:39 +0700 Subject: [PATCH] feat(ui): media gallery, file helpers, chat message improvements --- .../src/components/chat/active-run-zone.tsx | 2 +- ui/web/src/components/chat/media-gallery.tsx | 114 ++++++++++++++++-- ui/web/src/components/chat/message-bubble.tsx | 14 ++- .../components/shared/markdown-renderer.tsx | 37 ++---- ui/web/src/lib/file-helpers.ts | 27 +++++ .../src/pages/chat/hooks/use-chat-messages.ts | 55 ++++++--- ui/web/src/types/chat.ts | 3 + ui/web/src/types/session.ts | 1 + 8 files changed, 194 insertions(+), 59 deletions(-) diff --git a/ui/web/src/components/chat/active-run-zone.tsx b/ui/web/src/components/chat/active-run-zone.tsx index a9d7379f..9012c77c 100644 --- a/ui/web/src/components/chat/active-run-zone.tsx +++ b/ui/web/src/components/chat/active-run-zone.tsx @@ -53,7 +53,7 @@ export function ActiveRunZone({ {/* Streaming text: wrap in bubble matching MessageBubble's assistant style */} {(thinkingText !== null || streamText !== null) && ( -
+
{thinkingText !== null && (
diff --git a/ui/web/src/components/chat/media-gallery.tsx b/ui/web/src/components/chat/media-gallery.tsx index 029d90a4..084984b5 100644 --- a/ui/web/src/components/chat/media-gallery.tsx +++ b/ui/web/src/components/chat/media-gallery.tsx @@ -1,6 +1,14 @@ -import { useState } from "react"; -import { File, FileText, FileCode, Music, Film } from "lucide-react"; +import { useState, useCallback } from "react"; +import { Download, FileText, FileCode, Music, Film, File } from "lucide-react"; import { ImageLightbox } from "@/components/shared/image-lightbox"; +import { MarkdownRenderer } from "@/components/shared/markdown-renderer"; +import { formatSize } from "@/lib/file-helpers"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; import type { MediaItem } from "@/types/chat"; interface MediaGalleryProps { @@ -17,8 +25,42 @@ function fileIcon(kind: MediaItem["kind"]) { } } +function isMarkdownExt(name: string): boolean { + return /\.(md|mdx|markdown)$/i.test(name); +} + +function isMediaKind(kind: string): "image" | "audio" | "video" | null { + if (kind === "image" || kind === "audio" || kind === "video") return kind; + return null; +} + export function MediaGallery({ items }: MediaGalleryProps) { const [lightboxIdx, setLightboxIdx] = useState(null); + const [preview, setPreview] = useState<{ + name: string; + href: string; + content: string; + mediaType?: "image" | "audio" | "video"; + } | null>(null); + const [loading, setLoading] = useState(false); + + const handleFileClick = useCallback((item: MediaItem) => { + const media = isMediaKind(item.kind); + if (media) { + setPreview({ name: item.fileName ?? "file", href: item.path, content: "", mediaType: media }); + return; + } + // Text/code/document files: fetch content for preview + setLoading(true); + fetch(item.path) + .then((res) => { + if (!res.ok) throw new Error(res.statusText); + return res.text(); + }) + .then((text) => setPreview({ name: item.fileName ?? "file", href: item.path, content: text })) + .catch(() => { /* fetch failed — file may not exist yet, ignore */ }) + .finally(() => setLoading(false)); + }, []); if (items.length === 0) return null; @@ -50,16 +92,27 @@ export function MediaGallery({ items }: MediaGalleryProps) { {files.length > 0 && (
{files.map((item, i) => ( - - {fileIcon(item.kind)} - {item.fileName ?? "file"} - +
+ + e.stopPropagation()} + > + + +
))}
)} @@ -71,6 +124,43 @@ export function MediaGallery({ items }: MediaGalleryProps) { onClose={() => setLightboxIdx(null)} /> )} + + {loading && ( +
+
+
+ )} + + { if (!open) setPreview(null); }}> + {preview && ( + + + {preview.name} + + + Download + + +
+ {preview.mediaType === "image" ? ( + {preview.name} + ) : preview.mediaType === "audio" ? ( +
+
+ )} +
); } diff --git a/ui/web/src/components/chat/message-bubble.tsx b/ui/web/src/components/chat/message-bubble.tsx index aef2b449..24fe6a27 100644 --- a/ui/web/src/components/chat/message-bubble.tsx +++ b/ui/web/src/components/chat/message-bubble.tsx @@ -3,6 +3,7 @@ import { MessageContent } from "./message-content"; import { ThinkingBlock } from "./thinking-block"; import { ToolCallCard } from "./tool-call-card"; import { BlockReplyBubble } from "./block-reply-bubble"; +import { MediaGallery } from "./media-gallery"; import type { ChatMessage } from "@/types/chat"; interface MessageBubbleProps { @@ -47,11 +48,11 @@ export function MessageBubble({ message }: MessageBubbleProps) { ))}
) : ( - /* Normal message bubble */ -
{hasThinking && (
@@ -66,6 +67,11 @@ export function MessageBubble({ message }: MessageBubbleProps) {
)} + {message.mediaItems && message.mediaItems.length > 0 && ( +
+ +
+ )} {message.timestamp && (
{new Date(message.timestamp).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })} diff --git a/ui/web/src/components/shared/markdown-renderer.tsx b/ui/web/src/components/shared/markdown-renderer.tsx index 54ce9ffe..857eab5a 100644 --- a/ui/web/src/components/shared/markdown-renderer.tsx +++ b/ui/web/src/components/shared/markdown-renderer.tsx @@ -5,6 +5,7 @@ import remarkGfm from "remark-gfm"; import rehypeHighlight from "rehype-highlight"; import { useClipboard } from "@/hooks/use-clipboard"; import { useAuthStore } from "@/stores/use-auth-store"; +import { toFileUrl } from "@/lib/file-helpers"; import { Check, Copy, Download, FileText } from "lucide-react"; import { ImageLightbox } from "./image-lightbox"; import { @@ -63,27 +64,6 @@ function isFileLink(href: string | undefined): boolean { return false; } -/** Convert a local file path to a /v1/files/ URL for serving. - * For relative paths, uses just the filename so the backend fallback search - * can find generated files regardless of the directory the LLM wrote. */ -function toFileUrl(href: string, token?: string): string { - let url: string; - if (href.startsWith("/v1/files/") || href.includes("/v1/files/")) { - url = href; - } else { - // For relative paths, use just the basename — the backend will search - // the workspace for the file by name (goclaw_gen_* names are unique). - const basename = href.split("/").pop() ?? href; - url = `/v1/files/${basename}`; - } - // Append auth token as query param (server accepts ?token= for file serving) - if (token) { - const sep = url.includes("?") ? "&" : "?"; - url += `${sep}token=${encodeURIComponent(token)}`; - } - return url; -} - /** File type detection from name */ function isMarkdownExt(name: string): boolean { return /\.(md|mdx|markdown)$/i.test(name); @@ -124,7 +104,7 @@ export function MarkdownRenderer({ content, className }: MarkdownRendererProps) return res.text(); }) .then((text) => setFilePreview({ name, href, content: text })) - .catch(() => window.open(href, "_blank")) + .catch(() => { /* fetch failed — file may not exist, ignore */ }) .finally(() => setFileLoading(false)); }, []); @@ -185,15 +165,16 @@ export function MarkdownRenderer({ content, className }: MarkdownRendererProps) ); }, img({ src, alt, ...props }) { + const resolvedSrc = isFileLink(src) ? toFileUrl(src!, token) : src; return ( {alt { e.preventDefault(); - if (src) openLightbox(src, alt ?? "image"); + if (resolvedSrc) openLightbox(resolvedSrc, alt ?? "image"); }} {...props} /> @@ -247,13 +228,13 @@ export function MarkdownRenderer({ content, className }: MarkdownRendererProps) { if (!open) setFilePreview(null); }}> {filePreview && ( - - - {filePreview.name} + + + {filePreview.name} Download diff --git a/ui/web/src/lib/file-helpers.ts b/ui/web/src/lib/file-helpers.ts index 77228621..fa52aa2d 100644 --- a/ui/web/src/lib/file-helpers.ts +++ b/ui/web/src/lib/file-helpers.ts @@ -153,3 +153,30 @@ export function formatSize(bytes: number): string { if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; } + +/** Convert a local file path to a /v1/files/ URL for serving. + * Extracts basename so the backend fallback search can find generated files. */ +export function toFileUrl(path: string, token?: string): string { + let url: string; + if (path.startsWith("/v1/files/") || path.includes("/v1/files/")) { + url = path; + } else { + const basename = path.split("/").pop() ?? path; + url = `/v1/files/${basename}`; + } + if (token) { + const sep = url.includes("?") ? "&" : "?"; + url += `${sep}token=${encodeURIComponent(token)}`; + } + return url; +} + +/** Determine MediaItem kind from MIME type */ +export function mediaKindFromMime(mime: string): "image" | "video" | "audio" | "document" | "code" { + if (mime.startsWith("image/")) return "image"; + if (mime.startsWith("video/")) return "video"; + if (mime.startsWith("audio/")) return "audio"; + const ext = mime.split("/").pop() ?? ""; + if (["javascript", "typescript", "python", "json", "xml", "html", "css"].some((t) => ext.includes(t))) return "code"; + return "document"; +} diff --git a/ui/web/src/pages/chat/hooks/use-chat-messages.ts b/ui/web/src/pages/chat/hooks/use-chat-messages.ts index 3f3b7b63..2e7418c5 100644 --- a/ui/web/src/pages/chat/hooks/use-chat-messages.ts +++ b/ui/web/src/pages/chat/hooks/use-chat-messages.ts @@ -3,7 +3,9 @@ import { useWs } from "@/hooks/use-ws"; import { useWsEvent } from "@/hooks/use-ws-event"; import { Methods, Events } from "@/api/protocol"; import type { Message } from "@/types/session"; -import type { ChatMessage, AgentEventPayload, ToolStreamEntry, RunActivity, ActiveTeamTask } from "@/types/chat"; +import type { ChatMessage, AgentEventPayload, ToolStreamEntry, RunActivity, ActiveTeamTask, MediaItem } from "@/types/chat"; +import { useAuthStore } from "@/stores/use-auth-store"; +import { toFileUrl, mediaKindFromMime } from "@/lib/file-helpers"; /** * Manages chat message history and real-time streaming for a session. @@ -14,6 +16,7 @@ import type { ChatMessage, AgentEventPayload, ToolStreamEntry, RunActivity, Acti */ export function useChatMessages(sessionKey: string, agentId: string) { const ws = useWs(); + const token = useAuthStore((s) => s.token); const [messages, setMessages] = useState([]); const [streamText, setStreamText] = useState(null); const [thinkingText, setThinkingText] = useState(null); @@ -59,7 +62,7 @@ export function useChatMessages(sessionKey: string, agentId: string) { }, [sessionKey]); // Load history (no loading spinner — the empty state placeholder is shown instead) - const loadHistory = useCallback(async () => { + const loadHistory = useCallback(async (mediaItems?: MediaItem[]) => { if (!ws.isConnected || !sessionKey) { setLoading(false); return; @@ -82,6 +85,15 @@ export function useChatMessages(sessionKey: string, agentId: string) { ...m, timestamp: Date.now() - (allMsgs.length - i) * 1000, }; + // Convert persisted media_refs to mediaItems for gallery display + if (m.role === "assistant" && m.media_refs && m.media_refs.length > 0) { + chatMsg.mediaItems = m.media_refs.map((ref) => ({ + path: toFileUrl(ref.id, token), + mimeType: ref.mime_type, + fileName: ref.id, + kind: (ref.kind as MediaItem["kind"]) || "document", + })); + } // Reconstruct toolDetails for assistant messages with tool_calls if (m.role === "assistant" && m.tool_calls && m.tool_calls.length > 0) { chatMsg.toolDetails = m.tool_calls.map((tc) => { @@ -100,6 +112,15 @@ export function useChatMessages(sessionKey: string, agentId: string) { } return chatMsg; }); + // Attach media to the last assistant message if provided + if (mediaItems?.length && msgs.length > 0) { + for (let i = msgs.length - 1; i >= 0; i--) { + if (msgs[i]!.role === "assistant") { + msgs[i] = { ...msgs[i]!, mediaItems }; + break; + } + } + } setMessages(msgs); } catch { // will retry @@ -126,16 +147,10 @@ export function useChatMessages(sessionKey: string, agentId: string) { const event = payload as AgentEventPayload; if (!event) return; - // Announce run.completed: reload history when a subagent/delegate announce finishes - // for the current agent (these runs aren't tracked by runIdRef). - if (event.type === "run.completed" && event.runKind === "announce" && event.agentId === agentIdRef.current) { - loadHistory(); - return; - } - - // Capture run.started when we are expecting a run for this agent - if (event.type === "run.started") { - if (expectingRunRef.current && event.agentId === agentIdRef.current) { + // Capture run.started when we are expecting a run for this agent, + // OR when an announce run starts (leader summarising team results). + if (event.type === "run.started" && event.agentId === agentIdRef.current) { + if (expectingRunRef.current || event.runKind === "announce") { runIdRef.current = event.runId; expectingRunRef.current = false; setIsRunning(true); @@ -258,13 +273,25 @@ export function useChatMessages(sessionKey: string, agentId: string) { blockRepliesRef.current = []; setBlockReplies([]); + // Convert media from run.completed event to MediaItem[] + const rawMedia = event.payload?.media; + const mediaItems: MediaItem[] | undefined = rawMedia?.length + ? rawMedia.map((m) => ({ + path: toFileUrl(m.path, token), + mimeType: m.content_type ?? "application/octet-stream", + fileName: m.path.split("/").pop() ?? "file", + size: m.size, + kind: mediaKindFromMime(m.content_type ?? ""), + })) + : undefined; + if (streamed && !hadTools) { setMessages((prev) => [ ...prev, - { role: "assistant", content: streamed, timestamp: Date.now() }, + { role: "assistant", content: streamed, timestamp: Date.now(), mediaItems }, ]); } else { - loadHistory(); + loadHistory(mediaItems); } break; } diff --git a/ui/web/src/types/chat.ts b/ui/web/src/types/chat.ts index 09a0cf7a..c2038fc0 100644 --- a/ui/web/src/types/chat.ts +++ b/ui/web/src/types/chat.ts @@ -29,6 +29,7 @@ export interface MediaItem { path: string; mimeType: string; fileName?: string; + size?: number; kind: "image" | "video" | "audio" | "document" | "code"; } @@ -65,6 +66,8 @@ export interface AgentEventPayload { // run.retrying event fields attempt?: number; maxAttempts?: number; + // run.completed media files + media?: { path: string; content_type?: string; size?: number }[]; }; } diff --git a/ui/web/src/types/session.ts b/ui/web/src/types/session.ts index 5eab00c9..92a13908 100644 --- a/ui/web/src/types/session.ts +++ b/ui/web/src/types/session.ts @@ -33,6 +33,7 @@ export interface Message { tool_calls?: ToolCall[]; tool_call_id?: string; is_error?: boolean; + media_refs?: { id: string; mime_type: string; kind: string }[]; } export interface ToolCall {