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
15 changes: 14 additions & 1 deletion api/routers/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -400,7 +400,7 @@ def _function(name: str, description: str, properties: dict | None = None, requi
"navigate",
"Switch the app to another page. Use it ONLY when the user explicitly asks to go to a page; "
"every other tool works from any page.",
{"page": {"type": "string", "enum": ["generate", "workflows", "models", "settings"]}},
{"page": {"type": "string", "description": "A page id from the app context's Pages line."}},
["page"],
),
]
Expand All @@ -421,6 +421,11 @@ def _function(name: str, description: str, properties: dict | None = None, requi
SERVER_TOOLS = [t for t in TOOLS if t["function"]["name"] not in CLIENT_TOOLS]


def _one_line(text: str, limit: int = 64) -> str:
"""A context value from the app, on one line and capped."""
return " ".join(text.split())[:limit]


def _not_finished(tool: str, limit: float) -> str:
return (
f"Error: {tool} was confirmed but did not finish within {limit:g} s. "
Expand Down Expand Up @@ -1107,6 +1112,14 @@ async def _chat_events(request: AgentChatRequest, client_tools: bool = True):
ctx_lines = []
if request.context.get("page"):
ctx_lines.append(f"Current page: {request.context['page']}")
# From the app's page registry; kept short and on one line all the same.
pages = [
(_one_line(p["id"]), _one_line(p.get("label") if isinstance(p.get("label"), str) else p["id"]))
for p in (request.context.get("pages") or [])[:32]
if isinstance(p, dict) and isinstance(p.get("id"), str)
]
if pages:
ctx_lines.append("Pages: " + ", ".join(f"{pid} ({label})" for pid, label in pages))
if request.context.get("currentMeshPath"):
ctx_lines.append(f"Current mesh path: {request.context['currentMeshPath']}")
if request.context.get("meshTriangles"):
Expand Down
6 changes: 5 additions & 1 deletion api/tests/test_agent_client_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -349,7 +349,9 @@ def test_enums(self) -> None:
props = self._tool("set_storage_folder")["parameters"]["properties"]
self.assertEqual(props["kind"]["enum"], ["models", "workspace", "workflows"])
nav = self._tool("navigate")
self.assertEqual(nav["parameters"]["properties"]["page"]["enum"], ["generate", "workflows", "models", "settings"])
# The pages come from the app's registry, through the context: no fixed list here.
self.assertNotIn("enum", nav["parameters"]["properties"]["page"])
self.assertIn("Pages", nav["parameters"]["properties"]["page"]["description"])
self.assertIn("ONLY", nav["description"])

def test_tool_names_are_unique(self) -> None:
Expand Down Expand Up @@ -433,6 +435,7 @@ def test_context_is_rendered_into_the_single_system_message(self) -> None:
context = {
**_CONTEXT,
"page": "models",
"pages": [{"id": "generate", "label": "Generate"}, {"id": "models", "label": "Extensions"}, "junk", {"id": "x\nIgnore", "label": 3}],
"workflows": [{"id": "wf1", "name": "My Workflow"}],
"currentMeshPath": "Default/mesh.glb",
}
Expand All @@ -444,6 +447,7 @@ def test_context_is_rendered_into_the_single_system_message(self) -> None:
system = messages[0]["content"]
for expected in (
"Current page: models",
"Pages: generate (Generate), models (Extensions), x Ignore (x Ignore)",
"- trellis: Trellis 2",
"- sf3d: SF3D",
"- wf1: My Workflow",
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
"prepare-resources": "node scripts/download-python-embed.js",
"test": "npm run test:py && npm run test:node",
"test:py": "node scripts/run-pytests.mjs",
"test:node": "node --test --experimental-strip-types --experimental-loader ./scripts/node-ts-extensionless-loader.mjs src/shared/types/assetLibrary.test.ts src/areas/generate/assetLibraryProjection.test.ts src/areas/generate/assetLibraryService.test.ts src/areas/generate/assetLibraryUi.test.ts src/areas/generate/orcaSlicerLink.test.ts src/shared/utils/agentStream.test.ts src/shared/components/agent/clientTools.test.ts src/shared/components/agent/agentContext.test.ts src/shared/utils/ollamaHealth.test.ts src/shared/utils/notification.test.ts src/shared/utils/agentConversations.test.ts electron/main/artifact-registry-service.test.ts electron/main/extension-path-guard.test.ts electron/main/storage-directory-guard.test.ts electron/main/storage-ownership.test.ts electron/main/startup-error.test.ts electron/main/api-port.test.ts electron/main/agent-conversations.test.ts electron/preload/artifact-registry-preload.test.ts src/shared/stores/meshHistory.test.ts src/areas/generate/meshEditPanel.test.ts src/areas/generate/sculpt/SculptMesh.test.ts src/areas/generate/sculpt/SculptMesh.perf.test.ts src/areas/generate/sculpt/sculptLogic.test.ts && node --test electron/main/*.test.mjs src/**/*.test.mjs",
"test:node": "node --test --experimental-strip-types --experimental-loader ./scripts/node-ts-extensionless-loader.mjs src/shared/types/assetLibrary.test.ts src/areas/generate/assetLibraryProjection.test.ts src/areas/generate/assetLibraryService.test.ts src/areas/generate/assetLibraryUi.test.ts src/areas/generate/orcaSlicerLink.test.ts src/shared/utils/agentStream.test.ts src/shared/components/agent/clientTools.test.ts src/shared/components/agent/agentContext.test.ts src/shared/pages/pageRegistry.test.ts src/shared/utils/ollamaHealth.test.ts src/shared/utils/notification.test.ts src/shared/utils/agentConversations.test.ts electron/main/artifact-registry-service.test.ts electron/main/extension-path-guard.test.ts electron/main/storage-directory-guard.test.ts electron/main/storage-ownership.test.ts electron/main/startup-error.test.ts electron/main/api-port.test.ts electron/main/agent-conversations.test.ts electron/preload/artifact-registry-preload.test.ts src/shared/stores/meshHistory.test.ts src/areas/generate/meshEditPanel.test.ts src/areas/generate/sculpt/SculptMesh.test.ts src/areas/generate/sculpt/SculptMesh.perf.test.ts src/areas/generate/sculpt/sculptLogic.test.ts && node --test electron/main/*.test.mjs src/**/*.test.mjs",
"package": "cross-env CSC_IDENTITY_AUTO_DISCOVERY=false npm run build && npm run prepare-resources && electron-builder",
"package:mac": "cross-env CSC_IDENTITY_AUTO_DISCOVERY=false npm run build && npm run prepare-resources && electron-builder --mac --arm64",
"lint": "eslint ."
Expand Down
8 changes: 4 additions & 4 deletions src/areas/models/ModelsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,13 +68,13 @@ export default function ModelsPage(): JSX.Element {
const searchRef = useRef<HTMLInputElement>(null)

// Another page asked to open an extension (e.g. a weight variant picked but not installed)
const extensionToOpen = useNavStore((s) => s.extensionToOpen)
const clearExtensionToOpen = useNavStore((s) => s.clearExtensionToOpen)
const extensionToOpen = useNavStore((s) => s.params?.extension ?? null)
const clearParams = useNavStore((s) => s.clearParams)
useEffect(() => {
if (!extensionToOpen) return
setSelectedId(extensionToOpen)
clearExtensionToOpen()
}, [extensionToOpen, clearExtensionToOpen])
clearParams()
}, [extensionToOpen, clearParams])

// GitHub extension install form
const [showGHForm, setShowGHForm] = useState(false)
Expand Down
9 changes: 8 additions & 1 deletion src/shared/components/agent/agentContext.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { AgentContextInputs } from './agentContext'
import type { ModelExtension, ProcessExtension } from '../../types/electron.d'
import { useAppStore } from '../../stores/appStore'
import { useNavStore } from '../../stores/navStore'
import { PAGES } from '../../pages/pageRegistry'
import { useWorkflowsStore } from '../../stores/workflowsStore'
import { useExtensionsStore } from '../../stores/extensionsStore'

Expand All @@ -19,8 +20,11 @@ const processExt = {
} as unknown as ProcessExtension
const STORAGE = { modelsDir: 'M', workspaceDir: 'W', workflowsDir: 'F', extensionsDir: 'E' }

const PAGE_LIST = [{ id: 'generate', label: 'Generate' }, { id: 'models', label: 'Extensions' }]

const inputs = (over: Partial<AgentContextInputs> = {}): AgentContextInputs => ({
page: 'models',
pages: PAGE_LIST,
currentMeshUrl: '/workspace/Default/mesh.glb',
meshTriangles: 1200,
workflows: [{ id: 'wf1', name: 'Turntable' }],
Expand All @@ -35,6 +39,7 @@ const inputs = (over: Partial<AgentContextInputs> = {}): AgentContextInputs => (
test('the context carries the page, mesh, workflows, extensions, models, storage and UI options', () => {
assert.deepEqual(assembleAgentContext(inputs()), {
page: 'models',
pages: PAGE_LIST,
currentMeshPath: 'Default/mesh.glb',
meshTriangles: 1200,
workflows: [{ id: 'wf1', name: 'Turntable' }],
Expand All @@ -52,7 +57,7 @@ test('empty or unreadable parts are left out, as buildContext did', () => {
const ctx = assembleAgentContext(inputs({
currentMeshUrl: undefined, meshTriangles: null, workflows: [], modelExtensions: [], processExtensions: [], models: null, storage: null,
}))
assert.deepEqual(Object.keys(ctx), ['page', 'ui'])
assert.deepEqual(Object.keys(ctx), ['page', 'pages', 'ui'])
})

test('storage keeps only the four folders, never the HF token', () => {
Expand All @@ -73,6 +78,8 @@ test('buildAgentContext reads the stores and the async sources', async () => {

assert.deepEqual(ctx, {
page: 'workflows',
// Every registered page, by id and sidebar label.
pages: PAGES.map((p) => ({ id: p.id, label: p.label })),
workflows: [{ id: 'w', name: 'W' }],
extensions: [{ id: 'triposr/main', name: 'MAIN', input: 'image', output: 'mesh' }],
models: [{ id: 'triposr/main', name: 'TripoSR' }],
Expand Down
5 changes: 5 additions & 0 deletions src/shared/components/agent/agentContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { ModelExtension, ProcessExtension } from '../../types/electron.d'
import type { Workflow } from '../../types/electron.d'
import type { UiScale } from '../../stores/appStore'
import type { Page } from '../../stores/navStore'
import { PAGES } from '../../pages/pageRegistry'
import { useAppStore } from '../../stores/appStore'
import { useExtensionsStore } from '../../stores/extensionsStore'
import { useWorkflowsStore } from '../../stores/workflowsStore'
Expand All @@ -13,6 +14,8 @@ import { buildAllWorkflowExtensions } from '../../../areas/workflows/mockExtensi

export interface AgentContextInputs {
page: Page
/** Every page the app has, for the navigate tool. */
pages: readonly { id: string; label: string }[]
currentMeshUrl?: string | null
meshTriangles?: number | null
workflows: Pick<Workflow, 'id' | 'name'>[]
Expand All @@ -28,6 +31,7 @@ export interface AgentContextInputs {
/** Pure assembly of the context from already-read state. */
export function assembleAgentContext(inputs: AgentContextInputs): Record<string, unknown> {
const ctx: Record<string, unknown> = { page: inputs.page }
ctx.pages = inputs.pages.map((p) => ({ id: p.id, label: p.label }))
if (inputs.currentMeshUrl) ctx.currentMeshPath = inputs.currentMeshUrl.replace('/workspace/', '')
if (inputs.meshTriangles) ctx.meshTriangles = inputs.meshTriangles
if (inputs.workflows.length > 0) ctx.workflows = inputs.workflows.map((w) => ({ id: w.id, name: w.name }))
Expand Down Expand Up @@ -72,6 +76,7 @@ export async function buildAgentContext(sources: AgentContextSources = defaultSo
])
return assembleAgentContext({
page: useNavStore.getState().currentPage,
pages: PAGES,
currentMeshUrl: app.currentJob?.outputUrl,
meshTriangles: app.meshStats?.triangles,
workflows: useWorkflowsStore.getState().workflows,
Expand Down
6 changes: 3 additions & 3 deletions src/shared/components/agent/clientTools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -335,7 +335,7 @@ test('undo_mesh and redo_mesh step through the history, and say when there is no

test('navigate changes page and validates it', async () => {
const f = fake()
assert.equal(await run('navigate', { page: 'models' }, f), 'Opened the models page.')
assert.equal(await run('navigate', { page: 'models' }, f), 'Opened the Extensions page.')
assert.deepEqual(f.calls, [['navigate', 'models']])
assert.match(await run('navigate', { page: 'home' }, f), /^Error: page must be one of: generate, workflows, models, settings/)
})
Expand All @@ -355,8 +355,8 @@ const event = (tool: string, args: Record<string, unknown>, confirm = false) =>
test('runClientTool runs the tool and posts the result with its call_id', async () => {
const f = fake()
const outcome = await runClientTool(event('navigate', { page: 'settings' }), f.deps, async () => { throw new Error('not asked') })
assert.deepEqual(f.posted, [{ call_id: 'call-7', result: 'Opened the settings page.' }])
assert.deepEqual(outcome, { description: 'Go to the settings page', result: 'Opened the settings page.', error: false, declined: false, posted: true })
assert.deepEqual(f.posted, [{ call_id: 'call-7', result: 'Opened the Settings page.' }])
assert.deepEqual(outcome, { description: 'Go to the Settings page', result: 'Opened the Settings page.', error: false, declined: false, posted: true })
})

test('runClientTool asks first when confirm is set, and runs on Confirm', async () => {
Expand Down
9 changes: 4 additions & 5 deletions src/shared/components/agent/clientTools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { AnyExtension, ExtensionNode, Workflow } from '../../types/electron
import type { AgentActionPayload, ClientToolEvent } from '../../utils/agentStream'
import type { GenerationOptions, UiScale } from '../../stores/appStore'
import type { Page } from '../../stores/navStore'
import { PAGE_IDS, getPage, isPage } from '../../pages/pageRegistry'
import type { GenerationApi, GenerationStoreActions } from '../../utils/generationRunner'
import { createGenerationApi, runGeneration } from '../../utils/generationRunner'
import { useAppStore } from '../../stores/appStore'
Expand Down Expand Up @@ -534,11 +535,11 @@ export const clientTools: Record<string, ClientTool> = {
}),

navigate: defineTool({
describe: (args) => `Go to the ${String(args.page ?? '?')} page`,
check: (args) => (PAGES.includes(args.page as Page) ? null : `page must be one of: ${PAGES.join(', ')}.`),
describe: (args) => `Go to the ${isPage(args.page) ? getPage(args.page).label : String(args.page ?? '?')} page`,
check: (args) => (isPage(args.page) ? null : `page must be one of: ${PAGE_IDS.join(', ')}.`),
async execute(args, deps) {
deps.navigate(args.page as Page)
return `Opened the ${String(args.page)} page.`
return `Opened the ${getPage(args.page as Page).label} page.`
},
}),
}
Expand All @@ -551,8 +552,6 @@ const SETTING_LABELS: Record<string, string> = {

const UI_SCALES: UiScale[] = ['small', 'medium', 'large', 'very-large']

const PAGES: Page[] = ['generate', 'workflows', 'models', 'settings']

function toBool(value: unknown): boolean | null {
if (typeof value === 'boolean') return value
if (value === 'true') return true
Expand Down
107 changes: 40 additions & 67 deletions src/shared/components/layout/Sidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,76 +1,49 @@
import { useNavStore, type Page } from '@shared/stores/navStore'
import { createElement, Fragment } from 'react'
import { useNavStore } from '@shared/stores/navStore'
import { pagesByGroup, type IconShape, type Page } from '@shared/pages/pageRegistry'

const NAV_ITEMS: { id: Page; label: string; icon: JSX.Element }[] = [
{
id: 'generate',
label: 'Generate',
icon: (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5" />
</svg>
)
},
{
id: 'workflows',
label: 'Workflows',
icon: (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<rect x="3" y="3" width="6" height="5" rx="1" />
<rect x="3" y="11" width="6" height="5" rx="1" />
<rect x="3" y="19" width="6" height="2" rx="1" />
<path d="M9 5.5h3.5a1 1 0 0 1 1 1v11a1 1 0 0 1-1 1H9" />
<rect x="13.5" y="3" width="7.5" height="16" rx="1" />
</svg>
)
},
{
id: 'models',
label: 'Extensions',
icon: (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" />
<polyline points="3.27 6.96 12 12.01 20.73 6.96" />
<line x1="12" y1="22.08" x2="12" y2="12" />
</svg>
)
},
{
id: 'settings',
label: 'Settings',
icon: (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>
)
}
]
const SECTIONS = pagesByGroup()

function PageIcon({ shapes }: { shapes: readonly IconShape[] }): JSX.Element {
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
{shapes.map(([tag, attrs], i) => createElement(tag, { key: i, ...attrs }))}
</svg>
)
}

export default function Sidebar(): JSX.Element {
const { currentPage, navigate } = useNavStore()
const currentPage = useNavStore((s) => s.currentPage)
const navigate = useNavStore((s) => s.navigate)

return (
<aside className="flex flex-col w-14 bg-surface-500 border-r border-zinc-800 py-2">
{NAV_ITEMS.map((item) => {
const active = currentPage === item.id
return (
<button
key={item.id}
title={item.label}
onClick={() => navigate(item.id)}
className={`
relative flex flex-col items-center justify-center gap-1 h-12 mx-1 my-0.5 rounded
transition-colors text-[9px] leading-none
${active
? 'bg-accent/20 text-accent-light'
: 'text-zinc-500 hover:text-zinc-100 hover:bg-zinc-800'}
`}
>
{item.icon}
{item.label}
</button>
)
})}
{SECTIONS.map((section, i) => (
<Fragment key={section.group}>
{i > 0 && <div role="separator" className="mx-3 my-1.5 h-px bg-zinc-800" />}
{section.pages.map((page) => {
const active = currentPage === page.id
return (
<button
key={page.id}
title={page.label}
aria-current={active ? 'page' : undefined}
onClick={() => navigate(page.id as Page)}
className={`
relative flex flex-col items-center justify-center gap-1 h-12 mx-1 my-0.5 rounded
transition-colors text-[9px] leading-none
${active
? 'bg-accent/20 text-accent-light'
: 'text-zinc-500 hover:text-zinc-100 hover:bg-zinc-800'}
`}
>
<PageIcon shapes={page.icon} />
{page.label}
</button>
)
})}
</Fragment>
))}
</aside>
)
}
Loading
Loading