Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
116 changes: 116 additions & 0 deletions 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 Down Expand Up @@ -198,6 +199,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 +335,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