feat(ui): media gallery, file helpers, chat message improvements

This commit is contained in:
viettranx committed 2026-03-20 21:46:39 +07:00
1 parent 6275117dbd
commit 6fe59faa0c
8 files changed
+194 -59

No files matched your search

@@ -53,7 +53,7 @@ export function ActiveRunZone({
{/* Streaming text: wrap in bubble matching MessageBubble's assistant style */}
{(thinkingText !== null || streamText !== null) && (
<div className="max-w-[85%] rounded-lg px-4 py-2 bg-card text-card-foreground border border-border shadow-sm">
<div className="flex-1 min-w-0 rounded-lg px-4 py-2 bg-card text-card-foreground border border-border shadow-sm">
{thinkingText !== null && (
<div className={streamText !== null ? "mb-2" : ""}>
<ThinkingBlock text={thinkingText} isStreaming={isRunning && streamText === null} />
+102 -12
View File
@@ -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<number | null>(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 && (
<div className="flex flex-wrap gap-2">
{files.map((item, i) => (
<a
key={i}
href={item.path.startsWith("javascript:") ? "#" : item.path}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 rounded-md border bg-muted/50 px-3 py-1.5 text-sm hover:bg-muted"
>
{fileIcon(item.kind)}
<span className="max-w-[200px] truncate">{item.fileName ?? "file"}</span>
</a>
<div key={i} className="flex items-center rounded-md border bg-muted/50 text-sm">
<button
type="button"
onClick={() => handleFileClick(item)}
className="flex items-center gap-2 px-3 py-1.5 hover:bg-muted cursor-pointer rounded-l-md"
>
{fileIcon(item.kind)}
<span className="max-w-[200px] truncate">{item.fileName ?? "file"}</span>
{item.size != null && item.size > 0 && (
<span className="text-xs text-muted-foreground">{formatSize(item.size)}</span>
)}
</button>
<a
href={item.path}
download={item.fileName ?? "file"}
className="flex items-center px-2 py-1.5 text-muted-foreground hover:bg-muted cursor-pointer rounded-r-md border-l"
onClick={(e) => e.stopPropagation()}
>
<Download className="h-3.5 w-3.5" />
</a>
</div>
))}
</div>
)}
@@ -71,6 +124,43 @@ export function MediaGallery({ items }: MediaGalleryProps) {
onClose={() => setLightboxIdx(null)}
/>
)}
{loading && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/50">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-muted-foreground border-t-transparent" />
</div>
)}
<Dialog open={!!preview} onOpenChange={(open) => { if (!open) setPreview(null); }}>
{preview && (
<DialogContent className="sm:max-w-4xl max-h-[85vh] flex flex-col">
<DialogHeader className="flex-row items-center gap-2 pr-10">
<DialogTitle className="truncate text-base flex-1">{preview.name}</DialogTitle>
<a
href={preview.href}
download={preview.name}
className="flex shrink-0 items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs text-muted-foreground hover:bg-muted"
>
<Download className="h-3.5 w-3.5" />
Download
</a>
</DialogHeader>
<div className="min-h-0 flex-1 overflow-y-auto rounded-md border bg-muted/20 p-4">
{preview.mediaType === "image" ? (
<img src={preview.href} alt={preview.name} className="max-w-full rounded" />
) : preview.mediaType === "audio" ? (
<audio controls src={preview.href} className="w-full" />
) : preview.mediaType === "video" ? (
<video controls src={preview.href} className="max-w-full rounded" />
) : isMarkdownExt(preview.name) ? (
<MarkdownRenderer content={preview.content} />
) : (
<pre className="whitespace-pre-wrap text-xs font-mono"><code>{preview.content}</code></pre>
)}
</div>
</DialogContent>
)}
</Dialog>
</div>
);
}
+10 -4
View File
@@ -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) {
))}
</div>
) : (
/* Normal message bubble */
<div className={`max-w-[85%] rounded-lg px-4 py-2 ${
/* Normal message bubble — assistant uses full width, user capped at 85% */
<div className={`rounded-lg px-4 py-2 ${
isUser
? "bg-primary text-primary-foreground"
: "bg-card text-card-foreground border border-border shadow-sm"
? "max-w-[85%] bg-primary text-primary-foreground"
: "flex-1 min-w-0 bg-card text-card-foreground border border-border shadow-sm"
}`}>
{hasThinking && (
<div className="mb-2">
@@ -66,6 +67,11 @@ export function MessageBubble({ message }: MessageBubbleProps) {
</div>
)}
<MessageContent content={message.content} role={message.role} />
{message.mediaItems && message.mediaItems.length > 0 && (
<div className="mt-2">
<MediaGallery items={message.mediaItems} />
</div>
)}
{message.timestamp && (
<div className={`mt-1 text-[10px] ${isUser ? "text-primary-foreground/60" : "text-muted-foreground"}`}>
{new Date(message.timestamp).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })}
@@ -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 (
<img
src={src}
src={resolvedSrc}
alt={alt ?? "image"}
className="max-w-sm rounded-lg border shadow-sm cursor-pointer hover:opacity-90 transition-opacity"
loading="lazy"
onClick={(e) => {
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)
<Dialog open={!!filePreview} onOpenChange={(open) => { if (!open) setFilePreview(null); }}>
{filePreview && (
<DialogContent className="sm:max-w-2xl max-h-[85vh] flex flex-col">
<DialogHeader className="flex-row items-center justify-between gap-2">
<DialogTitle className="truncate text-base">{filePreview.name}</DialogTitle>
<DialogContent className="sm:max-w-4xl max-h-[85vh] flex flex-col">
<DialogHeader className="flex-row items-center gap-2 pr-10">
<DialogTitle className="truncate text-base flex-1">{filePreview.name}</DialogTitle>
<a
href={filePreview.href}
download={filePreview.name}
className="mr-8 flex shrink-0 items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs text-muted-foreground hover:bg-muted"
className="flex shrink-0 items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs text-muted-foreground hover:bg-muted"
>
<Download className="h-3.5 w-3.5" />
Download
+27
View File
@@ -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";
}
@@ -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<ChatMessage[]>([]);
const [streamText, setStreamText] = useState<string | null>(null);
const [thinkingText, setThinkingText] = useState<string | null>(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;
}
+3
View File
@@ -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 }[];
};
}
+1
View File
@@ -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 {