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:
1 parent
b8f360c04e
commit
45d8bc9443
77 files changed
+4035
-763
No files matched your search
+114
-105
@@ -26,6 +26,7 @@ import {
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconBrandGithub,
|
||||
IconCheck,
|
||||
IconCircleCheck,
|
||||
IconDatabaseSearch,
|
||||
@@ -51,14 +52,14 @@ import type { AssistantProvider, AssistantProviderCategory } from "@homarr/defin
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { getWorkshopAssistantProviderUrl } from "~/components/workshop/workshop-client";
|
||||
|
||||
import classes from "./assistant-configuration.module.css";
|
||||
import { formatAssistantContextWindow, getAssistantConnectionState } from "./assistant-configuration-state";
|
||||
|
||||
type HeaderEntry = { id: number; name: string; value: string };
|
||||
type CredentialFlow = "idle" | "remove";
|
||||
type AssistantProviderOption = AssistantProvider | "homarr";
|
||||
|
||||
const ProviderIcon = ({ providerId, size = 20 }: { providerId: AssistantProviderOption; size?: number }) => {
|
||||
const ProviderIcon = ({ providerId, size = 20 }: { providerId: AssistantProvider; size?: number }) => {
|
||||
if (providerId === "homarr") {
|
||||
return <Image src="/logo/logo.png" alt="" aria-hidden w={size} h={size} fit="contain" />;
|
||||
}
|
||||
@@ -154,8 +155,13 @@ export const AssistantConfiguration = () => {
|
||||
const [webSearchEnabled, setWebSearchEnabled] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setProvider(configuration?.provider ?? "openrouter");
|
||||
setBaseUrl(configuration?.baseUrl ?? assistantProviderPresets.openrouter.baseUrl);
|
||||
const configuredProvider = configuration?.provider ?? "openrouter";
|
||||
setProvider(configuredProvider);
|
||||
setBaseUrl(
|
||||
configuredProvider === "homarr"
|
||||
? getWorkshopAssistantProviderUrl()
|
||||
: (configuration?.baseUrl ?? assistantProviderPresets.openrouter.baseUrl),
|
||||
);
|
||||
setModelDiscoveryPath(configuration?.modelDiscoveryPath ?? "");
|
||||
setModelId(configuration?.modelId ?? "");
|
||||
setEnabled(configuration?.enabled ?? false);
|
||||
@@ -203,20 +209,7 @@ export const AssistantConfiguration = () => {
|
||||
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,
|
||||
};
|
||||
return { group: t(`provider.groups.${category}`), items };
|
||||
}),
|
||||
[t],
|
||||
);
|
||||
@@ -232,6 +225,7 @@ export const AssistantConfiguration = () => {
|
||||
}, [modelId, models]);
|
||||
|
||||
const preset = assistantProviderPresets[provider];
|
||||
const isHomarrProvider = provider === "homarr";
|
||||
const headerValuesValid = headers.every((header) => header.name.trim().length > 0 && header.value.length > 0);
|
||||
const { hasStoredApiKey, connectionPending, connectionReady } = getAssistantConnectionState({
|
||||
connectionConfigured: configuration?.connectionConfigured === true,
|
||||
@@ -304,7 +298,7 @@ export const AssistantConfiguration = () => {
|
||||
const nextPreset = assistantProviderPresets[nextProvider];
|
||||
clearDraftConnectionState();
|
||||
setProvider(nextProvider);
|
||||
setBaseUrl(nextPreset.baseUrl);
|
||||
setBaseUrl(nextProvider === "homarr" ? getWorkshopAssistantProviderUrl() : nextPreset.baseUrl);
|
||||
setModelDiscoveryPath(nextPreset.modelDiscoveryPath ?? "");
|
||||
};
|
||||
|
||||
@@ -334,16 +328,19 @@ export const AssistantConfiguration = () => {
|
||||
};
|
||||
|
||||
const saveConnection = (clearApiKey = false) => {
|
||||
const customHeaders =
|
||||
headers.length > 0 ? Object.fromEntries(headers.map((header) => [header.name.trim(), header.value])) : undefined;
|
||||
const customHeaders = isHomarrProvider
|
||||
? undefined
|
||||
: headers.length > 0
|
||||
? Object.fromEntries(headers.map((header) => [header.name.trim(), header.value]))
|
||||
: undefined;
|
||||
updateConnection.mutate({
|
||||
provider,
|
||||
baseUrl,
|
||||
modelDiscoveryPath: modelDiscoveryPath.trim() || null,
|
||||
apiKey: clearApiKey ? undefined : apiKey.trim() || undefined,
|
||||
clearApiKey,
|
||||
apiKey: clearApiKey || isHomarrProvider ? undefined : apiKey.trim() || undefined,
|
||||
clearApiKey: clearApiKey || isHomarrProvider,
|
||||
customHeaders,
|
||||
clearCustomHeaders: clearHeaders,
|
||||
clearCustomHeaders: clearHeaders || isHomarrProvider,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -434,17 +431,12 @@ export const AssistantConfiguration = () => {
|
||||
leftSection={<ProviderIcon providerId={provider} />}
|
||||
renderOption={({ option }) => (
|
||||
<Group gap="sm" wrap="nowrap" align="center" w="100%">
|
||||
<ProviderIcon providerId={option.value as AssistantProviderOption} />
|
||||
<ProviderIcon providerId={option.value as AssistantProvider} />
|
||||
<Box flex={1} miw={0}>
|
||||
<Group gap="xs" wrap="nowrap" justify="space-between">
|
||||
<Text size="sm" truncate>
|
||||
{option.label}
|
||||
</Text>
|
||||
{option.value === "homarr" && (
|
||||
<Badge size="xs" variant="light" color="gray" flex="0 0 auto">
|
||||
{t("provider.comingSoon")}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
</Box>
|
||||
</Group>
|
||||
@@ -455,6 +447,7 @@ export const AssistantConfiguration = () => {
|
||||
description={t("baseUrl.description")}
|
||||
leftSection={<IconWorld size={16} />}
|
||||
value={baseUrl}
|
||||
disabled={isHomarrProvider}
|
||||
onChange={(event) => changeBaseUrl(event.currentTarget.value)}
|
||||
placeholder="https://provider.example/v1"
|
||||
/>
|
||||
@@ -462,6 +455,7 @@ export const AssistantConfiguration = () => {
|
||||
label={t("model.discoveryPath")}
|
||||
description={t("model.discoveryPathDescription")}
|
||||
value={modelDiscoveryPath}
|
||||
disabled={isHomarrProvider}
|
||||
onChange={(event) => changeModelDiscoveryPath(event.currentTarget.value)}
|
||||
placeholder="/models"
|
||||
/>
|
||||
@@ -473,7 +467,11 @@ export const AssistantConfiguration = () => {
|
||||
title={t("credentials.title")}
|
||||
description={t("credentials.description")}
|
||||
status={
|
||||
hasStoredApiKey ? undefined : (
|
||||
isHomarrProvider ? (
|
||||
<Badge variant="light" color="blue">
|
||||
{t("credentials.workshop")}
|
||||
</Badge>
|
||||
) : hasStoredApiKey ? undefined : (
|
||||
<Badge variant="light" color={preset.requiresApiKey ? "yellow" : "gray"}>
|
||||
{preset.requiresApiKey ? t("credentials.required") : t("credentials.optional")}
|
||||
</Badge>
|
||||
@@ -494,7 +492,11 @@ export const AssistantConfiguration = () => {
|
||||
</Group>
|
||||
}
|
||||
>
|
||||
{hasStoredApiKey && credentialFlow === "idle" ? (
|
||||
{isHomarrProvider ? (
|
||||
<Alert color="blue" icon={<IconBrandGithub size={18} />} title={t("provider.homarrAuth.title")}>
|
||||
{t("provider.homarrAuth.description")}
|
||||
</Alert>
|
||||
) : hasStoredApiKey && credentialFlow === "idle" ? (
|
||||
<Group className={classes.credentialSummary} justify="space-between" align="center">
|
||||
<Group className={classes.credentialSummaryContent} gap="sm" wrap="nowrap">
|
||||
<ThemeIcon color="teal" variant="light" radius="xl">
|
||||
@@ -545,80 +547,87 @@ export const AssistantConfiguration = () => {
|
||||
/>
|
||||
)}
|
||||
|
||||
<Accordion variant="contained" radius="md">
|
||||
<Accordion.Item value="headers">
|
||||
<Accordion.Control className={classes.advancedControl} icon={<IconShieldLock size={18} />}>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Box>
|
||||
<Text size="sm" fw={600}>
|
||||
{t("headers.title")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("headers.description")}
|
||||
</Text>
|
||||
</Box>
|
||||
{configuration?.customHeadersConfigured && (
|
||||
<Badge variant="light" color={clearHeaders ? "yellow" : "green"}>
|
||||
{clearHeaders ? t("headers.pendingRemoval") : t("headers.savedBadge")}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between" align="center">
|
||||
<Text size="sm" c="dimmed">
|
||||
{configuration?.customHeadersConfigured ? t("headers.configured") : t("headers.empty")}
|
||||
</Text>
|
||||
<Button variant="light" size="compact-sm" leftSection={<IconPlus size={14} />} onClick={addHeader}>
|
||||
{t("headers.add")}
|
||||
</Button>
|
||||
{!isHomarrProvider && (
|
||||
<Accordion variant="contained" radius="md">
|
||||
<Accordion.Item value="headers">
|
||||
<Accordion.Control className={classes.advancedControl} icon={<IconShieldLock size={18} />}>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Box>
|
||||
<Text size="sm" fw={600}>
|
||||
{t("headers.title")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("headers.description")}
|
||||
</Text>
|
||||
</Box>
|
||||
{configuration?.customHeadersConfigured && (
|
||||
<Badge variant="light" color={clearHeaders ? "yellow" : "green"}>
|
||||
{clearHeaders ? t("headers.pendingRemoval") : t("headers.savedBadge")}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
{headers.map((header) => (
|
||||
<Group key={header.id} align="flex-end" wrap="wrap">
|
||||
<TextInput
|
||||
flex={1}
|
||||
miw="12rem"
|
||||
label={t("headers.name")}
|
||||
value={header.name}
|
||||
onChange={(event) => updateHeader(header.id, "name", event.currentTarget.value)}
|
||||
placeholder="X-Provider-Header"
|
||||
/>
|
||||
<PasswordInput
|
||||
flex={2}
|
||||
miw="12rem"
|
||||
label={t("headers.value")}
|
||||
value={header.value}
|
||||
onChange={(event) => updateHeader(header.id, "value", event.currentTarget.value)}
|
||||
placeholder={t("headers.valuePlaceholder")}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<Tooltip label={t("headers.remove")}>
|
||||
<ActionIcon
|
||||
size="lg"
|
||||
variant="subtle"
|
||||
color="red"
|
||||
aria-label={t("headers.remove")}
|
||||
onClick={() => setHeaders((current) => current.filter((item) => item.id !== header.id))}
|
||||
>
|
||||
<IconTrash size={17} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between" align="center">
|
||||
<Text size="sm" c="dimmed">
|
||||
{configuration?.customHeadersConfigured ? t("headers.configured") : t("headers.empty")}
|
||||
</Text>
|
||||
<Button
|
||||
variant="light"
|
||||
size="compact-sm"
|
||||
leftSection={<IconPlus size={14} />}
|
||||
onClick={addHeader}
|
||||
>
|
||||
{t("headers.add")}
|
||||
</Button>
|
||||
</Group>
|
||||
))}
|
||||
{configuration?.customHeadersConfigured && headers.length === 0 && (
|
||||
<Button
|
||||
variant="light"
|
||||
color={clearHeaders ? "gray" : "red"}
|
||||
onClick={() => setClearHeaders(!clearHeaders)}
|
||||
>
|
||||
{clearHeaders ? t("headers.keep") : t("headers.clear")}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
{headers.map((header) => (
|
||||
<Group key={header.id} align="flex-end" wrap="wrap">
|
||||
<TextInput
|
||||
flex={1}
|
||||
miw="12rem"
|
||||
label={t("headers.name")}
|
||||
value={header.name}
|
||||
onChange={(event) => updateHeader(header.id, "name", event.currentTarget.value)}
|
||||
placeholder="X-Provider-Header"
|
||||
/>
|
||||
<PasswordInput
|
||||
flex={2}
|
||||
miw="12rem"
|
||||
label={t("headers.value")}
|
||||
value={header.value}
|
||||
onChange={(event) => updateHeader(header.id, "value", event.currentTarget.value)}
|
||||
placeholder={t("headers.valuePlaceholder")}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<Tooltip label={t("headers.remove")}>
|
||||
<ActionIcon
|
||||
size="lg"
|
||||
variant="subtle"
|
||||
color="red"
|
||||
aria-label={t("headers.remove")}
|
||||
onClick={() => setHeaders((current) => current.filter((item) => item.id !== header.id))}
|
||||
>
|
||||
<IconTrash size={17} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
))}
|
||||
{configuration?.customHeadersConfigured && headers.length === 0 && (
|
||||
<Button
|
||||
variant="light"
|
||||
color={clearHeaders ? "gray" : "red"}
|
||||
onClick={() => setClearHeaders(!clearHeaders)}
|
||||
>
|
||||
{clearHeaders ? t("headers.keep") : t("headers.clear")}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
)}
|
||||
</ConfigurationSection>
|
||||
|
||||
<ConfigurationSection
|
||||
|
||||
@@ -5,7 +5,7 @@ import { assistantExecutionPolicy } from "./assistant-execution-policy";
|
||||
describe("assistantExecutionPolicy", () => {
|
||||
test("allows a sizeable batch of tool calls to finish in one request", () => {
|
||||
expect(assistantExecutionPolicy.maxSteps).toBeGreaterThanOrEqual(32);
|
||||
expect(assistantExecutionPolicy.maxOutputTokens).toBeGreaterThanOrEqual(65_536);
|
||||
expect(assistantExecutionPolicy.maxOutputTokens).toBe(32_768);
|
||||
expect(assistantExecutionPolicy.totalTimeoutMs).toBeGreaterThan(assistantExecutionPolicy.stepTimeoutMs);
|
||||
expect(assistantExecutionPolicy.toolTimeoutMs).toBeGreaterThanOrEqual(60_000);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export const assistantExecutionPolicy = {
|
||||
maxSteps: 32,
|
||||
maxOutputTokens: 65_536,
|
||||
maxOutputTokens: 32_768,
|
||||
totalTimeoutMs: 240_000,
|
||||
stepTimeoutMs: 60_000,
|
||||
toolTimeoutMs: 60_000,
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { toProviderOptionsKey } from "./assistant-provider-options";
|
||||
import { assistantHomarrProviderTokenHeader } from "@homarr/definitions";
|
||||
|
||||
import {
|
||||
getHomarrProviderBaseUrl,
|
||||
resolveHomarrProviderToken,
|
||||
toProviderOptionsKey,
|
||||
} from "./assistant-provider-options";
|
||||
|
||||
describe("toProviderOptionsKey", () => {
|
||||
test("converts the hyphenated provider name to the camelCase AI SDK provider option key", () => {
|
||||
@@ -16,3 +22,55 @@ describe("toProviderOptionsKey", () => {
|
||||
expect(toProviderOptionsKey("homarr")).toBe("homarr");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveHomarrProviderToken", () => {
|
||||
test("accepts a Workshop token only for the matching Homarr provider endpoint", () => {
|
||||
const headers = new Headers({ [assistantHomarrProviderTokenHeader]: "workshop-token" });
|
||||
expect(
|
||||
resolveHomarrProviderToken({
|
||||
provider: "homarr",
|
||||
configuredBaseUrl: "https://homarr.dev/api/ai/v1/",
|
||||
workshopApiUrl: "https://homarr.dev",
|
||||
headers,
|
||||
}),
|
||||
).toBe("workshop-token");
|
||||
expect(getHomarrProviderBaseUrl("https://homarr.dev/")).toBe("https://homarr.dev/api/ai/v1");
|
||||
});
|
||||
|
||||
test.each([
|
||||
[undefined, null],
|
||||
["", null],
|
||||
[" ", null],
|
||||
["a".repeat(4096), "a".repeat(4096)],
|
||||
["a".repeat(4097), null],
|
||||
])("validates the Workshop token header %s", (token, expected) => {
|
||||
const headers = new Headers(token === undefined ? undefined : { [assistantHomarrProviderTokenHeader]: token });
|
||||
expect(
|
||||
resolveHomarrProviderToken({
|
||||
provider: "homarr",
|
||||
configuredBaseUrl: "https://homarr.dev/api/ai/v1",
|
||||
workshopApiUrl: "https://homarr.dev",
|
||||
headers,
|
||||
}),
|
||||
).toBe(expected);
|
||||
});
|
||||
|
||||
test("never forwards a Workshop token to another endpoint", () => {
|
||||
expect(() =>
|
||||
resolveHomarrProviderToken({
|
||||
provider: "homarr",
|
||||
configuredBaseUrl: "https://attacker.example/v1",
|
||||
workshopApiUrl: "https://homarr.dev",
|
||||
headers: new Headers({ [assistantHomarrProviderTokenHeader]: "workshop-token" }),
|
||||
}),
|
||||
).toThrow("does not match");
|
||||
expect(
|
||||
resolveHomarrProviderToken({
|
||||
provider: "openrouter",
|
||||
configuredBaseUrl: "https://openrouter.ai/api/v1",
|
||||
workshopApiUrl: "https://homarr.dev",
|
||||
headers: new Headers({ [assistantHomarrProviderTokenHeader]: "workshop-token" }),
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,5 @@
|
||||
import { assistantHomarrProviderTokenHeader } from "@homarr/definitions";
|
||||
|
||||
/**
|
||||
* The AI SDK resolves provider options by the provider name registered on the
|
||||
* language model. The @ai-sdk/openai-compatible provider converts the raw
|
||||
@@ -15,3 +17,24 @@ export const toProviderOptionsKey = (name: string) =>
|
||||
return segment.charAt(0).toUpperCase() + segment.slice(1);
|
||||
})
|
||||
.join("");
|
||||
|
||||
export const getHomarrProviderBaseUrl = (workshopApiUrl: string) => `${workshopApiUrl.replace(/\/+$/u, "")}/api/ai/v1`;
|
||||
|
||||
export const resolveHomarrProviderToken = ({
|
||||
provider,
|
||||
configuredBaseUrl,
|
||||
workshopApiUrl,
|
||||
headers,
|
||||
}: {
|
||||
provider: string;
|
||||
configuredBaseUrl: string;
|
||||
workshopApiUrl: string;
|
||||
headers: Headers;
|
||||
}) => {
|
||||
if (provider !== "homarr") return undefined;
|
||||
if (configuredBaseUrl.replace(/\/+$/u, "") !== getHomarrProviderBaseUrl(workshopApiUrl)) {
|
||||
throw new Error("The Homarr provider endpoint does not match the configured Workshop server.");
|
||||
}
|
||||
const token = headers.get(assistantHomarrProviderTokenHeader)?.trim();
|
||||
return token && token.length <= 4096 ? token : null;
|
||||
};
|
||||
@@ -45,7 +45,7 @@ import {
|
||||
withOpenRouterToolRequestOptions,
|
||||
withOpenRouterWebSearch,
|
||||
} from "./assistant-openrouter";
|
||||
import { toProviderOptionsKey } from "./assistant-provider-options";
|
||||
import { resolveHomarrProviderToken, toProviderOptionsKey } from "./assistant-provider-options";
|
||||
import { assistantExecutionPolicy } from "./assistant-execution-policy";
|
||||
import { getAssistantStreamErrorMessage } from "./assistant-stream-error";
|
||||
import { getSafeAssistantToolError } from "./assistant-tool-error";
|
||||
@@ -72,6 +72,7 @@ export const maxDuration = 300;
|
||||
const assistantRequestMaxBytes = 12_000_000;
|
||||
|
||||
const logger = createLogger({ module: "assistant" });
|
||||
const getAssistantLogErrorType = (error: unknown) => (error instanceof Error ? error.name : typeof error);
|
||||
const getToolApprovalSecret = () =>
|
||||
Buffer.from(
|
||||
hkdfSync("sha256", Buffer.from(env.SECRET_ENCRYPTION_KEY, "hex"), "", "assistant-tool-approval", 32),
|
||||
@@ -424,6 +425,28 @@ export async function POST(request: Request) {
|
||||
return Response.json({ error: "Homarr Assistant is not configured." }, { status: 503 });
|
||||
}
|
||||
|
||||
let homarrProviderToken: string | null | undefined;
|
||||
try {
|
||||
homarrProviderToken = resolveHomarrProviderToken({
|
||||
provider: configuration.provider,
|
||||
configuredBaseUrl: configuration.baseUrl,
|
||||
workshopApiUrl: appEnv.WORKSHOP_API_URL ?? appEnv.HOMARR_WEBSITE_URL,
|
||||
headers: request.headers,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.warn("Rejected an unsafe Homarr provider endpoint", {
|
||||
provider: configuration.provider,
|
||||
errorType: getAssistantLogErrorType(error),
|
||||
});
|
||||
return Response.json({ error: "The Homarr provider endpoint is not configured safely." }, { status: 503 });
|
||||
}
|
||||
if (homarrProviderToken === null) {
|
||||
return Response.json(
|
||||
{ error: "Sign in to the Homarr Community Workshop to use the Homarr provider." },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
const context = createTRPCContext({ headers: request.headers, session });
|
||||
const requestStartedAt = Date.now();
|
||||
const requestId = crypto.randomUUID();
|
||||
@@ -440,7 +463,7 @@ export async function POST(request: Request) {
|
||||
requestId,
|
||||
provider: configuration.provider,
|
||||
modelId: requestedModelId,
|
||||
error: modelLookup.error,
|
||||
errorType: getAssistantLogErrorType(modelLookup.error),
|
||||
});
|
||||
}
|
||||
const selectedModel = modelLookup.model;
|
||||
@@ -514,7 +537,7 @@ export async function POST(request: Request) {
|
||||
} catch (error) {
|
||||
logger.error("Assistant tool call failed", {
|
||||
toolName: mcpTool.name,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
errorType: getAssistantLogErrorType(error),
|
||||
});
|
||||
return { error: getSafeAssistantToolError(error, { toolName: mcpTool.name }) };
|
||||
}
|
||||
@@ -559,13 +582,18 @@ export async function POST(request: Request) {
|
||||
...(configuration.provider === "openrouter" || openRouterServerToolsEnabled
|
||||
? {
|
||||
"HTTP-Referer": "https://homarr.dev",
|
||||
"X-Title": "Homarr Assistant",
|
||||
"X-OpenRouter-Title": "Homarr AI Assistant",
|
||||
"X-OpenRouter-Metadata": "enabled",
|
||||
}
|
||||
: {}),
|
||||
...customHeaders,
|
||||
};
|
||||
const providerApiKey = configuration.encryptedApiKey ? decryptSecret(configuration.encryptedApiKey) : undefined;
|
||||
const providerApiKey =
|
||||
configuration.provider === "homarr"
|
||||
? homarrProviderToken
|
||||
: configuration.encryptedApiKey
|
||||
? decryptSecret(configuration.encryptedApiKey)
|
||||
: undefined;
|
||||
const providerName = `homarr-${configuration.provider}`;
|
||||
const provider = createOpenAICompatible({
|
||||
name: providerName,
|
||||
@@ -720,7 +748,7 @@ export async function POST(request: Request) {
|
||||
requestId,
|
||||
provider: configuration.provider,
|
||||
modelId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
errorType: getAssistantLogErrorType(error),
|
||||
});
|
||||
},
|
||||
onFinish: async () => {
|
||||
@@ -732,7 +760,7 @@ export async function POST(request: Request) {
|
||||
} catch (error) {
|
||||
logger.error("Failed to update assistant conversation metadata", {
|
||||
threadId: thread.id,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
errorType: getAssistantLogErrorType(error),
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -839,7 +867,7 @@ export async function POST(request: Request) {
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("Assistant response could not start", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
errorType: getAssistantLogErrorType(error),
|
||||
});
|
||||
return Response.json(
|
||||
{
|
||||
|
||||
@@ -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`,
|
||||
|
||||
Reference in new issue
Block a user