feat: add assistant conversation widget

This commit is contained in:
ajnart committed 2026-08-13 19:56:26 +02:00
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);
});
});
+50
View File
@@ -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";
+3
View File
@@ -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;