diff --git a/.agents/skills/homarr-custom-widget/SKILL.md b/.agents/skills/homarr-custom-widget/SKILL.md index a067f4a54..af88d88f5 100644 --- a/.agents/skills/homarr-custom-widget/SKILL.md +++ b/.agents/skills/homarr-custom-widget/SKILL.md @@ -7,18 +7,26 @@ description: Author safe Homarr Custom JSX v2 widgets from API documentation. Author one widget at a time. When connected to Homarr, retrieve `homarr://custom-widgets/schema` and only the component resources needed for the design. The live resources are authoritative for the installed release. +Keep documentation retrieval targeted: fetch at most eight named component documents before the first validation, or at most four after loading a complete example. Fetch another component only when a validation issue or a concrete missing interaction requires it; never load the catalog component-by-component. + 1. Read the API documentation. 2. Create one credential-free widget with keyed `sources`, keyed `requests`, optional keyed `options`, and safe JSX `template`. 3. Validate, preview, test queries and simulated actions, visually inspect, and iterate. 4. Configure deployment-specific source URLs and request credentials through Homarr; never repeat plaintext. 5. Save only after narrow/wide, light/dark, loading, empty, error, and success states work. When connected through MCP, persist the exact final tested preview with `customWidget_createFromPreview`; do not resend a large template through `customWidget_create` unless no preview session is available. +Treat a supplied sample or successful preview response as the binding contract. Render every core requested field, guard optional arrays and nested values before indexing, and do not silently drop returned items. Show concise freshness context when the API supplies a timestamp. Give recoverable load errors and empty states a clear refresh or retry path. + Use `{option:name}` or `$option` for saved options. Use `{param:name}` or `$param` for values supplied by `SubFetch`, `ActionButton`, or `ToggleSwitch`. Load queries cannot use invocation parameters. Templates read `data`, `status`, `options`, and temporary `inputs`. `SubFetch` with `trigger="manual"` renders its own load button. Pass a card or image through `triggerContent` with `triggerAriaLabel` when that content should launch the request. Its callback is `(result, meta)`; never author `onClick` or a fetch callback. Use standard Mantine compound names and deliberate visual hierarchy, spacing, restrained semantic color, and responsive layouts. Avoid excessive nested cards. +Aim for distinctive polish without decorative gradients or fragile custom styling: use one semantic accent, clear surface contrast, purposeful typography, and compact rows that remain scannable in narrow tiles. Give standalone icons an accessible label or pair them with visible text. + +Make initial states actionable with an example, useful hint, or clear next step. Use wrapping groups for variable-length labels and values on narrow tiles. Do not use an unlabeled decorative icon as an empty state. + Do not use imports, hooks, refs, raw HTML, raw event callbacks, browser requests, arbitrary functions, eval, bigint, npm packages, authored statement blocks, IIFEs, or recursion. Do not pretend MCP tools exist in an offline chat session. To install an existing widget, use `customWidget_workshopSearch`, inspect it with `customWidget_workshopGet`, then use `customWidget_workshopInstall`. Configure each self-hosted source afterward with `customWidget_sourceConfigure`, or call `customWidget_configurationRequestUser` so the user can enter the server URL and credentials directly in Homarr. diff --git a/apps/docs/docs/management/assistant.mdx b/apps/docs/docs/management/assistant.mdx index e2032ee37..a3dab86bc 100644 --- a/apps/docs/docs/management/assistant.mdx +++ b/apps/docs/docs/management/assistant.mdx @@ -77,11 +77,8 @@ provider or base URL clears existing credentials unless replacements are submitt prevents credentials for one service from being sent to another endpoint. When a key is already stored, the page shows its protected state instead of an ambiguous empty password -field. **Replace key** starts a two-step flow: enter the new key, then review the provider, destination, -and impact before confirming. The existing key remains active until confirmation, and its value cannot be -restored after replacement. Removing only the API key also requires explicit confirmation. **Remove all -credentials** uses a separate destructive confirmation because it also clears the selected model and -disables the assistant. +field. Removing the API key requires explicit confirmation. Save that removal before entering and saving a +different key. :::info Container networking @@ -250,6 +247,11 @@ create result and placement continues without asking the user to select it again include safe Homarr paths that open in the assistant without a page reload. When placement is still a choice, the assistant uses the structured question UI instead of ending with a prose question. +The authoring phase limits broad component-document retrieval and then moves into validation with the smallest useful +reference set. Supplied samples and successful preview responses are treated as binding contracts: the assistant is +instructed to render the requested fields, guard optional nested data, preserve every returned item, expose freshness +when timestamps are available, and provide a refresh path for recoverable empty or error states. + The shield control in the conversation header can enable automatic approvals for the current conversation. While enabled, Homarr accepts every affirmative approval step as it arrives: protected mutations are approved, presented app details and board changes are used, and structured questions with an explicitly categorized diff --git a/apps/docs/docs/management/custom-widgets/agent-authoring.mdx b/apps/docs/docs/management/custom-widgets/agent-authoring.mdx index 413a9c48c..4e41bba03 100644 --- a/apps/docs/docs/management/custom-widgets/agent-authoring.mdx +++ b/apps/docs/docs/management/custom-widgets/agent-authoring.mdx @@ -113,7 +113,29 @@ Homarr maintainers can run the same self-contained prompt that the workbench cop OPENROUTER_API_KEY=... pnpm --filter @homarr/custom-widgets test:ai ``` -The suite uses DeepSeek V4 Pro to author Pokédex, Portainer, football, Jellyfin, and Home Assistant widgets. Before DeepSeek V4 Flash judges a result, it must pass Homarr's real clipboard parser, canonical schema, and deterministic scenario checks for the documented API routes, authentication, bindings, actions, and essential runtime components. It saves every response, validated widget manifest, and detailed category scores under `packages/custom-widgets/.ai-evaluations/`. +The manifest suite covers nine complex and public-API prompts. Its deterministic fixture-backed cases include a fake +homelab health API, CoinMarketCap's keyless public API, Bored, Agify, and a multi-request Pokédex. Before the configured +model judges a result, the candidate must pass Homarr's real clipboard parser, canonical schema, and deterministic +checks for documented endpoints, authentication, request triggers, query bindings, response fields, and essential +runtime components. + +To exercise the real Homarr Assistant lifecycle instead of accepting a returned JSON block, run: + +```sh +OPENROUTER_API_KEY=... pnpm --filter @homarr/custom-widgets test:assistant-ai +``` + +The assistant test exposes the same lazy skill, live schema, component catalog, targeted component references, +validation, preview-query, and create-from-preview tools as the product. It refuses to save a definition that was not +validated exactly or whose preview queries were not all tested. Multi-request fixtures return a different payload for +each documented path and require manual-query parameters. Use `--case=pokedex`, `--case=coinmarketcap-keyless`, +`--case=bored-activity`, or `--case=agify-name` for a focused run. + +Both suites default generation and harsh review to OpenRouter's `~deepseek/deepseek-v4-flash-latest` route. They save +tool traces, validated manifests, raw reviews, and detailed category scores under +`packages/custom-widgets/.ai-evaluations/`. These live tests are maintainer-invoked rather than CI-required because +they consume a configured provider account and exercise a nondeterministic model. The repository's ordinary tests +cover the lifecycle enforcement, fixture routing, score calculation, and failure behavior without network access. Each scenario stops as soon as it passes and has a hard limit of ten generation attempts. Override the models or per-scenario limit with `OPENROUTER_GENERATOR_MODEL`, `OPENROUTER_JUDGE_MODEL`, and @@ -121,3 +143,8 @@ per-scenario limit with `OPENROUTER_GENERATOR_MODEL`, `OPENROUTER_JUDGE_MODEL`, into the bundled-widget source deliberately. The reference API check uses a real PokeAPI list/detail request and a read-only Portainer container query when `PORTAINER_BASE_URL` and `PORTAINER_API_KEY` are set. Use `pnpm --filter @homarr/custom-widgets test:reference-apis -- --pokeapi-only` for the public PokeAPI check alone. To exercise start, restart, and stop against a disposable container, also set `PORTAINER_TEST_CONTAINER_ID` and the explicit safeguard `PORTAINER_ALLOW_MUTATIONS=1`; the script restores the container's original running state. + +Use `pnpm --filter @homarr/custom-widgets test:reference-apis -- --public-ai-apis-only` to verify the live Bored and +CoinMarketCap response shapes used by the assistant fixtures. The same command also verifies Agify when +`AGIFY_API_KEY` is configured; otherwise it reports the authenticated check as skipped without weakening the +deterministic Agify fixture tests. diff --git a/apps/nextjs/src/app/api/assistant/chat/assistant-tool-policy.spec.ts b/apps/nextjs/src/app/api/assistant/chat/assistant-tool-policy.spec.ts index 59cfcae00..cc7f6a650 100644 --- a/apps/nextjs/src/app/api/assistant/chat/assistant-tool-policy.spec.ts +++ b/apps/nextjs/src/app/api/assistant/chat/assistant-tool-policy.spec.ts @@ -42,6 +42,13 @@ describe("customWidgetAssistantInstructions", () => { expect(customWidgetAssistantInstructions).toContain("customWidget_getComponentCatalog"); expect(customWidgetAssistantInstructions).toContain("customWidget_getComponent"); expect(customWidgetAssistantInstructions).toContain("customWidget_getSharedProps once"); + expect(customWidgetAssistantInstructions).toContain("at most eight named component documents"); + expect(customWidgetAssistantInstructions).toContain("at most four after loading a complete example"); + expect(customWidgetAssistantInstructions).toContain("successful preview responses as binding contracts"); + expect(customWidgetAssistantInstructions).toContain("Make initial states actionable"); + expect(customWidgetAssistantInstructions).toContain("never load the catalog component-by-component"); + expect(customWidgetAssistantInstructions).toContain("compact rows that remain scannable in narrow tiles"); + expect(customWidgetAssistantInstructions).toContain("Label standalone icons"); expect(customWidgetAssistantInstructions).toContain("customWidget_getExample"); expect(customWidgetAssistantInstructions).toContain("templateLines"); expect(customWidgetAssistantInstructions).toContain("installed `pokedex` example"); diff --git a/apps/nextjs/src/app/api/assistant/chat/assistant-tool-policy.ts b/apps/nextjs/src/app/api/assistant/chat/assistant-tool-policy.ts index 72e3072a3..585404fe5 100644 --- a/apps/nextjs/src/app/api/assistant/chat/assistant-tool-policy.ts +++ b/apps/nextjs/src/app/api/assistant/chat/assistant-tool-policy.ts @@ -9,6 +9,10 @@ export const customWidgetAssistantInstructions = ` Custom Widget authoring rules: - Custom Widget tools are mandatory for Custom JSX work. Never answer a request to create, repair, or materially change a Custom Widget with only a manifest, JSX, instructions, or an unsupported claim that it should work. - For every request to create, repair, validate, preview, or explain a Custom JSX widget, use the complete installed skill and JSON Schema from the trusted Custom Widget authoring context when they are preloaded below; do not call customWidget_getSkill or customWidget_schema in that case because Homarr has already loaded their complete contents into this system prompt. When they are not preloaded, first call customWidget_getSkill, whose response contains the complete installed skill and all references, then call customWidget_schema. Do not merely say that you loaded them: use their rules for the subsequent tool inputs. Follow those resources as authoritative. Next call the compact customWidget_getComponentCatalog for the installed release. Fetch only the relevant named component documentation with customWidget_getComponent and, when its interaction pattern applies, one named example with customWidget_getExample before authoring. Component results contain only component-specific props; if the design needs shared props such as spacing or color, collect their names from the catalog and call customWidget_getSharedProps once with only those names. +- Keep documentation retrieval bounded so the authoring tool call has enough output budget. Fetch at most eight named component documents before the first validation, or at most four after loading a complete example. Fetch another only to resolve a concrete validation issue or missing interaction; never load the catalog component-by-component. +- For polished widgets, use one restrained semantic accent, clear surface contrast, purposeful typography, and compact rows that remain scannable in narrow tiles. Avoid decorative gradients and repetitive nested cards. Label standalone icons for assistive technology or pair them with visible text. +- Make initial states actionable with an example, useful hint, or clear next step. Use wrapping groups for variable-length labels and values on narrow tiles. Do not use an unlabeled decorative icon as an empty state. +- Treat supplied sample data and successful preview responses as binding contracts: render every core requested field, guard optional arrays and nested values before indexing, and do not silently drop returned items. Show freshness context when a timestamp is available, and give recoverable empty or load-error states a clear refresh or retry path. - Use templateLines for multiline JSX tool input. Do not send a heavily escaped multiline template string when templateLines is accepted. - Keep each tool call within a reliable streamed size. For Pokédex or PokéAPI requests, start from the installed \`pokedex\` example returned by customWidget_getExample and improve it through validated iterations instead of generating one enormous untested JSX argument from scratch. Completeness comes from working interactions and states, not gratuitous line count. - Honor an explicit iteration count. Each requested iteration must make a meaningful improvement and complete its own validation, preview, and required preview-query checks. Persist only the last tested preview session. diff --git a/apps/nextjs/src/app/api/assistant/chat/custom-widget-authoring-context.spec.ts b/apps/nextjs/src/app/api/assistant/chat/custom-widget-authoring-context.spec.ts index 9c713d1ad..991a1b31e 100644 --- a/apps/nextjs/src/app/api/assistant/chat/custom-widget-authoring-context.spec.ts +++ b/apps/nextjs/src/app/api/assistant/chat/custom-widget-authoring-context.spec.ts @@ -6,9 +6,12 @@ import { getCustomWidgetSkill, getCustomWidgetSkillContent } from "@homarr/custo import { getCustomWidgetJsonSchema } from "@homarr/custom-widgets/core"; import { + createCustomWidgetComponentDocumentBudget, createCustomWidgetDynamicContextController, getCustomWidgetAuthoringContext, isCustomWidgetAuthoringToolName, + MAX_CUSTOM_WIDGET_COMPONENT_DOCUMENTS, + MAX_CUSTOM_WIDGET_COMPONENT_DOCUMENTS_WITH_EXAMPLE, needsCustomWidgetAuthoringContext, preloadedCustomWidgetToolNames, prunePreloadedCustomWidgetModelMessages, @@ -190,6 +193,59 @@ describe("createCustomWidgetDynamicContextController", () => { }), ).toBeUndefined(); }); + + test("removes component documentation after the targeted retrieval budget is exhausted", () => { + const prepareContext = createCustomWidgetDynamicContextController({ + isAdmin: true, + baseInstructions: "Base instructions", + availableToolNames: ["customWidget_getComponent", "customWidget_validate", "customWidget_previewCreate"], + }); + const toolCalls = Array.from({ length: MAX_CUSTOM_WIDGET_COMPONENT_DOCUMENTS }, () => ({ + toolName: "customWidget_getComponent", + })); + + expect(prepareContext({ instructions: "Base instructions", messages: [], steps: [{ toolCalls }] })).toMatchObject({ + activeTools: ["customWidget_validate", "customWidget_previewCreate"], + }); + }); + + test("uses a smaller component budget after a complete example is loaded", () => { + const prepareContext = createCustomWidgetDynamicContextController({ + isAdmin: true, + baseInstructions: "Base instructions", + availableToolNames: ["customWidget_getComponent", "customWidget_getExample", "customWidget_validate"], + }); + const toolCalls = [ + { toolName: "customWidget_getExample" }, + ...Array.from({ length: MAX_CUSTOM_WIDGET_COMPONENT_DOCUMENTS_WITH_EXAMPLE }, () => ({ + toolName: "customWidget_getComponent", + })), + ]; + + expect(prepareContext({ instructions: "Base instructions", messages: [], steps: [{ toolCalls }] })).toMatchObject({ + activeTools: ["customWidget_getExample", "customWidget_validate"], + }); + }); + + test("rejects excess parallel component document executions without blocking other tools", () => { + const budget = createCustomWidgetComponentDocumentBudget(2); + + expect(budget.claim("customWidget_getComponent")).toBe(true); + expect(budget.claim("customWidget_getComponent")).toBe(true); + expect(budget.claim("customWidget_getComponent")).toBe(false); + expect(budget.claim("customWidget_validate")).toBe(true); + }); + + test("reduces the hard execution budget after loading an example", () => { + const budget = createCustomWidgetComponentDocumentBudget(); + + expect(budget.claim("customWidget_getExample")).toBe(true); + for (let index = 0; index < MAX_CUSTOM_WIDGET_COMPONENT_DOCUMENTS_WITH_EXAMPLE; index += 1) { + expect(budget.claim("customWidget_getComponent")).toBe(true); + } + expect(budget.claim("customWidget_getComponent")).toBe(false); + expect(budget.claim("customWidget_validate")).toBe(true); + }); }); describe("prunePreloadedCustomWidgetModelMessages", () => { diff --git a/apps/nextjs/src/app/api/assistant/chat/custom-widget-authoring-context.ts b/apps/nextjs/src/app/api/assistant/chat/custom-widget-authoring-context.ts index 463e9f11c..37139fc44 100644 --- a/apps/nextjs/src/app/api/assistant/chat/custom-widget-authoring-context.ts +++ b/apps/nextjs/src/app/api/assistant/chat/custom-widget-authoring-context.ts @@ -19,6 +19,7 @@ const customWidgetAuthoringToolNameSet = new Set([ "customWidget_previewAction", "customWidget_previewJournal", "customWidget_create", + "customWidget_createFromPreview", "customWidget_update", "customWidget_templatePatch", "customWidget_legacyMigrationPrompt", @@ -139,6 +140,29 @@ type CustomWidgetToolStep = { toolCalls: readonly { toolName: string }[]; }; +export const MAX_CUSTOM_WIDGET_COMPONENT_DOCUMENTS = 8; +export const MAX_CUSTOM_WIDGET_COMPONENT_DOCUMENTS_WITH_EXAMPLE = 4; + +export const createCustomWidgetComponentDocumentBudget = (limit = MAX_CUSTOM_WIDGET_COMPONENT_DOCUMENTS) => { + let served = 0; + let exampleWasLoaded = false; + return { + claim(toolName: string) { + if (toolName === "customWidget_getExample") { + exampleWasLoaded = true; + return true; + } + if (toolName !== "customWidget_getComponent") return true; + const activeLimit = exampleWasLoaded + ? Math.min(limit, MAX_CUSTOM_WIDGET_COMPONENT_DOCUMENTS_WITH_EXAMPLE) + : limit; + if (served >= activeLimit) return false; + served += 1; + return true; + }, + }; +}; + export const createCustomWidgetDynamicContextController = (options: { isAdmin: boolean; baseInstructions: string; @@ -163,6 +187,22 @@ export const createCustomWidgetDynamicContextController = + count + result.toolCalls.filter((toolCall) => toolCall.toolName === "customWidget_getComponent").length, + 0, + ); + const exampleWasLoaded = step.steps.some((result) => + result.toolCalls.some((toolCall) => toolCall.toolName === "customWidget_getExample"), + ); + const componentDocumentLimit = exampleWasLoaded + ? MAX_CUSTOM_WIDGET_COMPONENT_DOCUMENTS_WITH_EXAMPLE + : MAX_CUSTOM_WIDGET_COMPONENT_DOCUMENTS; + const boundedActiveToolNames = + componentDocumentCount >= componentDocumentLimit + ? activeToolNames.filter((toolName) => toolName !== "customWidget_getComponent") + : activeToolNames; + return { ...(shouldInjectContext ? { @@ -170,7 +210,7 @@ export const createCustomWidgetDynamicContextController = (customWidgetAuthoringContext.omittedToolNames); const mcpTools = extractMcpTools().filter((mcpTool) => !omittedCustomWidgetTools.has(mcpTool.name)); + const customWidgetComponentDocumentBudget = createCustomWidgetComponentDocumentBudget(); const homarrTools = Object.fromEntries( mcpTools.map((mcpTool) => { @@ -488,6 +490,12 @@ export async function POST(request: Request) { (mcpTool.inputSchema ?? { type: "object", properties: {} }) as Parameters[0], ), execute: async (input) => { + if (!customWidgetComponentDocumentBudget.claim(mcpTool.name)) { + return { + error: + "The targeted component-document budget is exhausted. Continue with validation and fetch another component only in a new repair turn if a concrete issue requires it.", + }; + } try { const procedure = mcpTool.pathInRouter.reduce( (current, segment) => diff --git a/packages/custom-widgets/package.json b/packages/custom-widgets/package.json index 5f3eeb3cd..c4ed9e443 100644 --- a/packages/custom-widgets/package.json +++ b/packages/custom-widgets/package.json @@ -28,8 +28,9 @@ "generate:catalog": "tsx ./scripts/generate-component-metadata.ts", "generate:components": "pnpm generate:catalog", "generate": "pnpm generate:catalog", - "evaluate:ai": "bun ./scripts/evaluate-ai-authoring.ts", - "test:ai": "bun ./scripts/evaluate-ai-authoring.ts --ci", + "evaluate:ai": "tsx ./scripts/evaluate-ai-authoring.ts", + "test:ai": "tsx ./scripts/evaluate-ai-authoring.ts --ci", + "test:assistant-ai": "tsx ./scripts/evaluate-ai-authoring.ts --assistant --ci", "test:reference-apis": "tsx ./scripts/verify-reference-apis.ts", "format": "oxfmt .", "lint": "oxlint", diff --git a/packages/custom-widgets/scripts/ai-assistant-evaluation.ts b/packages/custom-widgets/scripts/ai-assistant-evaluation.ts new file mode 100644 index 000000000..46584488b --- /dev/null +++ b/packages/custom-widgets/scripts/ai-assistant-evaluation.ts @@ -0,0 +1,702 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import path from "node:path"; + +import { z } from "zod/v4"; + +import { + getCustomWidgetComponent, + getCustomWidgetComponentCatalog, + getCustomWidgetExample, + getCustomWidgetSharedProps, + getCustomWidgetSkill, +} from "../src/core/authoring-resources"; +import { + customWidgetAuthoringDefinitionSchema, + customWidgetDefinitionSchema, + normalizeCustomWidgetAuthoringDefinition, +} from "../src/core/custom-jsx-schema"; +import type { HomarrCustomWidgetV2 } from "../src/core/custom-jsx-schema"; +import { CUSTOM_WIDGET_MCP_AUTHORING_PROMPT } from "../src/core/ai-prompt"; +import { getCustomWidgetJsonSchema } from "../src/core/schema"; +import type { CustomWidgetAiEvaluationCase } from "./ai-evaluation-cases"; +import { + DEFAULT_GENERATOR_MODEL, + getDeterministicEvaluationIssues, + judgePasses, + parseJudgeResult, + requestCustomWidgetJudge, +} from "./ai-evaluation"; +import type { CustomWidgetJudgeResult } from "./ai-evaluation"; + +const MAX_ASSISTANT_STEPS = 24; + +interface OpenRouterToolCall { + id: string; + type: "function"; + function: { name: string; arguments: string }; +} + +interface OpenRouterAssistantMessage { + role: "assistant"; + content: string | null; + tool_calls?: OpenRouterToolCall[]; +} + +type OpenRouterMessage = + | { role: "system" | "user"; content: string } + | OpenRouterAssistantMessage + | { role: "tool"; tool_call_id: string; content: string }; + +interface OpenRouterResponse { + choices?: Array<{ message?: OpenRouterAssistantMessage }>; + error?: { message?: string }; +} + +interface ToolDefinition { + type: "function"; + function: { + name: string; + description: string; + parameters: Record; + }; +} + +interface PreviewState { + widget: HomarrCustomWidgetV2; + testedQueries: Set; +} + +export const getRequiredAssistantEvaluationRequestParams = (request: HomarrCustomWidgetV2["requests"][string]) => { + const serialized = JSON.stringify(request); + return [ + ...new Set([ + ...Array.from(serialized.matchAll(/\{param:([^}]+)\}/gu), (match) => match[1]).filter( + (name): name is string => name !== undefined, + ), + ...Array.from(serialized.matchAll(/"\$param":"([^"]+)"/gu), (match) => match[1]).filter( + (name): name is string => name !== undefined, + ), + ]), + ]; +}; + +export const getAssistantEvaluationPreviewResponse = ( + testCase: CustomWidgetAiEvaluationCase, + request: HomarrCustomWidgetV2["requests"][string], +) => + testCase.previewResponses?.find(({ pathIncludes }) => request.path.includes(pathIncludes))?.response ?? + testCase.sampleResponse; + +export interface AssistantAttemptState { + calledTools: string[]; + validatedDefinitions: Set; + previews: Map; + createdWidget: HomarrCustomWidgetV2 | null; + finalText: string; +} + +export interface CustomWidgetAssistantEvaluationResult { + caseId: string; + attempts: number; + widget: HomarrCustomWidgetV2 | null; + judge: CustomWidgetJudgeResult | null; + outputDirectory: string; + errors: string[]; + calledTools: string[]; +} + +const objectSchema = (properties: Record, required: string[] = []) => ({ + type: "object", + properties, + required, + additionalProperties: false, +}); + +export const customWidgetAssistantEvaluationToolDefinitions: ToolDefinition[] = [ + { + type: "function", + function: { + name: "customWidget_getSkill", + description: + "Load the complete installed Custom Widget skill, including SKILL.md and every bundled reference. Call this before authoring.", + parameters: objectSchema({}), + }, + }, + { + type: "function", + function: { + name: "customWidget_schema", + description: "Get the authoritative installed Homarr Custom Widget v2 JSON Schema.", + parameters: objectSchema({}), + }, + }, + { + type: "function", + function: { + name: "customWidget_getComponentCatalog", + description: + "Get the compact catalog of supported JSX components, shared props, and example IDs before selecting component documentation.", + parameters: objectSchema({}), + }, + }, + { + type: "function", + function: { + name: "customWidget_getComponent", + description: "Get installed documentation and safety rules for one named Custom JSX component.", + parameters: objectSchema({ name: { type: "string" } }, ["name"]), + }, + }, + { + type: "function", + function: { + name: "customWidget_getSharedProps", + description: "Get installed documentation for only the named shared Custom JSX props.", + parameters: objectSchema({ names: { type: "array", items: { type: "string" }, minItems: 1, maxItems: 64 } }, [ + "names", + ]), + }, + }, + { + type: "function", + function: { + name: "customWidget_getExample", + description: "Get one installed Custom JSX example by catalog ID.", + parameters: objectSchema({ name: { type: "string" } }, ["name"]), + }, + }, + { + type: "function", + function: { + name: "customWidget_validate", + description: + "Validate one complete widget without saving it. Supply the complete definition in widget and prefer templateLines for multiline JSX.", + parameters: objectSchema({ widget: { type: "object", additionalProperties: true } }, ["widget"]), + }, + }, + { + type: "function", + function: { + name: "customWidget_previewCreate", + description: + "Create a preview for the exact complete definition after validation. Returns every query that must be tested.", + parameters: objectSchema( + { + definition: { type: "object", additionalProperties: true }, + secrets: { type: "array", items: { type: "object" }, maxItems: 0 }, + }, + ["definition"], + ), + }, + }, + { + type: "function", + function: { + name: "customWidget_previewQuery", + description: + "Execute one query from a preview against the evaluation fixture. Call once for every query returned by previewCreate.", + parameters: objectSchema( + { + sessionId: { type: "string" }, + requestId: { type: "string" }, + params: { type: "object", additionalProperties: true }, + }, + ["sessionId", "requestId"], + ), + }, + }, + { + type: "function", + function: { + name: "customWidget_createFromPreview", + description: "Persist the exact final tested preview. Every query in that preview must have succeeded first.", + parameters: objectSchema({ previewSessionId: { type: "string" } }, ["previewSessionId"]), + }, + }, +]; + +const getDefinitionSignature = (widget: HomarrCustomWidgetV2) => JSON.stringify(widget); + +function parseDefinition(value: unknown) { + const authoring = customWidgetAuthoringDefinitionSchema.safeParse(value); + if (!authoring.success) { + return { + success: false as const, + issues: authoring.error.issues.map((issue) => ({ + path: issue.path.map(String).join("."), + code: issue.code, + message: issue.message, + })), + }; + } + try { + return { success: true as const, widget: normalizeCustomWidgetAuthoringDefinition(authoring.data) }; + } catch (error) { + if (!(error instanceof z.ZodError)) throw error; + return { + success: false as const, + issues: error.issues.map((issue) => ({ + path: issue.path.map(String).join("."), + code: issue.code, + message: issue.message, + })), + }; + } +} + +export function executeAssistantEvaluationTool( + testCase: CustomWidgetAiEvaluationCase, + state: AssistantAttemptState, + name: string, + input: Record, +): unknown { + const componentDocumentLimit = state.calledTools.includes("customWidget_getExample") ? 4 : 8; + if ( + name === "customWidget_getComponent" && + state.calledTools.filter((toolName) => toolName === "customWidget_getComponent").length >= componentDocumentLimit + ) { + return { + error: + "The targeted component-document budget is exhausted. Continue with validation using the documentation already loaded.", + }; + } + state.calledTools.push(name); + if (name === "customWidget_getSkill") return getCustomWidgetSkill(); + if (name === "customWidget_schema") return getCustomWidgetJsonSchema(); + if (name === "customWidget_getComponentCatalog") return getCustomWidgetComponentCatalog(); + if (name === "customWidget_getComponent") { + const component = typeof input.name === "string" ? getCustomWidgetComponent(input.name) : null; + return component ?? { error: "Custom JSX component not found" }; + } + if (name === "customWidget_getSharedProps") { + const names = Array.isArray(input.names) + ? input.names.filter((entry): entry is string => typeof entry === "string") + : []; + return names.length > 0 + ? getCustomWidgetSharedProps(names) + : { error: "At least one shared prop name is required" }; + } + if (name === "customWidget_getExample") { + const example = typeof input.name === "string" ? getCustomWidgetExample(input.name) : null; + if (!example) return { error: "Custom JSX example not found" }; + const { template, ...widget } = example.widget; + return { ...example, widget: { ...widget, templateLines: template.split("\n") } }; + } + if (name === "customWidget_validate") { + const parsed = parseDefinition(input.widget); + if (!parsed.success) return { valid: false, issues: parsed.issues }; + state.validatedDefinitions.add(getDefinitionSignature(parsed.widget)); + return { + valid: true, + issues: [], + summary: { + name: parsed.widget.name, + sourceIds: Object.keys(parsed.widget.sources), + requestIds: Object.keys(parsed.widget.requests), + optionIds: Object.keys(parsed.widget.options), + templateLineCount: parsed.widget.template.split("\n").length, + }, + nextStep: "Reuse this exact definition with customWidget_previewCreate and test every returned query.", + }; + } + if (name === "customWidget_previewCreate") { + const parsed = parseDefinition(input.definition); + if (!parsed.success) return { error: "Definition is invalid", issues: parsed.issues }; + if (!state.validatedDefinitions.has(getDefinitionSignature(parsed.widget))) { + return { error: "Validate this exact complete definition before creating its preview." }; + } + const id = `preview-${state.previews.size + 1}`; + state.previews.set(id, { widget: parsed.widget, testedQueries: new Set() }); + return { + success: true, + previewSession: { id }, + previewPath: `/manage/custom-widgets/preview/${id}`, + queries: Object.entries(parsed.widget.requests).flatMap(([requestId, request]) => + request.kind === "query" + ? [ + { + requestId, + trigger: request.trigger, + requiredParams: getRequiredAssistantEvaluationRequestParams(request), + nextStep: `Call customWidget_previewQuery with sessionId '${id}' and requestId '${requestId}'.`, + }, + ] + : [], + ), + }; + } + if (name === "customWidget_previewQuery") { + const sessionId = typeof input.sessionId === "string" ? input.sessionId : ""; + const requestId = typeof input.requestId === "string" ? input.requestId : ""; + const preview = state.previews.get(sessionId); + const request = preview?.widget.requests[requestId]; + if (!preview || request?.kind !== "query") return { error: "Preview query was not found" }; + const params = + typeof input.params === "object" && input.params !== null && !Array.isArray(input.params) + ? (input.params as Record) + : {}; + const missingParams = getRequiredAssistantEvaluationRequestParams(request).filter( + (paramName) => !(paramName in params) || params[paramName] === null || params[paramName] === "", + ); + if (missingParams.length > 0) { + return { error: `Supply the required manual preview parameters: ${missingParams.join(", ")}` }; + } + const response = getAssistantEvaluationPreviewResponse(testCase, request); + if (response === undefined) return { error: `No deterministic preview response is configured for ${request.path}` }; + preview.testedQueries.add(requestId); + return { + ok: true, + status: 200, + data: response, + request: { method: request.method, path: request.path }, + }; + } + if (name === "customWidget_createFromPreview") { + const sessionId = typeof input.previewSessionId === "string" ? input.previewSessionId : ""; + const preview = state.previews.get(sessionId); + if (!preview) return { error: "Preview session not found" }; + const untested = Object.entries(preview.widget.requests).flatMap(([requestId, request]) => + request.kind === "query" && !preview.testedQueries.has(requestId) ? [requestId] : [], + ); + if (untested.length > 0) return { error: `Test every preview query before creation: ${untested.join(", ")}` }; + const completedPreviewCycles = [...state.previews.values()].filter((candidate) => + Object.entries(candidate.widget.requests).every( + ([requestId, request]) => request.kind !== "query" || candidate.testedQueries.has(requestId), + ), + ).length; + const minimumPreviewCycles = testCase.minimumPreviewCycles ?? 1; + if (completedPreviewCycles < minimumPreviewCycles) { + return { + error: `Complete at least ${minimumPreviewCycles} validated preview-and-query cycles before creation; ${completedPreviewCycles} completed. Revise the candidate meaningfully, validate it, create a fresh preview, and test every query again.`, + }; + } + state.createdWidget = customWidgetDefinitionSchema.parse(preview.widget); + return { + id: `created-${testCase.id}`, + managementPath: `/manage/custom-widgets/edit/created-${testCase.id}`, + nextAction: { type: "place-custom-widget", widgetKind: "customApi" }, + }; + } + return { error: `Unknown evaluation tool '${name}'` }; +} + +export function getAssistantEvaluationLifecycleIssues(state: AssistantAttemptState) { + const required = [ + "customWidget_getSkill", + "customWidget_schema", + "customWidget_getComponentCatalog", + "customWidget_validate", + "customWidget_previewCreate", + "customWidget_previewQuery", + "customWidget_createFromPreview", + ]; + const issues = required.flatMap((name) => + state.calledTools.includes(name) ? [] : [`The assistant never called ${name}.`], + ); + const componentDocumentCount = state.calledTools.filter((name) => name === "customWidget_getComponent").length; + if (componentDocumentCount > 8) { + issues.push( + `The assistant fetched ${componentDocumentCount} component documents before creation; use at most eight targeted component documents.`, + ); + } + return issues; +} + +export function createAssistantEvaluationState(): AssistantAttemptState { + return { + calledTools: [], + validatedDefinitions: new Set(), + previews: new Map(), + createdWidget: null, + finalText: "", + }; +} + +function buildAssistantPrompt(testCase: CustomWidgetAiEvaluationCase, feedback: readonly string[]) { + const sections = [ + testCase.request, + `Verified API documentation: ${testCase.documentationUrl}`, + `Verified API notes:\n${testCase.apiNotes}`, + ]; + if (testCase.sampleResponse !== undefined) { + sections.push(`Representative preview response:\n${JSON.stringify(testCase.sampleResponse, null, 2)}`); + } + if (testCase.previewResponses?.length) { + sections.push( + `Representative preview responses:\n${testCase.previewResponses + .map(({ pathIncludes, response }) => `${pathIncludes}:\n${JSON.stringify(response, null, 2)}`) + .join("\n\n")}`, + ); + } + if (feedback.length > 0) { + sections.push( + `A previous independent review found these problems. Correct them in a fresh complete lifecycle:\n${feedback.join("\n")}`, + ); + } + sections.push( + "Use the available Custom Widget tools and continue automatically until the exact tested preview is created. Do not merely return JSON or instructions.", + ); + return sections.join("\n\n"); +} + +async function callAssistantStep(args: { + apiKey: string; + model: string; + messages: OpenRouterMessage[]; + tools: ToolDefinition[]; + toolChoice?: string; +}): Promise { + const response = await fetch("https://openrouter.ai/api/v1/chat/completions", { + method: "POST", + headers: { + Authorization: `Bearer ${args.apiKey}`, + "Content-Type": "application/json", + "HTTP-Referer": "https://homarr.dev", + "X-Title": "Homarr Custom Widget Assistant Evaluation", + }, + body: JSON.stringify({ + model: args.model, + messages: args.messages, + tools: args.tools, + tool_choice: args.toolChoice ? { type: "function", function: { name: args.toolChoice } } : "auto", + temperature: 0.1, + max_tokens: 12_000, + reasoning: { effort: "low", exclude: true }, + }), + signal: AbortSignal.timeout(180_000), + }); + const payload = (await response.json()) as OpenRouterResponse; + if (!response.ok) { + throw new Error(`OpenRouter request failed (${response.status}): ${payload.error?.message ?? "Unknown error"}`); + } + const message = payload.choices?.[0]?.message; + if (!message) throw new Error("OpenRouter returned no assistant message"); + return message; +} + +export const getAssistantEvaluationRecoveryToolName = (state: AssistantAttemptState, minimumPreviewCycles = 1) => { + for (const requiredTool of ["customWidget_getSkill", "customWidget_schema", "customWidget_getComponentCatalog"]) { + if (!state.calledTools.includes(requiredTool)) return requiredTool; + } + if (state.validatedDefinitions.size === 0) return "customWidget_validate"; + const latestPreview = [...state.previews.entries()].at(-1); + if (!latestPreview) return "customWidget_previewCreate"; + const [previewId, preview] = latestPreview; + const untestedQuery = Object.entries(preview.widget.requests).find( + ([requestId, request]) => request.kind === "query" && !preview.testedQueries.has(requestId), + ); + if (untestedQuery) return "customWidget_previewQuery"; + const completedPreviewCycles = [...state.previews.values()].filter((candidate) => + Object.entries(candidate.widget.requests).every( + ([requestId, request]) => request.kind !== "query" || candidate.testedQueries.has(requestId), + ), + ).length; + if (completedPreviewCycles < minimumPreviewCycles) return "customWidget_validate"; + if (previewId && !state.createdWidget) return "customWidget_createFromPreview"; + return undefined; +}; + +async function callAssistantStepWithRecovery(args: { + apiKey: string; + model: string; + messages: OpenRouterMessage[]; + tools: ToolDefinition[]; + state: AssistantAttemptState; + testCase: CustomWidgetAiEvaluationCase; +}) { + try { + const message = await callAssistantStep(args); + if ((message.tool_calls?.length ?? 0) > 0 || (args.state.createdWidget && (message.content ?? "").trim())) { + return message; + } + } catch (error) { + if (!(error instanceof Error) || (error.name !== "TimeoutError" && error.name !== "AbortError")) throw error; + } + + const toolChoice = getAssistantEvaluationRecoveryToolName(args.state, args.testCase.minimumPreviewCycles ?? 1); + if (!toolChoice || !args.tools.some(({ function: definition }) => definition.name === toolChoice)) { + throw new Error( + "The model returned a prose-only, empty, or timed-out step before completing the Custom Widget lifecycle.", + ); + } + process.stdout.write(` recovering with required tool: ${toolChoice}\n`); + const recovered = await callAssistantStep({ ...args, toolChoice }); + if ((!recovered.tool_calls || recovered.tool_calls.length === 0) && !(recovered.content ?? "").trim()) { + throw new Error("The model returned an empty assistant step before completing the Custom Widget lifecycle."); + } + return recovered; +} + +async function runAssistantAttempt(args: { + testCase: CustomWidgetAiEvaluationCase; + apiKey: string; + model: string; + feedback: readonly string[]; +}) { + const state = createAssistantEvaluationState(); + const messages: OpenRouterMessage[] = [ + { + role: "system", + content: `${CUSTOM_WIDGET_MCP_AUTHORING_PROMPT}\n\nThis is a tool-driven evaluation. You must begin by loading the complete installed skill, live schema, and component catalog through their tools. Do not stop until customWidget_createFromPreview succeeds.`, + }, + { role: "user", content: buildAssistantPrompt(args.testCase, args.feedback) }, + ]; + + for (let step = 0; step < MAX_ASSISTANT_STEPS; step += 1) { + const componentDocumentCount = state.calledTools.filter((name) => name === "customWidget_getComponent").length; + const componentDocumentLimit = state.calledTools.includes("customWidget_getExample") ? 4 : 8; + const tools = + componentDocumentCount >= componentDocumentLimit + ? customWidgetAssistantEvaluationToolDefinitions.filter( + ({ function: definition }) => definition.name !== "customWidget_getComponent", + ) + : customWidgetAssistantEvaluationToolDefinitions; + const message = await callAssistantStepWithRecovery({ + apiKey: args.apiKey, + model: args.model, + messages, + tools, + state, + testCase: args.testCase, + }); + if ((!message.tool_calls || message.tool_calls.length === 0) && !(message.content ?? "").trim()) { + throw new Error("The model returned an empty assistant step before completing the Custom Widget lifecycle."); + } + messages.push(message); + if (!message.tool_calls || message.tool_calls.length === 0) { + state.finalText = message.content ?? ""; + break; + } + for (const toolCall of message.tool_calls) { + process.stdout.write(` tool: ${toolCall.function.name}\n`); + let output: unknown; + try { + const parsed = JSON.parse(toolCall.function.arguments) as unknown; + const input = + parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) + ? (parsed as Record) + : {}; + output = executeAssistantEvaluationTool(args.testCase, state, toolCall.function.name, input); + } catch (error) { + output = { error: error instanceof Error ? error.message : "Tool input was not valid JSON" }; + } + messages.push({ role: "tool", tool_call_id: toolCall.id, content: JSON.stringify(output) }); + } + if (state.createdWidget) { + break; + } + } + return { state, messages }; +} + +export async function evaluateCustomWidgetAssistantCase(args: { + testCase: CustomWidgetAiEvaluationCase; + apiKey: string; + outputRoot: string; + maxLoops: number; + generatorModel?: string; + judgeModel?: string; +}): Promise { + const caseDirectory = path.join(args.outputRoot, `assistant-${args.testCase.id}`); + await mkdir(caseDirectory, { recursive: true }); + const errors: string[] = []; + const feedback: string[] = []; + let bestWidget: HomarrCustomWidgetV2 | null = null; + let bestJudge: CustomWidgetJudgeResult | null = null; + let bestCalledTools: string[] = []; + + for (let attempt = 1; attempt <= args.maxLoops; attempt += 1) { + process.stdout.write(` assistant attempt ${attempt}/${args.maxLoops}\n`); + let run: Awaited>; + try { + run = await runAssistantAttempt({ + testCase: args.testCase, + apiKey: args.apiKey, + model: args.generatorModel ?? DEFAULT_GENERATOR_MODEL, + feedback, + }); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown assistant evaluation error"; + errors.push(`Attempt ${attempt}: ${message}`); + feedback.splice(0, feedback.length, message); + continue; + } + await writeFile(path.join(caseDirectory, `trace-${attempt}.json`), JSON.stringify(run.messages, null, 2), "utf8"); + const lifecycleIssues = getAssistantEvaluationLifecycleIssues(run.state); + if (!run.state.createdWidget || lifecycleIssues.length > 0) { + const issues = [ + ...lifecycleIssues, + ...(run.state.createdWidget ? [] : ["The assistant did not create the final tested preview."]), + ]; + errors.push(`Attempt ${attempt}: lifecycle failed — ${issues.join(" ")}`); + feedback.splice(0, feedback.length, ...issues); + continue; + } + const deterministicIssues = getDeterministicEvaluationIssues(args.testCase, run.state.createdWidget); + if (deterministicIssues.length > 0) { + const issues = deterministicIssues.map(({ message }) => message); + errors.push(`Attempt ${attempt}: deterministic checks failed — ${issues.join("; ")}`); + feedback.splice(0, feedback.length, ...issues); + continue; + } + + await writeFile( + path.join(caseDirectory, `widget-${attempt}.json`), + JSON.stringify(run.state.createdWidget, null, 2), + "utf8", + ); + let judgeRaw: string; + try { + judgeRaw = await requestCustomWidgetJudge({ + testCase: args.testCase, + widget: run.state.createdWidget, + apiKey: args.apiKey, + judgeModel: args.judgeModel, + }); + await writeFile(path.join(caseDirectory, `judge-${attempt}.json`), judgeRaw, "utf8"); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown judge error"; + errors.push(`Attempt ${attempt}: judge request failed — ${message}`); + feedback.splice(0, feedback.length, "Return a complete polished widget for another independent review."); + continue; + } + let judgeResult: CustomWidgetJudgeResult; + try { + judgeResult = parseJudgeResult(judgeRaw); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown judge parse error"; + errors.push(`Attempt ${attempt}: judge response invalid — ${message}`); + feedback.splice(0, feedback.length, "Return a complete polished widget for another independent review."); + continue; + } + if (!bestJudge || judgeResult.total > bestJudge.total) { + bestWidget = run.state.createdWidget; + bestJudge = judgeResult; + bestCalledTools = run.state.calledTools; + } + if (judgePasses(judgeResult)) { + return { + caseId: args.testCase.id, + attempts: attempt, + widget: run.state.createdWidget, + judge: judgeResult, + outputDirectory: caseDirectory, + errors, + calledTools: run.state.calledTools, + }; + } + const issues = judgeResult.highestImpactFixes.length ? judgeResult.highestImpactFixes : judgeResult.problems; + errors.push(`Attempt ${attempt}: judge ${judgeResult.total}/100 — ${issues.join("; ")}`); + feedback.splice(0, feedback.length, ...issues); + } + + return { + caseId: args.testCase.id, + attempts: args.maxLoops, + widget: bestWidget, + judge: bestJudge, + outputDirectory: caseDirectory, + errors, + calledTools: bestCalledTools, + }; +} diff --git a/packages/custom-widgets/scripts/ai-evaluation-cases.ts b/packages/custom-widgets/scripts/ai-evaluation-cases.ts index 975598ebb..dbb589edf 100644 --- a/packages/custom-widgets/scripts/ai-evaluation-cases.ts +++ b/packages/custom-widgets/scripts/ai-evaluation-cases.ts @@ -3,6 +3,25 @@ export interface CustomWidgetAiEvaluationCase { request: string; documentationUrl: string; apiNotes: string; + sampleResponse?: unknown; + previewResponses?: Array<{ + pathIncludes: string; + response: unknown; + }>; + minimumPreviewCycles?: number; + expectations?: { + sourceBaseUrl: string; + sourceAuth: "none" | "apiKeyQuery"; + sourceAuthName?: string; + requests: Array<{ + kind: "query" | "action"; + method: "GET" | "POST"; + pathIncludes: string; + trigger?: "load" | "manual"; + queryIncludes?: Record; + }>; + templateIncludes?: string[]; + }; } export const CUSTOM_WIDGET_AI_EVALUATION_CASES: readonly CustomWidgetAiEvaluationCase[] = [ @@ -10,9 +29,49 @@ export const CUSTOM_WIDGET_AI_EVALUATION_CASES: readonly CustomWidgetAiEvaluatio id: "pokedex", documentationUrl: "https://pokeapi.co/docs/v2", request: - "Create a polished Pokédex browser with searchable Pokémon, a responsive result grid, manual detail loading, sprites, types, abilities, base-stat progress bars, and clear loading, empty, and failure states.", + "Create a complete Pokédex widget using PokéAPI. Make at least three deliberate refinement passes through validation and preview testing. It must have a substantial polished UI with a direct name lookup, a searchable loaded list that stays visible while details open in a separate responsive result area, sprites, types, abilities, base-stat progress bars, and clear initial, loading, empty, failure, and success states. Use a restrained accent and flexible tile sizing rather than repetitive nested cards or a fixed-height list. Completeness must come from tested useful interactions, not filler JSX.", apiNotes: - "Use GET /api/v2/pokemon?limit=