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

@@ -0,0 +1,110 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
import { createLogger } from "@homarr/core/infrastructure/logs";
import {
customWidgetCreateSchema,
customWidgetDefinitionSchema,
normalizeCustomWidgetAuthoringDefinition,
} from "@homarr/custom-widgets/core";
import { permissionRequiredProcedure } from "../../trpc";
import { parseCustomWidgetAuthoringInput } from "./authoring-validation";
import { insertCustomWidgetDefinition } from "./definition-insert";
import { getPreviewJournal, getPreviewSession, getPreviewSessionSecrets } from "./preview-sessions";
import { assertSecretSources } from "./secret-policy";
const logger = createLogger({ module: "custom-widget" });
const getCreatedCustomWidgetResult = (id: string, targetBoardId?: string) => ({
id,
managementPath: `/manage/custom-widgets/edit/${id}`,
nextAction: {
type: "place-custom-widget" as const,
widgetKind: "customApi" as const,
options: { definitionId: id },
...(targetBoardId ? { targetBoardId } : {}),
whenTargetIsKnown: "Call configure_widget now with the requested board and these exact widget options.",
whenTargetIsUnknown:
"Call ask_user now with 'Place on a board' and 'Leave unplaced'. Never ask this choice in prose.",
},
});
export const creationProcedures = {
create: permissionRequiredProcedure
.requiresPermission("admin")
.meta({
mcp: {
enabled: true,
description:
"Create a Custom JSX widget only after customWidget_validate, customWidget_previewCreate, and every returned preview query succeed. Prefer templateLines for multiline JSX. Returns a client-navigable edit link.",
},
})
.input(customWidgetCreateSchema)
.mutation(async ({ ctx, input }) => {
const { secrets, ...candidate } = input;
const definition = parseCustomWidgetAuthoringInput(() => normalizeCustomWidgetAuthoringDefinition(candidate));
assertSecretSources(definition.sources, secrets);
const id = await insertCustomWidgetDefinition(ctx.db, definition, ctx.session.user.id, secrets);
logger.info("Created custom widget definition", { id, name: definition.name });
return getCreatedCustomWidgetResult(id);
}),
createFromPreview: permissionRequiredProcedure
.requiresPermission("admin")
.meta({
mcp: {
enabled: true,
description:
"Persist the exact tested definition from a customWidget_previewCreate session. Prefer this over resending a large widget to customWidget_create. Every query in the final preview must have a successful customWidget_previewQuery journal entry.",
},
})
.input(z.object({ previewSessionId: z.string().min(1), targetBoardId: z.string().min(1).optional() }))
.mutation(async ({ ctx, input }) => {
const session = await getPreviewSession(input.previewSessionId, ctx.session.user.id);
const journal = await getPreviewJournal(session.id, ctx.session.user.id);
const unverifiedQueryIds = Object.entries(session.requests).flatMap(([requestId, request]) => {
if (request.kind !== "query") return [];
const verified = journal.some(
(entry) =>
entry.kind === "query" &&
entry.requestId === requestId &&
entry.sessionRevision === session.revision &&
entry.status !== null &&
entry.status >= 200 &&
entry.status < 300,
);
return verified ? [] : [requestId];
});
if (unverifiedQueryIds.length > 0) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Test every final preview query successfully before creating the widget: ${unverifiedQueryIds.join(", ")}`,
});
}
const definition = parseCustomWidgetAuthoringInput(() =>
customWidgetDefinitionSchema.parse({
$schema: "homarr-custom-widget-v2",
name: session.name,
description: session.description,
iconUrl: session.iconUrl,
sources: session.sources,
requests: session.requests,
options: session.optionDefinitions,
template: session.template,
}),
);
const secrets = Object.keys(session.sources).flatMap((sourceId) =>
getPreviewSessionSecrets(session, sourceId).map((secret) => ({ sourceId, ...secret })),
);
assertSecretSources(definition.sources, secrets);
const id = await insertCustomWidgetDefinition(ctx.db, definition, ctx.session.user.id, secrets);
logger.info("Created custom widget definition from tested preview", {
id,
name: definition.name,
previewSessionId: session.id,
});
return getCreatedCustomWidgetResult(id, input.targetBoardId);
}),
};
@@ -108,7 +108,8 @@ export const metadataProcedures = {
.query(({ input }) => {
const example = getCustomWidgetExample(input.name);
if (!example) throw new TRPCError({ code: "NOT_FOUND", message: "Custom JSX example not found" });
return example;
const { template, ...widget } = example.widget;
return { ...example, widget: { ...widget, templateLines: template.split("\n") } };
}),
validate: permissionRequiredProcedure
@@ -109,6 +109,8 @@ const previewCreateProcedure = permissionRequiredProcedure
sources: definition.sources,
requests: definition.requests,
name: definition.name,
description: definition.description,
iconUrl: definition.iconUrl,
template: definition.template,
optionDefinitions: definition.options,
options,
@@ -145,6 +147,8 @@ export const previewBaseProcedures = {
return {
id: session.id,
name: session.name,
description: session.description,
iconUrl: session.iconUrl,
expiresAt: session.expiresAt,
template: session.template,
optionDefinitions: session.optionDefinitions,
@@ -95,8 +95,8 @@ export const setPreviewSessionLiveActions = (id: string, userId: string, enabled
call(() => getService().setLiveActions(id, userId, enabled));
export const appendPreviewJournal = (
session: CustomWidgetPreviewSession,
entry: Omit<CustomWidgetPreviewJournalEntry, "id" | "timestamp">,
) => call(() => getService().appendJournal(session, entry));
entry: Omit<CustomWidgetPreviewJournalEntry, "id" | "sessionRevision" | "timestamp">,
) => call(() => getService().appendJournal(session, { ...entry, sessionRevision: session.revision }));
export const getPreviewJournal = (id: string, userId: string) => call(() => getService().getJournal(id, userId));
export const getPreviewSessionSecrets = (session: CustomWidgetPreviewSession, sourceId: string) =>
getService().getSecrets(session, sourceId);