- New tool: image-to-3D (project file or an image attached to the chat, optional back/left/right views) or text-to-3D; image + prompt refines the image with the prompt first (the 3D models take no text with an image). Styles textured / geometry / lowpoly, optional PBR, face count. Runs through fal.ai's queue API with progress, saves GLB + preview, books the cost (published prices, overridable per model). Request bodies for the Hunyuan3D, Tripo and Trellis families; defaults [images] model_3d / model_3d_text. - Chat card: "Open in the 3D view"; media overview: kind "model" (also STL/3MF/OBJ written by agents) with previews. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
156 lines
6.8 KiB
TypeScript
156 lines
6.8 KiB
TypeScript
import { useEffect, useState } from "react";
|
|
import { get } from "../api";
|
|
import { useT, type Key } from "../i18n";
|
|
import { Spinner } from "./Activity";
|
|
import { Icon } from "./Icon";
|
|
import { navigate } from "../router";
|
|
|
|
/** Open a 3D model in the 3D view of the file manager. */
|
|
const open3d = (path: string) => {
|
|
sessionStorage.setItem("codecraft.openFile", path);
|
|
navigate("/files");
|
|
};
|
|
|
|
interface MediaItem {
|
|
kind: "image" | "video" | "audio" | "model";
|
|
url: string;
|
|
name: string;
|
|
source: "generated" | "tool" | "written" | "answer" | "upload";
|
|
created_at: number;
|
|
message_id?: string;
|
|
/** 3D models: file (opened in the 3D view) and preview image. */
|
|
path?: string;
|
|
preview?: string;
|
|
}
|
|
|
|
type Filter = "all" | MediaItem["kind"];
|
|
|
|
/**
|
|
* All images, videos and audio files of a chat (with its subagents), newest first.
|
|
* An item opens large; "show in chat" jumps to the message it came with.
|
|
*/
|
|
export function MediaGallery({ sessionId, onClose, onJump }: { sessionId: string; onClose: () => void; onJump: (messageId: string) => void }) {
|
|
const t = useT();
|
|
const [items, setItems] = useState<MediaItem[]>();
|
|
const [error, setError] = useState<string>();
|
|
const [filter, setFilter] = useState<Filter>("all");
|
|
const [open, setOpen] = useState<number>();
|
|
|
|
useEffect(() => {
|
|
get<MediaItem[]>(`/sessions/${sessionId}/media`)
|
|
.then(setItems)
|
|
.catch((e) => setError(e.message));
|
|
}, [sessionId]);
|
|
|
|
const shown = (items ?? []).filter((i) => filter === "all" || i.kind === filter);
|
|
const count = (k: Filter) => (items ?? []).filter((i) => k === "all" || i.kind === k).length;
|
|
const current = open !== undefined ? shown[open] : undefined;
|
|
|
|
// Keyboard: Esc closes, arrows step through the open item.
|
|
useEffect(() => {
|
|
const onKey = (e: KeyboardEvent) => {
|
|
if (e.key === "Escape") return current ? setOpen(undefined) : onClose();
|
|
if (current && e.key === "ArrowRight") setOpen((o) => Math.min((o ?? 0) + 1, shown.length - 1));
|
|
if (current && e.key === "ArrowLeft") setOpen((o) => Math.max((o ?? 0) - 1, 0));
|
|
};
|
|
window.addEventListener("keydown", onKey);
|
|
return () => window.removeEventListener("keydown", onKey);
|
|
}, [current, shown.length, onClose]);
|
|
|
|
return (
|
|
<div className="modal-backdrop" onClick={onClose}>
|
|
<div className="modal media-gallery" role="dialog" aria-label={t("media.title")} onClick={(e) => e.stopPropagation()}>
|
|
<div className="media-head">
|
|
<h3>
|
|
<Icon name="image" /> {t("media.title")}
|
|
</h3>
|
|
<div className="media-filters" role="radiogroup">
|
|
{(["all", "image", "video", "audio", "model"] as Filter[]).map((k) => (
|
|
<button key={k} role="radio" aria-checked={filter === k} className={`chip-btn ${filter === k ? "active" : ""}`} onClick={() => (setFilter(k), setOpen(undefined))} disabled={!!items && count(k) === 0 && k !== "all"}>
|
|
{t(`media.filter.${k}` as Key)} {items && <small>{count(k)}</small>}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<button className="small ghost" onClick={onClose} aria-label={t("common.close")}>
|
|
<Icon name="close" />
|
|
</button>
|
|
</div>
|
|
{error && <div className="notice err">{error}</div>}
|
|
{!items && !error && (
|
|
<div className="empty">
|
|
<Spinner />
|
|
</div>
|
|
)}
|
|
{items && shown.length === 0 && <div className="empty">{t("media.none")}</div>}
|
|
{current ? (
|
|
<div className="media-viewer">
|
|
<div className="media-stage">
|
|
{current.kind === "image" && <img src={current.url} alt={current.name} />}
|
|
{current.kind === "video" && <video src={current.url} controls autoPlay />}
|
|
{current.kind === "audio" && <audio src={current.url} controls autoPlay />}
|
|
{current.kind === "model" && (
|
|
<div className="media-model">
|
|
{current.preview ? <img src={current.preview} alt={current.name} /> : <Icon name="cube" />}
|
|
{current.path && (
|
|
<button className="primary" onClick={() => open3d(current.path!)}>
|
|
<Icon name="cube" /> {t("tool.open3d")}
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="media-bar">
|
|
<button className="small ghost" disabled={open === 0} onClick={() => setOpen((o) => (o ?? 1) - 1)} aria-label={t("media.prev")}>
|
|
<Icon name="arrow-left" />
|
|
</button>
|
|
<span className="grow">
|
|
{current.name} · {t(`media.source.${current.source}` as Key)} · {new Date(current.created_at).toLocaleString(undefined, { dateStyle: "short", timeStyle: "short" })}
|
|
</span>
|
|
{current.message_id && (
|
|
<button className="small" onClick={() => onJump(current.message_id!)}>
|
|
{t("media.showInChat")}
|
|
</button>
|
|
)}
|
|
<a className="small button" href={current.url} target="_blank" rel="noreferrer">
|
|
<Icon name="external" /> {t("media.open")}
|
|
</a>
|
|
<button className="small ghost" disabled={open === shown.length - 1} onClick={() => setOpen((o) => (o ?? 0) + 1)} aria-label={t("media.next")}>
|
|
<Icon name="arrow-right" />
|
|
</button>
|
|
<button className="small ghost" onClick={() => setOpen(undefined)} aria-label={t("media.back")}>
|
|
<Icon name="close" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="media-grid">
|
|
{shown.map((m, i) => (
|
|
<button key={m.url} className={`media-tile ${m.kind}`} onClick={() => setOpen(i)} title={`${m.name} · ${t(`media.source.${m.source}` as Key)}`}>
|
|
{m.kind === "image" && <img src={m.url} alt="" loading="lazy" decoding="async" />}
|
|
{m.kind === "video" && <video src={`${m.url}#t=0.1`} preload="metadata" muted />}
|
|
{m.kind === "audio" && (
|
|
<span className="media-audio">
|
|
<Icon name="music" />
|
|
<span>{m.name}</span>
|
|
</span>
|
|
)}
|
|
{m.kind === "video" && <Icon name="play" className="media-play" />}
|
|
{m.kind === "model" &&
|
|
(m.preview ? (
|
|
<img src={m.preview} alt="" loading="lazy" decoding="async" />
|
|
) : (
|
|
<span className="media-audio">
|
|
<Icon name="cube" />
|
|
<span>{m.name}</span>
|
|
</span>
|
|
))}
|
|
{m.kind === "model" && <span className="media-badge">3D</span>}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|