generate_3d: 3D models with fal.ai (image, text, or image steered by text)
- 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>
This commit is contained in:
1 parent
d7baa77922
commit
48621f21d6
15 files changed
+578
-14
No files matched your search
@@ -3,14 +3,24 @@ 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";
|
||||
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"];
|
||||
@@ -55,7 +65,7 @@ export function MediaGallery({ sessionId, onClose, onJump }: { sessionId: string
|
||||
<Icon name="image" /> {t("media.title")}
|
||||
</h3>
|
||||
<div className="media-filters" role="radiogroup">
|
||||
{(["all", "image", "video", "audio"] as Filter[]).map((k) => (
|
||||
{(["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>
|
||||
@@ -78,6 +88,16 @@ export function MediaGallery({ sessionId, onClose, onJump }: { sessionId: string
|
||||
{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")}>
|
||||
@@ -115,6 +135,16 @@ export function MediaGallery({ sessionId, onClose, onJump }: { sessionId: string
|
||||
</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>
|
||||
|
||||
@@ -121,7 +121,7 @@ export function ToolCard({ call, result, running, baseDir }: { call: ToolCall; r
|
||||
const diffLines = typeof meta?.diff === "string" ? meta.diff.split("\n").length : 0;
|
||||
const openByDefault =
|
||||
(status === "err" && !!result) ||
|
||||
(!!result && !result.is_error && ((diffLines > 0 && diffLines <= 40) || call.name === "generate_image"));
|
||||
(!!result && !result.is_error && ((diffLines > 0 && diffLines <= 40) || call.name === "generate_image" || call.name === "generate_3d"));
|
||||
const open = toggled ?? openByDefault;
|
||||
|
||||
return (
|
||||
@@ -164,6 +164,18 @@ export function ToolCard({ call, result, running, baseDir }: { call: ToolCall; r
|
||||
<pre className="mono">{result.content}</pre>
|
||||
</>
|
||||
)}
|
||||
{typeof meta?.model_file === "string" && (
|
||||
<button
|
||||
className="small"
|
||||
style={{ marginBottom: 8 }}
|
||||
onClick={() => {
|
||||
sessionStorage.setItem("codecraft.openFile", meta.model_file);
|
||||
navigate("/files");
|
||||
}}
|
||||
>
|
||||
<Icon name="cube" /> {t("tool.open3d")}
|
||||
</button>
|
||||
)}
|
||||
{Array.isArray(meta?.image_ids) ? (
|
||||
<div className="gallery small">
|
||||
{meta.image_ids.map((id: string, i: number) => {
|
||||
|
||||
Reference in new issue
Block a user