Complete Homarr provider integration
This commit is contained in:
1 parent
bc59355eab
commit
165201632d
23 files changed
+986
-61
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: var(--mantine-color-green-7);
|
||||
}
|
||||
|
||||
.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: var(--mantine-color-red-7);
|
||||
}
|
||||
|
||||
.conversationContextPopover {
|
||||
max-height: min(34rem, calc(100dvh - 1rem));
|
||||
overflow: hidden;
|
||||
|
||||
@@ -60,6 +60,7 @@ import {
|
||||
Image,
|
||||
Loader,
|
||||
Popover,
|
||||
Progress,
|
||||
RingProgress,
|
||||
ScrollArea,
|
||||
SegmentedControl,
|
||||
@@ -72,6 +73,7 @@ import {
|
||||
useComputedColorScheme,
|
||||
} from "@mantine/core";
|
||||
import { useReducedMotion, useWindowEvent } from "@mantine/hooks";
|
||||
import { BatteryFull, BatteryLow, BatteryMedium, BatteryWarning } from "lucide-react";
|
||||
import {
|
||||
IconActivityHeartbeat,
|
||||
IconAlertTriangle,
|
||||
@@ -115,6 +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 { assistantProviderIds, assistantProviderPresets, assistantReasoningModes } from "@homarr/definitions";
|
||||
import type { AssistantProvider } from "@homarr/definitions";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
@@ -129,6 +132,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 +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 { getAssistantToolTraceTarget } from "./assistant-tool-trace";
|
||||
import { getSafeAssistantHttpUrl } from "./assistant-url";
|
||||
|
||||
@@ -2537,6 +2542,141 @@ const RuntimeControls = ({
|
||||
);
|
||||
};
|
||||
|
||||
const providerQuotaIcons = {
|
||||
ok: BatteryFull,
|
||||
warning: BatteryMedium,
|
||||
bad: BatteryLow,
|
||||
dead: BatteryWarning,
|
||||
};
|
||||
|
||||
const providerQuotaColors = {
|
||||
ok: "green",
|
||||
warning: "yellow",
|
||||
bad: "orange",
|
||||
dead: "red",
|
||||
} as const;
|
||||
|
||||
const HomarrProviderQuota = () => {
|
||||
const t = useScopedI18n("common.assistant");
|
||||
const preferences = useAssistantPreferences();
|
||||
const resetAt = useMemo(() => new Date(preferences.quota?.resetsAt ?? Date.now()), [preferences.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
|
||||
? preferences.quota
|
||||
? t("providerQuota.remainingLabel", {
|
||||
remaining: preferences.quota.remaining,
|
||||
limit: preferences.quota.limit,
|
||||
})
|
||||
: t("providerQuota.loading")
|
||||
: t("providerQuota.signInRequired");
|
||||
|
||||
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("providerQuota.reset", { relative: resetRelative })}{" "}
|
||||
<time dateTime={preferences.quota.resetsAt} title={new Date(preferences.quota.resetsAt).toLocaleString()}>
|
||||
{new Date(preferences.quota.resetsAt).toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
timeZoneName: "short",
|
||||
})}
|
||||
</time>
|
||||
</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 running = useAuiState((state) => state.thread.isRunning);
|
||||
@@ -2618,6 +2758,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,15 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { getAssistantProviderQuotaLevel } 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"],
|
||||
] as const)("maps %o to %s", (usage, expected) => {
|
||||
expect(getAssistantProviderQuotaLevel(usage)).toBe(expected);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { WorkshopAssistantUsage } from "@homarr/workshop/schema";
|
||||
|
||||
export type AssistantProviderQuotaLevel = "ok" | "warning" | "bad" | "dead";
|
||||
|
||||
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";
|
||||
};
|
||||
@@ -19,12 +19,13 @@ 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";
|
||||
@@ -129,6 +130,11 @@ const AssistantPreferencesProvider = ({ children }: PropsWithChildren) => {
|
||||
});
|
||||
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 previousDefaultModelIdRef = useRef<string | null | undefined>(undefined);
|
||||
const preferencesRef = useRef<{ modelId: string | null; reasoning: AssistantReasoningMode }>({
|
||||
modelId: null,
|
||||
@@ -163,6 +169,41 @@ const AssistantPreferencesProvider = ({ children }: PropsWithChildren) => {
|
||||
reasoning: current.reasoning,
|
||||
};
|
||||
}, []);
|
||||
const getRequestHeaders = useCallback(() => {
|
||||
if (data?.provider !== "homarr") return {};
|
||||
const token = workshopClient.authToken;
|
||||
return token ? { [assistantHomarrProviderTokenHeader]: token } : {};
|
||||
}, [data?.provider, workshopClient]);
|
||||
const refreshQuota = useCallback(async () => {
|
||||
if (data?.provider !== "homarr" || !workshopClient.authToken) {
|
||||
setQuota(null);
|
||||
setQuotaError(null);
|
||||
return;
|
||||
}
|
||||
setQuotaLoading(true);
|
||||
setQuotaError(null);
|
||||
try {
|
||||
setQuota(await workshopClient.getAssistantUsage());
|
||||
} catch (error) {
|
||||
setQuotaError(error instanceof Error ? error.message : "The Homarr provider allowance could not be loaded.");
|
||||
} finally {
|
||||
setQuotaLoading(false);
|
||||
}
|
||||
}, [data?.provider, workshopClient]);
|
||||
const signInToProvider = useCallback(async () => {
|
||||
setQuotaLoading(true);
|
||||
setQuotaError(null);
|
||||
try {
|
||||
const user = await workshopClient.signInWithGitHub();
|
||||
setProviderUser(user);
|
||||
await refreshQuota();
|
||||
} catch (error) {
|
||||
setQuotaError(error instanceof Error ? error.message : "GitHub sign-in failed.");
|
||||
throw error;
|
||||
} finally {
|
||||
setQuotaLoading(false);
|
||||
}
|
||||
}, [refreshQuota, workshopClient]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!data) return;
|
||||
@@ -178,18 +219,60 @@ 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);
|
||||
return unsubscribe;
|
||||
}, [data?.provider, workshopClient]);
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.provider !== "homarr" || !providerUser) return;
|
||||
void refreshQuota();
|
||||
}, [data?.provider, providerUser, 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>;
|
||||
@@ -334,9 +417,13 @@ const AssistantThreadRuntime = () => {
|
||||
new AssistantChatTransport({
|
||||
api: "/api/assistant/chat",
|
||||
body: preferences.getRequestBody,
|
||||
fetch: (input, init) => globalThis.fetch(input, { ...init, body: prepareAssistantRequestBody(init?.body) }),
|
||||
fetch: (input, init) => {
|
||||
const headers = new Headers(init?.headers);
|
||||
for (const [name, value] of Object.entries(preferences.getRequestHeaders())) headers.set(name, value);
|
||||
return globalThis.fetch(input, { ...init, headers, body: prepareAssistantRequestBody(init?.body) });
|
||||
},
|
||||
}),
|
||||
[preferences.getRequestBody],
|
||||
[preferences.getRequestBody, preferences.getRequestHeaders],
|
||||
);
|
||||
const history = useMemo(() => createHistoryAdapter(threadId), [threadId]);
|
||||
const selectedModel = preferences.models.find((model) => model.id === preferences.modelId);
|
||||
@@ -361,19 +448,22 @@ const AssistantThreadRuntime = () => {
|
||||
[t, threadId],
|
||||
);
|
||||
const onError = useCallback(
|
||||
() =>
|
||||
() => {
|
||||
void preferences.refreshQuota();
|
||||
showErrorNotification({
|
||||
title: t("responseError.title"),
|
||||
message: t("responseError.description"),
|
||||
autoClose: 10_000,
|
||||
}),
|
||||
[t],
|
||||
});
|
||||
},
|
||||
[preferences, t],
|
||||
);
|
||||
|
||||
const chat = useChat<AssistantUIMessage>({
|
||||
id: localThreadId,
|
||||
transport,
|
||||
onError,
|
||||
onFinish: () => void preferences.refreshQuota(),
|
||||
sendAutomaticallyWhen: shouldAutomaticallyContinueAssistant,
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user