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
@@ -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);
|
||||
|
||||
@@ -15,6 +15,11 @@ import { describe, expect, test } from "vitest";
|
||||
import { customWidgetRouter } from "../../custom-widget/custom-widget-router";
|
||||
import { getCustomWidgetConfigurationRequestForUser } from "../../custom-widget/configuration-requests";
|
||||
import { configureCustomWidgetSourceFromRequest } from "../../custom-widget/secret-persistence";
|
||||
import {
|
||||
appendPreviewJournal,
|
||||
configurePreviewSessionSource,
|
||||
getPreviewSession,
|
||||
} from "../../custom-widget/preview-sessions";
|
||||
import { serializeCustomWidgetDefinition } from "../../custom-widget/stored-definition";
|
||||
|
||||
const userId = createId();
|
||||
@@ -33,6 +38,8 @@ const nonAdminSession = {
|
||||
|
||||
const jellyfin = BUNDLED_CUSTOM_WIDGETS.find(({ id }) => id === "seed-jellyfin")?.widget;
|
||||
if (!jellyfin) throw new Error("Jellyfin bundled widget is missing");
|
||||
const pokedex = BUNDLED_CUSTOM_WIDGETS.find(({ id }) => id === "seed-pokedex")?.widget;
|
||||
if (!pokedex) throw new Error("Pokédex bundled widget is missing");
|
||||
const jellyfinDefaultSource = jellyfin.sources.default;
|
||||
if (!jellyfinDefaultSource) throw new Error("Jellyfin default source is missing");
|
||||
|
||||
@@ -142,6 +149,69 @@ describe("custom widget definition persistence", () => {
|
||||
expect((await caller.get({ id: created.id })).template).toBe(template);
|
||||
});
|
||||
|
||||
test("persists the exact final tested Pokédex preview without resending its JSX", async () => {
|
||||
const db = await prepareDatabase();
|
||||
const caller = createCaller(db);
|
||||
const { template, ...definition } = pokedex;
|
||||
const candidate = { ...definition, templateLines: template.split("\n") };
|
||||
|
||||
// A long-running authoring request can iterate several times. Only the final preview session
|
||||
// is eligible for persistence, and its compact ID replaces a second large streamed payload.
|
||||
await caller.previewCreate({ definition: candidate, secrets: [] });
|
||||
await caller.previewCreate({ definition: candidate, secrets: [] });
|
||||
const finalPreview = await caller.previewCreate({ definition: candidate, secrets: [] });
|
||||
const createInput = { previewSessionId: finalPreview.previewSession.id, targetBoardId: "board-hey" };
|
||||
expect(Buffer.byteLength(JSON.stringify(createInput), "utf8")).toBeLessThan(128);
|
||||
|
||||
await expect(caller.createFromPreview(createInput)).rejects.toThrow("Test every final preview query successfully");
|
||||
|
||||
const previewSession = await getPreviewSession(finalPreview.previewSession.id, userId);
|
||||
for (const [requestId, request] of Object.entries(previewSession.requests)) {
|
||||
if (request.kind !== "query") continue;
|
||||
await appendPreviewJournal(previewSession, {
|
||||
requestId,
|
||||
kind: "query",
|
||||
method: request.method,
|
||||
path: request.path,
|
||||
status: 200,
|
||||
durationMs: 1,
|
||||
simulated: false,
|
||||
});
|
||||
}
|
||||
|
||||
const defaultSource = previewSession.sources.default;
|
||||
if (!defaultSource) throw new Error("Pokédex preview source is missing");
|
||||
await configurePreviewSessionSource(previewSession.id, userId, "default", { ...defaultSource }, []);
|
||||
await expect(caller.createFromPreview(createInput)).rejects.toThrow("Test every final preview query successfully");
|
||||
|
||||
const finalPreviewSession = await getPreviewSession(finalPreview.previewSession.id, userId);
|
||||
for (const [requestId, request] of Object.entries(finalPreviewSession.requests)) {
|
||||
if (request.kind !== "query") continue;
|
||||
await appendPreviewJournal(finalPreviewSession, {
|
||||
requestId,
|
||||
kind: "query",
|
||||
method: request.method,
|
||||
path: request.path,
|
||||
status: 200,
|
||||
durationMs: 1,
|
||||
simulated: false,
|
||||
});
|
||||
}
|
||||
|
||||
const created = await caller.createFromPreview(createInput);
|
||||
expect(created.managementPath).toBe(`/manage/custom-widgets/edit/${created.id}`);
|
||||
expect(created.nextAction.options).toEqual({ definitionId: created.id });
|
||||
expect(created.nextAction.targetBoardId).toBe("board-hey");
|
||||
await expect(caller.get({ id: created.id })).resolves.toMatchObject({
|
||||
name: pokedex.name,
|
||||
description: pokedex.description,
|
||||
template,
|
||||
sources: pokedex.sources,
|
||||
requests: pokedex.requests,
|
||||
options: pokedex.options,
|
||||
});
|
||||
});
|
||||
|
||||
test("updates a saved widget with MCP-friendly multiline templateLines", async () => {
|
||||
const db = await prepareDatabase();
|
||||
const caller = createCaller(db);
|
||||
|
||||
Reference in new issue
Block a user