mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-14 18:18:37 +00:00
feat(ui): media gallery, file helpers, chat message improvements
This commit is contained in:
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} />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 }[];
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user