integrate assistant custom widget workflow
This commit is contained in:
1 parent
abc17e7c2a
commit
5a3022a073
11 files changed
+406
-161
No files matched your search
@@ -39,6 +39,10 @@ Homarr includes presets for OpenRouter, OpenAI, Anthropic Claude, Google Gemini,
|
||||
DeepSeek, Together AI, Ollama, and LM Studio. Provider icons make the presets easy to identify. A custom
|
||||
preset connects any other service that implements the OpenAI Chat Completions API.
|
||||
|
||||
The provider list also shows **Homarr — Coming soon** as a disabled option. It reserves a clear place for
|
||||
Homarr's future managed assistant service without making an unavailable backend selectable or changing
|
||||
the server-side provider configuration today.
|
||||
|
||||
The management page presents setup as three compact sections: provider and endpoint, credentials, then
|
||||
model and availability. A status summary at the top shows whether the connection is ready, whether a key
|
||||
is protected, which model is selected, and whether users can open the assistant.
|
||||
@@ -136,6 +140,12 @@ validated input, result, and error state without flooding the conversation with
|
||||
Common collection and widget results are summarized inline as compact responsive cards, while the full
|
||||
structured result remains available in the tool card details.
|
||||
|
||||
Custom-widget tools let an administrator list, inspect, create, and update definitions. The custom-widget
|
||||
form's **Design with AI** action sends a focused request into the current conversation, where the assistant
|
||||
can propose the matching mutation and use the same approval controls. Existing stored secret values are
|
||||
reported only as configured/not configured and are preserved on update unless replacements are explicitly
|
||||
provided.
|
||||
|
||||
The shield control in the conversation header can enable automatic approvals for the current conversation.
|
||||
While enabled, Homarr accepts every affirmative approval step as it arrives: protected mutations are approved,
|
||||
presented app details and board changes are used, and structured questions with an explicitly categorized
|
||||
|
||||
@@ -50,9 +50,17 @@ To create a custom widget, navigate to the **Custom Widgets** section in the man
|
||||
| **Action Button** | Clickable button executing the API request | Triggers: restart, clear cache |
|
||||
| **Custom JSX** | Custom JSX template with Mantine v9 components | Full layout control |
|
||||
|
||||
### AI-Powered JSX Generation
|
||||
### Design with AI
|
||||
|
||||
When editing or creating a custom widget with the **Custom JSX** display type, you'll find a **Copy Prompt** button. Clicking it copies a pre-formatted prompt describing your widget's name, API endpoint, and available Mantine components. Paste this into your preferred AI assistant (ChatGPT, Claude, Copilot, etc.) to generate a JSX template tailored to your data.
|
||||
The **Design with AI** action is available while creating or editing a custom widget. Describe what the
|
||||
widget should show and, when [Homarr Assistant](../assistant) is enabled, send the request directly from
|
||||
the form. The assistant receives the widget schema, the current non-secret configuration, and the latest
|
||||
test response. It can then propose and run the matching create or update tool after you approve the
|
||||
mutation in the conversation.
|
||||
|
||||
Run **Test** first when possible so the prompt includes the endpoint's real response shape. Saved secret
|
||||
values are never added to the prompt. **Copy to clipboard** remains available when Homarr Assistant is
|
||||
disabled or when you prefer to use another AI service such as ChatGPT, Claude, or Gemini.
|
||||
|
||||
<details>
|
||||
<summary>Example: Pokédex widget using Custom JSX</summary>
|
||||
|
||||
+32
-10
@@ -59,9 +59,10 @@ import { getAssistantConnectionState } from "./assistant-configuration-state";
|
||||
|
||||
type HeaderEntry = { id: number; name: string; value: string };
|
||||
type CredentialFlow = "idle" | "replace" | "remove";
|
||||
type AssistantProviderOption = AssistantProvider | "homarr";
|
||||
|
||||
const ProviderIcon = ({ providerId, size = 20 }: { providerId: AssistantProvider; size?: number }) => {
|
||||
const iconUrl = assistantProviderPresets[providerId].iconUrl;
|
||||
const ProviderIcon = ({ providerId, size = 20 }: { providerId: AssistantProviderOption; size?: number }) => {
|
||||
const iconUrl = providerId === "homarr" ? "/logo/logo.png" : assistantProviderPresets[providerId].iconUrl;
|
||||
return iconUrl ? (
|
||||
<Image src={iconUrl} alt="" aria-hidden w={size} h={size} fit="contain" />
|
||||
) : (
|
||||
@@ -187,15 +188,29 @@ export const AssistantConfiguration = () => {
|
||||
);
|
||||
const providerOptions = useMemo(
|
||||
() =>
|
||||
(["hosted", "local", "custom"] satisfies AssistantProviderCategory[]).map((category) => ({
|
||||
group: t(`provider.groups.${category}`),
|
||||
items: assistantProviderIds
|
||||
(["hosted", "local", "custom"] satisfies AssistantProviderCategory[]).map((category) => {
|
||||
const items = assistantProviderIds
|
||||
.filter((providerId) => assistantProviderPresets[providerId].category === category)
|
||||
.map((providerId) => ({
|
||||
value: providerId,
|
||||
label: t(`provider.options.${providerId}.label`),
|
||||
})),
|
||||
})),
|
||||
}));
|
||||
|
||||
return {
|
||||
group: t(`provider.groups.${category}`),
|
||||
items:
|
||||
category === "hosted"
|
||||
? [
|
||||
{
|
||||
value: "homarr",
|
||||
label: t("provider.options.homarr.label"),
|
||||
disabled: true,
|
||||
},
|
||||
...items,
|
||||
]
|
||||
: items,
|
||||
};
|
||||
}),
|
||||
[t],
|
||||
);
|
||||
const selectedModel = connectionChanged ? undefined : models?.find((model) => model.id === modelId);
|
||||
@@ -431,9 +446,16 @@ export const AssistantConfiguration = () => {
|
||||
allowDeselect={false}
|
||||
leftSection={<ProviderIcon providerId={provider} />}
|
||||
renderOption={({ option }) => (
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<ProviderIcon providerId={option.value as AssistantProvider} />
|
||||
<Text size="sm">{option.label}</Text>
|
||||
<Group gap="sm" wrap="nowrap" justify="space-between" w="100%">
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<ProviderIcon providerId={option.value as AssistantProviderOption} />
|
||||
<Text size="sm">{option.label}</Text>
|
||||
</Group>
|
||||
{option.value === "homarr" && (
|
||||
<Badge size="xs" variant="light" color="gray">
|
||||
{t("provider.comingSoon")}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { buildAiPrompt } from "./_copy-ai-prompt-button";
|
||||
|
||||
const schema = {
|
||||
type: "object",
|
||||
required: ["name", "url", "displayType", "displayConfig"],
|
||||
};
|
||||
|
||||
describe("buildAiPrompt", () => {
|
||||
test("asks Homarr Assistant to create a definition and includes the user's request", () => {
|
||||
const prompt = buildAiPrompt(
|
||||
schema,
|
||||
'{"status":"online"}',
|
||||
{
|
||||
$schema: "homarr-custom-widget-v2",
|
||||
name: "Status",
|
||||
url: "https://example.com/status",
|
||||
},
|
||||
"Show a compact green status when the service is online.",
|
||||
);
|
||||
|
||||
expect(prompt).toContain("`customWidget_create`");
|
||||
expect(prompt).toContain("`secrets: []`");
|
||||
expect(prompt).toContain("do not ask for typed confirmation");
|
||||
expect(prompt).toContain('```json\n{"status":"online"}\n```');
|
||||
expect(prompt).toContain('"name": "Status"');
|
||||
expect(prompt).toContain("Show a compact green status when the service is online.");
|
||||
});
|
||||
|
||||
test("asks Homarr Assistant to update the selected definition without replacing its secrets", () => {
|
||||
const prompt = buildAiPrompt(
|
||||
schema,
|
||||
null,
|
||||
{
|
||||
name: "Existing widget",
|
||||
authType: "bearer",
|
||||
},
|
||||
"Use a progress bar.",
|
||||
"widget-definition-id",
|
||||
);
|
||||
|
||||
expect(prompt).toContain("custom widget definition `widget-definition-id`");
|
||||
expect(prompt).toContain("`customWidget_update`");
|
||||
expect(prompt).toContain("Preserve configured secrets by omitting the `secrets` field");
|
||||
expect(prompt).not.toContain('"secrets"');
|
||||
});
|
||||
|
||||
test("keeps copy-to-clipboard prompts useful before a request is entered", () => {
|
||||
const prompt = buildAiPrompt(schema);
|
||||
|
||||
expect(prompt).toContain("PASTE_YOUR_API_RESPONSE_HERE");
|
||||
expect(prompt).toContain("Describe what you want the widget to show or change here.");
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { Button, Popover, Stack, Text } from "@mantine/core";
|
||||
import { Alert, Button, Group, Popover, Stack, Text, Textarea } from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { IconCopy, IconSparkles } from "@tabler/icons-react";
|
||||
import { IconCopy, IconRobot, IconSparkles } from "@tabler/icons-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { useOptionalHomarrAssistant } from "~/components/assistant/assistant-provider";
|
||||
|
||||
const PROMPT_HEADER = `You are helping configure a Homarr custom widget. Homarr is a self-hosted dashboard that can display data from any API endpoint as a widget.
|
||||
|
||||
## Your Task
|
||||
@@ -267,14 +270,16 @@ PASTE_YOUR_API_RESPONSE_HERE
|
||||
|
||||
const PROMPT_FOOTER = `
|
||||
## Your Request
|
||||
Describe what you want the widget to show:
|
||||
|
||||
`;
|
||||
|
||||
function buildAiPrompt(
|
||||
const PROMPT_REQUEST_PLACEHOLDER = "Describe what you want the widget to show or change here.";
|
||||
|
||||
export function buildAiPrompt(
|
||||
jsonSchema: unknown,
|
||||
rawResponse?: string | null,
|
||||
currentConfig?: Record<string, unknown> | null,
|
||||
request?: string,
|
||||
definitionId?: string,
|
||||
) {
|
||||
const schemaStr = JSON.stringify(jsonSchema, null, 2);
|
||||
const responseSection = rawResponse
|
||||
@@ -285,22 +290,57 @@ function buildAiPrompt(
|
||||
? `\n## Current Widget Configuration\nThe widget is currently configured as follows. Use this as a starting point and modify based on the user's request:\n\n\`\`\`json\n${JSON.stringify(currentConfig, null, 2)}\n\`\`\`\n`
|
||||
: "";
|
||||
|
||||
return PROMPT_HEADER + schemaStr + PROMPT_RULES + responseSection + configSection + PROMPT_FOOTER;
|
||||
const homarrActionSection = definitionId
|
||||
? `\n## Homarr Assistant Action\nThis is custom widget definition \`${definitionId}\`. If the customWidget tools are available, validate the proposed configuration and immediately call \`customWidget_update\` with this ID and the changed fields. Calling the tool is the required result and takes priority over the JSON-only output instruction above; do not ask for typed confirmation because Homarr will show its approval UI. Preserve configured secrets by omitting the \`secrets\` field. If tools are unavailable, return the JSON configuration instead.\n`
|
||||
: `\n## Homarr Assistant Action\nIf the customWidget tools are available, validate the proposed configuration and immediately call \`customWidget_create\` with \`secrets: []\`. Calling the tool is the required result and takes priority over the JSON-only output instruction above; do not ask for typed confirmation because Homarr will show its approval UI. If tools are unavailable, return the JSON configuration instead.\n`;
|
||||
|
||||
const requestText = request?.trim() || PROMPT_REQUEST_PLACEHOLDER;
|
||||
|
||||
return (
|
||||
PROMPT_HEADER +
|
||||
schemaStr +
|
||||
PROMPT_RULES +
|
||||
responseSection +
|
||||
configSection +
|
||||
homarrActionSection +
|
||||
PROMPT_FOOTER +
|
||||
requestText
|
||||
);
|
||||
}
|
||||
|
||||
interface CopyAiPromptButtonProps {
|
||||
rawResponse?: string | null;
|
||||
currentConfig?: Record<string, unknown> | null;
|
||||
definitionId?: string;
|
||||
}
|
||||
|
||||
export const CopyAiPromptButton = ({ rawResponse, currentConfig }: CopyAiPromptButtonProps) => {
|
||||
export const CopyAiPromptButton = ({ rawResponse, currentConfig, definitionId }: CopyAiPromptButtonProps) => {
|
||||
const t = useScopedI18n("customWidget");
|
||||
const [opened, { open, close }] = useDisclosure(false);
|
||||
const assistant = useOptionalHomarrAssistant();
|
||||
const [opened, { toggle, close }] = useDisclosure(false);
|
||||
const [request, setRequest] = useState("");
|
||||
const requestInputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const { data: schema, isLoading } = clientApi.customWidget.schema.useQuery();
|
||||
|
||||
useEffect(() => {
|
||||
if (opened) requestInputRef.current?.focus();
|
||||
}, [opened]);
|
||||
|
||||
const getPrompt = () => (schema ? buildAiPrompt(schema, rawResponse, currentConfig, request, definitionId) : null);
|
||||
|
||||
const handleAssistant = () => {
|
||||
const prompt = getPrompt();
|
||||
if (!prompt || !assistant?.enabled || request.trim().length === 0) return;
|
||||
if (!assistant.sendPrompt(prompt)) return;
|
||||
|
||||
close();
|
||||
setRequest("");
|
||||
showSuccessNotification({ title: t("action.aiPrompt"), message: t("notification.aiPromptSent") });
|
||||
};
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!schema) return;
|
||||
const prompt = buildAiPrompt(schema, rawResponse, currentConfig);
|
||||
const prompt = getPrompt();
|
||||
if (!prompt) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(prompt);
|
||||
close();
|
||||
@@ -311,31 +351,70 @@ export const CopyAiPromptButton = ({ rawResponse, currentConfig }: CopyAiPromptB
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover opened={opened} onClose={close} width={320} position="bottom" shadow="md" withinPortal>
|
||||
<Popover opened={opened} onClose={close} width="target" position="bottom" shadow="md" withinPortal>
|
||||
<Popover.Target>
|
||||
<Button
|
||||
type="button"
|
||||
variant="light"
|
||||
leftSection={<IconSparkles size={16} />}
|
||||
onClick={open}
|
||||
onClick={toggle}
|
||||
loading={isLoading}
|
||||
disabled={!schema}
|
||||
fullWidth
|
||||
size="sm"
|
||||
>
|
||||
{t("action.copyAiPrompt")}
|
||||
{t("action.aiPrompt")}
|
||||
</Button>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<Stack gap="sm">
|
||||
<Text size="sm">{t("notification.aiPromptDescription")}</Text>
|
||||
<Textarea
|
||||
ref={requestInputRef}
|
||||
label={t("notification.aiPromptRequestLabel")}
|
||||
placeholder={t("notification.aiPromptRequestPlaceholder")}
|
||||
value={request}
|
||||
onChange={(event) => setRequest(event.currentTarget.value)}
|
||||
autosize
|
||||
minRows={3}
|
||||
maxRows={6}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
|
||||
event.preventDefault();
|
||||
handleAssistant();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{!rawResponse && (
|
||||
<Text size="xs" c="dimmed" fs="italic">
|
||||
{t("notification.aiPromptNoResponse")}
|
||||
</Text>
|
||||
)}
|
||||
<Button leftSection={<IconCopy size={16} />} onClick={() => void handleCopy()} fullWidth>
|
||||
{t("notification.aiPromptCopy")}
|
||||
</Button>
|
||||
{assistant?.enabled ? (
|
||||
<Button
|
||||
type="button"
|
||||
leftSection={<IconRobot size={16} />}
|
||||
onClick={handleAssistant}
|
||||
disabled={request.trim().length === 0 || assistant.isRunning}
|
||||
fullWidth
|
||||
>
|
||||
{assistant.isRunning ? t("notification.aiPromptAssistantBusy") : t("notification.aiPromptAssistant")}
|
||||
</Button>
|
||||
) : (
|
||||
<Alert color="gray" icon={<IconRobot size={16} />}>
|
||||
{t("notification.aiPromptAssistantUnavailable")}
|
||||
</Alert>
|
||||
)}
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
leftSection={<IconCopy size={16} />}
|
||||
onClick={() => void handleCopy()}
|
||||
>
|
||||
{t("notification.aiPromptCopy")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
|
||||
@@ -688,6 +688,19 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
|
||||
}, [previewRefreshSignal]);
|
||||
|
||||
const previewInput = getPreviewInput();
|
||||
const aiPromptCurrentConfig = {
|
||||
$schema: "homarr-custom-widget-v2",
|
||||
name: form.values.name,
|
||||
description: form.values.description || undefined,
|
||||
iconUrl: form.values.iconUrl || undefined,
|
||||
url: form.values.url,
|
||||
authType: form.values.authType,
|
||||
headerName: form.values.headerName || undefined,
|
||||
method: form.values.method,
|
||||
requestBody: form.values.requestBody || undefined,
|
||||
displayType: form.values.displayType,
|
||||
displayConfig: previewInput.displayConfig,
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
@@ -809,15 +822,22 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
|
||||
</Fieldset>
|
||||
|
||||
<Box hiddenFrom="md">
|
||||
<CustomWidgetPreview
|
||||
getFormValues={getPreviewInput}
|
||||
formValues={previewInput}
|
||||
fetchResult={previewFetchResult}
|
||||
cachedJson={previewJson}
|
||||
onTest={() => void handlePreviewTest()}
|
||||
isTesting={previewMutation.isPending}
|
||||
testError={previewMutation.error?.message ?? null}
|
||||
/>
|
||||
<Stack gap="sm">
|
||||
<CopyAiPromptButton
|
||||
rawResponse={previewFetchResult?.rawResponse}
|
||||
currentConfig={aiPromptCurrentConfig}
|
||||
definitionId={definitionId}
|
||||
/>
|
||||
<CustomWidgetPreview
|
||||
getFormValues={getPreviewInput}
|
||||
formValues={previewInput}
|
||||
fetchResult={previewFetchResult}
|
||||
cachedJson={previewJson}
|
||||
onTest={() => void handlePreviewTest()}
|
||||
isTesting={previewMutation.isPending}
|
||||
testError={previewMutation.error?.message ?? null}
|
||||
/>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Group justify="end">
|
||||
@@ -845,19 +865,8 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
|
||||
</Button>
|
||||
<CopyAiPromptButton
|
||||
rawResponse={previewFetchResult?.rawResponse}
|
||||
currentConfig={{
|
||||
$schema: "homarr-custom-widget-v2",
|
||||
name: form.values.name,
|
||||
description: form.values.description,
|
||||
iconUrl: form.values.iconUrl,
|
||||
url: form.values.url,
|
||||
authType: form.values.authType,
|
||||
headerName: form.values.headerName,
|
||||
method: form.values.method,
|
||||
requestBody: form.values.requestBody,
|
||||
displayType: form.values.displayType,
|
||||
displayConfig: previewInput.displayConfig,
|
||||
}}
|
||||
currentConfig={aiPromptCurrentConfig}
|
||||
definitionId={definitionId}
|
||||
/>
|
||||
<CustomWidgetPreview
|
||||
getFormValues={getPreviewInput}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createTRPCRouter } from "./trpc";
|
||||
import { appRouter as appRouterForApps } from "./router/app";
|
||||
import { apiKeysRouter } from "./router/apiKeys";
|
||||
import { boardRouter } from "./router/board";
|
||||
import { customWidgetRouter } from "./router/custom-widget/custom-widget-router";
|
||||
import { dockerRouter } from "./router/docker/docker-router";
|
||||
import { iconsRouter } from "./router/icons";
|
||||
import { infoRouter } from "./router/info";
|
||||
@@ -26,6 +27,7 @@ export const mcpRouter = createTRPCRouter({
|
||||
app: appRouterForApps,
|
||||
apiKeys: apiKeysRouter,
|
||||
board: boardRouter,
|
||||
customWidget: customWidgetRouter,
|
||||
docker: dockerRouter,
|
||||
icon: iconsRouter,
|
||||
info: infoRouter,
|
||||
|
||||
@@ -54,130 +54,165 @@ function getImportJsonSchema() {
|
||||
export const customWidgetRouter = createTRPCRouter({
|
||||
schema: publicProcedure.query(() => getImportJsonSchema()),
|
||||
|
||||
all: protectedProcedure.query(async ({ ctx }) => {
|
||||
const definitions = await ctx.db.query.customWidgetDefinitions.findMany({
|
||||
orderBy: (table, { asc }) => asc(table.name),
|
||||
});
|
||||
all: protectedProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"List custom widget definitions. Returns IDs, names, endpoints, request methods, display types, authentication types, and enabled states.",
|
||||
},
|
||||
})
|
||||
.query(async ({ ctx }) => {
|
||||
const definitions = await ctx.db.query.customWidgetDefinitions.findMany({
|
||||
orderBy: (table, { asc }) => asc(table.name),
|
||||
});
|
||||
|
||||
return definitions.map((def) => ({
|
||||
id: def.id,
|
||||
name: def.name,
|
||||
description: def.description,
|
||||
iconUrl: def.iconUrl,
|
||||
url: def.url,
|
||||
method: def.method,
|
||||
displayType: def.displayType,
|
||||
authType: def.authType,
|
||||
enabled: def.enabled,
|
||||
}));
|
||||
}),
|
||||
return definitions.map((def) => ({
|
||||
id: def.id,
|
||||
name: def.name,
|
||||
description: def.description,
|
||||
iconUrl: def.iconUrl,
|
||||
url: def.url,
|
||||
method: def.method,
|
||||
displayType: def.displayType,
|
||||
authType: def.authType,
|
||||
enabled: def.enabled,
|
||||
}));
|
||||
}),
|
||||
|
||||
byId: protectedProcedure.input(z.object({ id: z.string() })).query(async ({ ctx, input }) => {
|
||||
const definition = await ctx.db.query.customWidgetDefinitions.findFirst({
|
||||
where: eq(customWidgetDefinitions.id, input.id),
|
||||
with: { secrets: true },
|
||||
});
|
||||
byId: protectedProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Get one custom widget definition by ID, including its display configuration and flags indicating which secrets are configured. Secret values are never returned.",
|
||||
},
|
||||
})
|
||||
.query(async ({ ctx, input }) => {
|
||||
const definition = await ctx.db.query.customWidgetDefinitions.findFirst({
|
||||
where: eq(customWidgetDefinitions.id, input.id),
|
||||
with: { secrets: true },
|
||||
});
|
||||
|
||||
if (!definition) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Custom widget definition not found" });
|
||||
}
|
||||
|
||||
const displayConfig = parseDisplayConfig(
|
||||
definition.displayConfig,
|
||||
input.id,
|
||||
logger,
|
||||
"Corrupt displayConfig in custom widget",
|
||||
);
|
||||
|
||||
return {
|
||||
...definition,
|
||||
enabled: definition.enabled,
|
||||
displayConfig,
|
||||
secrets: definition.secrets.map((s) => ({
|
||||
kind: s.kind,
|
||||
hasValue: true,
|
||||
updatedAt: s.updatedAt,
|
||||
})),
|
||||
};
|
||||
}),
|
||||
|
||||
create: adminProcedure.input(customWidgetCreateSchema).mutation(async ({ ctx, input }) => {
|
||||
const id = createId();
|
||||
|
||||
await ctx.db.insert(customWidgetDefinitions).values({
|
||||
id,
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
iconUrl: input.iconUrl,
|
||||
url: input.url,
|
||||
authType: input.authType,
|
||||
headerName: input.headerName,
|
||||
method: input.method,
|
||||
requestBody: input.requestBody,
|
||||
displayType: input.displayType,
|
||||
displayConfig: superjson.stringify(input.displayConfig),
|
||||
creatorId: ctx.session.user.id,
|
||||
});
|
||||
|
||||
if (input.secrets.length > 0) {
|
||||
await ctx.db.insert(customWidgetSecrets).values(
|
||||
input.secrets.map((secret) => ({
|
||||
kind: secret.kind,
|
||||
value: encryptSecret(secret.value),
|
||||
definitionId: id,
|
||||
updatedAt: new Date(),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
logger.info("Created custom widget definition", { id, name: input.name });
|
||||
return { id };
|
||||
}),
|
||||
|
||||
update: adminProcedure.input(customWidgetUpdateSchema).mutation(async ({ ctx, input }) => {
|
||||
const existing = await ctx.db.query.customWidgetDefinitions.findFirst({
|
||||
where: eq(customWidgetDefinitions.id, input.id),
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new TRPCError({ code: "NOT_FOUND" });
|
||||
}
|
||||
|
||||
const { id, secrets, ...updateFields } = input;
|
||||
const updateValues: Record<string, unknown> = { updatedAt: new Date() };
|
||||
|
||||
for (const [key, value] of Object.entries(updateFields)) {
|
||||
if (value === undefined) continue;
|
||||
const serialize = updateFieldSerializers[key];
|
||||
if (serialize) {
|
||||
updateValues[key] = serialize(value);
|
||||
} else {
|
||||
updateValues[key] = value;
|
||||
if (!definition) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Custom widget definition not found" });
|
||||
}
|
||||
}
|
||||
|
||||
await ctx.db.update(customWidgetDefinitions).set(updateValues).where(eq(customWidgetDefinitions.id, id));
|
||||
const displayConfig = parseDisplayConfig(
|
||||
definition.displayConfig,
|
||||
input.id,
|
||||
logger,
|
||||
"Corrupt displayConfig in custom widget",
|
||||
);
|
||||
|
||||
if (secrets !== undefined) {
|
||||
const effectiveAuthType = (updateFields.authType as string | undefined) ?? existing.authType;
|
||||
return {
|
||||
...definition,
|
||||
enabled: definition.enabled,
|
||||
displayConfig,
|
||||
secrets: definition.secrets.map((s) => ({
|
||||
kind: s.kind,
|
||||
hasValue: true,
|
||||
updatedAt: s.updatedAt,
|
||||
})),
|
||||
};
|
||||
}),
|
||||
|
||||
if (secrets.length > 0) {
|
||||
await ctx.db.delete(customWidgetSecrets).where(eq(customWidgetSecrets.definitionId, id));
|
||||
create: adminProcedure
|
||||
.input(customWidgetCreateSchema)
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Create a custom widget definition. Admin only. Validate that displayConfig.type matches displayType. Pass secrets as an empty array unless the user explicitly provided new secret values.",
|
||||
},
|
||||
})
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const id = createId();
|
||||
|
||||
await ctx.db.insert(customWidgetDefinitions).values({
|
||||
id,
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
iconUrl: input.iconUrl,
|
||||
url: input.url,
|
||||
authType: input.authType,
|
||||
headerName: input.headerName,
|
||||
method: input.method,
|
||||
requestBody: input.requestBody,
|
||||
displayType: input.displayType,
|
||||
displayConfig: superjson.stringify(input.displayConfig),
|
||||
creatorId: ctx.session.user.id,
|
||||
});
|
||||
|
||||
if (input.secrets.length > 0) {
|
||||
await ctx.db.insert(customWidgetSecrets).values(
|
||||
secrets.map((secret) => ({
|
||||
input.secrets.map((secret) => ({
|
||||
kind: secret.kind,
|
||||
value: encryptSecret(secret.value),
|
||||
definitionId: id,
|
||||
updatedAt: new Date(),
|
||||
})),
|
||||
);
|
||||
} else if (effectiveAuthType === "none") {
|
||||
await ctx.db.delete(customWidgetSecrets).where(eq(customWidgetSecrets.definitionId, id));
|
||||
}
|
||||
}
|
||||
|
||||
logger.info("Updated custom widget definition", { id });
|
||||
}),
|
||||
logger.info("Created custom widget definition", { id, name: input.name });
|
||||
return { id };
|
||||
}),
|
||||
|
||||
update: adminProcedure
|
||||
.input(customWidgetUpdateSchema)
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Update a custom widget definition by ID. Admin only. Call customWidget_byId first, send only the fields that should change, and omit secrets to preserve stored secret values.",
|
||||
},
|
||||
})
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const existing = await ctx.db.query.customWidgetDefinitions.findFirst({
|
||||
where: eq(customWidgetDefinitions.id, input.id),
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new TRPCError({ code: "NOT_FOUND" });
|
||||
}
|
||||
|
||||
const { id, secrets, ...updateFields } = input;
|
||||
const updateValues: Record<string, unknown> = { updatedAt: new Date() };
|
||||
|
||||
for (const [key, value] of Object.entries(updateFields)) {
|
||||
if (value === undefined) continue;
|
||||
const serialize = updateFieldSerializers[key];
|
||||
if (serialize) {
|
||||
updateValues[key] = serialize(value);
|
||||
} else {
|
||||
updateValues[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
await ctx.db.update(customWidgetDefinitions).set(updateValues).where(eq(customWidgetDefinitions.id, id));
|
||||
|
||||
if (secrets !== undefined) {
|
||||
const effectiveAuthType = (updateFields.authType as string | undefined) ?? existing.authType;
|
||||
|
||||
if (secrets.length > 0) {
|
||||
await ctx.db.delete(customWidgetSecrets).where(eq(customWidgetSecrets.definitionId, id));
|
||||
await ctx.db.insert(customWidgetSecrets).values(
|
||||
secrets.map((secret) => ({
|
||||
kind: secret.kind,
|
||||
value: encryptSecret(secret.value),
|
||||
definitionId: id,
|
||||
updatedAt: new Date(),
|
||||
})),
|
||||
);
|
||||
} else if (effectiveAuthType === "none") {
|
||||
await ctx.db.delete(customWidgetSecrets).where(eq(customWidgetSecrets.definitionId, id));
|
||||
}
|
||||
}
|
||||
|
||||
logger.info("Updated custom widget definition", { id });
|
||||
}),
|
||||
|
||||
toggleEnabled: adminProcedure
|
||||
.input(z.object({ id: z.string(), enabled: z.boolean() }))
|
||||
|
||||
@@ -6,6 +6,7 @@ import { extractToolsFromProcedures } from "trpc-to-mcp";
|
||||
import { assistantRouter } from "../router/assistant";
|
||||
import { appRouter } from "../router/app";
|
||||
import { boardRouter } from "../router/board";
|
||||
import { customWidgetRouter } from "../router/custom-widget/custom-widget-router";
|
||||
import { iconsRouter } from "../router/icons";
|
||||
import { infoRouter } from "../router/info";
|
||||
import { inviteRouter } from "../router/invite";
|
||||
@@ -30,6 +31,7 @@ const mcpTestRouter = createTRPCRouter({
|
||||
assistant: assistantRouter,
|
||||
app: appRouter,
|
||||
board: boardRouter,
|
||||
customWidget: customWidgetRouter,
|
||||
icon: iconsRouter,
|
||||
info: infoRouter,
|
||||
invite: inviteRouter,
|
||||
@@ -56,6 +58,10 @@ test("MCP tools should contain expected procedures", () => {
|
||||
expect(toolNames).toContain("board_savePartialBoardSettings");
|
||||
expect(toolNames).toContain("board_getBoardSettings");
|
||||
expect(toolNames).toContain("board_duplicateBoard");
|
||||
expect(toolNames).toContain("customWidget_all");
|
||||
expect(toolNames).toContain("customWidget_byId");
|
||||
expect(toolNames).toContain("customWidget_create");
|
||||
expect(toolNames).toContain("customWidget_update");
|
||||
expect(toolNames).toContain("icon_findIcons");
|
||||
expect(toolNames).toContain("info_getInfo");
|
||||
expect(toolNames).toContain("invite_getAll");
|
||||
|
||||
@@ -719,6 +719,7 @@
|
||||
"removeItem": "Remove item",
|
||||
"addBar": "Add Bar",
|
||||
"duplicate": "Duplicate",
|
||||
"aiPrompt": "Design with AI",
|
||||
"copyAiPrompt": "Copy AI prompt",
|
||||
"enable": "Enable",
|
||||
"disable": "Disable",
|
||||
@@ -868,7 +869,13 @@
|
||||
"deleted": "Widget \"{name}\" deleted.",
|
||||
"imported": "Widget imported successfully.",
|
||||
"aiPromptCopied": "AI prompt copied to clipboard",
|
||||
"aiPromptDescription": "Copy a pre-built prompt to paste into any AI chatbot (ChatGPT, Claude, Gemini...). It includes the full widget schema and your API response so the AI can generate a ready-to-import JSON config for you.",
|
||||
"aiPromptDescription": "Describe the widget you want. Homarr Assistant can create or update it after your approval, or you can copy the same prompt into another AI service.",
|
||||
"aiPromptRequestLabel": "What should the widget do?",
|
||||
"aiPromptRequestPlaceholder": "For example: Show service health as a compact status grid and highlight failures.",
|
||||
"aiPromptAssistant": "Ask Homarr Assistant",
|
||||
"aiPromptAssistantBusy": "Assistant is working",
|
||||
"aiPromptAssistantUnavailable": "Homarr Assistant is not available. You can still copy this prompt and use it with another AI service.",
|
||||
"aiPromptSent": "Your request was sent to Homarr Assistant. You can follow its progress from the activity bar.",
|
||||
"aiPromptCopy": "Copy to clipboard",
|
||||
"aiPromptNoResponse": "Tip: Run a test first to include your API response in the prompt for better results.",
|
||||
"aiPromptCopyError": "Failed to copy prompt to clipboard. Your browser may not support this feature.",
|
||||
|
||||
@@ -719,6 +719,7 @@
|
||||
"removeItem": "Remove item",
|
||||
"addBar": "Add Bar",
|
||||
"duplicate": "Duplicate",
|
||||
"aiPrompt": "Design with AI",
|
||||
"copyAiPrompt": "Copy AI prompt",
|
||||
"enable": "Enable",
|
||||
"disable": "Disable",
|
||||
@@ -868,7 +869,13 @@
|
||||
"deleted": "Widget \"{name}\" deleted.",
|
||||
"imported": "Widget imported successfully.",
|
||||
"aiPromptCopied": "AI prompt copied to clipboard",
|
||||
"aiPromptDescription": "Copy a pre-built prompt to paste into any AI chatbot (ChatGPT, Claude, Gemini...). It includes the full widget schema and your API response so the AI can generate a ready-to-import JSON config for you.",
|
||||
"aiPromptDescription": "Describe the widget you want. Homarr Assistant can create or update it after your approval, or you can copy the same prompt into another AI service.",
|
||||
"aiPromptRequestLabel": "What should the widget do?",
|
||||
"aiPromptRequestPlaceholder": "For example, show service health as a compact status grid and highlight offline services.",
|
||||
"aiPromptAssistant": "Ask Homarr Assistant",
|
||||
"aiPromptAssistantBusy": "Assistant is working",
|
||||
"aiPromptAssistantUnavailable": "Homarr Assistant is unavailable. You can still copy the prepared prompt.",
|
||||
"aiPromptSent": "Prompt sent. You can keep editing while the assistant works in the background.",
|
||||
"aiPromptCopy": "Copy to clipboard",
|
||||
"aiPromptNoResponse": "Tip: Run a test first to include your API response in the prompt for better results.",
|
||||
"aiPromptCopyError": "Failed to copy prompt to clipboard. Your browser may not support this feature.",
|
||||
@@ -5176,6 +5183,10 @@
|
||||
"custom": "Custom"
|
||||
},
|
||||
"options": {
|
||||
"homarr": {
|
||||
"label": "Homarr",
|
||||
"description": "Homarr's managed assistant provider. Backend availability will be added in a future release."
|
||||
},
|
||||
"openrouter": {
|
||||
"label": "OpenRouter",
|
||||
"description": "Hosted model routing with a large tool-capable model catalog."
|
||||
@@ -5224,7 +5235,8 @@
|
||||
"label": "Custom endpoint",
|
||||
"description": "Any other endpoint compatible with OpenAI Chat Completions."
|
||||
}
|
||||
}
|
||||
},
|
||||
"comingSoon": "Coming soon"
|
||||
},
|
||||
"baseUrl": {
|
||||
"title": "API base URL",
|
||||
|
||||
Reference in new issue
Block a user