Harden Homarr provider integration
This commit is contained in:
1 parent
165201632d
commit
4ebaea29fd
23 files changed
+427
-159
No files matched your search
@@ -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
|
||||
@@ -16,8 +18,7 @@ export const toProviderOptionsKey = (name: string) =>
|
||||
})
|
||||
.join("");
|
||||
|
||||
export const getHomarrProviderBaseUrl = (workshopApiUrl: string) =>
|
||||
`${workshopApiUrl.replace(/\/+$/u, "")}/api/ai/v1`;
|
||||
export const getHomarrProviderBaseUrl = (workshopApiUrl: string) => `${workshopApiUrl.replace(/\/+$/u, "")}/api/ai/v1`;
|
||||
|
||||
export const resolveHomarrProviderToken = ({
|
||||
provider,
|
||||
@@ -37,4 +38,3 @@ export const resolveHomarrProviderToken = ({
|
||||
const token = headers.get(assistantHomarrProviderTokenHeader)?.trim();
|
||||
return token && token.length <= 4096 ? token : null;
|
||||
};
|
||||
import { assistantHomarrProviderTokenHeader } from "@homarr/definitions";
|
||||
@@ -117,7 +117,7 @@ import remarkGfm from "remark-gfm";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { clientApi, fetchApi } from "@homarr/api/client";
|
||||
import { useTimeAgo } from "@homarr/common/hooks";
|
||||
import { useTimeAgo } from "@homarr/common";
|
||||
import { assistantProviderIds, assistantProviderPresets, assistantReasoningModes } from "@homarr/definitions";
|
||||
import type { AssistantProvider } from "@homarr/definitions";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
@@ -153,7 +153,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 } from "./assistant-provider-quota";
|
||||
import { getAssistantProviderQuotaLevel, isAssistantProviderUnavailable } from "./assistant-provider-quota";
|
||||
import { getAssistantToolTraceTarget } from "./assistant-tool-trace";
|
||||
import { getSafeAssistantHttpUrl } from "./assistant-url";
|
||||
|
||||
@@ -2581,12 +2581,7 @@ const HomarrProviderQuota = () => {
|
||||
return (
|
||||
<Popover position="top" width={290} shadow="md" withinPortal>
|
||||
<Popover.Target>
|
||||
<UnstyledButton
|
||||
className={classes.providerQuotaTrigger}
|
||||
data-level={level}
|
||||
type="button"
|
||||
aria-label={label}
|
||||
>
|
||||
<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 ?? "–"}
|
||||
@@ -2644,7 +2639,10 @@ const HomarrProviderQuota = () => {
|
||||
</Box>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("providerQuota.reset", { relative: resetRelative })}{" "}
|
||||
<time dateTime={preferences.quota.resetsAt} title={new Date(preferences.quota.resetsAt).toLocaleString()}>
|
||||
<time
|
||||
dateTime={preferences.quota.resetsAt}
|
||||
title={new Date(preferences.quota.resetsAt).toLocaleString()}
|
||||
>
|
||||
{new Date(preferences.quota.resetsAt).toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
@@ -2679,8 +2677,15 @@ const HomarrProviderQuota = () => {
|
||||
|
||||
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");
|
||||
|
||||
@@ -2701,7 +2706,7 @@ const Composer = (props: ComposerProps) => {
|
||||
className={classes.composer}
|
||||
data-pending-action={hasPendingAction || undefined}
|
||||
onSubmit={(event) => {
|
||||
if (hasPendingAction) event.preventDefault();
|
||||
if (sendBlocked) event.preventDefault();
|
||||
}}
|
||||
>
|
||||
<ComposerTriggers />
|
||||
@@ -2747,8 +2752,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>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { getAssistantProviderQuotaLevel } from "./assistant-provider-quota";
|
||||
import { getAssistantProviderQuotaLevel, isAssistantProviderUnavailable } from "./assistant-provider-quota";
|
||||
|
||||
describe("getAssistantProviderQuotaLevel", () => {
|
||||
test.each([
|
||||
@@ -12,4 +12,11 @@ describe("getAssistantProviderQuotaLevel", () => {
|
||||
] as const)("maps %o to %s", (usage, expected) => {
|
||||
expect(getAssistantProviderQuotaLevel(usage)).toBe(expected);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { WorkshopAssistantUsage } from "@homarr/workshop/schema";
|
||||
import type { AssistantProvider } from "@homarr/definitions";
|
||||
|
||||
export type AssistantProviderQuotaLevel = "ok" | "warning" | "bad" | "dead";
|
||||
|
||||
@@ -11,3 +12,13 @@ export const getAssistantProviderQuotaLevel = (
|
||||
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);
|
||||
@@ -29,6 +29,7 @@ import { createWorkshopClient } from "~/components/workshop/workshop-client";
|
||||
|
||||
import { AssistantContext, AssistantPreferencesContext, useAssistantPreferences } from "./assistant-context";
|
||||
import { shouldAutomaticallyContinueAssistant } from "./assistant-auto-submit";
|
||||
import { isAssistantProviderUnavailable } from "./assistant-provider-quota";
|
||||
import { AssistantAutoApprovalProvider } from "./assistant-auto-approval";
|
||||
import { getRunningAssistantPartType } from "./assistant-activity-state";
|
||||
import { prepareAssistantRequestBody } from "./assistant-attachment-payload";
|
||||
@@ -169,7 +170,7 @@ const AssistantPreferencesProvider = ({ children }: PropsWithChildren) => {
|
||||
reasoning: current.reasoning,
|
||||
};
|
||||
}, []);
|
||||
const getRequestHeaders = useCallback(() => {
|
||||
const getRequestHeaders = useCallback((): Record<string, string> => {
|
||||
if (data?.provider !== "homarr") return {};
|
||||
const token = workshopClient.authToken;
|
||||
return token ? { [assistantHomarrProviderTokenHeader]: token } : {};
|
||||
@@ -412,18 +413,19 @@ const AssistantThreadRuntime = () => {
|
||||
const localThreadId = useAuiState((state) => state.threadListItem.id);
|
||||
const threadId = useAuiState((state) => state.threadListItem.remoteId);
|
||||
const preferences = useAssistantPreferences();
|
||||
const { getRequestBody, getRequestHeaders } = preferences;
|
||||
const transport = useMemo(
|
||||
() =>
|
||||
new AssistantChatTransport({
|
||||
api: "/api/assistant/chat",
|
||||
body: preferences.getRequestBody,
|
||||
body: getRequestBody,
|
||||
fetch: (input, init) => {
|
||||
const headers = new Headers(init?.headers);
|
||||
for (const [name, value] of Object.entries(preferences.getRequestHeaders())) headers.set(name, value);
|
||||
for (const [name, value] of Object.entries(getRequestHeaders())) headers.set(name, value);
|
||||
return globalThis.fetch(input, { ...init, headers, body: prepareAssistantRequestBody(init?.body) });
|
||||
},
|
||||
}),
|
||||
[preferences.getRequestBody, preferences.getRequestHeaders],
|
||||
[getRequestBody, getRequestHeaders],
|
||||
);
|
||||
const history = useMemo(() => createHistoryAdapter(threadId), [threadId]);
|
||||
const selectedModel = preferences.models.find((model) => model.id === preferences.modelId);
|
||||
@@ -447,17 +449,14 @@ const AssistantThreadRuntime = () => {
|
||||
}),
|
||||
[t, threadId],
|
||||
);
|
||||
const onError = useCallback(
|
||||
() => {
|
||||
void preferences.refreshQuota();
|
||||
showErrorNotification({
|
||||
title: t("responseError.title"),
|
||||
message: t("responseError.description"),
|
||||
autoClose: 10_000,
|
||||
});
|
||||
},
|
||||
[preferences, t],
|
||||
);
|
||||
const onError = useCallback(() => {
|
||||
void preferences.refreshQuota();
|
||||
showErrorNotification({
|
||||
title: t("responseError.title"),
|
||||
message: t("responseError.description"),
|
||||
autoClose: 10_000,
|
||||
});
|
||||
}, [preferences, t]);
|
||||
|
||||
const chat = useChat<AssistantUIMessage>({
|
||||
id: localThreadId,
|
||||
@@ -650,6 +649,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> }>({
|
||||
@@ -680,6 +684,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"),
|
||||
@@ -694,7 +707,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) => {
|
||||
@@ -712,10 +725,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;
|
||||
@@ -757,12 +774,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) =>
|
||||
@@ -773,7 +794,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(
|
||||
|
||||
Reference in new issue
Block a user