feat: expose assistant global shortcuts
This commit is contained in:
1 parent
4b647ec2c9
commit
73e4b80a26
9 files changed
+90
-28
No files matched your search
@@ -9,8 +9,10 @@ tags:
|
||||
# Assistant
|
||||
|
||||
Administrators can configure the instance-wide AI assistant from **Management → Assistant**.
|
||||
It is available to signed-in users from the user menu and the `Cmd/Ctrl + K` command menu. Selecting
|
||||
**Ask Homarr Assistant** in the command menu turns its search field into a prompt composer. Press
|
||||
It is available to signed-in users from the user menu, with `Cmd/Ctrl + Shift + K`, and from the
|
||||
`Cmd/Ctrl + K` command menu. **Ask Homarr Assistant** is always available as a local result in the default
|
||||
search. Selecting it with an existing query sends that query in the background; selecting it without a
|
||||
query turns the command-menu search field into a prompt composer. Press
|
||||
`Enter` to send the prompt in the background without opening the conversation. The search field receives
|
||||
focus whenever the command menu opens, including when it is opened by an assistant tool.
|
||||
|
||||
|
||||
@@ -30,12 +30,7 @@ import { createId } from "@homarr/common";
|
||||
import { hotkeys } from "@homarr/definitions";
|
||||
import { showErrorNotification, showWarningNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import {
|
||||
openMediaRequestSearch,
|
||||
openSpotlight,
|
||||
useRegisterSpotlightContextActions,
|
||||
useRegisterSpotlightContextResults,
|
||||
} from "@homarr/spotlight";
|
||||
import { openMediaRequestSearch, openSpotlight, useRegisterSpotlightContextResults } from "@homarr/spotlight";
|
||||
|
||||
import { shouldAutomaticallyContinueAssistant } from "./assistant-auto-submit";
|
||||
import { AssistantAskUserTool, AssistantConfigureAppTool } from "./assistant-human-tools";
|
||||
@@ -638,12 +633,12 @@ const EnabledAssistantProvider = ({ children }: PropsWithChildren) => {
|
||||
name: t("spotlight"),
|
||||
icon: "/logo/logo.png",
|
||||
description: t("spotlightDescription"),
|
||||
interaction: () => createAssistantPromptInteraction({ sendPrompt }),
|
||||
alwaysVisible: true,
|
||||
interaction: (query: string) => createAssistantPromptInteraction({ sendPrompt, prompt: query }),
|
||||
}),
|
||||
[sendPrompt, t],
|
||||
);
|
||||
useRegisterSpotlightContextResults("homarr-assistant", [spotlightItem], [spotlightItem]);
|
||||
useRegisterSpotlightContextActions("homarr-assistant", [spotlightItem], [spotlightItem]);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ enabled: true, opened, isRunning: assistantIsRunning, unreadCount, open, close, toggle, sendPrompt }),
|
||||
@@ -691,12 +686,12 @@ const DisabledAssistantProvider = ({ children, description }: DisabledAssistantP
|
||||
icon: "/logo/logo.png",
|
||||
description,
|
||||
unavailable: true,
|
||||
interaction: () => ({ type: "none" as const }),
|
||||
alwaysVisible: true,
|
||||
interaction: (_query: string) => ({ type: "none" as const }),
|
||||
}),
|
||||
[description, t],
|
||||
);
|
||||
useRegisterSpotlightContextResults("homarr-assistant", [spotlightItem], [spotlightItem]);
|
||||
useRegisterSpotlightContextActions("homarr-assistant", [spotlightItem], [spotlightItem]);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
|
||||
@@ -3,9 +3,27 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import { createAssistantPromptInteraction } from "./assistant-spotlight";
|
||||
|
||||
describe("createAssistantPromptInteraction", () => {
|
||||
it("sends an existing default-search query immediately", () => {
|
||||
const sendPrompt = vi.fn();
|
||||
const interaction = createAssistantPromptInteraction({
|
||||
sendPrompt,
|
||||
prompt: " Check my services ",
|
||||
});
|
||||
|
||||
expect(interaction.type).toBe("javaScript");
|
||||
if (interaction.type !== "javaScript") return;
|
||||
|
||||
interaction.onSelect();
|
||||
expect(interaction.closeSpotlightOnTrigger).toBe(true);
|
||||
expect(sendPrompt).toHaveBeenCalledWith("Check my services");
|
||||
});
|
||||
|
||||
it("keeps Spotlight open when the prompt is empty", () => {
|
||||
const sendPrompt = vi.fn();
|
||||
const interaction = createAssistantPromptInteraction({ sendPrompt });
|
||||
expect(interaction.type).toBe("children");
|
||||
if (interaction.type !== "children") return;
|
||||
|
||||
const [action] = interaction.useActions({}, " ");
|
||||
expect(action).toBeDefined();
|
||||
if (!action) return;
|
||||
@@ -22,6 +40,9 @@ describe("createAssistantPromptInteraction", () => {
|
||||
it("trims and sends a prompt in the background", () => {
|
||||
const sendPrompt = vi.fn();
|
||||
const interaction = createAssistantPromptInteraction({ sendPrompt });
|
||||
expect(interaction.type).toBe("children");
|
||||
if (interaction.type !== "children") return;
|
||||
|
||||
const [action] = interaction.useActions({}, " Check my services ");
|
||||
expect(action).toBeDefined();
|
||||
if (!action) return;
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
interface AssistantPromptInteractionOptions {
|
||||
sendPrompt: (prompt: string) => void;
|
||||
prompt?: string;
|
||||
}
|
||||
|
||||
const AssistantPromptDetail = () => {
|
||||
@@ -39,25 +40,35 @@ const AssistantPromptAction = ({ hasPrompt }: { hasPrompt: boolean }) => {
|
||||
);
|
||||
};
|
||||
|
||||
export const createAssistantPromptInteraction = ({ sendPrompt }: AssistantPromptInteractionOptions) =>
|
||||
({
|
||||
export const createAssistantPromptInteraction = ({ sendPrompt, prompt = "" }: AssistantPromptInteractionOptions) => {
|
||||
const trimmedPrompt = prompt.trim();
|
||||
if (trimmedPrompt.length > 0) {
|
||||
return {
|
||||
type: "javaScript" as const,
|
||||
onSelect: () => sendPrompt(trimmedPrompt),
|
||||
closeSpotlightOnTrigger: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: "children",
|
||||
option: {},
|
||||
DetailComponent: AssistantPromptDetail,
|
||||
useActions: (_options: Record<string, unknown>, query: string) => {
|
||||
const prompt = query.trim();
|
||||
const nextPrompt = query.trim();
|
||||
return [
|
||||
{
|
||||
key: "send-assistant-prompt",
|
||||
Component: () => <AssistantPromptAction hasPrompt={prompt.length > 0} />,
|
||||
Component: () => <AssistantPromptAction hasPrompt={nextPrompt.length > 0} />,
|
||||
useInteraction: () => ({
|
||||
type: "javaScript" as const,
|
||||
onSelect: () => {
|
||||
if (prompt.length > 0) sendPrompt(prompt);
|
||||
if (nextPrompt.length > 0) sendPrompt(nextPrompt);
|
||||
},
|
||||
closeSpotlightOnTrigger: prompt.length > 0,
|
||||
closeSpotlightOnTrigger: nextPrompt.length > 0,
|
||||
}),
|
||||
},
|
||||
];
|
||||
},
|
||||
}) as const;
|
||||
} as const;
|
||||
};
|
||||
@@ -22,6 +22,7 @@ import type { inferSearchInteractionOptions } from "../lib/interaction";
|
||||
import type { SearchMode } from "../lib/mode";
|
||||
import { searchModes } from "../modes";
|
||||
import { useHomeEmptyGroupsWithPreferences } from "../modes/help/home-empty-groups";
|
||||
import { contextSpecificSearchGroups } from "../modes/home/context-specific-group";
|
||||
import { mediaRequestSearchScopeAtom, selectAction, spotlightActions, spotlightStore } from "../spotlight-store";
|
||||
import { SpotlightChildrenActions } from "./actions/children-actions";
|
||||
import { SpotlightActionGroups } from "./actions/groups/action-group";
|
||||
@@ -244,7 +245,9 @@ const SpotlightWithActiveMode = ({ modeState, queryState, activeMode }: Spotligh
|
||||
pushChildrenOptions(options);
|
||||
}}
|
||||
query={query}
|
||||
groups={mode === defaultMode && query.length === 0 ? [...homeEmptyGroups] : groups}
|
||||
groups={
|
||||
mode === defaultMode && query.length === 0 ? [...homeEmptyGroups, contextSpecificSearchGroups] : groups
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</MantineSpotlight.ActionsList>
|
||||
|
||||
@@ -29,8 +29,8 @@ export const contextSpecificActionsSearchGroups = createGroup<ContextSpecificIte
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
useInteraction(option) {
|
||||
return option.interaction();
|
||||
useInteraction(option, query) {
|
||||
return option.interaction(query);
|
||||
},
|
||||
filter(query, option) {
|
||||
return option.name.toLowerCase().includes(query.toLowerCase());
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import type { ContextSpecificItem } from "./context";
|
||||
import { shouldShowContextSpecificResult } from "./context-specific-group";
|
||||
|
||||
const item = (overrides: Partial<ContextSpecificItem> = {}): ContextSpecificItem => ({
|
||||
id: "result",
|
||||
name: "Matching result",
|
||||
icon: "/icon.png",
|
||||
interaction: () => ({ type: "none" }),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("contextSpecificSearchGroups", () => {
|
||||
test("keeps an always-visible fallback in every default search", () => {
|
||||
expect(shouldShowContextSpecificResult("unrelated query", item({ alwaysVisible: true }))).toBe(true);
|
||||
expect(shouldShowContextSpecificResult("", item({ alwaysVisible: true }))).toBe(true);
|
||||
});
|
||||
|
||||
test("shows ordinary local results only when their names match", () => {
|
||||
expect(shouldShowContextSpecificResult("matching", item())).toBe(true);
|
||||
expect(shouldShowContextSpecificResult("unrelated query", item())).toBe(false);
|
||||
expect(shouldShowContextSpecificResult("", item())).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,12 @@ import { createGroup } from "../../lib/group";
|
||||
import type { ContextSpecificItem } from "./context";
|
||||
import { useSpotlightContextResults } from "./context";
|
||||
|
||||
export const shouldShowContextSpecificResult = (query: string, option: ContextSpecificItem) => {
|
||||
if (option.alwaysVisible) return true;
|
||||
if (query.length === 0) return false;
|
||||
return option.name.toLowerCase().includes(query.toLowerCase());
|
||||
};
|
||||
|
||||
export const contextSpecificSearchGroups = createGroup<ContextSpecificItem>({
|
||||
title: (t) => t("search.mode.home.group.local.title"),
|
||||
keyPath: "id",
|
||||
@@ -29,12 +35,10 @@ export const contextSpecificSearchGroups = createGroup<ContextSpecificItem>({
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
useInteraction(option) {
|
||||
return option.interaction();
|
||||
},
|
||||
filter(query, option) {
|
||||
return option.name.toLowerCase().includes(query.toLowerCase());
|
||||
useInteraction(option, query) {
|
||||
return option.interaction(query);
|
||||
},
|
||||
filter: shouldShowContextSpecificResult,
|
||||
useOptions() {
|
||||
return useSpotlightContextResults();
|
||||
},
|
||||
|
||||
@@ -10,10 +10,11 @@ export type ContextSpecificItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: TablerIcon | string;
|
||||
interaction: () => inferSearchInteractionDefinition<SearchInteraction>;
|
||||
interaction: (query: string) => inferSearchInteractionDefinition<SearchInteraction>;
|
||||
disabled?: boolean;
|
||||
description?: string;
|
||||
unavailable?: boolean;
|
||||
alwaysVisible?: boolean;
|
||||
};
|
||||
|
||||
interface SpotlightContextProps {
|
||||
|
||||
Reference in new issue
Block a user