feat: add the community-funded Homarr assistant provider (#6563)

* Add Homarr provider proxy foundation

* Complete Homarr provider integration

* Harden Homarr provider integration

* Address Homarr provider review feedback

* Address CodeRabbit provider feedback

* Localize provider errors

* Handle provider activity load failures

* docs: improve workshop and homepage

* Harden Homarr provider limits

* fix: harden the Homarr provider

* fix: address provider review feedback

* fix: clarify provider request accounting

* feat: complete the Homarr provider endpoint

* fix: terminate invalid provider streams
This commit is contained in:
Thomas authored and ajnart committed 2026-08-14 11:32:11 +02:00
1 parent b8f360c04e
commit 45d8bc9443
77 files changed
+4035 -763

No files matched your search

@@ -2,6 +2,9 @@
import { createContext, useContext } from "react";
import type { AssistantProvider } from "@homarr/definitions";
import type { WorkshopAssistantUsage, WorkshopUser } from "@homarr/workshop/schema";
import type { AssistantReasoningMode, AssistantRuntimeModelOption } from "./assistant-preferences";
/**
@@ -27,14 +30,22 @@ export interface AssistantContextValue {
export const AssistantContext = createContext<AssistantContextValue | null>(null);
export interface AssistantPreferencesContextValue {
provider: AssistantProvider | null;
defaultModelId: string | null;
modelId: string | null;
models: AssistantRuntimeModelOption[];
reasoning: AssistantReasoningMode;
isLoading: boolean;
providerUser: WorkshopUser | null;
quota: WorkshopAssistantUsage | null;
quotaLoading: boolean;
quotaError: string | null;
setModelId: (modelId: string) => void;
setReasoning: (reasoning: AssistantReasoningMode) => void;
getRequestBody: () => { modelId?: string; reasoning: AssistantReasoningMode };
getRequestHeaders: () => Record<string, string>;
refreshQuota: () => Promise<void>;
signInToProvider: () => Promise<void>;
}
export const AssistantPreferencesContext = createContext<AssistantPreferencesContextValue | null>(null);
@@ -1210,6 +1210,43 @@
outline-offset: 2px;
}
.providerQuotaTrigger {
display: inline-flex;
height: 1.75rem;
flex: 0 0 auto;
align-items: center;
gap: 0.25rem;
padding-inline: 0.38rem;
border: 1px solid var(--mantine-color-default-border);
border-radius: var(--mantine-radius-sm);
background: var(--mantine-color-body);
}
.providerQuotaTrigger:hover {
background: var(--mantine-color-default-hover);
}
.providerQuotaTrigger:focus-visible {
outline: 2px solid var(--mantine-primary-color-filled);
outline-offset: 2px;
}
.providerQuotaTrigger[data-level="ok"] {
color: light-dark(var(--mantine-color-green-8), var(--mantine-color-green-4));
}
.providerQuotaTrigger[data-level="warning"] {
color: light-dark(var(--mantine-color-yellow-8), var(--mantine-color-yellow-4));
}
.providerQuotaTrigger[data-level="bad"] {
color: light-dark(var(--mantine-color-orange-8), var(--mantine-color-orange-4));
}
.providerQuotaTrigger[data-level="dead"] {
color: light-dark(var(--mantine-color-red-8), var(--mantine-color-red-4));
}
.conversationContextPopover {
max-height: min(34rem, calc(100dvh - 1rem));
overflow: hidden;
@@ -60,6 +60,7 @@ import {
Image,
Loader,
Popover,
Progress,
RingProgress,
ScrollArea,
SegmentedControl,
@@ -79,6 +80,10 @@ import {
IconArrowUp,
IconArrowsMaximize,
IconAt,
IconBattery1,
IconBattery3,
IconBatteryFilled,
IconBatteryOff,
IconCheck,
IconChevronDown,
IconChevronLeft,
@@ -115,10 +120,11 @@ import remarkGfm from "remark-gfm";
import { useRouter } from "next/navigation";
import { clientApi, fetchApi } from "@homarr/api/client";
import { useTimeAgo } from "@homarr/common";
import { assistantProviderIds, assistantProviderPresets, assistantReasoningModes } from "@homarr/definitions";
import type { AssistantProvider } from "@homarr/definitions";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client";
import classes from "./assistant-panel.module.css";
import { getAssistantActivityState } from "./assistant-activity-state";
@@ -129,6 +135,7 @@ import {
getAssistantConversationExportFilename,
} from "./assistant-conversation-export";
import { AssistantDotMatrix } from "./assistant-dot-matrix";
import { useAssistantPreferences } from "./assistant-context";
import { AssistantImage } from "./assistant-image";
import { getAssistantDirectiveTranslationKey, parseAssistantDirectives } from "./assistant-directives";
import { remarkAssistantDirectives, resolveAssistantDirectiveEntity } from "./assistant-markdown-directives";
@@ -149,6 +156,7 @@ import { useAssistantReasoningState } from "./assistant-reasoning-state";
import { getNearestTriggerScrollTop } from "./assistant-trigger-scroll";
import { getToolResultPresentation } from "./assistant-tool-result";
import { getAssistantIconSearchQuery } from "./assistant-tool-label";
import { getAssistantProviderQuotaLevel, isAssistantProviderUnavailable } from "./assistant-provider-quota";
import { getAssistantToolTraceTarget } from "./assistant-tool-trace";
import { getSafeAssistantHttpUrl } from "./assistant-url";
@@ -2537,10 +2545,154 @@ const RuntimeControls = ({
);
};
const providerQuotaIcons = {
ok: IconBatteryFilled,
warning: IconBattery3,
bad: IconBattery1,
dead: IconBatteryOff,
};
const providerQuotaColors = {
ok: "green",
warning: "yellow",
bad: "orange",
dead: "red",
} as const;
const HomarrProviderQuota = () => {
const t = useScopedI18n("common.assistant");
const locale = useCurrentIntlLocale();
const preferences = useAssistantPreferences();
const quota = preferences.quota;
const resetAt = useMemo(() => new Date(quota?.resetsAt ?? Date.now()), [quota?.resetsAt]);
const resetRelative = useTimeAgo(resetAt, 30_000);
if (preferences.provider !== "homarr") return null;
const level = preferences.quota ? getAssistantProviderQuotaLevel(preferences.quota) : "dead";
const Icon = providerQuotaIcons[level];
const color = providerQuotaColors[level];
const percentage = preferences.quota
? Math.min(100, Math.max(0, (preferences.quota.remaining / Math.max(preferences.quota.limit, 1)) * 100))
: 0;
const label = preferences.providerUser
? quota
? t("providerQuota.remainingLabel", {
remaining: quota.remaining,
limit: quota.limit,
})
: t("providerQuota.loading")
: t("providerQuota.signInRequired");
const resetTime = quota ? (
<time dateTime={quota.resetsAt} title={new Date(quota.resetsAt).toLocaleString(locale)}>
{new Date(quota.resetsAt).toLocaleTimeString(locale, {
hour: "2-digit",
minute: "2-digit",
timeZoneName: "short",
})}
</time>
) : null;
return (
<Popover position="top" width={290} shadow="md" withinPortal>
<Popover.Target>
<UnstyledButton className={classes.providerQuotaTrigger} data-level={level} type="button" aria-label={label}>
{preferences.quotaLoading && !preferences.quota ? <Loader size={14} /> : <Icon size={16} aria-hidden />}
<Text component="span" size="xs" fw={650}>
{preferences.quota?.remaining ?? "–"}
</Text>
</UnstyledButton>
</Popover.Target>
<Popover.Dropdown>
<Stack gap="sm">
<Group justify="space-between" wrap="nowrap">
<Group gap="xs" wrap="nowrap">
<ThemeIcon color={color} variant="light" size="sm" radius="xl">
<Icon size={15} aria-hidden />
</ThemeIcon>
<Box>
<Text size="sm" fw={700}>
{t("providerQuota.title")}
</Text>
<Text size="xs" c="dimmed">
{preferences.providerUser?.name || t("providerQuota.communityWorkshop")}
</Text>
</Box>
</Group>
{preferences.quota && (
<Badge color={color} variant="light" size="sm">
{t(`providerQuota.level.${level}`)}
</Badge>
)}
</Group>
{!preferences.providerUser ? (
<>
<Text size="sm" c="dimmed">
{t("providerQuota.signInDescription")}
</Text>
<Button
size="compact-sm"
loading={preferences.quotaLoading}
onClick={() => void preferences.signInToProvider().catch(() => undefined)}
>
{t("providerQuota.signIn")}
</Button>
</>
) : preferences.quota ? (
<>
<Box>
<Group justify="space-between" gap="xs" mb={5}>
<Text size="xs" c="dimmed">
{t("providerQuota.dailyAllowance")}
</Text>
<Text size="xs" fw={650}>
{preferences.quota.remaining} / {preferences.quota.limit}
</Text>
</Group>
<Progress value={percentage} color={color} size="sm" aria-label={label} />
</Box>
<Text size="xs" c="dimmed">
{t.rich("providerQuota.reset", {
relative: resetRelative,
time: () => resetTime,
})}
</Text>
<Text size="xs" c="dimmed">
{t("providerQuota.toolCalls")}
</Text>
</>
) : (
<Button
variant="light"
size="compact-sm"
loading={preferences.quotaLoading}
onClick={() => void preferences.refreshQuota()}
>
{t("providerQuota.retry")}
</Button>
)}
{preferences.quotaError && (
<Text size="xs" c="red">
{preferences.quotaError}
</Text>
)}
</Stack>
</Popover.Dropdown>
</Popover>
);
};
const Composer = (props: ComposerProps) => {
const t = useScopedI18n("common.assistant");
const preferences = useAssistantPreferences();
const running = useAuiState((state) => state.thread.isRunning);
const hasPendingAction = props.pendingAction !== undefined;
const providerUnavailable = isAssistantProviderUnavailable({
provider: preferences.provider,
signedIn: preferences.providerUser !== null,
remaining: preferences.quota?.remaining,
});
const sendBlocked = hasPendingAction || providerUnavailable;
const composerInputRef = useRef<HTMLDivElement>(null);
const composerLabel = t("composerPlaceholder");
@@ -2561,7 +2713,7 @@ const Composer = (props: ComposerProps) => {
className={classes.composer}
data-pending-action={hasPendingAction || undefined}
onSubmit={(event) => {
if (hasPendingAction) event.preventDefault();
if (sendBlocked) event.preventDefault();
}}
>
<ComposerTriggers />
@@ -2607,8 +2759,14 @@ const Composer = (props: ComposerProps) => {
color="red"
variant="filled"
size="lg"
aria-label={hasPendingAction ? t("pendingAction.sendBlocked") : t("send")}
disabled={hasPendingAction}
aria-label={
hasPendingAction
? t("pendingAction.sendBlocked")
: providerUnavailable
? t("providerQuota.unavailableDescription")
: t("send")
}
disabled={sendBlocked}
>
<IconArrowUp size={18} />
</ActionIcon>
@@ -2618,6 +2776,7 @@ const Composer = (props: ComposerProps) => {
<Group className={classes.composerFooter} justify="space-between" gap="xs" wrap="nowrap">
<Group className={classes.composerControls} gap={5} wrap="nowrap">
<RuntimeControls {...props} />
<HomarrProviderQuota />
<ConversationContext />
</Group>
<Group className={classes.composerHints} gap="xs" wrap="nowrap">
@@ -0,0 +1,37 @@
import { describe, expect, test } from "vitest";
import {
getAssistantProviderQuotaLevel,
getAssistantProviderQuotaRefreshDelay,
isAssistantProviderUnavailable,
} from "./assistant-provider-quota";
describe("getAssistantProviderQuotaLevel", () => {
test.each([
[{ limit: 50, remaining: 50 }, "ok"],
[{ limit: 50, remaining: 26 }, "ok"],
[{ limit: 50, remaining: 25 }, "warning"],
[{ limit: 50, remaining: 10 }, "bad"],
[{ limit: 50, remaining: 0 }, "dead"],
[{ limit: 0, remaining: 0 }, "dead"],
] as const)("maps %o to %s", (usage, expected) => {
expect(getAssistantProviderQuotaLevel(usage)).toBe(expected);
});
});
describe("isAssistantProviderUnavailable", () => {
test("blocks only unavailable Homarr provider sessions", () => {
expect(isAssistantProviderUnavailable({ provider: "homarr", signedIn: false, remaining: undefined })).toBe(true);
expect(isAssistantProviderUnavailable({ provider: "homarr", signedIn: true, remaining: 0 })).toBe(true);
expect(isAssistantProviderUnavailable({ provider: "homarr", signedIn: true, remaining: undefined })).toBe(false);
expect(isAssistantProviderUnavailable({ provider: "openrouter", signedIn: false, remaining: 0 })).toBe(false);
});
});
describe("getAssistantProviderQuotaRefreshDelay", () => {
test("refreshes just after the UTC reset, or immediately when it has passed", () => {
const reset = "2026-08-12T00:00:00.000Z";
expect(getAssistantProviderQuotaRefreshDelay(reset, Date.parse("2026-08-11T23:59:00.000Z"))).toBe(61_000);
expect(getAssistantProviderQuotaRefreshDelay(reset, Date.parse("2026-08-12T00:00:01.000Z"))).toBe(1_000);
});
});
@@ -0,0 +1,27 @@
import type { WorkshopAssistantUsage } from "@homarr/workshop/schema";
import type { AssistantProvider } from "@homarr/definitions";
export type AssistantProviderQuotaLevel = "ok" | "warning" | "bad" | "dead";
export const getAssistantProviderQuotaRefreshDelay = (resetsAt: string, now = Date.now()) =>
Math.max(0, Date.parse(resetsAt) - now) + 1_000;
export const getAssistantProviderQuotaLevel = (
usage: Pick<WorkshopAssistantUsage, "limit" | "remaining">,
): AssistantProviderQuotaLevel => {
if (usage.remaining === 0 || usage.limit === 0) return "dead";
const remainingRatio = usage.remaining / usage.limit;
if (remainingRatio <= 0.2) return "bad";
if (remainingRatio <= 0.5) return "warning";
return "ok";
};
export const isAssistantProviderUnavailable = ({
provider,
signedIn,
remaining,
}: {
provider: AssistantProvider | null;
signedIn: boolean;
remaining: number | undefined;
}) => provider === "homarr" && (!signedIn || remaining === 0);
@@ -19,15 +19,17 @@ import { createAssistantStream } from "assistant-stream";
import { clientApi, fetchApi } from "@homarr/api/client";
import { createId } from "@homarr/common";
import { hotkeys } from "@homarr/definitions";
import { assistantHomarrProviderTokenHeader, hotkeys } from "@homarr/definitions";
import { showErrorNotification, showWarningNotification } from "@homarr/notifications";
import { closeSpotlight } from "@homarr/spotlight/store";
import { useScopedI18n } from "@homarr/translation/client";
import { openMediaRequestSearch, openSpotlight, useRegisterSpotlightContextResults } from "@homarr/spotlight";
import { AssistantWidgetRendererProvider } from "@homarr/widgets";
import { createWorkshopClient } from "~/components/workshop/workshop-client";
import { AssistantContext, AssistantPreferencesContext, useAssistantPreferences } from "./assistant-context";
import { shouldAutomaticallyContinueAssistant } from "./assistant-auto-submit";
import { getAssistantProviderQuotaRefreshDelay, isAssistantProviderUnavailable } from "./assistant-provider-quota";
import { AssistantAutoApprovalProvider } from "./assistant-auto-approval";
import { getRunningAssistantPartType } from "./assistant-activity-state";
import { prepareAssistantRequestBody } from "./assistant-attachment-payload";
@@ -124,11 +126,18 @@ const createAssistantAttachmentAdapter = (allowImages: boolean): AttachmentAdapt
};
const AssistantPreferencesProvider = ({ children }: PropsWithChildren) => {
const t = useScopedI18n("common.assistant");
const { data, isLoading } = clientApi.assistant.getRuntimeOptions.useQuery(undefined, {
staleTime: 10 * 60_000,
});
const [modelId, setModelIdState] = useState<string | null>(null);
const [reasoning, setReasoningState] = useState<AssistantReasoningMode>("auto");
const workshopClient = useMemo(createWorkshopClient, []);
const [providerUser, setProviderUser] = useState(workshopClient.currentUser);
const [quota, setQuota] = useState<Awaited<ReturnType<typeof workshopClient.getAssistantUsage>> | null>(null);
const [quotaLoading, setQuotaLoading] = useState(false);
const [quotaError, setQuotaError] = useState<string | null>(null);
const quotaRequestRef = useRef<AbortController | null>(null);
const previousDefaultModelIdRef = useRef<string | null | undefined>(undefined);
const preferencesRef = useRef<{ modelId: string | null; reasoning: AssistantReasoningMode }>({
modelId: null,
@@ -163,6 +172,52 @@ const AssistantPreferencesProvider = ({ children }: PropsWithChildren) => {
reasoning: current.reasoning,
};
}, []);
const getRequestHeaders = useCallback((): Record<string, string> => {
if (data?.provider !== "homarr") return {};
const token = workshopClient.authToken;
return token ? { [assistantHomarrProviderTokenHeader]: token } : {};
}, [data?.provider, workshopClient]);
const refreshQuota = useCallback(async () => {
quotaRequestRef.current?.abort();
if (data?.provider !== "homarr" || !workshopClient.authToken) {
quotaRequestRef.current = null;
setQuota(null);
setQuotaError(null);
setQuotaLoading(false);
return;
}
const controller = new AbortController();
quotaRequestRef.current = controller;
setQuotaLoading(true);
setQuotaError(null);
try {
const nextQuota = await workshopClient.getAssistantUsage(controller.signal);
if (quotaRequestRef.current === controller && !controller.signal.aborted) setQuota(nextQuota);
} catch {
if (quotaRequestRef.current === controller && !controller.signal.aborted) {
setQuotaError(t("providerQuota.loadError"));
}
} finally {
if (quotaRequestRef.current === controller) {
quotaRequestRef.current = null;
setQuotaLoading(false);
}
}
}, [data?.provider, t, workshopClient]);
const signInToProvider = useCallback(async () => {
setQuotaLoading(true);
setQuotaError(null);
try {
const user = await workshopClient.signInWithGitHub();
setProviderUser(user);
await refreshQuota();
} catch (error) {
setQuotaError(t("providerQuota.signInError"));
throw error;
} finally {
setQuotaLoading(false);
}
}, [refreshQuota, t, workshopClient]);
useEffect(() => {
if (!data) return;
@@ -178,18 +233,76 @@ const AssistantPreferencesProvider = ({ children }: PropsWithChildren) => {
setModelIdState(nextModelId);
}, [data]);
useEffect(() => {
if (data?.provider !== "homarr") {
setProviderUser(null);
setQuota(null);
setQuotaError(null);
return;
}
const unsubscribe = workshopClient.subscribeToAuth(setProviderUser);
void workshopClient
.refreshAuth()
.then(setProviderUser)
.catch(() => undefined);
return unsubscribe;
}, [data?.provider, workshopClient]);
useEffect(
() => () => {
quotaRequestRef.current?.abort();
},
[],
);
useEffect(() => {
if (data?.provider !== "homarr" || !providerUser) return;
void refreshQuota();
}, [data?.provider, providerUser, refreshQuota]);
useEffect(() => {
if (data?.provider !== "homarr" || !providerUser || !quota) return;
const timeout = window.setTimeout(() => void refreshQuota(), getAssistantProviderQuotaRefreshDelay(quota.resetsAt));
return () => window.clearTimeout(timeout);
}, [data?.provider, providerUser, quota, refreshQuota]);
const value = useMemo(
() => ({
provider: data?.provider ?? null,
defaultModelId: data?.defaultModelId ?? null,
modelId,
models,
reasoning,
isLoading,
providerUser,
quota,
quotaLoading,
quotaError,
setModelId,
setReasoning,
getRequestBody,
getRequestHeaders,
refreshQuota,
signInToProvider,
}),
[data?.defaultModelId, getRequestBody, isLoading, modelId, models, reasoning, setModelId, setReasoning],
[
data?.defaultModelId,
data?.provider,
getRequestBody,
getRequestHeaders,
isLoading,
modelId,
models,
providerUser,
quota,
quotaError,
quotaLoading,
reasoning,
refreshQuota,
setModelId,
setReasoning,
signInToProvider,
],
);
return <AssistantPreferencesContext.Provider value={value}>{children}</AssistantPreferencesContext.Provider>;
@@ -329,14 +442,19 @@ const AssistantThreadRuntime = () => {
const localThreadId = useAuiState((state) => state.threadListItem.id);
const threadId = useAuiState((state) => state.threadListItem.remoteId);
const preferences = useAssistantPreferences();
const { getRequestBody, getRequestHeaders, refreshQuota } = preferences;
const transport = useMemo(
() =>
new AssistantChatTransport({
api: "/api/assistant/chat",
body: preferences.getRequestBody,
fetch: (input, init) => globalThis.fetch(input, { ...init, body: prepareAssistantRequestBody(init?.body) }),
body: getRequestBody,
fetch: (input, init) => {
const headers = new Headers(init?.headers);
for (const [name, value] of Object.entries(getRequestHeaders())) headers.set(name, value);
return globalThis.fetch(input, { ...init, headers, body: prepareAssistantRequestBody(init?.body) });
},
}),
[preferences.getRequestBody],
[getRequestBody, getRequestHeaders],
);
const history = useMemo(() => createHistoryAdapter(threadId), [threadId]);
const selectedModel = preferences.models.find((model) => model.id === preferences.modelId);
@@ -360,20 +478,21 @@ const AssistantThreadRuntime = () => {
}),
[t, threadId],
);
const onError = useCallback(
() =>
showErrorNotification({
title: t("responseError.title"),
message: t("responseError.description"),
autoClose: 10_000,
}),
[t],
);
const onError = useCallback(() => {
void refreshQuota();
showErrorNotification({
title: t("responseError.title"),
message: t("responseError.description"),
autoClose: 10_000,
});
}, [refreshQuota, t]);
const onFinish = useCallback(() => void refreshQuota(), [refreshQuota]);
const chat = useChat<AssistantUIMessage>({
id: localThreadId,
transport,
onError,
onFinish,
sendAutomaticallyWhen: shouldAutomaticallyContinueAssistant,
});
@@ -560,6 +679,11 @@ const EnabledAssistantProvider = ({ children }: PropsWithChildren) => {
);
const pendingAction = getPendingAssistantAction(latestAssistantMessage);
const assistantIsRunning = isRunning || queuedPrompt !== null;
const providerUnavailable = isAssistantProviderUnavailable({
provider: preferences.provider,
signedIn: preferences.providerUser !== null,
remaining: preferences.quota?.remaining,
});
const notificationKey = getNotificationKey(latestAssistantMessage);
const notificationStateRef = useRef(initialAssistantNotificationState);
const mutationRefreshStateRef = useRef<{ conversationId: string | null; toolCallIds: Set<string> }>({
@@ -590,6 +714,15 @@ const EnabledAssistantProvider = ({ children }: PropsWithChildren) => {
(prompt: string) => {
const text = prompt.trim();
if (text.length === 0) return false;
if (providerUnavailable) {
showWarningNotification({
title: t("providerQuota.title"),
message: preferences.providerUser
? t("providerQuota.exhaustedDescription")
: t("providerQuota.signInDescription"),
});
return false;
}
if (assistantIsRunning || latestStatus?.type === "requires-action") {
showWarningNotification({
title: t("busy.title"),
@@ -604,7 +737,7 @@ const EnabledAssistantProvider = ({ children }: PropsWithChildren) => {
}
return sendPromptThroughRuntime(aui, text);
},
[assistantIsRunning, aui, isLoading, latestStatus?.type, t],
[assistantIsRunning, aui, isLoading, latestStatus?.type, preferences.providerUser, providerUnavailable, t],
);
const selectModel = useCallback(
(modelId: string) => {
@@ -622,10 +755,14 @@ const EnabledAssistantProvider = ({ children }: PropsWithChildren) => {
useEffect(() => {
if (isLoading || isRunning || queuedPrompt === null) return;
if (providerUnavailable) {
setQueuedPrompt(null);
return;
}
const prompt = queuedPrompt;
setQueuedPrompt(null);
sendPromptThroughRuntime(aui, prompt);
}, [aui, isLoading, isRunning, queuedPrompt]);
}, [aui, isLoading, isRunning, providerUnavailable, queuedPrompt]);
useEffect(() => {
if (isLoading) return;
@@ -667,12 +804,16 @@ const EnabledAssistantProvider = ({ children }: PropsWithChildren) => {
useHotkeys([[hotkeys.openAssistant, open, { preventDefault: true }]]);
const spotlightItem = useMemo(() => {
const canSend = !assistantIsRunning && latestStatus?.type !== "requires-action";
const canSend = !providerUnavailable && !assistantIsRunning && latestStatus?.type !== "requires-action";
return {
id: "homarr-assistant",
name: t("spotlight"),
icon: "/logo/logo.png",
description: canSend ? t("spotlightDescription") : t("busy.description"),
description: providerUnavailable
? t("providerQuota.unavailableDescription")
: canSend
? t("spotlightDescription")
: t("busy.description"),
unavailable: !canSend,
alwaysVisible: true,
interaction: (query: string) =>
@@ -683,7 +824,7 @@ const EnabledAssistantProvider = ({ children }: PropsWithChildren) => {
canSend,
}),
};
}, [assistantIsRunning, latestStatus?.type, sendPrompt, t]);
}, [assistantIsRunning, latestStatus?.type, providerUnavailable, sendPrompt, t]);
useRegisterSpotlightContextResults("homarr-assistant", [spotlightItem], [spotlightItem]);
const value = useMemo(
@@ -3,13 +3,23 @@ import { WORKSHOP_API_URL, WORKSHOP_WEB_URL } from "@homarr/workshop/schema";
let browserClient: WorkshopBackend | undefined;
export function getWorkshopApiUrl() {
const runtimeUrl =
typeof document === "undefined"
? undefined
: document.querySelector<HTMLMetaElement>('meta[name="homarr-workshop-api-url"]')?.content;
return (runtimeUrl || WORKSHOP_API_URL).replace(/\/+$/u, "");
}
export function getWorkshopAssistantProviderUrl() {
return `${getWorkshopApiUrl()}/api/ai/v1`;
}
export function createWorkshopClient() {
if (typeof document === "undefined") return new WorkshopBackend(WORKSHOP_API_URL);
if (typeof document === "undefined") return new WorkshopBackend(getWorkshopApiUrl());
if (browserClient) return browserClient;
const runtimeUrl = document.querySelector<HTMLMetaElement>('meta[name="homarr-workshop-api-url"]')?.content;
browserClient = new WorkshopBackend(runtimeUrl || WORKSHOP_API_URL);
browserClient = new WorkshopBackend(getWorkshopApiUrl());
return browserClient;
}
@@ -3,7 +3,7 @@
import { readFileSync } from "node:fs";
import { afterEach, describe, expect, it } from "vitest";
import { getWorkshopWebUrl } from "./workshop-client";
import { getWorkshopAssistantProviderUrl, getWorkshopWebUrl } from "./workshop-client";
describe("Workshop widget install flow", () => {
afterEach(() => {
@@ -17,6 +17,12 @@ describe("Workshop widget install flow", () => {
expect(getWorkshopWebUrl("widget/id")).toBe("https://preview.example/workshop/widget%2Fid");
});
it("derives the Homarr provider endpoint from the runtime Workshop API", () => {
document.head.innerHTML = '<meta name="homarr-workshop-api-url" content="https://api.preview.example/">';
expect(getWorkshopAssistantProviderUrl()).toBe("https://api.preview.example/api/ai/v1");
});
it("uses one registered modal stack for browsing, details, reports, and confirmation", () => {
const installButton = readFileSync(
`${process.cwd()}/apps/nextjs/src/components/workshop/workshop-install-button.tsx`,