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

@@ -55,5 +55,6 @@ export const widgetDocSlugs: Record<WidgetKind, string | null> = {
beszelSystemStats: "beszel-system-stats",
traefik: "traefik",
customApi: "custom-api",
assistant: "assistant",
wud: "whats-up-docker",
};
+2
View File
@@ -53,6 +53,7 @@ export const widgetKinds = [
"beszelSystemStats",
"traefik",
"customApi",
"assistant",
"wud",
] as const;
export type WidgetKind = (typeof widgetKinds)[number];
@@ -64,4 +65,5 @@ export const widgetDefaultSizes: Partial<Record<WidgetKind, { width: number; hei
patchmon: { width: 2, height: 2 },
mediaMissing: { width: 4, height: 3 },
bazarr: { width: 2, height: 2 },
assistant: { width: 10, height: 4 },
};
+36
View File
@@ -4873,6 +4873,42 @@
"unhealthy": "Unhealthy"
}
},
"assistant": {
"name": "Assistant",
"description": "Have a complete assistant conversation directly on your board.",
"option": {
"conversationMode": {
"label": "Conversation",
"description": "Follow the conversation used elsewhere in Homarr, or keep this widget attached to a specific conversation.",
"option": {
"current": "Current conversation",
"pinned": "Pinned conversation"
}
},
"conversation": {
"label": "Pinned conversation",
"description": "This selector stays hidden while the widget follows the current conversation."
}
},
"pinned": {
"title": "Pinned conversation",
"description": "This widget is attached to {conversation}. Open it to continue here without silently changing your active conversation.",
"selectTitle": "Choose a conversation",
"selectDescription": "Open the widget settings and select the conversation this widget should keep available.",
"open": "Open conversation",
"opening": "Opening conversation...",
"error": "This conversation could not be opened. It may have been deleted."
},
"unavailable": {
"title": "Assistant unavailable",
"description": "Sign in and ask an administrator to configure the Assistant from the management panel."
},
"compact": {
"title": "Assistant",
"description": "Resize this widget to chat here, or open the full conversation.",
"open": "Open assistant"
}
},
"customApi": {
"name": "Custom widget",
"description": "Render a configured Custom JSX definition.",
@@ -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;