fix assistant custom widget creation

This commit is contained in:
ajnart committed 2026-08-14 11:32:11 +02:00
1 parent 474d9968b5
commit ceed0979a1
21 files changed
+513 -47

No files matched your search

@@ -129,7 +129,7 @@ export const CUSTOM_WIDGET_MCP_AUTHORING_PROMPT = `Author one Homarr Custom JSX
First call customWidget_getSkill. Its single lazy response contains the complete installed SKILL.md and every bundled reference; follow all of it. Read the live schema and compact component catalog. Fetch only the named component and example resources needed for the design. Component resources contain component-specific props; if shared props are needed, collect their catalog names and fetch those docs together once with customWidget_getSharedProps. The live resources are authoritative for this Homarr release.
Follow this lifecycle without skipping steps: construct one credential-free definition; call customWidget_validate; repair every reported issue; call customWidget_previewCreate; call customWidget_previewQuery once for every query returned by that preview (supplying required manual parameters); inspect the real response shape and HTTP status; adjust paths and template bindings; validate and preview again after changes; open or provide the returned previewPath for visual review; only then call customWidget_create. Prefer templateLines over a JSON-escaped template string for multiline JSX in validate, preview, and create tool inputs.
Follow this lifecycle without skipping steps: construct one credential-free definition; call customWidget_validate; repair every reported issue; call customWidget_previewCreate; call customWidget_previewQuery once for every query returned by that preview (supplying required manual parameters); inspect the real response shape and HTTP status; adjust paths and template bindings; validate and preview again after changes; open or provide the returned previewPath for visual review; only then call customWidget_createFromPreview with the exact final tested preview session ID. Use customWidget_create only when no reusable preview session exists. Prefer templateLines over a JSON-escaped template string for multiline JSX in validate and preview tool inputs.
Do not claim a query works when it has not returned a successful preview response. Configure deployment-specific source URLs and request credentials through Homarr when needed; never repeat plaintext. Use simulated preview actions unless the user explicitly enables live preview actions.
@@ -3,6 +3,7 @@ import {
customJsxCatalogGlobalPropByName,
resolveCustomJsxPropDescriptor,
} from "./component-catalog";
import { BUNDLED_CUSTOM_WIDGETS } from "./bundled-widgets";
import { customJsxExamples } from "./examples";
import { customJsxTablerIconNames } from "./tabler-icons";
@@ -11,7 +12,7 @@ export const CUSTOM_WIDGET_SKILL_SOURCE_URL =
"https://github.com/homarr-labs/homarr/tree/HEAD/.agents/skills/homarr-custom-widget";
export const CUSTOM_WIDGET_SKILL_INSTALL_COMMAND =
"npx skills add https://github.com/homarr-labs/homarr --skill homarr-custom-widget";
export const CUSTOM_WIDGET_SKILL_VERSION = "2.2.0";
export const CUSTOM_WIDGET_SKILL_VERSION = "2.3.0";
export const CUSTOM_WIDGET_SKILL_REFERENCES = {
"references/schema.md": `# Schema
@@ -77,7 +78,7 @@ Author one widget at a time. When connected to Homarr, retrieve \`homarr://custo
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.
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.
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\`.
@@ -101,6 +102,8 @@ const CUSTOM_WIDGET_SKILL_BUNDLE_MD = [
),
].join("");
const pokedexAuthoringExample = BUNDLED_CUSTOM_WIDGETS.find(({ id }) => id === "seed-pokedex");
export function getCustomWidgetSkill() {
return {
name: "homarr-custom-widget",
@@ -135,7 +138,19 @@ export function getCustomWidgetComponentCatalog() {
}
export function getCustomWidgetExampleCatalog() {
return customJsxExamples.map(({ id, title, description }) => ({ id, title, description }));
return [
...customJsxExamples.map(({ id, title, description }) => ({ id, title, description })),
...(pokedexAuthoringExample
? [
{
id: "pokedex",
title: "Complete Pokédex",
description:
"A production-safe PokéAPI browser with searchable species, artwork, types, abilities, base stats, loading states, and manual detail requests.",
},
]
: []),
];
}
export function getCustomWidgetComponent(name: string) {
@@ -183,5 +198,14 @@ export function getCustomWidgetSharedProps(names: readonly string[]) {
}
export function getCustomWidgetExample(name: string) {
if (name === "pokedex" && pokedexAuthoringExample) {
return {
id: "pokedex",
title: "Complete Pokédex",
description:
"A production-safe PokéAPI browser with searchable species, artwork, types, abilities, base stats, loading states, and manual detail requests.",
widget: pokedexAuthoringExample.widget,
};
}
return customJsxExamples.find((example) => example.id === name) ?? null;
}
@@ -29,6 +29,8 @@ const sessionSchema = z.object({
secrets: z.array(encryptedSecretSchema),
requests: customWidgetRequestsSchema,
name: z.string(),
description: z.string().optional(),
iconUrl: z.string().optional(),
template: z.string(),
optionDefinitions: customWidgetOptionsSchema,
options: z.record(z.string(), z.unknown()),
@@ -46,6 +48,7 @@ const journalEntrySchema = z.object({
status: z.number().nullable(),
durationMs: z.number().nonnegative(),
simulated: z.boolean(),
sessionRevision: z.number().int().nonnegative(),
timestamp: z.number(),
});
export type CustomWidgetPreviewJournalEntry = z.infer<typeof journalEntrySchema>;
@@ -56,6 +59,8 @@ export interface CreatePreviewSessionInput {
secrets: Array<{ sourceId: string; kind: (typeof customWidgetSecretKinds)[number]; value: string }>;
requests: Record<string, CustomJsxRequest>;
name: string;
description?: string;
iconUrl?: string;
template: string;
optionDefinitions: CustomWidgetOptions;
options: Record<string, unknown>;
@@ -105,6 +110,8 @@ export class CustomWidgetPreviewSessionService {
secrets: input.secrets.map((secret) => ({ ...secret, value: this.options.encrypt(secret.value) })),
requests: input.requests,
name: input.name,
description: input.description,
iconUrl: input.iconUrl,
template: input.template,
optionDefinitions: input.optionDefinitions,
options: input.options,
@@ -45,6 +45,7 @@ describe("AI prompt", () => {
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("customWidget_validate");
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("customWidget_previewCreate");
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("customWidget_previewQuery once for every query");
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("customWidget_createFromPreview");
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("customWidget_getSharedProps");
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("Prefer templateLines");
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT.indexOf("customWidget_previewQuery")).toBeLessThan(
@@ -5,6 +5,7 @@ import { describe, expect, it } from "vitest";
import {
getCustomWidgetComponent,
getCustomWidgetComponentCatalog,
getCustomWidgetExample,
getCustomWidgetSharedProps,
getCustomWidgetSkill,
getCustomWidgetSkillContent,
@@ -15,7 +16,7 @@ const skillSourceDirectory = path.resolve(import.meta.dirname, "../../../../.age
describe("authoring resources", () => {
it("returns the complete portable skill and keeps every bundled source file in sync", () => {
const skill = getCustomWidgetSkill();
expect(skill).toMatchObject({ name: "homarr-custom-widget", version: "2.2.0" });
expect(skill).toMatchObject({ name: "homarr-custom-widget", version: "2.3.0" });
expect(skill.skillsShUrl).toContain("skills.sh/homarr-labs/homarr");
expect(skill.sourceUrl).toContain("/tree/HEAD/.agents/skills/homarr-custom-widget");
expect(skill.installCommand).toContain("--skill homarr-custom-widget");
@@ -40,6 +41,17 @@ describe("authoring resources", () => {
expect.arrayContaining([expect.objectContaining({ name: "Stack", category: expect.any(String) })]),
);
expect(catalog.examples).toEqual(expect.arrayContaining([expect.objectContaining({ id: "service-dashboard" })]));
expect(catalog.examples).toEqual(expect.arrayContaining([expect.objectContaining({ id: "pokedex" })]));
const pokedex = getCustomWidgetExample("pokedex");
expect(pokedex).toMatchObject({
id: "pokedex",
widget: {
$schema: "homarr-custom-widget-v2",
name: "Pokédex",
sources: { default: { baseUrl: "https://pokeapi.co" } },
},
});
expect(Buffer.byteLength(JSON.stringify(pokedex), "utf8")).toBeLessThan(16_000);
expect(catalog.sharedProps).toMatchObject({
count: expect.any(Number),
fetchTool: "customWidget_getSharedProps",
@@ -64,8 +64,13 @@ describe("preview sessions", () => {
status: 200,
durationMs: 5,
simulated: false,
sessionRevision: session.revision,
});
expect((await service.getJournal(created.id, "user"))[0]).toMatchObject({
path: "/data",
status: 200,
sessionRevision: session.revision,
});
expect((await service.getJournal(created.id, "user"))[0]).toMatchObject({ path: "/data", status: 200 });
});
it("applies source deployment values without restoring stale source metadata", async () => {