test custom widget assistant authoring

This commit is contained in:
ajnart committed 2026-08-14 11:32:11 +02:00
1 parent ceed0979a1
commit dfafad15bf
20 files changed
+1675 -36

No files matched your search

@@ -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");
@@ -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.
@@ -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", () => {
@@ -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 = <TToolName extends string>(options: {
isAdmin: boolean;
baseInstructions: string;
@@ -163,6 +187,22 @@ export const createCustomWidgetDynamicContextController = <TToolName extends str
const shouldInjectContext = !contextWasInjected;
if (shouldInjectContext) contextWasInjected = true;
const componentDocumentCount = step.steps.reduce(
(count, result) =>
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 = <TToolName extends str
}
: {}),
messages: prunePreloadedCustomWidgetModelMessages(step.messages),
activeTools: activeToolNames,
activeTools: boundedActiveToolNames,
};
};
};
@@ -52,6 +52,7 @@ import { getSafeAssistantToolError } from "./assistant-tool-error";
import { repairAssistantToolInput } from "./assistant-tool-input-repair";
import { toAssistantToolOutput } from "./assistant-tool-output";
import {
createCustomWidgetComponentDocumentBudget,
createCustomWidgetDynamicContextController,
getCustomWidgetAuthoringContext,
prunePreloadedCustomWidgetModelMessages,
@@ -476,6 +477,7 @@ export async function POST(request: Request) {
const procedureTypes = getProcedureTypeMap();
const omittedCustomWidgetTools = new Set<string>(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<typeof jsonSchema>[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<unknown>(
(current, segment) =>