streamline AI import and Workshop errors

This commit is contained in:
ajnart committed 2026-07-30 08:00:47 +02:00
1 parent 7a2d6009f7
commit 944c674939
21 files changed
+149 -109

No files matched your search

@@ -27,11 +27,12 @@ Every option has a default. Supported controls include text, textarea, number, s
Enter the desired widget and optional API documentation URL, then select **Copy AI prompt**. The single self-contained prompt is below 12,000 characters, starts with your request, includes the lean contract and two polished examples, and never claims that an offline chat has MCP tools.
The model must return exactly:
The model must return one fenced `json` block containing the complete widget, including the JSX in its `template`
field. Copy that single block and select **Paste AI response**. Homarr fills the visual builders and the JSX editor
without any placeholder replacement or second paste.
1. One fenced `json` block with `template: "__HOMARR_TEMPLATE__"`.
2. One fenced `jsx` block with the readable template.
Select **Paste AI response**. Homarr fills the visual builders and shows all actionable schema or JSX errors inline. Enter credentials separately. The prompt deliberately recommends a focused Mantine component set, visual hierarchy, responsive layout, theme compatibility, and loading, empty, failure, and success states.
Homarr shows all actionable schema or JSX errors inline. Enter credentials separately. The prompt deliberately
recommends a focused Mantine component set, visual hierarchy, responsive layout, theme compatibility, and loading,
empty, failure, and success states.
Connected agents should use the skills.sh skill and retrieve the live schema and individual component metadata only as needed.
@@ -78,8 +78,9 @@ The workbench keeps one continuous layout with visual source, request, and optio
Exports, imports, the official Workshop client, and MCP validate the same schema. PocketBase stores community
submissions without interpreting their content, and Homarr validates them before installation. Widgets saved in the v1
format remain visible to administrators with a **Migration required** badge after upgrading. Use **Copy migration
prompt**, give the redacted prompt to an LLM, then import its v2 JSON from that widget's action menu. The prompt keeps
safe request metadata but deliberately omits the v1 display configuration because legacy free-text and JSX can contain
prompt**, give the redacted prompt to an LLM, copy its single complete JSON block, then select **Paste migrated widget**
from that widget's action menu. Homarr reads the `template` value into the JSX editor automatically. The prompt keeps safe
request metadata but deliberately omits the v1 display configuration because legacy free-text and JSX can contain
hard-coded credentials. Add a credential-free description of the widget's visible layout and behavior when sending the
prompt.
@@ -6,9 +6,10 @@ The definition does not exist. If it was deleted, remove the unavailable tile fr
If the Custom Widgets page shows the definition with a **Migration required** badge, an administrator can open its
action menu and select **Copy migration prompt**. Give that redacted prompt to an LLM, then import the resulting v2 JSON
from the same menu. Because arbitrary v1 display text or JSX may contain credentials, the prompt omits that display
configuration; describe the old widget's visible layout and behavior without including secrets. Other viewers see
**Contact an administrator** instead of a migration link.
from the same menu. The LLM returns one complete JSON block; copy it once and select **Paste migrated widget**. Homarr
places its `template` value into the JSX editor automatically. Because arbitrary v1 display text or JSX may contain
credentials, the prompt omits that display configuration; describe the old widget's visible layout and behavior without
including secrets. Other viewers see **Contact an administrator** instead of a migration link.
Homarr keeps the v1 definition and encrypted secret after migration. The validated v2 replacement uses the same ID and
takes precedence; the archived v1 record reappears if the v2 replacement is deleted. There is no v1 permanent-delete
@@ -84,7 +84,6 @@ export function BundledCustomWidgetGalleryClient() {
const [selectedId, setSelectedId] = useState(BUNDLED_CUSTOM_WIDGETS[0]?.id ?? null);
const selected = BUNDLED_CUSTOM_WIDGETS.find(({ id }) => id === selectedId) ?? BUNDLED_CUSTOM_WIDGETS[0];
if (!selected) return null;
const manifest = { ...selected.widget, template: "__HOMARR_TEMPLATE__" };
return (
<MantineProvider forceColorScheme={colorMode}>
@@ -103,19 +102,11 @@ export function BundledCustomWidgetGalleryClient() {
</Text>
<ReadOnlyCustomWidgetCode
id={`${selected.id}-manifest`}
label="widget.json"
label="Complete widget.json"
language="json"
value={JSON.stringify(manifest, null, 2)}
value={JSON.stringify(selected.widget, null, 2)}
messages={messages}
height="340px"
/>
<ReadOnlyCustomWidgetCode
id={`${selected.id}-template`}
label="widget.jsx"
language="jsx"
value={selected.widget.template}
messages={messages}
height="340px"
height="680px"
/>
</Stack>
</MantineProvider>
@@ -18,7 +18,7 @@ describe("Copy AI prompt", () => {
const prompt = buildCustomWidgetAiPrompt(undefined, null, null, "Create a Pokédex");
expect(prompt.length).toBeLessThanOrEqual(12_000);
expect(prompt).toContain("exactly one complete `json` fenced block");
expect(prompt).toContain("one complete `jsx` fenced block");
expect(prompt).toContain("Put the complete JSX source directly in the `template` string");
});
it("keeps AI actions and section navigation readable on narrow screens", () => {
@@ -5,6 +5,7 @@ import { ActionIcon, Menu } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import {
IconBuildingStore,
IconClipboard,
IconCopy,
IconDots,
IconDownload,
@@ -63,22 +64,37 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
}
};
const queueMigratedWidget = (text: string) => {
const result = parseCustomWidgetClipboardDetailed(text);
if (!result.success) {
showErrorNotification({
title: t("action.migrate"),
message: formatCustomWidgetImportIssues(result.issues),
});
return;
}
setMigratedWidget(result.widget);
migrationControls.open();
};
const pasteMigratedWidget = async () => {
try {
queueMigratedWidget(await navigator.clipboard.readText());
} catch {
showErrorNotification({
title: t("action.migrate"),
message: t("notification.migrationError"),
});
}
};
const handleMigrationFile = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.addEventListener("load", (loadEvent) => {
try {
const result = parseCustomWidgetClipboardDetailed(loadEvent.target?.result as string);
if (!result.success) {
showErrorNotification({
title: t("action.migrate"),
message: formatCustomWidgetImportIssues(result.issues),
});
return;
}
setMigratedWidget(result.widget);
migrationControls.open();
queueMigratedWidget(loadEvent.target?.result as string);
} catch {
showErrorNotification({
title: t("action.migrate"),
@@ -186,6 +202,9 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
<Menu.Item onClick={() => void copyMigrationPrompt()} leftSection={<IconSparkles {...iconProps} />}>
{t("action.copyMigrationPrompt")}
</Menu.Item>
<Menu.Item onClick={() => void pasteMigratedWidget()} leftSection={<IconClipboard {...iconProps} />}>
{t("action.pasteMigration")}
</Menu.Item>
<Menu.Item
onClick={() => migrationFileInputRef.current?.click()}
leftSection={<IconUpload {...iconProps} />}
@@ -26,6 +26,9 @@ export function CustomWidgetAiCard(props: AiCardProps) {
<IconSparkles size={18} />
<Text fw={600}>{t("title")}</Text>
</Group>
<Text size="sm" c="dimmed">
{t("description")}
</Text>
<Textarea
label={t("request")}
value={props.request}
@@ -3,6 +3,7 @@ import { describe, expect, it, vi } from "vitest";
import { customWidgetDefinitionSchema } from "@homarr/custom-widgets/core";
import {
applyDefinition,
filterSecretsForSourceAuthentication,
getChangedSecrets,
getCustomWidgetPreviewOptionIssues,
@@ -23,6 +24,28 @@ const definition = customWidgetDefinitionSchema.parse({
});
describe("Custom Widget workbench preview options", () => {
it("loads a complete pasted widget template into the JSX editor field", () => {
const setValues = vi.fn();
applyDefinition(
{
values: {
name: "",
description: "",
iconUrl: "",
sources: "{}",
requests: "{}",
options: "{}",
template: "",
secrets: [],
},
setValues,
} as never,
definition,
);
expect(setValues).toHaveBeenCalledWith(expect.objectContaining({ template: "<Text>Preview</Text>" }));
});
it("shows the default source first regardless of manifest key order", () => {
expect(
parseSources(
@@ -12,6 +12,8 @@ describe("legacy custom widget migration UI", () => {
expect(list).toContain('t("page.list.migrationInstructions")');
expect(actions).toContain("legacyMigrationPrompt.fetch");
expect(actions).toContain("navigator.clipboard.writeText(result.prompt)");
expect(actions).toContain("navigator.clipboard.readText()");
expect(actions).toContain('t("action.pasteMigration")');
expect(actions).toContain("parseCustomWidgetClipboardDetailed");
expect(actions).toContain("legacyId={widget.id}");
});
@@ -28,6 +28,7 @@ import {
IconBuildingStore,
IconDownload,
IconFlag,
IconRefresh,
IconSearch,
IconThumbDown,
IconThumbUp,
@@ -217,7 +218,20 @@ export function WorkshopBrowser({ type = "customWidget", onInstall, onUseCss }:
<Skeleton h={280} />
</SimpleGrid>
) : list.isError ? (
<Alert color="red">{list.error instanceof Error ? list.error.message : t("unavailable")}</Alert>
<Alert color="yellow" icon={<IconAlertTriangle size={18} />} title={t("unavailableTitle")}>
<Stack gap="sm" align="flex-start">
<Text size="sm">{t("unavailable")}</Text>
<Button
size="xs"
variant="light"
leftSection={<IconRefresh size={14} />}
loading={list.isFetching}
onClick={() => void list.refetch()}
>
{t("retry")}
</Button>
</Stack>
</Alert>
) : list.data?.items.length ? (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }}>
{list.data.items.map((item) => (
+2 -5
View File
@@ -377,12 +377,9 @@ describe("Custom JSX v2 workbench", () => {
expect(prompt).not.toContain("nested-secret");
expect(prompt).not.toContain("private-filter");
await page.evaluate((widget) => navigator.clipboard.writeText(JSON.stringify(widget)), migratedWidget);
await legacyCard.getByRole("button", { name: "Actions" }).click();
await page.getByLabel("Import migrated v2 JSON").setInputFiles({
name: "legacy-widget-v2.json",
mimeType: "application/json",
buffer: Buffer.from(JSON.stringify(migratedWidget)),
});
await page.getByRole("menuitem", { name: "Paste migrated widget" }).click();
const migrationDialog = page.getByRole("dialog", { name: "Review migrated v2 replacement" });
await migrationDialog.getByRole("button", { name: "Confirm v2 replacement" }).click();
await expect(
@@ -267,15 +267,7 @@ export function parseJudgeResult(raw: string): CustomWidgetJudgeResult {
}
async function writeWidgetFiles(directory: string, widget: HomarrCustomWidgetV2, basename: string) {
const { template, ...manifest } = widget;
await Promise.all([
writeFile(
path.join(directory, `${basename}.widget.json`),
JSON.stringify({ ...manifest, template: "__HOMARR_TEMPLATE__" }, null, 2),
"utf8",
),
writeFile(path.join(directory, `${basename}.widget.jsx`), template, "utf8"),
]);
await writeFile(path.join(directory, `${basename}.widget.json`), JSON.stringify(widget, null, 2), "utf8");
}
async function callOpenRouter(args: {
@@ -11,7 +11,7 @@ export const CUSTOM_WIDGET_MANTINE_VERSION = customJsxAuthoringCatalog.mantineVe
const CUSTOM_WIDGET_AI_PROMPT_LIMIT = 12_000;
export const CUSTOM_WIDGET_FINAL_OUTPUT_INSTRUCTION =
"Return exactly one complete `json` fenced block followed by one complete `jsx` fenced block. Do not include prose outside those blocks.";
"Return exactly one complete `json` fenced block containing the entire widget. Put the complete JSX source directly in the `template` string and JSON-escape it correctly. Do not include prose or additional code blocks.";
const RECOMMENDED_COMPONENTS = [
"Stack",
@@ -81,14 +81,13 @@ const leanShape = `{
"category": { "label": "Category", "control": "text", "default": "all" },
"limit": { "label": "Result limit", "control": "number", "default": 20, "min": 1, "max": 100 }
},
"template": "__HOMARR_TEMPLATE__"
"template": "<Stack><Text>{data.items?.name}</Text></Stack>"
}`;
function compactExample(index: number) {
const example = customJsxExamples[index];
if (!example) return "";
const { template, ...manifest } = example.widget;
return `Example — ${example.title}:\n\n\`\`\`json\n${JSON.stringify({ ...manifest, template: "__HOMARR_TEMPLATE__" }, null, 2)}\n\`\`\`\n\n\`\`\`jsx\n${template}\n\`\`\``;
return `Example — ${example.title}:\n\n\`\`\`json\n${JSON.stringify(example.widget, null, 2)}\n\`\`\``;
}
const AUTHORING_PROMPT = `You are writing one safe Homarr Custom JSX v2 dashboard widget for Mantine ${CUSTOM_WIDGET_MANTINE_VERSION}.
@@ -122,7 +121,7 @@ ${compactExample(0)}
${compactExample(1)}
Output the manifest with template set exactly to "__HOMARR_TEMPLATE__", then put the complete template in the JSX block.`;
Output one complete JSON manifest. Put the complete JSX directly in its template string so the user can copy one code block and paste it into Homarr once.`;
export const CUSTOM_WIDGET_AUTHORING_PROMPT = AUTHORING_PROMPT;
+5 -35
View File
@@ -69,10 +69,6 @@ function removedStateIssues(value: Record<string, unknown>): CustomWidgetImportI
);
}
function isTemplatePlaceholder(value: unknown) {
return typeof value === "string" && value.replace(/[\\*_`\s]/gu, "").toUpperCase() === "HOMARRTEMPLATE";
}
type ParsedWidgetValue =
| { success: true; value: Record<string, unknown> }
| { success: false; issue: CustomWidgetImportIssue };
@@ -100,24 +96,10 @@ export function parseCustomWidgetClipboard(text: string): HomarrCustomWidgetV2 |
export function parseCustomWidgetClipboardDetailed(text: string): CustomWidgetParseResult {
const blocks = extractFencedBlocks(text);
const jsonBlock = blocks.find((block) => block.language === "json" || block.language === "");
const jsxBlock = blocks.find((block) => block.language === "jsx" || block.language === "tsx");
const source = jsonBlock?.content ?? text;
const parsedValue = parseWidgetValue(source);
if (!parsedValue.success) return { success: false, issues: [parsedValue.issue] };
const widget = { ...parsedValue.value };
if (jsonBlock && isTemplatePlaceholder(widget.template) && !jsxBlock) {
return {
success: false,
issues: [
{
code: "MISSING_JSX_BLOCK",
path: ["template"],
message: "The manifest uses a template placeholder but no fenced JSX block was provided.",
},
],
};
}
if (jsxBlock && isTemplatePlaceholder(widget.template)) widget.template = jsxBlock.content.trim();
const removedIssues = removedStateIssues(widget);
if (removedIssues.length > 0) return { success: false, issues: removedIssues };
const parsed = customWidgetImportSchema.safeParse(widget);
@@ -128,20 +110,17 @@ export function parseCustomWidgetClipboardDetailed(text: string): CustomWidgetPa
export function parseCustomWidgetAiResponse(text: string): CustomWidgetParseResult {
const blocks = extractFencedBlocks(text);
const jsonBlocks = blocks.filter((block) => block.language === "json" || block.language === "");
const jsxBlocks = blocks.filter((block) => block.language === "jsx" || block.language === "tsx");
const formatIssues: CustomWidgetImportIssue[] = [];
if (jsonBlocks.length !== 1)
formatIssues.push({ code: "AI_JSON_BLOCK_REQUIRED", message: "Expected exactly one fenced json block." });
if (jsxBlocks.length !== 1)
formatIssues.push({ code: "AI_JSX_BLOCK_REQUIRED", message: "Expected exactly one fenced jsx block." });
if (blocks.length !== jsonBlocks.length)
formatIssues.push({
code: "AI_SINGLE_JSON_BLOCK_REQUIRED",
message: "Expected one JSON block only. Put the complete JSX directly in its template field.",
});
const source = jsonBlocks[0]?.content ?? text;
const parsedValue = parseWidgetValue(source);
if (!parsedValue.success) {
if (/\\_/u.test(source))
formatIssues.push({
code: "INVALID_JSON_ESCAPE",
message: 'JSON does not allow \\_ escapes. The template placeholder must be "__HOMARR_TEMPLATE__".',
});
if (/"(?:stateSchema|defaultState)"\s*:/u.test(source))
formatIssues.push({
code: "REMOVED_LOCAL_STATE",
@@ -153,15 +132,6 @@ export function parseCustomWidgetAiResponse(text: string): CustomWidgetParseResu
const removedIssues = removedStateIssues(widget);
if (removedIssues.length > 0) formatIssues.push(...removedIssues);
const warnings: CustomWidgetImportIssue[] = [];
if (jsxBlocks[0]) {
if (isTemplatePlaceholder(widget.template) || !widget.template) widget.template = jsxBlocks[0].content.trim();
else if (widget.template !== jsxBlocks[0].content.trim())
formatIssues.push({
code: "AI_TEMPLATE_PLACEHOLDER_REQUIRED",
path: ["template"],
message: 'The manifest template must be "__HOMARR_TEMPLATE__" when a separate JSX block is returned.',
});
}
if (formatIssues.length > 0) return { success: false, issues: formatIssues.slice(0, MAX_IMPORT_ISSUES) };
const parsed = customWidgetImportSchema.safeParse(widget);
if (!parsed.success) return { success: false, issues: zodIssues(parsed.error) };
@@ -18,7 +18,9 @@ describe("AI prompt", () => {
expect(prompt).toContain("Example — Service dashboard");
expect(prompt).toContain("Example — Search and action");
expect(prompt).toContain("visual hierarchy");
expect(prompt).toContain('template set exactly to "__HOMARR_TEMPLATE__"');
expect(prompt).toContain("Put the complete JSX directly in its template string");
expect(prompt).toContain("copy one code block and paste it into Homarr once");
expect(prompt).not.toContain("fenced block followed by");
expect(prompt).toContain('trigger="manual"');
expect(prompt).toContain("never write `=> {` anywhere");
expect(prompt).toContain('"choicesFrom"');
@@ -7,12 +7,11 @@ import {
parseCustomWidgetClipboardDetailed,
} from "../core";
const response = (manifest: unknown, jsx = "<Text>Ready</Text>") =>
`\`\`\`json\n${JSON.stringify(manifest, null, 2)}\n\`\`\`\n\`\`\`jsx\n${jsx}\n\`\`\``;
const response = (manifest: unknown) => `\`\`\`json\n${JSON.stringify(manifest, null, 2)}\n\`\`\``;
describe("Custom Widget imports", () => {
it("accepts exactly one lean manifest and JSX block", () => {
const result = parseCustomWidgetAiResponse(response({ ...CUSTOM_WIDGET_STARTER, template: "__HOMARR_TEMPLATE__" }));
it("accepts one complete JSON block and loads its template", () => {
const result = parseCustomWidgetAiResponse(response({ ...CUSTOM_WIDGET_STARTER, template: "<Text>Ready</Text>" }));
expect(result.success).toBe(true);
if (result.success) expect(result.widget.template).toBe("<Text>Ready</Text>");
});
@@ -25,7 +24,7 @@ describe("Custom Widget imports", () => {
requests: [],
optionsSchema: {},
defaultOptions: {},
template: "__HOMARR_TEMPLATE__",
template: "<Text>Ready</Text>",
}),
);
expect(result.success).toBe(false);
@@ -34,12 +33,20 @@ describe("Custom Widget imports", () => {
it("reports missing fences and removed local state", () => {
expect(parseCustomWidgetAiResponse(JSON.stringify(CUSTOM_WIDGET_STARTER)).success).toBe(false);
const result = parseCustomWidgetAiResponse(
response({ ...CUSTOM_WIDGET_STARTER, stateSchema: {}, template: "__HOMARR_TEMPLATE__" }),
response({ ...CUSTOM_WIDGET_STARTER, stateSchema: {}, template: "<Text>Ready</Text>" }),
);
expect(result.success).toBe(false);
if (!result.success) expect(result.issues.some(({ code }) => code === "REMOVED_LOCAL_STATE")).toBe(true);
});
it("rejects additional code blocks", () => {
const result = parseCustomWidgetAiResponse(
`${response({ ...CUSTOM_WIDGET_STARTER, template: "<Text>Ready</Text>" })}\n\`\`\`jsx\n<Text>Old format</Text>\n\`\`\``,
);
expect(result.success).toBe(false);
if (!result.success) expect(result.issues.some(({ code }) => code === "AI_SINGLE_JSON_BLOCK_REQUIRED")).toBe(true);
});
it("returns a capability review from keyed records", () => {
const review = getImportReview(CUSTOM_WIDGET_STARTER);
expect(review).toMatchObject({ origins: ["https://example.com"], authTypes: ["none"], hasActions: false });
@@ -13,15 +13,12 @@ describe("reference widget capabilities", () => {
expect(parsed.requests.detail?.trigger).toBe("manual");
});
it("round-trips the full Pokédex through the exact two-block AI paste format", () => {
it("round-trips the full Pokédex through the single-block AI paste format", () => {
const pokedex = BUNDLED_CUSTOM_WIDGETS.find(({ id }) => id === "seed-pokedex")?.widget;
if (!pokedex) throw new Error("Pokédex seed was not found");
const { template, ...manifest } = pokedex;
const parsed = parseCustomWidgetAiResponse(
`\`\`\`json\n${JSON.stringify({ ...manifest, template: "__HOMARR_TEMPLATE__" }, null, 2)}\n\`\`\`\n\n\`\`\`jsx\n${template}\n\`\`\``,
);
const parsed = parseCustomWidgetAiResponse(`\`\`\`json\n${JSON.stringify(pokedex, null, 2)}\n\`\`\``);
expect(parsed.success).toBe(true);
if (parsed.success) expect(parsed.widget.template).toBe(template);
if (parsed.success) expect(parsed.widget.template).toBe(pokedex.template);
});
it("validates a polished Portainer dashboard with start, stop, and restart actions", () => {
@@ -38,10 +35,10 @@ describe("reference widget capabilities", () => {
});
it("round-trips the Portainer dashboard through AI paste", () => {
const { template, ...manifest } = PORTAINER_REFERENCE_WIDGET;
const parsed = parseCustomWidgetAiResponse(
`\`\`\`json\n${JSON.stringify({ ...manifest, template: "__HOMARR_TEMPLATE__" }, null, 2)}\n\`\`\`\n\n\`\`\`jsx\n${template}\n\`\`\``,
`\`\`\`json\n${JSON.stringify(PORTAINER_REFERENCE_WIDGET, null, 2)}\n\`\`\``,
);
expect(parsed.success).toBe(true);
if (parsed.success) expect(parsed.widget.template).toBe(PORTAINER_REFERENCE_WIDGET.template);
});
});
+9 -5
View File
@@ -739,7 +739,7 @@
"invalidDefinitionDescription": "This stored widget no longer matches the current Custom Widget schema. Delete it and recreate or import a valid definition.",
"migrationRequired": "Migration required",
"migrationDescription": "This v1 custom widget is preserved but cannot run until it is migrated to Custom JSX v2.",
"migrationInstructions": "Open Actions, copy the redacted migration prompt into an LLM, then import the v2 JSON it produces. Existing board placements keep working after migration.",
"migrationInstructions": "Open Actions, copy the redacted migration prompt into an LLM, then copy its single JSON response and choose Paste migrated widget. Existing board placements keep working after migration.",
"missingCredentials": "{count, plural, one {Credential missing} other {# credentials missing}}",
"missingCredentialsDescription": "{count, plural, one {This widget still needs one credential.} other {This widget still needs # credentials.}} Edit it to configure the encrypted values."
},
@@ -769,7 +769,8 @@
"publishWorkshop": "Publish to Workshop",
"migrate": "Migrate Custom Widget",
"copyMigrationPrompt": "Copy LLM migration prompt",
"importMigration": "Import migrated v2 JSON"
"pasteMigration": "Paste migrated widget",
"importMigration": "Import migrated JSON file"
},
"field": {
"name": "Name",
@@ -976,6 +977,7 @@
},
"ai": {
"title": "Create or fix with any AI",
"description": "Copy one prompt, then paste the AI's single JSON block. Homarr loads its JSX into the editor automatically.",
"request": "What should this widget do?",
"documentationUrl": "API documentation URL (optional)",
"paste": "Paste AI response",
@@ -1076,7 +1078,7 @@
"imported": "Widget imported successfully.",
"migrated": "Legacy widget migrated successfully. Existing board placements now use the v2 definition.",
"migrationError": "Failed to migrate the legacy widget. Ensure the file uses the homarr-custom-widget-v2 schema.",
"migrationPromptCopied": "A redacted migration prompt was copied. Paste it into an LLM, save the returned v2 JSON, then import it here.",
"migrationPromptCopied": "A redacted migration prompt was copied. Paste it into an LLM, copy its single JSON response, then choose Paste migrated widget here.",
"migrationPromptCopyError": "Failed to copy the migration prompt.",
"aiPromptCopied": "AI prompt copied to clipboard",
"aiPromptCopiedWithCount": "AI prompt copied to clipboard ({count, number} characters).",
@@ -1151,7 +1153,9 @@
"sort": "Sort",
"sortTop": "Top rated",
"sortNewest": "Newest",
"unavailable": "Workshop is unavailable. Your installed widgets are unaffected.",
"unavailableTitle": "Homarr Workshop seems to be unavailable",
"unavailable": "The community service may be temporarily down. Try again in a moment. Your installed widgets are unaffected.",
"retry": "Try again",
"empty": "No widgets match this search.",
"emptyCss": "No CSS themes match this search.",
"noDescription": "No description",
@@ -4199,7 +4203,7 @@
"sources": "{count, plural, one {# source} other {# sources}}",
"requests": "{count, plural, one {# request} other {# requests}}",
"migrationRequired": "Migration required",
"migrationDescription": "This placed v1 widget cannot run yet. Copy its redacted prompt to an LLM and import the returned v2 JSON.",
"migrationDescription": "This placed v1 widget cannot run yet. Copy its redacted prompt to an LLM, then paste the single JSON response into Homarr.",
"manageMigration": "Open Manage custom widgets",
"contactAdmin": "Contact an administrator."
},
@@ -75,7 +75,7 @@ vi.mock("@homarr/translation/client", () => ({
useScopedI18n: () => (key: string) =>
({
migrationRequired: "Migration required",
migrationDescription: "Copy its redacted prompt to an LLM and import the returned v2 JSON.",
migrationDescription: "Copy its redacted prompt to an LLM, then paste the single JSON response into Homarr.",
manageMigration: "Open Manage custom widgets",
contactAdmin: "Contact an administrator.",
})[key] ?? key,
+7
View File
@@ -257,6 +257,13 @@ function workshopError(error: unknown, fallback: string) {
/invalid|missing|not found|failed/iu.test(rawMessage)
)
return new Error("GitHub sign-in is not configured correctly on the Workshop server.", { cause: error });
if (
(error instanceof ClientResponseError && error.status === 0) ||
/networkerror|failed to fetch|fetch failed|load failed|connection (?:refused|reset)|econn(?:refused|reset)|offline/iu.test(
rawMessage,
)
)
return new Error("Homarr Workshop seems to be unavailable right now. Try again in a moment.", { cause: error });
if (error instanceof ClientResponseError) {
const sdkMessage = error.data?.message || error.message;
const originalMessage =
+10
View File
@@ -247,6 +247,16 @@ describe("WorkshopBackend", () => {
expect(result.items).toHaveLength(1);
expect(mocks.getFullList).toHaveBeenCalledTimes(2);
});
test("replaces low-level network errors with an actionable Workshop outage message", async () => {
mocks.getList.mockRejectedValueOnce(new Error("NetworkError when attempting to fetch resource."));
const client = new WorkshopBackend("https://workshop.example.com");
await expect(client.list({ type: "customWidget" })).rejects.toThrow(
"Homarr Workshop seems to be unavailable right now. Try again in a moment.",
);
});
});
function listingRecord() {