Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/dry-papayas-heal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@viamrobotics/motion-tools': patch
---

Quick frame builder UX hits
8 changes: 6 additions & 2 deletions server/routes/scene-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ const ResponseSchema = z.object({
updates: z
.array(FrameDeltaSchema)
.describe(
'One entry per component that needs to change. Always populate for any requested change — empty only if truly nothing needs to change.'
'One entry per component that needs to change. If a component needs both translation and orientation changes, put ALL of them in the same entry — never create two entries with the same componentName. Empty only if truly nothing needs to change.'
),
explanation: z
.string()
Expand All @@ -61,6 +61,7 @@ const SYSTEM_PROMPT = `You are a robot spatial configuration assistant. The user
Rules:
- Only modify components listed in the context below. Each component has a "name" field — use that exact string as "componentName" in your response.
- Return only components that actually need to change.
- CRITICAL: Each component may appear at most once in the updates array. If a component needs both a translation change and an orientation change, combine them into a single entry with both fields set. Never create two separate entries for the same componentName.
- For translation, return only the changed axes (x, y, z are each optional). All translation values are in millimeters.
- For orientation, current values are shown as { roll, pitch, yaw } in degrees. Return only the axes that are changing with their new absolute values.
- Coordinate system: X is forward, Y is left, Z is up (right-handed).
Expand Down Expand Up @@ -120,6 +121,9 @@ export async function handleSceneBuilder(req: Request): Promise<Response> {
return Response.json(result, { status: 200, headers: CORS_HEADERS })
} catch (error) {
console.error('LLM error:', error)
return new Response('LLM call failed', { status: 502, headers: CORS_HEADERS })
return new Response('Something went wrong — check that ANTHROPIC_API_KEY is set correctly.', {
status: 502,
headers: CORS_HEADERS,
})
}
}
42 changes: 25 additions & 17 deletions src/lib/plugins/LLMSceneBuilder/SceneBuilder.svelte
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<script lang="ts">
import { Portal } from '@threlte/extras'
import { Icon } from '@viamrobotics/prime-core'

import DashboardButton from '$lib/components/overlay/dashboard/Button.svelte'
import FloatingPanel from '$lib/components/overlay/FloatingPanel.svelte'
Expand Down Expand Up @@ -37,7 +38,7 @@
<div class="flex gap-2">
<textarea
class="flex-1 resize-none rounded border border-gray-300 p-2 text-xs focus:ring-1 focus:ring-gray-400 focus:outline-none disabled:bg-gray-50 disabled:text-gray-400"
placeholder="Describe the frame change, e.g. 'Move the arm 200mm forward along X'"
placeholder="Describe a frame change — translation, rotation, or parent. e.g. 'Move arm 200mm forward and rotate 90° left'"
rows={3}
disabled={sceneBuilder.uiState === 'loading' || sceneBuilder.uiState === 'diff'}
bind:value={prompt}
Expand Down Expand Up @@ -69,10 +70,6 @@

<!-- diff ready -->
{#if sceneBuilder.uiState === 'diff'}
{#if sceneBuilder.explanation}
<p class="text-gray-6 italic">{sceneBuilder.explanation}</p>
{/if}

{#if sceneBuilder.diffGroups.length > 0}
<div class="flex flex-col gap-2 overflow-auto">
{#each sceneBuilder.diffGroups as group (group.componentName)}
Expand Down Expand Up @@ -111,11 +108,13 @@
{/if}

{#if sceneBuilder.updateErrors.length > 0}
<ul class="text-red-6 space-y-0.5">
{#each sceneBuilder.updateErrors as err (err.componentName)}
<li><span class="font-mono">{err.componentName}</span>: {err.reason}</li>
{/each}
</ul>
<div class="border-danger-medium bg-danger-light rounded border p-2">
<ul class="text-danger-dark space-y-0.5">
{#each sceneBuilder.updateErrors as err (err.componentName)}
<li><span class="font-mono">{err.componentName}</span>: {err.reason}</li>
{/each}
</ul>
</div>
{/if}

<div class="mt-auto flex gap-2">
Expand All @@ -136,13 +135,22 @@

<!-- error -->
{#if sceneBuilder.uiState === 'error'}
<p class="text-red-6">{sceneBuilder.errorMessage}</p>
<button
class="self-start rounded border border-gray-300 px-3 py-1.5 hover:bg-gray-50"
onclick={sceneBuilder.resetError}
>
Try again
</button>
<div class="border-danger-medium bg-danger-light flex flex-col gap-2 rounded border p-2.5">
<div class="text-danger-dark flex items-center gap-1.5 font-medium">
<Icon
name="alert-circle-outline"
aria-hidden="true"
/>
Error
</div>
<p class="text-danger-dark">{sceneBuilder.errorMessage}</p>
<button
class="border-danger-medium text-danger-dark self-start rounded border px-3 py-1.5 hover:bg-[#F8E1DF]"
onclick={sceneBuilder.resetError}
>
Try again
</button>
</div>
{/if}
</div>
</FloatingPanel>
Expand Down
35 changes: 35 additions & 0 deletions src/lib/plugins/LLMSceneBuilder/frameDeltaAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,22 @@ export interface UpdateError {
reason: string
}

function mergeTranslation(
a?: FrameDelta['translation'],
b?: FrameDelta['translation']
): FrameDelta['translation'] {
return a || b ? { x: b?.x ?? a?.x, y: b?.y ?? a?.y, z: b?.z ?? a?.z } : undefined
}

function mergeOrientation(
a?: FrameDelta['orientation'],
b?: FrameDelta['orientation']
): FrameDelta['orientation'] {
return a || b
? { roll: b?.roll ?? a?.roll, pitch: b?.pitch ?? a?.pitch, yaw: b?.yaw ?? a?.yaw }
: undefined
}

/**
* Validates LLM-proposed frame deltas and computes the resulting changes without
* applying them. Each PreparedUpdate carries old and new values so the caller
Expand All @@ -41,7 +57,26 @@ export function validateProposedFrameDeltas(
const prepared: PreparedUpdate[] = []
const knownNames = new Set(config.components.map((c) => c.name))

// Merge multiple deltas for the same component — the LLM sometimes splits
// translation and orientation into separate entries despite the schema saying one per component.
const mergedDeltas = new Map<string, FrameDelta>()
for (const delta of deltas) {
const existing = mergedDeltas.get(delta.componentName)
if (existing) {
mergedDeltas.set(delta.componentName, {
componentName: delta.componentName,
translation: mergeTranslation(existing.translation, delta.translation),
orientation: mergeOrientation(existing.orientation, delta.orientation),
parent: delta.parent ?? existing.parent,
explanation:
[existing.explanation, delta.explanation].filter(Boolean).join(', ') || undefined,
})
} else {
mergedDeltas.set(delta.componentName, delta)
}
}

for (const delta of mergedDeltas.values()) {
const component = config.components.find((c) => c.name === delta.componentName)

if (!component) {
Expand Down
2 changes: 1 addition & 1 deletion src/routes/lib/hooks/useStandaloneLLM.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export const provideStandaloneLLM = (): StandaloneLLMContext => {
}),
})
if (!res.ok) {
throw new Error(`${res.status}: ${await res.text()}`)
throw new Error(await res.text())
}
return res.json()
}
Expand Down
Loading