feat: add assistant conversation widget
This commit is contained in:
1 parent
c7b0c1bcf0
commit
00fa1192d7
17 files changed
+779
-93
No files matched your search
@@ -0,0 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import { Center, Stack, Text, ThemeIcon } from "@mantine/core";
|
||||
import { IconRobot } from "@tabler/icons-react";
|
||||
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { useAssistantWidgetRenderer } from "./context";
|
||||
|
||||
export default function AssistantWidget(props: WidgetComponentProps<"assistant">) {
|
||||
const Renderer = useAssistantWidgetRenderer();
|
||||
const t = useI18n();
|
||||
|
||||
if (Renderer) return <Renderer {...props} />;
|
||||
|
||||
return (
|
||||
<Center h="100%" p="md">
|
||||
<Stack align="center" gap="xs" ta="center">
|
||||
<ThemeIcon variant="light" color="red" radius="xl" size="lg">
|
||||
<IconRobot size={20} />
|
||||
</ThemeIcon>
|
||||
<Text size="sm" fw={600}>
|
||||
{t("widget.assistant.unavailable.title")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("widget.assistant.unavailable.description")}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import type { ComponentType, PropsWithChildren } from "react";
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
|
||||
type AssistantWidgetRenderer = ComponentType<WidgetComponentProps<"assistant">>;
|
||||
|
||||
const AssistantWidgetRendererContext = createContext<AssistantWidgetRenderer | null>(null);
|
||||
|
||||
interface AssistantWidgetRendererProviderProps extends PropsWithChildren {
|
||||
renderer: AssistantWidgetRenderer;
|
||||
}
|
||||
|
||||
export const AssistantWidgetRendererProvider = ({ children, renderer }: AssistantWidgetRendererProviderProps) => (
|
||||
<AssistantWidgetRendererContext.Provider value={renderer}>{children}</AssistantWidgetRendererContext.Provider>
|
||||
);
|
||||
|
||||
export const useAssistantWidgetRenderer = () => useContext(AssistantWidgetRendererContext);
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { definition } from ".";
|
||||
|
||||
describe("assistant widget definition", () => {
|
||||
it("follows the current conversation by default", () => {
|
||||
const options = definition.createOptions();
|
||||
|
||||
expect(options.conversationMode.defaultValue).toBe("current");
|
||||
expect(options.conversation.shouldHide?.({ conversationMode: "current", conversation: null }, [])).toBe(true);
|
||||
});
|
||||
|
||||
it("reveals the conversation selector only when a conversation is pinned", () => {
|
||||
const options = definition.createOptions();
|
||||
|
||||
expect(options.conversation.shouldHide?.({ conversationMode: "pinned", conversation: null }, [])).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { IconRobot } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
|
||||
import { createWidgetDefinition } from "../definition";
|
||||
import { optionsBuilder } from "../options";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("assistant", {
|
||||
icon: IconRobot,
|
||||
createOptions() {
|
||||
return optionsBuilder.from(
|
||||
(factory) => ({
|
||||
conversationMode: factory.select({
|
||||
options: (["current", "pinned"] as const).map((value) => ({
|
||||
value,
|
||||
label: (t) => t(`widget.assistant.option.conversationMode.option.${value}`),
|
||||
})),
|
||||
defaultValue: "current",
|
||||
withDescription: true,
|
||||
}),
|
||||
conversation: factory.dynamicSelect({
|
||||
withDescription: true,
|
||||
useOptions(query) {
|
||||
const { data: threads = [], isPending } = clientApi.assistant.listThreads.useQuery();
|
||||
const normalizedQuery = query.trim().toLocaleLowerCase();
|
||||
return {
|
||||
isPending,
|
||||
options: threads
|
||||
.filter((thread) => {
|
||||
if (normalizedQuery.length === 0) return true;
|
||||
return (thread.title ?? "").toLocaleLowerCase().includes(normalizedQuery);
|
||||
})
|
||||
.map((thread) => ({
|
||||
value: thread.id,
|
||||
label: thread.title?.trim() || thread.id,
|
||||
})),
|
||||
};
|
||||
},
|
||||
}),
|
||||
}),
|
||||
{
|
||||
conversation: {
|
||||
shouldHide: (options) => options.conversationMode !== "pinned",
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
}).withDynamicImport(() => import("./component"));
|
||||
|
||||
export { AssistantWidgetRendererProvider } from "./context";
|
||||
@@ -64,6 +64,7 @@ import * as beszelSystemStats from "./beszel-system-stats";
|
||||
import * as video from "./video";
|
||||
import * as weather from "./weather";
|
||||
import * as customApi from "./custom-api";
|
||||
import * as assistant from "./assistant";
|
||||
import * as wud from "./wud";
|
||||
|
||||
export type {
|
||||
@@ -88,6 +89,7 @@ export {
|
||||
} from "./definition";
|
||||
export type { WidgetComponentProps };
|
||||
export type { WidgetOptionDefinition, WidgetOptionType } from "./options";
|
||||
export { AssistantWidgetRendererProvider } from "./assistant/context";
|
||||
|
||||
export const widgetImports = {
|
||||
clock,
|
||||
@@ -144,6 +146,7 @@ export const widgetImports = {
|
||||
beszelSystemStats,
|
||||
traefik,
|
||||
customApi,
|
||||
assistant,
|
||||
wud,
|
||||
} satisfies WidgetImportRecord;
|
||||
|
||||
|
||||
Reference in new issue
Block a user