From 87a42f09be555228d8930ed50912200bd4577d8e Mon Sep 17 00:00:00 2001 From: ajnart Date: Mon, 3 Aug 2026 15:22:08 +0200 Subject: [PATCH] integrate assistant custom widget workflow --- apps/docs/docs/management/assistant.mdx | 10 + .../docs/management/custom-widgets/index.mdx | 6 + .../_components/assistant-configuration.tsx | 42 +- .../_copy-ai-prompt-button.spec.ts | 55 ++ .../custom-widgets/_copy-ai-prompt-button.tsx | 71 +- packages/api/src/mcp.ts | 2 + .../custom-widget/custom-widget-router.ts | 35 +- packages/translation/src/lang/en.json | 680 ++++++++++-------- 8 files changed, 550 insertions(+), 351 deletions(-) create mode 100644 apps/nextjs/src/app/[locale]/manage/custom-widgets/_copy-ai-prompt-button.spec.ts diff --git a/apps/docs/docs/management/assistant.mdx b/apps/docs/docs/management/assistant.mdx index cf9c7eb31..93db77730 100644 --- a/apps/docs/docs/management/assistant.mdx +++ b/apps/docs/docs/management/assistant.mdx @@ -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 diff --git a/apps/docs/docs/management/custom-widgets/index.mdx b/apps/docs/docs/management/custom-widgets/index.mdx index 3490a5d41..e21af4a87 100644 --- a/apps/docs/docs/management/custom-widgets/index.mdx +++ b/apps/docs/docs/management/custom-widgets/index.mdx @@ -40,6 +40,12 @@ interface HomarrCustomWidgetV2 { } ``` +### Design with AI + +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, current non-secret configuration, and latest test response, then proposes the matching create or update tool for your approval. + +Run **Test** first when possible so the prompt includes the endpoint's real response shape. Saved secrets are never added to the prompt. **Copy to clipboard** remains available when the Assistant is disabled or when you prefer another AI service. + { - 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 ? ( ) : ( @@ -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={} renderOption={({ option }) => ( - - - {option.label} + + + + {option.label} + + {option.value === "homarr" && ( + + {t("provider.comingSoon")} + + )} )} /> diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_copy-ai-prompt-button.spec.ts b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_copy-ai-prompt-button.spec.ts new file mode 100644 index 000000000..ed662bfb9 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_copy-ai-prompt-button.spec.ts @@ -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."); + }); +}); diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_copy-ai-prompt-button.tsx b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_copy-ai-prompt-button.tsx index 48d95f0bc..2cc4cb1e4 100644 --- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_copy-ai-prompt-button.tsx +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_copy-ai-prompt-button.tsx @@ -1,12 +1,14 @@ "use client"; -import { ActionIcon, Tooltip } from "@mantine/core"; -import { IconCopy } from "@tabler/icons-react"; +import { ActionIcon, Group, Tooltip } from "@mantine/core"; +import { IconCopy, IconRobot } from "@tabler/icons-react"; import { buildCustomWidgetAiPrompt } from "@homarr/custom-widgets/authoring-prompt"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { useScopedI18n } from "@homarr/translation/client"; +import { useOptionalHomarrAssistant } from "~/components/assistant/assistant-context"; + interface CopyAiPromptButtonProps { rawResponse?: string | null; currentConfig?: Record | null; @@ -21,31 +23,68 @@ export const CopyAiPromptButton = ({ documentationUrl, }: CopyAiPromptButtonProps) => { const t = useScopedI18n("customWidget"); + const assistant = useOptionalHomarrAssistant(); + const prompt = () => buildCustomWidgetAiPrompt(undefined, rawResponse, currentConfig, request, documentationUrl); + + const handleAssistant = () => { + if (!assistant?.enabled || !assistant.sendPrompt(prompt())) return; + showSuccessNotification({ title: t("action.aiPrompt"), message: t("notification.aiPromptSent") }); + }; const handleCopy = async () => { try { - const prompt = buildCustomWidgetAiPrompt(undefined, rawResponse, currentConfig, request, documentationUrl); - await navigator.clipboard.writeText(prompt); + const value = prompt(); + await navigator.clipboard.writeText(value); showSuccessNotification({ title: t("action.copyAiPrompt"), - message: t("notification.aiPromptCopiedWithCount", { count: prompt.length }), + message: t("notification.aiPromptCopiedWithCount", { count: value.length }), }); } catch { showErrorNotification({ title: t("action.copyAiPrompt"), message: t("notification.aiPromptCopyError") }); } }; + if (!assistant?.enabled) { + return ( + + void handleCopy()} + > + + + + ); + } + return ( - - void handleCopy()} - > - - - + + + + + + + + void handleCopy()} + > + + + + ); }; diff --git a/packages/api/src/mcp.ts b/packages/api/src/mcp.ts index c3939c983..4892ada81 100644 --- a/packages/api/src/mcp.ts +++ b/packages/api/src/mcp.ts @@ -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"; @@ -27,6 +28,7 @@ export const mcpRouter = createTRPCRouter({ app: appRouterForApps, apiKeys: apiKeysRouter, board: boardRouter, + customWidget: customWidgetRouter, docker: dockerRouter, icon: iconsRouter, info: infoRouter, diff --git a/packages/api/src/router/custom-widget/custom-widget-router.ts b/packages/api/src/router/custom-widget/custom-widget-router.ts index 83c6bb080..10ba02d28 100644 --- a/packages/api/src/router/custom-widget/custom-widget-router.ts +++ b/packages/api/src/router/custom-widget/custom-widget-router.ts @@ -7,13 +7,14 @@ import { createLogger } from "@homarr/core/infrastructure/logs"; import { and, eq, handleTransactionsAsync, notInArray } from "@homarr/db"; import { customWidgetDefinitions, customWidgetSecrets } from "@homarr/db/schema"; import { - customWidgetCreateSchema, customWidgetDefinitionSchema, customWidgetUpdateSchema, + normalizeCustomWidgetAuthoringUpdate, } from "@homarr/custom-widgets/core"; import { createTRPCRouter, permissionRequiredProcedure } from "../../trpc"; -import { insertCustomWidgetDefinition } from "./definition-insert"; +import { parseCustomWidgetAuthoringInput } from "./authoring-validation"; +import { creationProcedures } from "./creation-procedures"; import { managementQueryProcedures } from "./management-queries"; import { metadataProcedures } from "./metadata-procedures"; import { previewActionProcedures } from "./preview-action-procedures"; @@ -35,23 +36,17 @@ const logger = createLogger({ module: "custom-widget" }); export const customWidgetRouter = createTRPCRouter({ ...metadataProcedures, ...managementQueryProcedures, - - create: permissionRequiredProcedure - .requiresPermission("admin") - .meta({ mcp: { enabled: true, description: "Create one validated Custom JSX widget." } }) - .input(customWidgetCreateSchema) - .mutation(async ({ ctx, input }) => { - const { secrets, ...candidate } = input; - const definition = customWidgetDefinitionSchema.parse(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 { id }; - }), + ...creationProcedures, update: permissionRequiredProcedure .requiresPermission("admin") - .meta({ mcp: { enabled: true, description: "Update one Custom JSX widget." } }) + .meta({ + mcp: { + enabled: true, + description: + "Update one Custom JSX widget. Prefer templateLines for multiline JSX changes. Returns a client-navigable edit link.", + }, + }) .input(customWidgetUpdateSchema) .mutation(async ({ ctx, input }) => { const existing = await ctx.db.query.customWidgetDefinitions.findFirst({ @@ -60,8 +55,11 @@ export const customWidgetRouter = createTRPCRouter({ if (!existing) throw new TRPCError({ code: "NOT_FOUND" }); const current = parseStoredCustomWidgetDefinition(existing); - const { id, secrets, ...changes } = input; - const definition = customWidgetDefinitionSchema.parse({ ...current, ...changes }); + const { id, secrets, ...authoringChanges } = input; + const definition = parseCustomWidgetAuthoringInput(() => { + const changes = normalizeCustomWidgetAuthoringUpdate(authoringChanges); + return customWidgetDefinitionSchema.parse({ ...current, ...changes }); + }); if (secrets) assertSecretSources(definition.sources, secrets); const definitionChanges = { ...serializeCustomWidgetDefinition(definition), updatedAt: new Date() }; const secretRows = secrets?.map((secret) => ({ @@ -178,6 +176,7 @@ export const customWidgetRouter = createTRPCRouter({ }, }); logger.info("Updated custom widget definition", { id }); + return { id, managementPath: `/manage/custom-widgets/edit/${id}` }; }), ...secretProcedures, diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index aed6647fb..6797df4a7 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -757,6 +757,7 @@ "export": "Export", "edit": "Edit", "duplicate": "Duplicate", + "aiPrompt": "Design with AI", "copyAiPrompt": "Copy AI prompt", "enable": "Enable", "disable": "Disable", @@ -1127,6 +1128,7 @@ "aiPromptCopied": "AI prompt copied to clipboard", "aiPromptCopiedWithCount": "AI prompt copied to clipboard ({count, number} characters).", "aiPromptCopyError": "Failed to copy prompt to clipboard. Your browser may not support this feature.", + "aiPromptSent": "Prompt sent. You can keep editing while the assistant works in the background.", "updateError": "Failed to update widget. Your changes were not saved.", "importError": "Failed to import widget. Ensure the file is a valid custom widget JSON export.", "exportError": "Failed to export widget.", @@ -1140,7 +1142,12 @@ "aiPromptNoResponse": "Tip: Run a test first to include your API response in the prompt for better results.", "createError": "Failed to create widget. Check your API endpoint is reachable.", "executed": "Action executed successfully.", - "executeError": "Action failed: {error}" + "executeError": "Action failed: {error}", + "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." }, "importReview": { "title": "Review custom widget import", @@ -1968,24 +1975,76 @@ "switchToDarkMode": "Switch to dark mode", "switchToLightMode": "Switch to light mode", "management": "Management", - "assistant": "Homarr Assistant", - "assistantThinking": "Thinking", - "modifier": "Mod", "docker": "Docker", "preferences": "Your preferences", "logout": "Logout", "login": "Login", "homeBoard": "Your home board", "loggedOut": "Logged out", - "updateAvailable": "{countUpdates} updates available: {tag}" + "updateAvailable": "{countUpdates} updates available: {tag}", + "assistant": "Homarr Assistant", + "assistantThinking": "Thinking", + "modifier": "Mod" } }, + "dangerZone": "Danger zone", + "noResults": "No results found", + "unsavedChanges": "You have unsaved changes!", + "servers": "Servers", + "applications": "Apps", + "services": "Services", + "preview": { + "show": "Show preview", + "hide": "Hide preview" + }, + "zod": { + "errors": { + "default": "This field is invalid", + "required": "This field is required", + "string": { + "startsWith": "This field must start with {startsWith}", + "endsWith": "This field must end with {endsWith}", + "includes": "This field must include {includes}", + "invalidEmail": "This field must be a valid email" + }, + "tooSmall": { + "string": "This field must be at least {minimum} characters long", + "number": "This field must be greater than or equal to {minimum}" + }, + "tooBig": { + "string": "This field must be at most {maximum} characters long", + "number": "This field must be less than or equal to {maximum}" + }, + "custom": { + "passwordsDoNotMatch": "Passwords do not match", + "passwordRequirements": "Password does not meet the requirements", + "boardAlreadyExists": "A board with this name already exists", + "invalidFileType": "Invalid file type, expected {expected}", + "invalidFileName": "Invalid file name", + "fileTooLarge": "File is too large, maximum size is {maxSize}", + "invalidConfiguration": "Invalid configuration", + "groupNameTaken": "Group name already taken" + } + } + }, + "unit": { + "speed": { + "kilometersPerHour": "km/h", + "milesPerHour": "mph" + } + }, + "widgetQuery": { + "loading": "Loading widget data", + "stale": "Could not refresh {widget}; showing saved data" + }, + "total": "Total", + "filesystemUsage": "{count, plural, one {Show usage for # filesystem} other {Show usage for # filesystems}}", "assistant": { "title": "Homarr Assistant", - "subtitle": "Connected to this Homarr instance", "open": "Open Homarr Assistant", "close": "Close assistant", "minimize": "Minimize assistant", + "windowControls": "Conversation window controls", "spotlight": "Ask Homarr Assistant", "spotlightDescription": "Type and send a prompt without leaving the command menu.", "spotlightPrompt": { @@ -2028,7 +2087,10 @@ "composerPlaceholder": "Ask Homarr Assistant…", "runtime": { "model": "Model", + "searchModels": "Search models", "noModels": "No models available", + "otherModels": "Other models", + "imageInput": "Accepts image input", "thinking": "Thinking mode", "reasoning": { "auto": "Auto", @@ -2039,6 +2101,29 @@ "high": "High" } }, + "providerQuota": { + "title": "Homarr provider", + "communityWorkshop": "Community Workshop", + "dailyAllowance": "Daily request allowance", + "remainingLabel": "{remaining} of {limit} Homarr provider requests remaining", + "loading": "Loading Homarr provider allowance", + "signInRequired": "Workshop sign-in required for the Homarr provider", + "signInDescription": "Sign in with GitHub through the Community Workshop to receive your separate daily allowance.", + "exhaustedDescription": "Your Homarr provider allowance is used for today. It resets at midnight UTC.", + "unavailableDescription": "Sign in to the Workshop or wait for your Homarr provider allowance to reset.", + "signIn": "Sign in with GitHub", + "signInError": "GitHub sign-in failed. Try again in a moment.", + "loadError": "The Homarr provider allowance could not be loaded.", + "reset": "Resets {relative} at ", + "toolCalls": "Each model request and completed tool call uses one request.", + "retry": "Reload allowance", + "level": { + "ok": "OK", + "warning": "Warning", + "bad": "Low", + "dead": "Used" + } + }, "stop": "Stop response", "send": "Send message", "copy": "Copy response", @@ -2051,28 +2136,45 @@ "notHelpful": "Not helpful", "feedbackError": "Your feedback could not be saved. Try again.", "exportMarkdown": "Export as Markdown", - "reasoning": "Reasoning", + "exportConversation": { + "action": "Export conversation as Markdown", + "failedTitle": "Conversation could not be exported", + "failedDescription": "Homarr could not load this conversation. Try again." + }, + "reasoning": "Thinking", "previousResponse": "Previous response", "nextResponse": "Next response", "removeAttachment": "Remove attachment", "downloadFile": "Download {name}", "attachedImage": "Attached image", "externalImageBlocked": "External image blocked for privacy", + "image": { + "loading": "Loading image", + "failed": "This image could not be loaded.", + "retry": "Try image again" + }, "quoteSelection": "Quote", "dismissQuote": "Remove quote", "attachments": { "add": "Attach an image or document", - "errorTitle": "Attachment could not be added" + "errorTitle": "Attachment could not be added", + "imageUnsupported": "The selected model does not support image input." }, "mentions": { "menu": "Add Homarr context", "hint": "Type @ to add context", + "context": "Context", "app": "Apps", "integration": "Integrations", "board": "Boards", "widget": "Widgets", "tools": "Available tools" }, + "markdown": { + "diagram": "Diagram", + "renderingDiagram": "Rendering diagram…", + "diagramUnavailable": "This diagram could not be rendered." + }, "commands": { "menu": "Assistant commands", "hint": "Type / for shortcuts", @@ -2117,6 +2219,7 @@ "reported": "Reported by provider", "estimated": "Estimated from model pricing", "upstreamCost": "Upstream inference", + "webSearches": "Web searches", "finishReason": "Finish reason", "notReported": "Not reported", "agentSteps": "Agent steps", @@ -2125,6 +2228,20 @@ }, "toolInput": "Input", "toolOutput": "Output", + "toolActivity": { + "iconSearch": "Looking for “{query}” icons", + "iconBrowse": "Browsing available icons" + }, + "toolNavigation": { + "openPreview": "Open preview", + "openWidget": "Open widget" + }, + "webSearch": { + "title": "Web search", + "completed": "OpenRouter searched the live web for this response.", + "searches": "{count, plural, one {# search} other {# searches}}", + "sources": "{count, plural, one {# source} other {# sources}}" + }, "responseError": { "title": "The assistant could not finish", "description": "Try again. If the problem continues, ask an administrator to verify the configured model endpoint.", @@ -2219,6 +2336,23 @@ "remoteCssTitle": "This CSS loads a remote resource", "remoteCssDescription": "Imported stylesheets and remote URLs contact another server when the board loads. Review them before continuing." }, + "configureWidget": { + "title": "Review widget", + "preparing": "Preparing widget settings", + "review": "Review widget and integrations", + "ready": "Widget configuration ready", + "cancelled": "Widget was not added", + "automaticContinue": "Accepting the proposed widget configuration automatically", + "loadErrorTitle": "Integrations could not be loaded", + "loadErrorDescription": "Homarr could not load the integrations available for this widget.", + "retry": "Try again", + "noIntegrationTitle": "A compatible integration is required", + "noIntegrationDescription": "Configure an integration supported by this widget before adding it to the dashboard.", + "manageIntegrations": "Manage integrations", + "skip": "Do not add", + "errorTitle": "Widget settings could not be prepared", + "errorDescription": "The model did not provide a complete widget configuration. No widget was added." + }, "approvalRequired": "Approval required", "complete": "Complete", "denied": "Denied", @@ -2236,59 +2370,7 @@ "enabled": "Automatic approvals are on", "approving": "Approving automatically" } - }, - "dangerZone": "Danger zone", - "noResults": "No results found", - "unsavedChanges": "You have unsaved changes!", - "servers": "Servers", - "applications": "Apps", - "services": "Services", - "preview": { - "show": "Show preview", - "hide": "Hide preview" - }, - "zod": { - "errors": { - "default": "This field is invalid", - "required": "This field is required", - "string": { - "startsWith": "This field must start with {startsWith}", - "endsWith": "This field must end with {endsWith}", - "includes": "This field must include {includes}", - "invalidEmail": "This field must be a valid email" - }, - "tooSmall": { - "string": "This field must be at least {minimum} characters long", - "number": "This field must be greater than or equal to {minimum}" - }, - "tooBig": { - "string": "This field must be at most {maximum} characters long", - "number": "This field must be less than or equal to {maximum}" - }, - "custom": { - "passwordsDoNotMatch": "Passwords do not match", - "passwordRequirements": "Password does not meet the requirements", - "boardAlreadyExists": "A board with this name already exists", - "invalidFileType": "Invalid file type, expected {expected}", - "invalidFileName": "Invalid file name", - "fileTooLarge": "File is too large, maximum size is {maxSize}", - "invalidConfiguration": "Invalid configuration", - "groupNameTaken": "Group name already taken" - } - } - }, - "unit": { - "speed": { - "kilometersPerHour": "km/h", - "milesPerHour": "mph" - } - }, - "widgetQuery": { - "loading": "Loading widget data", - "stale": "Could not refresh {widget}; showing saved data" - }, - "total": "Total", - "filesystemUsage": "{count, plural, one {Show usage for # filesystem} other {Show usage for # filesystems}}" + } }, "section": { "option": { @@ -2511,10 +2593,6 @@ } }, "widget": { - "dynamicSelect": { - "noResults": "No results found", - "loadError": "Failed to load options" - }, "integrationSelect": { "configureIntegrationFirst": "Connect an integration first", "loading": "Loading...", @@ -5001,43 +5079,6 @@ "free": "{percentage}% free" } }, - "assistant": { - "name": "Assistant", - "description": "Have a complete assistant conversation directly on your board.", - "option": { - "conversationMode": { - "label": "Conversation", - "description": "Follow the conversation used elsewhere in Homarr, or keep this widget attached to a specific conversation.", - "option": { - "current": "Current conversation", - "pinned": "Pinned conversation" - } - }, - "conversation": { - "label": "Pinned conversation", - "description": "Choose the saved conversation this widget should keep available.", - "placeholder": "Select a saved conversation" - } - }, - "pinned": { - "title": "Pinned conversation", - "description": "This widget is attached to {conversation}. Open it to continue here without silently changing your active conversation.", - "selectTitle": "Choose a conversation", - "selectDescription": "Open the widget settings and select the conversation this widget should keep available.", - "open": "Open conversation", - "opening": "Opening conversation...", - "error": "This conversation could not be opened. It may have been deleted." - }, - "unavailable": { - "title": "Assistant unavailable", - "description": "Sign in and ask an administrator to configure the Assistant from the management panel." - }, - "compact": { - "title": "Assistant", - "description": "Resize this widget to chat here, or open the full conversation.", - "open": "Open assistant" - } - }, "customApi": { "name": "Custom widget", "description": "Render a configured Custom JSX definition.", @@ -5369,6 +5410,47 @@ "oneWeek": "1w", "thirtyDays": "30d" } + }, + "dynamicSelect": { + "noResults": "No results found", + "loadError": "Failed to load options" + }, + "assistant": { + "name": "Assistant", + "description": "Have a complete assistant conversation directly on your board.", + "option": { + "conversationMode": { + "label": "Conversation", + "description": "Follow the conversation used elsewhere in Homarr, or keep this widget attached to a specific conversation.", + "option": { + "current": "Current conversation", + "pinned": "Pinned conversation" + } + }, + "conversation": { + "label": "Pinned conversation", + "description": "Choose the saved conversation this widget should keep available.", + "placeholder": "Select a saved conversation" + } + }, + "pinned": { + "title": "Pinned conversation", + "description": "This widget is attached to {conversation}. Open it to continue here without silently changing your active conversation.", + "selectTitle": "Choose a conversation", + "selectDescription": "Open the widget settings and select the conversation this widget should keep available.", + "open": "Open conversation", + "opening": "Opening conversation...", + "error": "This conversation could not be opened. It may have been deleted." + }, + "unavailable": { + "title": "Assistant unavailable", + "description": "Sign in and ask an administrator to configure the Assistant from the management panel." + }, + "compact": { + "title": "Assistant", + "description": "Resize this widget to chat here, or open the full conversation.", + "open": "Open assistant" + } } }, "widgetPreview": { @@ -5828,7 +5910,6 @@ "workshop": "Workshop", "searchEngies": "Search engines", "medias": "Medias", - "assistant": "Assistant", "users": { "label": "Users", "items": { @@ -5859,7 +5940,8 @@ "sourceCode": "Source Code" } }, - "about": "About" + "about": "About", + "assistant": "Assistant" } }, "page": { @@ -6105,206 +6187,6 @@ "text": "This helps us figure out what people actually use so we know where to spend our time. We see stuff like how many widgets or integrations you have, your Homarr version, and what database you're on. That's it — no personal info, no URLs, no names, nothing that could identify you or your setup." } }, - "assistant": { - "loading": "Loading assistant configuration", - "title": "Assistant", - "description": "Connect an OpenAI-compatible model endpoint for the instance-wide assistant.", - "security": { - "title": "Server-side secrets", - "description": "API keys and additional header values are encrypted in the Homarr database. They are never returned to browsers. Model requests are sent from the Homarr server directly to the configured endpoint." - }, - "overview": { - "connectionReady": "Connection ready", - "unsavedChanges": "Unsaved changes", - "setupRequired": "Setup required", - "keyProtected": "Key protected", - "available": "Available", - "disabled": "Disabled", - "noModel": "No model selected" - }, - "connection": { - "title": "Provider connection", - "endpointTitle": "Provider and endpoint", - "description": "Choose a preset or connect any service that implements the OpenAI Chat Completions API.", - "saved": "Connection saved", - "notSaved": "Not configured", - "save": "Save provider connection", - "clearCredentials": "Remove all credentials" - }, - "credentials": { - "title": "Credentials", - "description": "Add the secrets this endpoint needs. Stored values stay encrypted and cannot be read back.", - "required": "Key required", - "optional": "Key optional" - }, - "provider": { - "title": "Provider", - "groups": { - "hosted": "Hosted providers", - "local": "Local providers", - "custom": "Custom" - }, - "options": { - "openrouter": { - "label": "OpenRouter", - "description": "Hosted model routing with a large tool-capable model catalog." - }, - "openai": { - "label": "OpenAI", - "description": "OpenAI Chat Completions through the official API endpoint." - }, - "anthropic": { - "label": "Anthropic Claude", - "description": "Claude through Anthropic's OpenAI SDK compatibility endpoint." - }, - "google-gemini": { - "label": "Google Gemini", - "description": "Gemini through Google's OpenAI-compatible API endpoint." - }, - "xai": { - "label": "xAI", - "description": "Grok models through xAI's OpenAI-compatible API." - }, - "groq": { - "label": "Groq", - "description": "Fast hosted inference through Groq's OpenAI-compatible API." - }, - "mistral": { - "label": "Mistral AI", - "description": "Mistral models through the official API." - }, - "deepseek": { - "label": "DeepSeek", - "description": "DeepSeek models through its OpenAI-compatible API." - }, - "together": { - "label": "Together AI", - "description": "Hosted open models through Together's OpenAI-compatible API." - }, - "ollama": { - "label": "Ollama", - "description": "A local or network Ollama server using its OpenAI-compatible API." - }, - "lm-studio": { - "label": "LM Studio", - "description": "An LM Studio local server with OpenAI compatibility enabled." - }, - "custom": { - "label": "Custom endpoint", - "description": "Any other endpoint compatible with OpenAI Chat Completions." - } - } - }, - "baseUrl": { - "title": "API base URL", - "description": "Include the API prefix, usually /v1. In containers, localhost refers to the Homarr container." - }, - "destinationChanged": { - "title": "Credentials will not follow this endpoint", - "description": "Changing the provider or base URL clears the stored API key and headers unless replacements are entered now. This prevents sending one provider's secrets to another service." - }, - "apiKey": { - "title": "API key", - "required": "Required by this provider. The value is encrypted and write-only.", - "optional": "Optional for local endpoints. Leave it blank when the service does not require bearer authentication.", - "placeholder": "Enter an API key", - "replacementPlaceholder": "Enter a replacement API key", - "configuredBadge": "Saved key active", - "pendingRemoval": "Removal pending", - "savedTitle": "Encrypted API key saved", - "savedDescription": "Homarr can use this key, but it cannot display or recover its value.", - "replace": "Replace key", - "remove": "Remove saved API key", - "removeDescription": "Deletes the saved key after you click Save provider connection.", - "removalPendingDescription": "The saved key will be deleted when you save the provider connection.", - "replaceFlow": { - "title": "Replace the saved API key", - "description": "Enter the new key first. You will review the endpoint and impact before anything changes.", - "enterStep": "Enter new key", - "reviewStep": "Review change", - "newKey": "New API key", - "newKeyDescription": "The existing key remains active until you confirm the replacement.", - "cancel": "Cancel replacement", - "review": "Review replacement", - "warningTitle": "The current key will stop being used", - "warningDescription": "Confirming replaces the encrypted key for this endpoint. Homarr cannot restore the previous value, and model discovery and assistant requests will use the new key immediately.", - "provider": "Provider", - "endpoint": "Destination", - "back": "Edit new key", - "confirm": "Replace API key" - }, - "removeFlow": { - "title": "Remove the saved API key?", - "description": "This provider requires an API key. Removing it can stop model discovery and assistant responses until another key is saved.", - "optionalDescription": "The endpoint will stop receiving bearer authentication after this key is removed. Confirm that it accepts unauthenticated requests or uses another stored header.", - "cancel": "Keep saved key", - "confirm": "Remove API key" - } - }, - "headers": { - "title": "Additional request headers", - "description": "Optional authentication or routing headers. Header values are encrypted and write-only.", - "add": "Add header", - "name": "Header name", - "value": "Header value", - "valuePlaceholder": "Secret or routing value", - "remove": "Remove header", - "configured": "Additional encrypted headers are configured. Adding headers replaces the complete stored set.", - "empty": "No additional headers are stored for this provider.", - "savedBadge": "Headers saved", - "pendingRemoval": "Removal pending", - "clear": "Remove stored headers when saving", - "keep": "Keep stored headers" - }, - "model": { - "title": "Model", - "description": "Select a discovered model or enter an exact endpoint model ID.", - "placeholder": "Model ID", - "refresh": "Refresh models", - "chooseModel": "Choose the model used for new conversations.", - "discoveryPath": "Model discovery path", - "discoveryPathDescription": "Appended to the API base URL. Leave blank to disable discovery and use a manual model ID.", - "manual": "Enter a model ID manually or save the connection to discover models.", - "manualFallback": "Discovery is unavailable. You can still enter a model ID manually.", - "saveConnectionFirst": "Save the provider connection before choosing a model.", - "discovering": "Refreshing the models available from this provider…", - "discovered": "{count, plural, one {Discovered # model. Tool support depends on the selected endpoint and model.} other {Discovered # models. Tool support depends on the selected endpoint and model.}}", - "discoveryFailed": "Model discovery did not complete", - "context": "{count, number} token context", - "toolsConfirmed": "Tools advertised", - "toolsUnknown": "Verify tool support" - }, - "enabled": { - "label": "Enable Assistant", - "description": "Shows the assistant to all signed-in users. Existing Homarr permissions still apply to every internal tool call." - }, - "clearCredentials": { - "title": "Remove all assistant credentials", - "warningTitle": "The assistant will be disabled", - "description": "This permanently removes the stored API key and additional headers, clears the selected model, and disables the assistant. The encrypted values cannot be recovered.", - "cancel": "Keep credentials", - "confirm": "Remove credentials" - }, - "save": "Save assistant", - "notification": { - "connectionSaved": { - "title": "Provider connection saved", - "message": "The endpoint and encrypted credentials are ready for model selection.", - "credentialsCleared": "The endpoint changed, so previous credentials were cleared. Add credentials for the new destination if required." - }, - "credentialsCleared": { - "title": "Provider credentials removed", - "message": "The stored API key and headers were removed, and the assistant was disabled." - }, - "saved": { - "title": "Assistant settings saved", - "message": "The new model and availability settings are active." - }, - "error": { - "title": "Assistant settings could not be saved" - } - } - }, "crawlingAndIndexing": { "title": "Crawling and Indexing", "warning": "Enabling or disabling any settings here will severely impact how search engines will index & crawl your page. Any setting is a request and it is up to the crawler to apply these settings. Any modification may take up to multiple days or weeks to apply. Some settings may be search engine specific.", @@ -6374,6 +6256,190 @@ "defaultLocale": { "label": "Default language" } + }, + "assistant": { + "loading": "Loading assistant configuration", + "title": "Assistant", + "description": "Connect an OpenAI-compatible model endpoint for the instance-wide assistant.", + "security": { + "title": "Server-side secrets", + "description": "API keys and additional header values are encrypted in the Homarr database. They are never returned to browsers. Model requests are sent from the Homarr server directly to the configured endpoint." + }, + "overview": { + "connectionReady": "Connection ready", + "unsavedChanges": "Unsaved changes", + "setupRequired": "Setup required", + "available": "Available", + "disabled": "Disabled", + "noModel": "No model selected" + }, + "connection": { + "title": "Provider connection", + "endpointTitle": "Provider and endpoint", + "description": "Choose a preset or connect any service that implements the OpenAI Chat Completions API.", + "saved": "Connection saved", + "notSaved": "Not configured", + "save": "Save provider connection" + }, + "credentials": { + "title": "Credentials", + "description": "Add the secrets this endpoint needs. Stored values stay encrypted and cannot be read back.", + "required": "Key required", + "optional": "Key optional", + "workshop": "Per-user GitHub sign-in" + }, + "provider": { + "title": "Provider", + "groups": { + "hosted": "Hosted providers", + "local": "Local providers", + "custom": "Custom" + }, + "options": { + "homarr": { + "label": "Homarr", + "description": "Community-funded access to the model selected by the Homarr team. Each user signs in with the Community Workshop and receives a daily allowance." + }, + "openrouter": { + "label": "OpenRouter", + "description": "Hosted model routing with a large tool-capable model catalog." + }, + "openai": { + "label": "OpenAI", + "description": "OpenAI Chat Completions through the official API endpoint." + }, + "anthropic": { + "label": "Anthropic Claude", + "description": "Claude through Anthropic's OpenAI SDK compatibility endpoint." + }, + "google-gemini": { + "label": "Google Gemini", + "description": "Gemini through Google's OpenAI-compatible API endpoint." + }, + "xai": { + "label": "xAI", + "description": "Grok models through xAI's OpenAI-compatible API." + }, + "groq": { + "label": "Groq", + "description": "Fast hosted inference through Groq's OpenAI-compatible API." + }, + "mistral": { + "label": "Mistral AI", + "description": "Mistral models through the official API." + }, + "deepseek": { + "label": "DeepSeek", + "description": "DeepSeek models through its OpenAI-compatible API." + }, + "together": { + "label": "Together AI", + "description": "Hosted open models through Together's OpenAI-compatible API." + }, + "ollama": { + "label": "Ollama", + "description": "A local or network Ollama server using its OpenAI-compatible API." + }, + "lm-studio": { + "label": "LM Studio", + "description": "An LM Studio local server with OpenAI compatibility enabled." + }, + "custom": { + "label": "Custom endpoint", + "description": "Any other endpoint compatible with OpenAI Chat Completions." + } + }, + "homarrAuth": { + "title": "No administrator API key required", + "description": "Homarr keeps the upstream OpenRouter credential on the Community Workshop server. Each user authorizes requests with their own Workshop GitHub session, so their daily allowance stays separate." + } + }, + "baseUrl": { + "title": "API base URL", + "description": "Include the API prefix, usually /v1. In containers, localhost refers to the Homarr container." + }, + "apiKey": { + "title": "API key", + "required": "Required by this provider. The value is encrypted and write-only.", + "optional": "Optional for local endpoints. Leave it blank when the service does not require bearer authentication.", + "placeholder": "Enter an API key", + "configuredBadge": "Saved key active", + "pendingRemoval": "Removal pending", + "savedTitle": "Encrypted API key saved", + "savedDescription": "Homarr can use this key, but it cannot display or recover its value.", + "remove": "Remove saved API key", + "removeDescription": "Deletes the saved key after you click Save provider connection.", + "removalPendingDescription": "The saved key will be deleted when you save the provider connection.", + "removeFlow": { + "title": "Remove the saved API key?", + "description": "This provider requires an API key. Removing it can stop model discovery and assistant responses until another key is saved.", + "optionalDescription": "The endpoint will stop receiving bearer authentication after this key is removed. Confirm that it accepts unauthenticated requests or uses another stored header.", + "cancel": "Keep saved key", + "confirm": "Remove API key" + } + }, + "headers": { + "title": "Additional request headers", + "description": "Optional authentication or routing headers. Header values are encrypted and write-only.", + "add": "Add header", + "name": "Header name", + "value": "Header value", + "valuePlaceholder": "Secret or routing value", + "remove": "Remove header", + "configured": "Additional encrypted headers are configured. Adding headers replaces the complete stored set.", + "empty": "No additional headers are stored for this provider.", + "savedBadge": "Headers saved", + "pendingRemoval": "Removal pending", + "clear": "Remove stored headers when saving", + "keep": "Keep stored headers" + }, + "model": { + "title": "Model", + "description": "Select a discovered model or enter an exact endpoint model ID.", + "placeholder": "Model ID", + "refresh": "Refresh models", + "chooseModel": "Choose the model used for new conversations.", + "discoveryPath": "Model discovery path", + "discoveryPathDescription": "Appended to the API base URL. Leave blank to disable discovery and use a manual model ID.", + "manual": "Enter a model ID manually or save the connection to discover models.", + "manualFallback": "Discovery is unavailable. You can still enter a model ID manually.", + "saveConnectionFirst": "Save the provider connection before choosing a model.", + "discovering": "Refreshing the models available from this provider…", + "discovered": "{count, plural, one {Discovered # model. Tool support depends on the selected endpoint and model.} other {Discovered # models. Tool support depends on the selected endpoint and model.}}", + "discoveryFailed": "Model discovery did not complete", + "context": "{count} context window", + "toolsConfirmed": "Tools advertised", + "toolsUnknown": "Verify tool support" + }, + "serverTools": { + "title": "Server tools", + "beta": "Beta", + "webSearch": { + "label": "Web search", + "description": "Allow the model to search the live web through OpenRouter when a request needs current information. Search usage is charged separately by OpenRouter.", + "proxyDescription": "Send OpenRouter's web-search server tool to this endpoint. Enable this only when the custom endpoint forwards the OpenRouter server-tools format, such as a Homarr Router proxy.", + "limit": "The model decides when to search, with a limit of 3 searches and 5 results per search for each request." + } + }, + "enabled": { + "label": "Enable Assistant", + "description": "Shows the assistant to all signed-in users. Existing Homarr permissions still apply to every internal tool call." + }, + "save": "Save assistant", + "notification": { + "connectionSaved": { + "title": "Provider connection saved", + "message": "The endpoint and encrypted credentials are ready for model selection.", + "credentialsCleared": "The endpoint changed, so previous credentials were cleared. Add credentials for the new destination if required." + }, + "saved": { + "title": "Assistant settings saved", + "message": "The new model and availability settings are active." + }, + "error": { + "title": "Assistant settings could not be saved" + } + } } } }, @@ -7192,9 +7258,6 @@ "medias": { "label": "Medias" }, - "assistant": { - "label": "Assistant" - }, "apps": { "label": "Apps", "new": { @@ -7287,6 +7350,9 @@ }, "about": { "label": "About" + }, + "assistant": { + "label": "Assistant" } } },