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
@@ -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",
|
||||
};
|
||||
@@ -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 },
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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