Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
ceebd8e
Play animation clips in the 3D viewer
OmarB97 Jul 27, 2026
9112730
Add Library thumbnail previews and a resizable panel
Jul 27, 2026
18faa49
Merge remote-tracking branch 'fork/feat/library-thumbnails-and-resize…
OmarB97 Jul 28, 2026
0c7eb46
Refresh package-lock.json
OmarB97 Jul 28, 2026
333edb2
Add Ctrl/Cmd zoom shortcuts to the main process
OmarB97 Jul 28, 2026
be4de10
Widen and remember the generate options panel
OmarB97 Jul 28, 2026
49bc542
Improve Generate form ergonomics and add tag suggestions
OmarB97 Jul 28, 2026
62c40e6
Rebuild the animation clip picker as a React motion bar
OmarB97 Jul 28, 2026
ad5e71d
Name, group, and auto-tag mesh exports
OmarB97 Jul 28, 2026
2716e5e
Make Chat aware of the viewed model, and trace exports to their source
OmarB97 Jul 28, 2026
067ca76
Rework the asset Library popover for usable browsing
Jul 28, 2026
4d12003
Widen the Library panel, open assets on click, wire up hover previews
Jul 28, 2026
2981df2
Organize the asset Library by project and lineage
Jul 28, 2026
b3e584b
Replace mesh budget input and trim Library footer
Jul 28, 2026
4ecd6c6
Unify asset weight and Library state
Jul 28, 2026
e779d09
Carry artist intent through workflow stages
Jul 28, 2026
11cc7ae
Add Extra details to the style instead of replacing it
Jul 28, 2026
515a19d
Suggest the careful setting for thin parts rather than forcing it
Jul 28, 2026
7785e4f
Resume a paused run without rebuilding the model
Jul 28, 2026
28a849c
Keep one saved interface scale instead of two that fight
Jul 28, 2026
278b533
Add live texture reload to the 3D preview
OmarB97 Jul 29, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions api/routers/agent.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
"""
Agent chat endpoint — runs an Ollama-powered tool-use loop against Modly's API.
"""
import json
import re
import uuid
import httpx
from fastapi import APIRouter
from pydantic import BaseModel

import services.generator_registry as reg_module

router = APIRouter(prefix="/agent", tags=["agent"])

MODLY_API = "http://localhost:8765"
Expand Down Expand Up @@ -408,6 +411,60 @@ def _extract_thinking(msg: dict) -> tuple[str, str | None]:
return content, thinking


def _read_glb_modly_extras(glb_path) -> dict | None:
"""Parse a GLB's leading JSON chunk and return asset.extras.modly, if present.
Used only as a fallback for assets that predate the .tags.json sidecar."""
try:
with open(glb_path, "rb") as f:
header = f.read(12)
if len(header) < 12 or header[0:4] != b"glTF":
return None
chunk_header = f.read(8)
if len(chunk_header) < 8 or chunk_header[4:8] != b"JSON":
return None
chunk_length = int.from_bytes(chunk_header[0:4], "little")
doc = json.loads(f.read(chunk_length))
return (doc.get("asset") or {}).get("extras", {}).get("modly")
except Exception:
return None


def _load_asset_meta(workspace_rel_path: str) -> dict | None:
"""Resolve name/project/tags/lineage for the model currently in the viewer.

Reads the .tags.json sidecar when present, falling back to the GLB's own
embedded extras.modly for name/project/tags. Lineage (derived_from) is only
ever written to the sidecar, so an asset with no sidecar has none to report.
"""
workspace_dir = reg_module.WORKSPACE_DIR.resolve()
abs_path = (workspace_dir / workspace_rel_path).resolve()
if not str(abs_path).startswith(str(workspace_dir)):
return None # escapes the workspace — refuse to read

sidecar_path = abs_path.with_suffix(".tags.json")
if sidecar_path.exists():
try:
data = json.loads(sidecar_path.read_text(encoding="utf-8"))
return {
"name": data.get("name"),
"project": data.get("project"),
"tags": data.get("tags") or [],
"derived_from": data.get("derived_from"),
}
except Exception:
pass # malformed sidecar — fall through to the GLB fallback

modly = _read_glb_modly_extras(abs_path)
if modly:
return {
"name": modly.get("name"),
"project": modly.get("project"),
"tags": modly.get("tags") or [],
"derived_from": None,
}
return None


@router.get("/models")
async def list_ollama_models(ollama_url: str = "http://localhost:11434"):
async with httpx.AsyncClient(timeout=5.0) as client:
Expand All @@ -431,6 +488,30 @@ async def agent_chat(request: AgentChatRequest):
ctx_lines.append(f"Current mesh path: {request.context['currentMeshPath']}")
if request.context.get("meshTriangles"):
ctx_lines.append(f"Current mesh triangles: {request.context['meshTriangles']:,}")
if request.context.get("currentClipName"):
ctx_lines.append(f"Current animation clip: {request.context['currentClipName']}")
if request.context.get("availableClips"):
ctx_lines.append(f"Available animation clips: {', '.join(request.context['availableClips'])}")

mesh_path = request.context.get("currentMeshPath")
meta = _load_asset_meta(mesh_path) if mesh_path else None
if meta:
if meta.get("name"):
ctx_lines.append(f"Model name: {meta['name']}")
if meta.get("project"):
ctx_lines.append(f"Project: {meta['project']}")
if meta.get("tags"):
ctx_lines.append(f"Tags: {', '.join(meta['tags'])}")
derived = meta.get("derived_from")
if derived:
parent = derived.get("parent") or {}
root = derived.get("root") or {}
parent_label = parent.get("name") or parent.get("path")
root_label = root.get("name") or root.get("path")
if parent_label == root_label:
ctx_lines.append(f"Derived from: {parent_label}")
else:
ctx_lines.append(f"Derived from: {parent_label} (originally: {root_label})")
if ctx_lines:
messages.append({
"role": "system",
Expand Down
162 changes: 161 additions & 1 deletion electron/main/artifact-registry-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
normalizeWorkspaceAssetPath,
openWorkspaceAssetLibraryEntry,
readWorkspaceAssetLibraryEntry,
readWorkspaceAssetLibraryThumbnail,
registerWorkspaceAssetLibraryIpcHandlers,
} from './artifact-registry-service.ts'

Expand All @@ -22,7 +23,8 @@ async function withWorkspace(run: (workspaceDir: string) => Promise<void>) {
}
}

test('normalizes only workspace-relative paths under allowed Workflows and Exports roots', () => withWorkspace(async (workspaceDir) => {
test('normalizes only workspace-relative paths under allowed model roots', () => withWorkspace(async (workspaceDir) => {
assert.equal(normalizeWorkspaceAssetPath(workspaceDir, 'Default/hero.glb').workspacePath, 'Default/hero.glb')
assert.equal(normalizeWorkspaceAssetPath(workspaceDir, 'Workflows/checkpoints/hero.glb').workspacePath, 'Workflows/checkpoints/hero.glb')
assert.equal(normalizeWorkspaceAssetPath(workspaceDir, 'Exports/hero.glb').workspacePath, 'Exports/hero.glb')
assert.throws(() => normalizeWorkspaceAssetPath(workspaceDir, '../secret.glb'), /traversal|escape|relative/i)
Expand Down Expand Up @@ -64,6 +66,49 @@ test('lists Workflows and Exports assets while skipping hidden, cache, and inter
assert.equal(result.success && result.entries.find((entry) => entry.workspacePath.endsWith('exported.ply'))?.openable, false)
}))

test('indexes generated models and reads project, structural tags, creation time, and lineage from model sidecars', () => withWorkspace(async (workspaceDir) => {
await mkdir(path.join(workspaceDir, 'Default'), { recursive: true })
await mkdir(path.join(workspaceDir, 'Exports/adventure'), { recursive: true })
await writeFile(path.join(workspaceDir, 'Default/hero.glb'), 'glb')
await writeFile(path.join(workspaceDir, 'Default/hero.tags.json'), JSON.stringify({
name: 'Hero',
project: null,
tags: ['mid-poly'],
created: '2026-06-15T10:00:00.000Z',
}))
await writeFile(path.join(workspaceDir, 'Exports/adventure/hero-rigged.glb'), 'glb')
await writeFile(path.join(workspaceDir, 'Exports/adventure/hero-rigged.tags.json'), JSON.stringify({
name: 'Hero Rigged',
project: 'Adventure',
tags: ['rigged', 'animated', 'clip-hero-walk', 'mid-poly'],
created: '2026-06-16T10:00:00.000Z',
derived_from: {
parent: { path: 'Default/hero.glb', name: 'Hero' },
root: { path: 'Default/hero.glb', name: 'Hero' },
},
}))

const result = await listWorkspaceAssetLibrary({ workspaceDir })
assert.equal(result.success, true)
if (!result.success) return
assert.deepEqual(result.entries.map((entry) => entry.workspacePath), [
'Default/hero.glb',
'Exports/adventure/hero-rigged.glb',
])

const root = result.entries[0]
const derived = result.entries[1]
assert.equal(root.sourceScope, 'generated')
assert.equal(root.displayName, 'Hero')
assert.equal(derived.displayName, 'Hero Rigged')
assert.equal(derived.capability, 'rigged-mesh')
assert.equal(derived.createdAt, '2026-06-16T10:00:00.000Z')
assert.equal(derived.semantic?.project, 'Adventure')
assert.deepEqual(derived.semantic?.tags, ['rigged', 'animated', 'clip-hero-walk', 'mid-poly'])
assert.equal(derived.semantic?.derivedFrom?.parent.workspacePath, 'Default/hero.glb')
assert.equal(derived.semantic?.derivedFrom?.root.workspacePath, 'Default/hero.glb')
}))

test('reads and opens only safe GLB/GLTF workspace assets', () => withWorkspace(async (workspaceDir) => {
await mkdir(path.join(workspaceDir, 'Workflows/checkpoints'), { recursive: true })
await mkdir(path.join(workspaceDir, 'Exports'), { recursive: true })
Expand Down Expand Up @@ -198,6 +243,116 @@ test('fails closed for unsafe, self, missing, and mismatched sourceWorkspacePath
assert.equal(!mismatched.success && mismatched.error.code, 'not-openable')
}))

test('reads a sibling .thumb.png for GLB assets and stays silent when one is missing', () => withWorkspace(async (workspaceDir) => {
await mkdir(path.join(workspaceDir, 'Exports/props'), { recursive: true })
await writeFile(path.join(workspaceDir, 'Exports/props/hero.glb'), 'glb')
await writeFile(path.join(workspaceDir, 'Exports/props/hero.thumb.png'), 'fake-png-bytes')
await writeFile(path.join(workspaceDir, 'Exports/props/no-thumb.glb'), 'glb')
await writeFile(path.join(workspaceDir, 'Exports/static.ply'), 'ply')

const withThumb = await readWorkspaceAssetLibraryThumbnail({ workspaceDir, workspacePath: 'Exports/props/hero.glb' })
assert.equal(withThumb.success, true)
assert.equal(withThumb.success && withThumb.dataUrl, `data:image/png;base64,${Buffer.from('fake-png-bytes').toString('base64')}`)

const withoutThumb = await readWorkspaceAssetLibraryThumbnail({ workspaceDir, workspacePath: 'Exports/props/no-thumb.glb' })
assert.equal(withoutThumb.success, false)

const nonMesh = await readWorkspaceAssetLibraryThumbnail({ workspaceDir, workspacePath: 'Exports/static.ply' })
assert.equal(nonMesh.success, false)

const unsafe = await readWorkspaceAssetLibraryThumbnail({ workspaceDir, workspacePath: '../secret.glb' })
assert.equal(unsafe.success, false)
}))

test('attaches preview clip metadata to the thumbnail response when a manifest exists', () => withWorkspace(async (workspaceDir) => {
await mkdir(path.join(workspaceDir, 'Exports/props'), { recursive: true })
await writeFile(path.join(workspaceDir, 'Exports/props/hero.glb'), 'glb')
await writeFile(path.join(workspaceDir, 'Exports/props/hero.thumb.png'), 'fake-png-bytes')
await writeFile(path.join(workspaceDir, 'Exports/props/hero.preview-idle.webp'), 'fake-idle-webp')
await writeFile(path.join(workspaceDir, 'Exports/props/hero.preview-walk.webp'), 'fake-walk-webp')
await writeFile(path.join(workspaceDir, 'Exports/props/hero.previews.json'), JSON.stringify([
{ clip: 'idle', file: 'hero.preview-idle.webp', duration: 1.5 },
{ clip: 'walk', file: 'hero.preview-walk.webp', duration: 0.8 },
]))

const withThumb = await readWorkspaceAssetLibraryThumbnail({ workspaceDir, workspacePath: 'Exports/props/hero.glb' })
assert.equal(withThumb.success, true)
assert.deepEqual(withThumb.success && withThumb.previews, [
{ clip: 'idle', duration: 1.5 },
{ clip: 'walk', duration: 0.8 },
])
}))

test('fetches a specific preview clip WebP by name over the same thumbnail request', () => withWorkspace(async (workspaceDir) => {
await mkdir(path.join(workspaceDir, 'Exports/props'), { recursive: true })
await writeFile(path.join(workspaceDir, 'Exports/props/hero.glb'), 'glb')
await writeFile(path.join(workspaceDir, 'Exports/props/hero.preview-walk.webp'), 'fake-walk-webp')
await writeFile(path.join(workspaceDir, 'Exports/props/hero.previews.json'), JSON.stringify([
{ clip: 'walk', file: 'hero.preview-walk.webp', duration: 0.8 },
]))

const clip = await readWorkspaceAssetLibraryThumbnail({ workspaceDir, workspacePath: 'Exports/props/hero.glb', previewClip: 'walk' })
assert.equal(clip.success, true)
assert.equal(clip.success && clip.dataUrl, `data:image/webp;base64,${Buffer.from('fake-walk-webp').toString('base64')}`)

const unknownClip = await readWorkspaceAssetLibraryThumbnail({ workspaceDir, workspacePath: 'Exports/props/hero.glb', previewClip: 'sprint' })
assert.equal(unknownClip.success, false)
}))

test('thumbnail response carries no previews field when no manifest exists, and stays silent on a malformed one', () => withWorkspace(async (workspaceDir) => {
await mkdir(path.join(workspaceDir, 'Exports/props'), { recursive: true })
await writeFile(path.join(workspaceDir, 'Exports/props/hero.glb'), 'glb')
await writeFile(path.join(workspaceDir, 'Exports/props/hero.thumb.png'), 'fake-png-bytes')
await writeFile(path.join(workspaceDir, 'Exports/props/broken.glb'), 'glb')
await writeFile(path.join(workspaceDir, 'Exports/props/broken.thumb.png'), 'fake-png-bytes')
await writeFile(path.join(workspaceDir, 'Exports/props/broken.previews.json'), 'not valid json{')

const noManifest = await readWorkspaceAssetLibraryThumbnail({ workspaceDir, workspacePath: 'Exports/props/hero.glb' })
assert.equal(noManifest.success, true)
assert.equal(noManifest.success && noManifest.previews, undefined)

const malformedManifest = await readWorkspaceAssetLibraryThumbnail({ workspaceDir, workspacePath: 'Exports/props/broken.glb' })
assert.equal(malformedManifest.success, true)
assert.equal(malformedManifest.success && malformedManifest.previews, undefined)
}))

test('rejects a preview manifest entry that tries to escape the asset directory via its file field', () => withWorkspace(async (workspaceDir) => {
await mkdir(path.join(workspaceDir, 'Exports/props'), { recursive: true })
await writeFile(path.join(workspaceDir, 'Exports/props/hero.glb'), 'glb')
await writeFile(path.join(workspaceDir, 'Exports/props/hero.thumb.png'), 'fake-png-bytes')
await writeFile(path.join(workspaceDir, 'secret.webp'), 'top-secret-bytes')
await writeFile(path.join(workspaceDir, 'Exports/props/hero.previews.json'), JSON.stringify([
{ clip: 'escape-relative', file: '../../secret.webp', duration: 1 },
{ clip: 'escape-absolute', file: '/etc/passwd', duration: 1 },
{ clip: 'wrong-extension', file: 'hero.glb', duration: 1 },
]))

// The manifest lists the clip in its metadata (harmless — it's just a name),
// but every attempt to actually fetch the referenced file must fail closed.
const listed = await readWorkspaceAssetLibraryThumbnail({ workspaceDir, workspacePath: 'Exports/props/hero.glb' })
assert.equal(listed.success, true)
assert.equal(listed.success && listed.previews?.length, 3)

const relative = await readWorkspaceAssetLibraryThumbnail({ workspaceDir, workspacePath: 'Exports/props/hero.glb', previewClip: 'escape-relative' })
assert.equal(relative.success, false)
const absolute = await readWorkspaceAssetLibraryThumbnail({ workspaceDir, workspacePath: 'Exports/props/hero.glb', previewClip: 'escape-absolute' })
assert.equal(absolute.success, false)
const wrongExtension = await readWorkspaceAssetLibraryThumbnail({ workspaceDir, workspacePath: 'Exports/props/hero.glb', previewClip: 'wrong-extension' })
assert.equal(wrongExtension.success, false)
}))

test('IPC thumbnail handler forwards previewClip from payload', async () => {
const handlers = new Map<string, (event: unknown, payload: unknown) => Promise<unknown>>()
registerWorkspaceAssetLibraryIpcHandlers({
ipcMain: { handle: (channel, handler) => handlers.set(channel, handler) },
getWorkspaceDir: () => '/tmp/modly-workspace',
})

const result = await handlers.get('workspace:library:thumbnail')?.({}, { workspacePath: 'Exports/hero.glb', previewClip: 'walk' })
// No such workspace/file in this test, so it must fail closed rather than throw.
assert.equal((result as { success: boolean }).success, false)
})

test('IPC read and open handlers forward sourceWorkspacePath without trusting malformed payloads', async () => {
const handlers = new Map<string, (event: unknown, payload: unknown) => Promise<unknown>>()
registerWorkspaceAssetLibraryIpcHandlers({
Expand All @@ -224,7 +379,12 @@ test('registers workspace library IPC handlers with structured results', async (
assert.equal(typeof handlers.get('workspace:library:list'), 'function')
assert.equal(typeof handlers.get('workspace:library:read'), 'function')
assert.equal(typeof handlers.get('workspace:library:open'), 'function')
assert.equal(typeof handlers.get('workspace:library:thumbnail'), 'function')
const result = await handlers.get('workspace:library:read')?.({}, { workspacePath: '../escape.glb' })
assert.equal(typeof (result as { success?: unknown }).success, 'boolean')
assert.equal((result as { success: boolean, error?: { code: string } }).error?.code, 'unsafe-path')
const thumbnail = await handlers.get('workspace:library:thumbnail')?.({}, { workspacePath: '../escape.glb' })
assert.equal((thumbnail as { success: boolean }).success, false)
const missingPayload = await handlers.get('workspace:library:thumbnail')?.({}, {})
assert.equal((missingPayload as { success: boolean }).success, false)
})
Loading