fix assistant prompt submission
This commit is contained in:
1 parent
fc07e911d1
commit
427db07a95
6 files changed
+141
-47
No files matched your search
@@ -14,7 +14,9 @@ It is available to signed-in users from the user menu, with `Shift + A`, and fro
|
||||
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.
|
||||
focus whenever the command menu opens, including when it is opened by an assistant tool. Sending from the
|
||||
command menu preserves any draft in the expanded conversation. The local result is temporarily disabled while
|
||||
the assistant is responding or waiting for an approval.
|
||||
|
||||
While a response is running, a compact activity bar appears at the bottom of the page and the user-menu
|
||||
avatar shows a thinking indicator. The activity bar can be opened to follow the response. Completed
|
||||
|
||||
@@ -22,7 +22,12 @@ 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, useRegisterSpotlightContextResults } from "@homarr/spotlight";
|
||||
import {
|
||||
closeSpotlight,
|
||||
openMediaRequestSearch,
|
||||
openSpotlight,
|
||||
useRegisterSpotlightContextResults,
|
||||
} from "@homarr/spotlight";
|
||||
import { AssistantWidgetRendererProvider } from "@homarr/widgets";
|
||||
|
||||
import { shouldAutomaticallyContinueAssistant } from "./assistant-auto-submit";
|
||||
@@ -34,7 +39,7 @@ import { getPendingAssistantAction } from "./assistant-pending-action";
|
||||
import type { AssistantReasoningMode, AssistantRuntimeModelOption } from "./assistant-preferences";
|
||||
import { resolveAssistantPreferenceModelId, resolveAssistantThreadPreferenceModelId } from "./assistant-preferences";
|
||||
import { AssistantRuntimeProviderWithTools } from "./assistant-runtime-provider";
|
||||
import { sendAssistantPrompt as sendPromptThroughComposer } from "./assistant-send";
|
||||
import { sendAssistantPrompt as sendPromptThroughRuntime } from "./assistant-send";
|
||||
import { createAssistantPromptInteraction } from "./assistant-spotlight";
|
||||
import { browserToolContracts } from "./assistant-tool-contracts";
|
||||
import { AssistantConfigureBoardSettingsTool } from "./assistant-board-settings-tool";
|
||||
@@ -57,7 +62,7 @@ interface AssistantContextValue {
|
||||
open: () => void;
|
||||
close: () => void;
|
||||
toggle: () => void;
|
||||
sendPrompt: (prompt: string) => void;
|
||||
sendPrompt: (prompt: string) => boolean;
|
||||
refreshCurrentView: () => Promise<void>;
|
||||
}
|
||||
|
||||
@@ -598,20 +603,20 @@ const EnabledAssistantProvider = ({ children }: PropsWithChildren) => {
|
||||
const sendPrompt = useCallback(
|
||||
(prompt: string) => {
|
||||
const text = prompt.trim();
|
||||
if (text.length === 0) return;
|
||||
if (text.length === 0) return false;
|
||||
if (assistantIsRunning || latestStatus?.type === "requires-action") {
|
||||
showWarningNotification({
|
||||
title: t("busy.title"),
|
||||
message: t("busy.description"),
|
||||
});
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
setActivityDismissed(false);
|
||||
if (isLoading) {
|
||||
setQueuedPrompt(text);
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
sendPromptThroughComposer(aui.composer(), text);
|
||||
return sendPromptThroughRuntime(aui, text);
|
||||
},
|
||||
[assistantIsRunning, aui, isLoading, latestStatus?.type, t],
|
||||
);
|
||||
@@ -633,7 +638,7 @@ const EnabledAssistantProvider = ({ children }: PropsWithChildren) => {
|
||||
if (isLoading || isRunning || queuedPrompt === null) return;
|
||||
const prompt = queuedPrompt;
|
||||
setQueuedPrompt(null);
|
||||
sendPromptThroughComposer(aui.composer(), prompt);
|
||||
sendPromptThroughRuntime(aui, prompt);
|
||||
}, [aui, isLoading, isRunning, queuedPrompt]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -675,17 +680,24 @@ const EnabledAssistantProvider = ({ children }: PropsWithChildren) => {
|
||||
|
||||
useHotkeys([[hotkeys.openAssistant, open, { preventDefault: true }]]);
|
||||
|
||||
const spotlightItem = useMemo(
|
||||
() => ({
|
||||
const spotlightItem = useMemo(() => {
|
||||
const canSend = !assistantIsRunning && latestStatus?.type !== "requires-action";
|
||||
return {
|
||||
id: "homarr-assistant",
|
||||
name: t("spotlight"),
|
||||
icon: "/logo/logo.png",
|
||||
description: t("spotlightDescription"),
|
||||
description: canSend ? t("spotlightDescription") : t("busy.description"),
|
||||
unavailable: !canSend,
|
||||
alwaysVisible: true,
|
||||
interaction: (query: string) => createAssistantPromptInteraction({ sendPrompt, prompt: query }),
|
||||
}),
|
||||
[sendPrompt, t],
|
||||
);
|
||||
interaction: (query: string) =>
|
||||
createAssistantPromptInteraction({
|
||||
sendPrompt,
|
||||
onPromptAccepted: closeSpotlight,
|
||||
prompt: query,
|
||||
canSend,
|
||||
}),
|
||||
};
|
||||
}, [assistantIsRunning, latestStatus?.type, sendPrompt, t]);
|
||||
useRegisterSpotlightContextResults("homarr-assistant", [spotlightItem], [spotlightItem]);
|
||||
|
||||
const value = useMemo(
|
||||
@@ -769,7 +781,7 @@ const DisabledAssistantProvider = ({ children, description }: DisabledAssistantP
|
||||
open: () => undefined,
|
||||
close: () => undefined,
|
||||
toggle: () => undefined,
|
||||
sendPrompt: () => undefined,
|
||||
sendPrompt: () => false,
|
||||
refreshCurrentView: () => Promise.resolve(),
|
||||
}),
|
||||
[description],
|
||||
|
||||
@@ -3,22 +3,44 @@ import { describe, expect, test, vi } from "vitest";
|
||||
import { sendAssistantPrompt } from "./assistant-send";
|
||||
|
||||
describe("sendAssistantPrompt", () => {
|
||||
test("uses the composer lifecycle so programmatic prompts start a run", () => {
|
||||
const calls: string[] = [];
|
||||
const composer = {
|
||||
setText: vi.fn((text: string) => calls.push(`set:${text}`)),
|
||||
send: vi.fn(() => calls.push("send")),
|
||||
test("appends a trimmed user prompt and explicitly starts a run", () => {
|
||||
const append = vi.fn();
|
||||
const runtime = {
|
||||
composer: () => ({ getState: () => ({ runConfig: { custom: { source: "spotlight" } } }) }),
|
||||
thread: () => ({ append }),
|
||||
};
|
||||
|
||||
expect(sendAssistantPrompt(composer, " Check my services ")).toBe(true);
|
||||
expect(calls).toEqual(["set:Check my services", "send"]);
|
||||
expect(sendAssistantPrompt(runtime, " Check my services ")).toBe(true);
|
||||
expect(append).toHaveBeenCalledWith({
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "Check my services" }],
|
||||
runConfig: { custom: { source: "spotlight" } },
|
||||
startRun: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("ignores empty prompts", () => {
|
||||
const composer = { setText: vi.fn(), send: vi.fn() };
|
||||
test("does not replace an existing composer draft", () => {
|
||||
const append = vi.fn();
|
||||
const getState = vi.fn(() => ({ text: "Keep this draft", runConfig: {} }));
|
||||
const runtime = {
|
||||
composer: () => ({ getState }),
|
||||
thread: () => ({ append }),
|
||||
};
|
||||
|
||||
expect(sendAssistantPrompt(composer, " ")).toBe(false);
|
||||
expect(composer.setText).not.toHaveBeenCalled();
|
||||
expect(composer.send).not.toHaveBeenCalled();
|
||||
sendAssistantPrompt(runtime, "Run a health check");
|
||||
|
||||
expect(getState().text).toBe("Keep this draft");
|
||||
expect(append).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test("ignores empty prompts without touching the runtime", () => {
|
||||
const runtime = {
|
||||
composer: vi.fn(),
|
||||
thread: vi.fn(),
|
||||
};
|
||||
|
||||
expect(sendAssistantPrompt(runtime, " ")).toBe(false);
|
||||
expect(runtime.composer).not.toHaveBeenCalled();
|
||||
expect(runtime.thread).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,22 @@
|
||||
type AssistantComposerActions = {
|
||||
setText: (text: string) => void;
|
||||
send: () => void;
|
||||
import type { AssistantClient } from "@assistant-ui/react";
|
||||
|
||||
type AssistantRunConfig = ReturnType<ReturnType<AssistantClient["composer"]>["getState"]>["runConfig"];
|
||||
type AssistantAppendMessage = Parameters<ReturnType<AssistantClient["thread"]>["append"]>[0];
|
||||
|
||||
type AssistantPromptRuntime = {
|
||||
composer: () => { getState: () => { runConfig: AssistantRunConfig } };
|
||||
thread: () => { append: (message: AssistantAppendMessage) => void };
|
||||
};
|
||||
|
||||
export const sendAssistantPrompt = (composer: AssistantComposerActions, prompt: string) => {
|
||||
export const sendAssistantPrompt = (runtime: AssistantPromptRuntime, prompt: string) => {
|
||||
const text = prompt.trim();
|
||||
if (text.length === 0) return false;
|
||||
|
||||
composer.setText(text);
|
||||
composer.send();
|
||||
runtime.thread().append({
|
||||
role: "user",
|
||||
content: [{ type: "text", text }],
|
||||
runConfig: runtime.composer().getState().runConfig,
|
||||
startRun: true,
|
||||
});
|
||||
return true;
|
||||
};
|
||||
@@ -4,9 +4,11 @@ import { createAssistantPromptInteraction } from "./assistant-spotlight";
|
||||
|
||||
describe("createAssistantPromptInteraction", () => {
|
||||
it("sends an existing default-search query immediately", () => {
|
||||
const sendPrompt = vi.fn();
|
||||
const sendPrompt = vi.fn(() => true);
|
||||
const onPromptAccepted = vi.fn();
|
||||
const interaction = createAssistantPromptInteraction({
|
||||
sendPrompt,
|
||||
onPromptAccepted,
|
||||
prompt: " Check my services ",
|
||||
});
|
||||
|
||||
@@ -14,13 +16,15 @@ describe("createAssistantPromptInteraction", () => {
|
||||
if (interaction.type !== "javaScript") return;
|
||||
|
||||
interaction.onSelect();
|
||||
expect(interaction.closeSpotlightOnTrigger).toBe(true);
|
||||
expect(interaction.closeSpotlightOnTrigger).toBe(false);
|
||||
expect(sendPrompt).toHaveBeenCalledWith("Check my services");
|
||||
expect(onPromptAccepted).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("keeps Spotlight open when the prompt is empty", () => {
|
||||
const sendPrompt = vi.fn();
|
||||
const interaction = createAssistantPromptInteraction({ sendPrompt });
|
||||
const onPromptAccepted = vi.fn();
|
||||
const interaction = createAssistantPromptInteraction({ sendPrompt, onPromptAccepted });
|
||||
expect(interaction.type).toBe("children");
|
||||
if (interaction.type !== "children") return;
|
||||
|
||||
@@ -35,11 +39,13 @@ describe("createAssistantPromptInteraction", () => {
|
||||
sendInteraction.onSelect();
|
||||
expect(sendInteraction.closeSpotlightOnTrigger).toBe(false);
|
||||
expect(sendPrompt).not.toHaveBeenCalled();
|
||||
expect(onPromptAccepted).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("trims and sends a prompt in the background", () => {
|
||||
const sendPrompt = vi.fn();
|
||||
const interaction = createAssistantPromptInteraction({ sendPrompt });
|
||||
const sendPrompt = vi.fn(() => true);
|
||||
const onPromptAccepted = vi.fn();
|
||||
const interaction = createAssistantPromptInteraction({ sendPrompt, onPromptAccepted });
|
||||
expect(interaction.type).toBe("children");
|
||||
if (interaction.type !== "children") return;
|
||||
|
||||
@@ -52,7 +58,39 @@ describe("createAssistantPromptInteraction", () => {
|
||||
if (sendInteraction.type !== "javaScript") return;
|
||||
|
||||
sendInteraction.onSelect();
|
||||
expect(sendInteraction.closeSpotlightOnTrigger).toBe(true);
|
||||
expect(sendInteraction.closeSpotlightOnTrigger).toBe(false);
|
||||
expect(sendPrompt).toHaveBeenCalledWith("Check my services");
|
||||
expect(onPromptAccepted).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("keeps Spotlight open when the runtime rejects the prompt", () => {
|
||||
const sendPrompt = vi.fn(() => false);
|
||||
const onPromptAccepted = vi.fn();
|
||||
const interaction = createAssistantPromptInteraction({
|
||||
sendPrompt,
|
||||
onPromptAccepted,
|
||||
prompt: "Check my services",
|
||||
});
|
||||
expect(interaction.type).toBe("javaScript");
|
||||
if (interaction.type !== "javaScript") return;
|
||||
|
||||
interaction.onSelect();
|
||||
|
||||
expect(interaction.closeSpotlightOnTrigger).toBe(false);
|
||||
expect(sendPrompt).toHaveBeenCalledWith("Check my services");
|
||||
expect(onPromptAccepted).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("cannot trigger while the assistant is already handling a request", () => {
|
||||
const sendPrompt = vi.fn();
|
||||
const interaction = createAssistantPromptInteraction({
|
||||
sendPrompt,
|
||||
onPromptAccepted: vi.fn(),
|
||||
prompt: "Check my services",
|
||||
canSend: false,
|
||||
});
|
||||
|
||||
expect(interaction).toEqual({ type: "none" });
|
||||
expect(sendPrompt).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -6,8 +6,10 @@ import { IconArrowUp, IconRobot } from "@tabler/icons-react";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
interface AssistantPromptInteractionOptions {
|
||||
sendPrompt: (prompt: string) => void;
|
||||
sendPrompt: (prompt: string) => boolean;
|
||||
onPromptAccepted: () => void;
|
||||
prompt?: string;
|
||||
canSend?: boolean;
|
||||
}
|
||||
|
||||
const AssistantPromptDetail = () => {
|
||||
@@ -40,13 +42,22 @@ const AssistantPromptAction = ({ hasPrompt }: { hasPrompt: boolean }) => {
|
||||
);
|
||||
};
|
||||
|
||||
export const createAssistantPromptInteraction = ({ sendPrompt, prompt = "" }: AssistantPromptInteractionOptions) => {
|
||||
export const createAssistantPromptInteraction = ({
|
||||
sendPrompt,
|
||||
onPromptAccepted,
|
||||
prompt = "",
|
||||
canSend = true,
|
||||
}: AssistantPromptInteractionOptions) => {
|
||||
if (!canSend) return { type: "none" as const };
|
||||
|
||||
const trimmedPrompt = prompt.trim();
|
||||
if (trimmedPrompt.length > 0) {
|
||||
return {
|
||||
type: "javaScript" as const,
|
||||
onSelect: () => sendPrompt(trimmedPrompt),
|
||||
closeSpotlightOnTrigger: true,
|
||||
onSelect: () => {
|
||||
if (sendPrompt(trimmedPrompt)) onPromptAccepted();
|
||||
},
|
||||
closeSpotlightOnTrigger: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -63,9 +74,9 @@ export const createAssistantPromptInteraction = ({ sendPrompt, prompt = "" }: As
|
||||
useInteraction: () => ({
|
||||
type: "javaScript" as const,
|
||||
onSelect: () => {
|
||||
if (nextPrompt.length > 0) sendPrompt(nextPrompt);
|
||||
if (nextPrompt.length > 0 && sendPrompt(nextPrompt)) onPromptAccepted();
|
||||
},
|
||||
closeSpotlightOnTrigger: nextPrompt.length > 0,
|
||||
closeSpotlightOnTrigger: false,
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
Reference in new issue
Block a user