fix assistant custom widget creation
This commit is contained in:
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 () => {
|
||||
|
||||
Reference in new issue
Block a user