+
+
{t("title")}
+
+ {t("description")}
+
+
+
+
+ >
+ );
+}
diff --git a/apps/nextjs/src/app/[locale]/manage/layout.tsx b/apps/nextjs/src/app/[locale]/manage/layout.tsx
index d5d96d3f6..7e8a7fff2 100644
--- a/apps/nextjs/src/app/[locale]/manage/layout.tsx
+++ b/apps/nextjs/src/app/[locale]/manage/layout.tsx
@@ -21,6 +21,7 @@ import {
IconMailForward,
IconPhotoFilled,
IconPointerFilled,
+ IconRobot,
IconSearch,
IconSettingsFilled,
IconUserFilled,
@@ -198,6 +199,12 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
},
],
},
+ {
+ label: t("items.assistant"),
+ href: "/manage/assistant",
+ icon: IconRobot,
+ hidden: !session?.user.permissions.includes("admin"),
+ },
{
label: t("items.settings"),
href: "/manage/settings",
diff --git a/apps/nextjs/src/components/assistant/assistant-provider.tsx b/apps/nextjs/src/components/assistant/assistant-provider.tsx
index 98b29f6b6..012b40610 100644
--- a/apps/nextjs/src/components/assistant/assistant-provider.tsx
+++ b/apps/nextjs/src/components/assistant/assistant-provider.tsx
@@ -259,7 +259,7 @@ export const AssistantProvider = ({ children }: PropsWithChildren) => {
const t = useScopedI18n("common.assistant");
const session = useSession();
const [opened, setOpened] = useState(false);
- const { data } = clientApi.assistant.getAvailability.useQuery(undefined, {
+ const { data, isLoading: isAvailabilityLoading } = clientApi.assistant.getAvailability.useQuery(undefined, {
enabled: session.status === "authenticated",
staleTime: 60_000,
});
@@ -268,19 +268,32 @@ export const AssistantProvider = ({ children }: PropsWithChildren) => {
const open = useCallback(() => setOpened(true), []);
const close = useCallback(() => setOpened(false), []);
const toggle = useCallback(() => setOpened((current) => !current), []);
- useHotkeys([[hotkeys.openAssistant, open]]);
+ const openIfEnabled = useCallback(() => {
+ if (enabled) {
+ open();
+ }
+ }, [enabled, open]);
+ useHotkeys([[hotkeys.openAssistant, openIfEnabled]]);
const spotlightItem = useMemo(
() => ({
id: "homarr-assistant",
name: t("spotlight"),
icon: "/logo/logo.png",
- interaction: () => ({ type: "javaScript" as const, onSelect: open }),
+ description: enabled
+ ? t("spotlightDescription")
+ : session.status !== "authenticated"
+ ? t("unavailable.signIn")
+ : isAvailabilityLoading
+ ? t("unavailable.checking")
+ : t("unavailable.notConfigured"),
+ unavailable: !enabled,
+ interaction: () => (enabled ? { type: "javaScript" as const, onSelect: open } : { type: "none" as const }),
}),
- [open, t],
+ [enabled, isAvailabilityLoading, open, session.status, t],
);
- useRegisterSpotlightContextResults("homarr-assistant", enabled ? [spotlightItem] : [], [enabled, spotlightItem]);
- useRegisterSpotlightContextActions("homarr-assistant", enabled ? [spotlightItem] : [], [enabled, spotlightItem]);
+ useRegisterSpotlightContextResults("homarr-assistant", [spotlightItem], [spotlightItem]);
+ useRegisterSpotlightContextActions("homarr-assistant", [spotlightItem], [spotlightItem]);
const value = useMemo(() => ({ enabled, opened, open, close, toggle }), [close, enabled, open, opened, toggle]);
diff --git a/packages/api/src/test/assistant-providers.spec.ts b/packages/api/src/test/assistant-providers.spec.ts
index 2858cd9d9..78e1b5080 100644
--- a/packages/api/src/test/assistant-providers.spec.ts
+++ b/packages/api/src/test/assistant-providers.spec.ts
@@ -21,4 +21,13 @@ describe("assistant provider presets", () => {
expect(assistantProviderRequiresApiKey(provider)).toBe(assistantProviderPresets[provider].category === "hosted");
}
});
+
+ test.each(assistantProviderIds.filter((provider) => provider !== "custom"))(
+ "%s has a Dashboard Icons provider mark",
+ (provider) => {
+ const iconUrl = assistantProviderPresets[provider].iconUrl;
+ expect(iconUrl).not.toBeNull();
+ expect(() => new URL(iconUrl ?? "")).not.toThrow();
+ },
+ );
});
diff --git a/packages/definitions/src/assistant.ts b/packages/definitions/src/assistant.ts
index 1d926d207..1bafb7df0 100644
--- a/packages/definitions/src/assistant.ts
+++ b/packages/definitions/src/assistant.ts
@@ -16,12 +16,15 @@ export const assistantProviderIds = [
export type AssistantProvider = (typeof assistantProviderIds)[number];
export type AssistantProviderCategory = "hosted" | "local" | "custom";
+const providerIconBaseUrl = "https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons";
+
interface AssistantProviderPreset {
baseUrl: string;
modelDiscoveryPath: string | null;
requiresApiKey: boolean;
category: AssistantProviderCategory;
discoveryAuthentication: "bearer" | "anthropic";
+ iconUrl: string | null;
}
export const assistantProviderPresets = {
@@ -31,6 +34,7 @@ export const assistantProviderPresets = {
requiresApiKey: true,
category: "hosted",
discoveryAuthentication: "bearer",
+ iconUrl: `${providerIconBaseUrl}/openrouter-color.svg`,
},
openai: {
baseUrl: "https://api.openai.com/v1",
@@ -38,6 +42,7 @@ export const assistantProviderPresets = {
requiresApiKey: true,
category: "hosted",
discoveryAuthentication: "bearer",
+ iconUrl: `${providerIconBaseUrl}/openai.svg`,
},
anthropic: {
baseUrl: "https://api.anthropic.com/v1",
@@ -45,6 +50,7 @@ export const assistantProviderPresets = {
requiresApiKey: true,
category: "hosted",
discoveryAuthentication: "anthropic",
+ iconUrl: `${providerIconBaseUrl}/anthropic.svg`,
},
"google-gemini": {
baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai",
@@ -52,6 +58,7 @@ export const assistantProviderPresets = {
requiresApiKey: true,
category: "hosted",
discoveryAuthentication: "bearer",
+ iconUrl: `${providerIconBaseUrl}/gemini-color.svg`,
},
xai: {
baseUrl: "https://api.x.ai/v1",
@@ -59,6 +66,7 @@ export const assistantProviderPresets = {
requiresApiKey: true,
category: "hosted",
discoveryAuthentication: "bearer",
+ iconUrl: `${providerIconBaseUrl}/xai.svg`,
},
groq: {
baseUrl: "https://api.groq.com/openai/v1",
@@ -66,6 +74,7 @@ export const assistantProviderPresets = {
requiresApiKey: true,
category: "hosted",
discoveryAuthentication: "bearer",
+ iconUrl: `${providerIconBaseUrl}/groq.svg`,
},
mistral: {
baseUrl: "https://api.mistral.ai/v1",
@@ -73,6 +82,7 @@ export const assistantProviderPresets = {
requiresApiKey: true,
category: "hosted",
discoveryAuthentication: "bearer",
+ iconUrl: `${providerIconBaseUrl}/mistral-color.svg`,
},
deepseek: {
baseUrl: "https://api.deepseek.com",
@@ -80,6 +90,7 @@ export const assistantProviderPresets = {
requiresApiKey: true,
category: "hosted",
discoveryAuthentication: "bearer",
+ iconUrl: `${providerIconBaseUrl}/deepseek-color.svg`,
},
together: {
baseUrl: "https://api.together.xyz/v1",
@@ -87,6 +98,7 @@ export const assistantProviderPresets = {
requiresApiKey: true,
category: "hosted",
discoveryAuthentication: "bearer",
+ iconUrl: `${providerIconBaseUrl}/together-color.svg`,
},
ollama: {
baseUrl: "http://localhost:11434/v1",
@@ -94,6 +106,7 @@ export const assistantProviderPresets = {
requiresApiKey: false,
category: "local",
discoveryAuthentication: "bearer",
+ iconUrl: `${providerIconBaseUrl}/ollama.svg`,
},
"lm-studio": {
baseUrl: "http://localhost:1234/v1",
@@ -101,6 +114,7 @@ export const assistantProviderPresets = {
requiresApiKey: false,
category: "local",
discoveryAuthentication: "bearer",
+ iconUrl: `${providerIconBaseUrl}/lmstudio.svg`,
},
custom: {
baseUrl: "",
@@ -108,6 +122,7 @@ export const assistantProviderPresets = {
requiresApiKey: false,
category: "custom",
discoveryAuthentication: "bearer",
+ iconUrl: null,
},
} as const satisfies Record