fix: address assistant review findings
This commit is contained in:
1 parent
87f264a286
commit
b988e431e8
8 files changed
+71
-180
No files matched your search
@@ -5,7 +5,6 @@ hide_title: true
|
||||
---
|
||||
|
||||
import { AddingWidget } from "@site/src/components/widgets/adding";
|
||||
import { WidgetConfig } from "@site/src/components/widgets/configuration";
|
||||
import { WidgetHeader } from "@site/src/components/widgets/header";
|
||||
import { assistantWidget } from ".";
|
||||
|
||||
@@ -28,15 +27,8 @@ touch devices. When a board layout does not provide enough height for conversati
|
||||
a compact **Open assistant** action instead of squeezing an unusable composer into the card. Increase its height
|
||||
in that layout to keep the complete conversation inline.
|
||||
|
||||
### Configuration
|
||||
|
||||
<WidgetConfig configuration={assistantWidget.configuration} />
|
||||
|
||||
**Current conversation** follows whichever conversation is active in the user-menu panel, `Shift + A`, or
|
||||
the command menu. This is the default and keeps every assistant entry point synchronized.
|
||||
|
||||
**Pinned conversation** reveals a searchable conversation selector. A pinned widget never changes the
|
||||
active conversation silently: select **Open conversation** inside the widget before continuing that thread.
|
||||
The widget follows whichever conversation is active in the user-menu panel, `Shift + A`, or the command menu. This
|
||||
keeps every assistant entry point synchronized without storing a user's private conversation details in board settings.
|
||||
|
||||
### Conversation features
|
||||
|
||||
|
||||
@@ -44,9 +44,11 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
|
||||
page: async ({ params }: { params: Promise<TParams> }) => {
|
||||
const resolvedParams = await params;
|
||||
const queryClient = getQueryClient();
|
||||
const session = await auth();
|
||||
const sessionPromise = auth();
|
||||
const boardPromise = getInitialBoard(resolvedParams);
|
||||
const session = await sessionPromise;
|
||||
|
||||
const board = await getInitialBoard(resolvedParams).catch((error) => {
|
||||
const board = await boardPromise.catch((error) => {
|
||||
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
|
||||
if (!session) {
|
||||
logger.debug("No home board found for anonymous user, redirecting to login");
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
Accordion,
|
||||
ActionIcon,
|
||||
@@ -140,6 +141,7 @@ const ConfigurationSkeleton = ({ label }: { label: string }) => (
|
||||
|
||||
export const AssistantConfiguration = () => {
|
||||
const t = useScopedI18n("management.page.settings.section.assistant");
|
||||
const router = useRouter();
|
||||
const utils = clientApi.useUtils();
|
||||
const { data: configuration, isLoading } = clientApi.assistant.getAdminConfiguration.useQuery();
|
||||
const [provider, setProvider] = useState<AssistantProvider>("openrouter");
|
||||
@@ -268,6 +270,7 @@ export const AssistantConfiguration = () => {
|
||||
utils.assistant.getAvailability.invalidate(),
|
||||
utils.assistant.getRuntimeOptions.invalidate(),
|
||||
]);
|
||||
router.refresh();
|
||||
showSuccessNotification({
|
||||
title: t("notification.saved.title"),
|
||||
message: t("notification.saved.message"),
|
||||
|
||||
@@ -159,6 +159,7 @@ import { getAssistantIconSearchQuery } from "./assistant-tool-label";
|
||||
import { getAssistantProviderQuotaLevel, isAssistantProviderUnavailable } from "./assistant-provider-quota";
|
||||
import { getAssistantToolTraceTarget } from "./assistant-tool-trace";
|
||||
import { getSafeAssistantHttpUrl } from "./assistant-url";
|
||||
import { isEscapeOwnedByNestedOverlay } from "../board/advanced-focus/escape";
|
||||
|
||||
// Shiki's web grammar bundle is intentionally loaded only when a settled response contains a code
|
||||
// block. Most assistant conversations never need it, so it should not inflate the global dashboard
|
||||
@@ -407,7 +408,27 @@ type MarkdownSpanProps = ComponentPropsWithoutRef<"span"> & {
|
||||
"data-directive-type"?: string;
|
||||
};
|
||||
|
||||
const AssistantDirectiveEntitiesContext = createContext<AssistantDirectiveEntity[]>([]);
|
||||
interface AssistantDirectiveEntitiesState {
|
||||
entities: AssistantDirectiveEntity[];
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
const AssistantDirectiveEntitiesContext = createContext<AssistantDirectiveEntitiesState>({
|
||||
entities: [],
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
const AssistantDirectiveEntitiesProvider = ({ children }: { children: ReactNode }) => {
|
||||
const { data: entities = [], isLoading } = clientApi.assistant.getContextEntities.useQuery(undefined, {
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
return (
|
||||
<AssistantDirectiveEntitiesContext.Provider value={{ entities, isLoading }}>
|
||||
{children}
|
||||
</AssistantDirectiveEntitiesContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
const AssistantMarkdownSpan = ({
|
||||
children,
|
||||
@@ -417,7 +438,7 @@ const AssistantMarkdownSpan = ({
|
||||
"data-directive-type": directiveType,
|
||||
...props
|
||||
}: MarkdownSpanProps) => {
|
||||
const entities = useContext(AssistantDirectiveEntitiesContext);
|
||||
const { entities } = useContext(AssistantDirectiveEntitiesContext);
|
||||
if (assistantDirective === "true" && directiveId && directiveLabel && directiveType) {
|
||||
const entity = resolveAssistantDirectiveEntity(entities, {
|
||||
id: directiveId,
|
||||
@@ -444,26 +465,20 @@ const assistantMarkdownComponents = unstable_memoizeMarkdownComponents({
|
||||
SyntaxHighlighter: AssistantSyntaxHighlighter,
|
||||
});
|
||||
|
||||
const AssistantTextPart = () => {
|
||||
const { data: entities = [] } = clientApi.assistant.getContextEntities.useQuery(undefined, { staleTime: 60_000 });
|
||||
|
||||
return (
|
||||
<AssistantDirectiveEntitiesContext.Provider value={entities}>
|
||||
<MarkdownTextPrimitive
|
||||
className={classes.messageMarkdown}
|
||||
preprocess={normalizeAssistantMarkdown}
|
||||
remarkPlugins={markdownRemarkPlugins}
|
||||
components={assistantMarkdownComponents}
|
||||
componentsByLanguage={{ mermaid: { SyntaxHighlighter: MermaidDiagram } }}
|
||||
defer
|
||||
/>
|
||||
</AssistantDirectiveEntitiesContext.Provider>
|
||||
);
|
||||
};
|
||||
const AssistantTextPart = () => (
|
||||
<MarkdownTextPrimitive
|
||||
className={classes.messageMarkdown}
|
||||
preprocess={normalizeAssistantMarkdown}
|
||||
remarkPlugins={markdownRemarkPlugins}
|
||||
components={assistantMarkdownComponents}
|
||||
componentsByLanguage={{ mermaid: { SyntaxHighlighter: MermaidDiagram } }}
|
||||
defer
|
||||
/>
|
||||
);
|
||||
|
||||
const UserTextPart = () => {
|
||||
const { text } = useMessagePartText();
|
||||
const { data: entities = [] } = clientApi.assistant.getContextEntities.useQuery(undefined, { staleTime: 60_000 });
|
||||
const { entities } = useContext(AssistantDirectiveEntitiesContext);
|
||||
const segments = parseAssistantDirectives(text);
|
||||
return (
|
||||
<span className={classes.messageText}>
|
||||
@@ -548,7 +563,7 @@ const SentAttachment = () => <Attachment />;
|
||||
const HiddenMessagePart = () => null;
|
||||
|
||||
const ReasoningPart = (_props: ReasoningMessagePartProps) => (
|
||||
<AssistantDirectiveEntitiesContext.Provider value={[]}>
|
||||
<AssistantDirectiveEntitiesContext.Provider value={{ entities: [], isLoading: false }}>
|
||||
<MarkdownTextPrimitive
|
||||
className={`${classes.messageMarkdown} ${classes.reasoningText}`}
|
||||
preprocess={normalizeAssistantMarkdown}
|
||||
@@ -2264,9 +2279,7 @@ const ComposerTriggers = () => {
|
||||
const aui = useAui();
|
||||
const mentionViewportRef = useRef<HTMLDivElement>(null);
|
||||
const slashViewportRef = useRef<HTMLDivElement>(null);
|
||||
const { data: entities = [], isLoading } = clientApi.assistant.getContextEntities.useQuery(undefined, {
|
||||
staleTime: 60_000,
|
||||
});
|
||||
const { entities, isLoading } = useContext(AssistantDirectiveEntitiesContext);
|
||||
const categories = useMemo(
|
||||
() =>
|
||||
(["app", "integration", "board", "widget"] as const).map((type) => ({
|
||||
@@ -2914,7 +2927,7 @@ const AssistantActivityBar = ({
|
||||
|
||||
return (
|
||||
<Box component="output" className={classes.activityBar} aria-live="polite">
|
||||
<UnstyledButton className={classes.activityTrigger} onClick={onOpen} aria-label={t("activity.expand")}>
|
||||
<UnstyledButton className={classes.activityTrigger} onClick={onOpen}>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<ThemeIcon variant="light" color={activityColor} radius="xl">
|
||||
<AssistantDotMatrix state={activityState} label={title} role="presentation" aria-hidden />
|
||||
@@ -2996,7 +3009,7 @@ export const AssistantConversationSurface = ({
|
||||
const scrollToLatestBehavior = isRunning || reducedMotion ? "instant" : "smooth";
|
||||
|
||||
return (
|
||||
<>
|
||||
<AssistantDirectiveEntitiesProvider>
|
||||
<Group className={classes.panelHeader} justify="space-between" wrap="nowrap" gap="xs">
|
||||
{onWindowClose && onMinimize && (
|
||||
<Group
|
||||
@@ -3107,7 +3120,7 @@ export const AssistantConversationSurface = ({
|
||||
</ThreadPrimitive.Root>
|
||||
</ReasoningVisibilityContext.Provider>
|
||||
</AssistantQuestionPortalProvider>
|
||||
</>
|
||||
</AssistantDirectiveEntitiesProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3135,9 +3148,19 @@ export const AssistantPanel = ({
|
||||
}: AssistantPanelProps) => {
|
||||
const t = useScopedI18n("common.assistant");
|
||||
const previousFocusRef = useRef<HTMLElement | null>(null);
|
||||
const panelRef = useRef<HTMLDialogElement>(null);
|
||||
|
||||
useWindowEvent("keydown", (event) => {
|
||||
if (opened && event.key === "Escape") onClose();
|
||||
if (
|
||||
!opened ||
|
||||
event.key !== "Escape" ||
|
||||
event.defaultPrevented ||
|
||||
event.isComposing ||
|
||||
isEscapeOwnedByNestedOverlay(event.target, panelRef.current)
|
||||
)
|
||||
return;
|
||||
event.preventDefault();
|
||||
onClose();
|
||||
});
|
||||
useEffect(() => {
|
||||
if (!opened) {
|
||||
@@ -3174,7 +3197,7 @@ export const AssistantPanel = ({
|
||||
/>
|
||||
)}
|
||||
{opened && (
|
||||
<dialog className={classes.floatingPanel} aria-label={t("title")} open>
|
||||
<dialog ref={panelRef} className={classes.floatingPanel} aria-label={t("title")} open>
|
||||
<AssistantConversationSurface
|
||||
isRunning={isRunning}
|
||||
pendingAction={pendingAction}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import type { CSSProperties, ReactNode } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { ActionIcon, Box, Center, Group, Stack, Text, ThemeIcon, Tooltip } from "@mantine/core";
|
||||
import { IconArrowsMaximize, IconRobot } from "@tabler/icons-react";
|
||||
import { useAui, useAuiState } from "@assistant-ui/react";
|
||||
import { ActionIcon, Box, Button, Center, Group, Loader, Stack, Text, ThemeIcon, Tooltip } from "@mantine/core";
|
||||
import { IconAlertTriangle, IconArrowsMaximize, IconMessage, IconRobot } from "@tabler/icons-react";
|
||||
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import type { WidgetComponentProps } from "@homarr/widgets";
|
||||
@@ -30,81 +29,22 @@ export const AssistantBoardWidget = (props: WidgetComponentProps<"assistant">) =
|
||||
);
|
||||
};
|
||||
|
||||
const EnabledAssistantBoardWidget = ({ options, width, height, isEditMode }: WidgetComponentProps<"assistant">) => {
|
||||
const t = useI18n();
|
||||
const EnabledAssistantBoardWidget = ({ width, height, isEditMode }: WidgetComponentProps<"assistant">) => {
|
||||
const board = useRequiredBoard();
|
||||
const assistant = useHomarrAssistant();
|
||||
const preferences = useAssistantPreferences();
|
||||
const aui = useAui();
|
||||
const currentThreadId = useAuiState((state) => state.threadListItem.remoteId);
|
||||
const messages = useAuiState((state) => state.thread.messages);
|
||||
const isLoading = useAuiState((state) => state.thread.isLoading);
|
||||
const latestAssistantMessage = messages.findLast((message) => message.role === "assistant");
|
||||
const pendingAction = getPendingAssistantAction(latestAssistantMessage);
|
||||
const pinnedConversation = options.conversationMode === "pinned" ? options.conversation : null;
|
||||
const pinnedConversationIsActive = pinnedConversation === null || pinnedConversation.value === currentThreadId;
|
||||
const [switching, setSwitching] = useState(false);
|
||||
const [switchErrorConversationId, setSwitchErrorConversationId] = useState<string | null>(null);
|
||||
const hasSwitchError = switchErrorConversationId === pinnedConversation?.value;
|
||||
|
||||
const selectModel = useCallback(
|
||||
(modelId: string) => {
|
||||
preferences.setModelId(modelId);
|
||||
const threadListItem = aui.threadListItem();
|
||||
if (!threadListItem.getState().remoteId) return;
|
||||
threadListItem.updateCustom({ ...threadListItem.getState().custom, modelId });
|
||||
},
|
||||
[aui, preferences],
|
||||
);
|
||||
|
||||
const openPinnedConversation = async () => {
|
||||
if (!pinnedConversation) return;
|
||||
setSwitchErrorConversationId(null);
|
||||
setSwitching(true);
|
||||
try {
|
||||
await aui.threads().switchToThread(pinnedConversation.value);
|
||||
} catch {
|
||||
setSwitchErrorConversationId(pinnedConversation.value);
|
||||
} finally {
|
||||
setSwitching(false);
|
||||
}
|
||||
const selectModel = (modelId: string) => {
|
||||
preferences.setModelId(modelId);
|
||||
const threadListItem = aui.threadListItem();
|
||||
if (!threadListItem.getState().remoteId) return;
|
||||
threadListItem.updateCustom({ ...threadListItem.getState().custom, modelId });
|
||||
};
|
||||
|
||||
if (options.conversationMode === "pinned" && pinnedConversation === null) {
|
||||
return (
|
||||
<AssistantWidgetState
|
||||
icon={IconMessage}
|
||||
title={t("widget.assistant.pinned.selectTitle")}
|
||||
description={t("widget.assistant.pinned.selectDescription")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!pinnedConversationIsActive && pinnedConversation) {
|
||||
return (
|
||||
<AssistantWidgetState
|
||||
icon={hasSwitchError ? IconAlertTriangle : IconMessage}
|
||||
color={hasSwitchError ? "red" : "gray"}
|
||||
title={t("widget.assistant.pinned.title")}
|
||||
description={
|
||||
hasSwitchError
|
||||
? t("widget.assistant.pinned.error")
|
||||
: t("widget.assistant.pinned.description", { conversation: pinnedConversation.label })
|
||||
}
|
||||
>
|
||||
<Button
|
||||
variant="light"
|
||||
color={hasSwitchError ? "red" : "gray"}
|
||||
onClick={() => void openPinnedConversation()}
|
||||
loading={switching}
|
||||
leftSection={switching ? <Loader type="bars" size="xs" /> : <IconMessage size={16} />}
|
||||
>
|
||||
{switching ? t("widget.assistant.pinned.opening") : t("widget.assistant.pinned.open")}
|
||||
</Button>
|
||||
</AssistantWidgetState>
|
||||
);
|
||||
}
|
||||
|
||||
if (width < 300 || height < 280) {
|
||||
return <CompactAssistantWidget onOpen={assistant.open} />;
|
||||
}
|
||||
|
||||
@@ -5418,30 +5418,6 @@
|
||||
"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": "Choose the saved conversation this widget should keep available.",
|
||||
"placeholder": "Select a saved 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."
|
||||
|
||||
@@ -8,16 +8,9 @@ describe("assistant widget definition", () => {
|
||||
expect(supportsAdvancedFocus(definition)).toBe(false);
|
||||
});
|
||||
|
||||
it("follows the current conversation by default", () => {
|
||||
it("does not persist private conversation data in board options", () => {
|
||||
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);
|
||||
expect(options).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -1,50 +1,12 @@
|
||||
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, isError } = clientApi.assistant.listThreads.useQuery();
|
||||
const normalizedQuery = query.trim().toLocaleLowerCase();
|
||||
return {
|
||||
isPending,
|
||||
isError,
|
||||
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",
|
||||
},
|
||||
},
|
||||
);
|
||||
return optionsBuilder.from(() => ({}));
|
||||
},
|
||||
}).withDynamicImport(() => import("./component"));
|
||||
|
||||
|
||||
Reference in new issue
Block a user