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
|
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
|
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
|
`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
|
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
|
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 { hotkeys } from "@homarr/definitions";
|
||||||
import { showErrorNotification, showWarningNotification } from "@homarr/notifications";
|
import { showErrorNotification, showWarningNotification } from "@homarr/notifications";
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
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 { AssistantWidgetRendererProvider } from "@homarr/widgets";
|
||||||
|
|
||||||
import { shouldAutomaticallyContinueAssistant } from "./assistant-auto-submit";
|
import { shouldAutomaticallyContinueAssistant } from "./assistant-auto-submit";
|
||||||
@@ -34,7 +39,7 @@ import { getPendingAssistantAction } from "./assistant-pending-action";
|
|||||||
import type { AssistantReasoningMode, AssistantRuntimeModelOption } from "./assistant-preferences";
|
import type { AssistantReasoningMode, AssistantRuntimeModelOption } from "./assistant-preferences";
|
||||||
import { resolveAssistantPreferenceModelId, resolveAssistantThreadPreferenceModelId } from "./assistant-preferences";
|
import { resolveAssistantPreferenceModelId, resolveAssistantThreadPreferenceModelId } from "./assistant-preferences";
|
||||||
import { AssistantRuntimeProviderWithTools } from "./assistant-runtime-provider";
|
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 { createAssistantPromptInteraction } from "./assistant-spotlight";
|
||||||
import { browserToolContracts } from "./assistant-tool-contracts";
|
import { browserToolContracts } from "./assistant-tool-contracts";
|
||||||
import { AssistantConfigureBoardSettingsTool } from "./assistant-board-settings-tool";
|
import { AssistantConfigureBoardSettingsTool } from "./assistant-board-settings-tool";
|
||||||
@@ -57,7 +62,7 @@ interface AssistantContextValue {
|
|||||||
open: () => void;
|
open: () => void;
|
||||||
close: () => void;
|
close: () => void;
|
||||||
toggle: () => void;
|
toggle: () => void;
|
||||||
sendPrompt: (prompt: string) => void;
|
sendPrompt: (prompt: string) => boolean;
|
||||||
refreshCurrentView: () => Promise<void>;
|
refreshCurrentView: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -598,20 +603,20 @@ const EnabledAssistantProvider = ({ children }: PropsWithChildren) => {
|
|||||||
const sendPrompt = useCallback(
|
const sendPrompt = useCallback(
|
||||||
(prompt: string) => {
|
(prompt: string) => {
|
||||||
const text = prompt.trim();
|
const text = prompt.trim();
|
||||||
if (text.length === 0) return;
|
if (text.length === 0) return false;
|
||||||
if (assistantIsRunning || latestStatus?.type === "requires-action") {
|
if (assistantIsRunning || latestStatus?.type === "requires-action") {
|
||||||
showWarningNotification({
|
showWarningNotification({
|
||||||
title: t("busy.title"),
|
title: t("busy.title"),
|
||||||
message: t("busy.description"),
|
message: t("busy.description"),
|
||||||
});
|
});
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
setActivityDismissed(false);
|
setActivityDismissed(false);
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
setQueuedPrompt(text);
|
setQueuedPrompt(text);
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
sendPromptThroughComposer(aui.composer(), text);
|
return sendPromptThroughRuntime(aui, text);
|
||||||
},
|
},
|
||||||
[assistantIsRunning, aui, isLoading, latestStatus?.type, t],
|
[assistantIsRunning, aui, isLoading, latestStatus?.type, t],
|
||||||
);
|
);
|
||||||
@@ -633,7 +638,7 @@ const EnabledAssistantProvider = ({ children }: PropsWithChildren) => {
|
|||||||
if (isLoading || isRunning || queuedPrompt === null) return;
|
if (isLoading || isRunning || queuedPrompt === null) return;
|
||||||
const prompt = queuedPrompt;
|
const prompt = queuedPrompt;
|
||||||
setQueuedPrompt(null);
|
setQueuedPrompt(null);
|
||||||
sendPromptThroughComposer(aui.composer(), prompt);
|
sendPromptThroughRuntime(aui, prompt);
|
||||||
}, [aui, isLoading, isRunning, queuedPrompt]);
|
}, [aui, isLoading, isRunning, queuedPrompt]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -675,17 +680,24 @@ const EnabledAssistantProvider = ({ children }: PropsWithChildren) => {
|
|||||||
|
|
||||||
useHotkeys([[hotkeys.openAssistant, open, { preventDefault: true }]]);
|
useHotkeys([[hotkeys.openAssistant, open, { preventDefault: true }]]);
|
||||||
|
|
||||||
const spotlightItem = useMemo(
|
const spotlightItem = useMemo(() => {
|
||||||
() => ({
|
const canSend = !assistantIsRunning && latestStatus?.type !== "requires-action";
|
||||||
|
return {
|
||||||
id: "homarr-assistant",
|
id: "homarr-assistant",
|
||||||
name: t("spotlight"),
|
name: t("spotlight"),
|
||||||
icon: "/logo/logo.png",
|
icon: "/logo/logo.png",
|
||||||
description: t("spotlightDescription"),
|
description: canSend ? t("spotlightDescription") : t("busy.description"),
|
||||||
|
unavailable: !canSend,
|
||||||
alwaysVisible: true,
|
alwaysVisible: true,
|
||||||
interaction: (query: string) => createAssistantPromptInteraction({ sendPrompt, prompt: query }),
|
interaction: (query: string) =>
|
||||||
}),
|
createAssistantPromptInteraction({
|
||||||
[sendPrompt, t],
|
sendPrompt,
|
||||||
);
|
onPromptAccepted: closeSpotlight,
|
||||||
|
prompt: query,
|
||||||
|
canSend,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}, [assistantIsRunning, latestStatus?.type, sendPrompt, t]);
|
||||||
useRegisterSpotlightContextResults("homarr-assistant", [spotlightItem], [spotlightItem]);
|
useRegisterSpotlightContextResults("homarr-assistant", [spotlightItem], [spotlightItem]);
|
||||||
|
|
||||||
const value = useMemo(
|
const value = useMemo(
|
||||||
@@ -769,7 +781,7 @@ const DisabledAssistantProvider = ({ children, description }: DisabledAssistantP
|
|||||||
open: () => undefined,
|
open: () => undefined,
|
||||||
close: () => undefined,
|
close: () => undefined,
|
||||||
toggle: () => undefined,
|
toggle: () => undefined,
|
||||||
sendPrompt: () => undefined,
|
sendPrompt: () => false,
|
||||||
refreshCurrentView: () => Promise.resolve(),
|
refreshCurrentView: () => Promise.resolve(),
|
||||||
}),
|
}),
|
||||||
[description],
|
[description],
|
||||||
|
|||||||
@@ -3,22 +3,44 @@ import { describe, expect, test, vi } from "vitest";
|
|||||||
import { sendAssistantPrompt } from "./assistant-send";
|
import { sendAssistantPrompt } from "./assistant-send";
|
||||||
|
|
||||||
describe("sendAssistantPrompt", () => {
|
describe("sendAssistantPrompt", () => {
|
||||||
test("uses the composer lifecycle so programmatic prompts start a run", () => {
|
test("appends a trimmed user prompt and explicitly starts a run", () => {
|
||||||
const calls: string[] = [];
|
const append = vi.fn();
|
||||||
const composer = {
|
const runtime = {
|
||||||
setText: vi.fn((text: string) => calls.push(`set:${text}`)),
|
composer: () => ({ getState: () => ({ runConfig: { custom: { source: "spotlight" } } }) }),
|
||||||
send: vi.fn(() => calls.push("send")),
|
thread: () => ({ append }),
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(sendAssistantPrompt(composer, " Check my services ")).toBe(true);
|
expect(sendAssistantPrompt(runtime, " Check my services ")).toBe(true);
|
||||||
expect(calls).toEqual(["set:Check my services", "send"]);
|
expect(append).toHaveBeenCalledWith({
|
||||||
|
role: "user",
|
||||||
|
content: [{ type: "text", text: "Check my services" }],
|
||||||
|
runConfig: { custom: { source: "spotlight" } },
|
||||||
|
startRun: true,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("ignores empty prompts", () => {
|
test("does not replace an existing composer draft", () => {
|
||||||
const composer = { setText: vi.fn(), send: vi.fn() };
|
const append = vi.fn();
|
||||||
|
const getState = vi.fn(() => ({ text: "Keep this draft", runConfig: {} }));
|
||||||
|
const runtime = {
|
||||||
|
composer: () => ({ getState }),
|
||||||
|
thread: () => ({ append }),
|
||||||
|
};
|
||||||
|
|
||||||
expect(sendAssistantPrompt(composer, " ")).toBe(false);
|
sendAssistantPrompt(runtime, "Run a health check");
|
||||||
expect(composer.setText).not.toHaveBeenCalled();
|
|
||||||
expect(composer.send).not.toHaveBeenCalled();
|
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 = {
|
import type { AssistantClient } from "@assistant-ui/react";
|
||||||
setText: (text: string) => void;
|
|
||||||
send: () => void;
|
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();
|
const text = prompt.trim();
|
||||||
if (text.length === 0) return false;
|
if (text.length === 0) return false;
|
||||||
|
|
||||||
composer.setText(text);
|
runtime.thread().append({
|
||||||
composer.send();
|
role: "user",
|
||||||
|
content: [{ type: "text", text }],
|
||||||
|
runConfig: runtime.composer().getState().runConfig,
|
||||||
|
startRun: true,
|
||||||
|
});
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
@@ -4,9 +4,11 @@ import { createAssistantPromptInteraction } from "./assistant-spotlight";
|
|||||||
|
|
||||||
describe("createAssistantPromptInteraction", () => {
|
describe("createAssistantPromptInteraction", () => {
|
||||||
it("sends an existing default-search query immediately", () => {
|
it("sends an existing default-search query immediately", () => {
|
||||||
const sendPrompt = vi.fn();
|
const sendPrompt = vi.fn(() => true);
|
||||||
|
const onPromptAccepted = vi.fn();
|
||||||
const interaction = createAssistantPromptInteraction({
|
const interaction = createAssistantPromptInteraction({
|
||||||
sendPrompt,
|
sendPrompt,
|
||||||
|
onPromptAccepted,
|
||||||
prompt: " Check my services ",
|
prompt: " Check my services ",
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -14,13 +16,15 @@ describe("createAssistantPromptInteraction", () => {
|
|||||||
if (interaction.type !== "javaScript") return;
|
if (interaction.type !== "javaScript") return;
|
||||||
|
|
||||||
interaction.onSelect();
|
interaction.onSelect();
|
||||||
expect(interaction.closeSpotlightOnTrigger).toBe(true);
|
expect(interaction.closeSpotlightOnTrigger).toBe(false);
|
||||||
expect(sendPrompt).toHaveBeenCalledWith("Check my services");
|
expect(sendPrompt).toHaveBeenCalledWith("Check my services");
|
||||||
|
expect(onPromptAccepted).toHaveBeenCalledOnce();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps Spotlight open when the prompt is empty", () => {
|
it("keeps Spotlight open when the prompt is empty", () => {
|
||||||
const sendPrompt = vi.fn();
|
const sendPrompt = vi.fn();
|
||||||
const interaction = createAssistantPromptInteraction({ sendPrompt });
|
const onPromptAccepted = vi.fn();
|
||||||
|
const interaction = createAssistantPromptInteraction({ sendPrompt, onPromptAccepted });
|
||||||
expect(interaction.type).toBe("children");
|
expect(interaction.type).toBe("children");
|
||||||
if (interaction.type !== "children") return;
|
if (interaction.type !== "children") return;
|
||||||
|
|
||||||
@@ -35,11 +39,13 @@ describe("createAssistantPromptInteraction", () => {
|
|||||||
sendInteraction.onSelect();
|
sendInteraction.onSelect();
|
||||||
expect(sendInteraction.closeSpotlightOnTrigger).toBe(false);
|
expect(sendInteraction.closeSpotlightOnTrigger).toBe(false);
|
||||||
expect(sendPrompt).not.toHaveBeenCalled();
|
expect(sendPrompt).not.toHaveBeenCalled();
|
||||||
|
expect(onPromptAccepted).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("trims and sends a prompt in the background", () => {
|
it("trims and sends a prompt in the background", () => {
|
||||||
const sendPrompt = vi.fn();
|
const sendPrompt = vi.fn(() => true);
|
||||||
const interaction = createAssistantPromptInteraction({ sendPrompt });
|
const onPromptAccepted = vi.fn();
|
||||||
|
const interaction = createAssistantPromptInteraction({ sendPrompt, onPromptAccepted });
|
||||||
expect(interaction.type).toBe("children");
|
expect(interaction.type).toBe("children");
|
||||||
if (interaction.type !== "children") return;
|
if (interaction.type !== "children") return;
|
||||||
|
|
||||||
@@ -52,7 +58,39 @@ describe("createAssistantPromptInteraction", () => {
|
|||||||
if (sendInteraction.type !== "javaScript") return;
|
if (sendInteraction.type !== "javaScript") return;
|
||||||
|
|
||||||
sendInteraction.onSelect();
|
sendInteraction.onSelect();
|
||||||
expect(sendInteraction.closeSpotlightOnTrigger).toBe(true);
|
expect(sendInteraction.closeSpotlightOnTrigger).toBe(false);
|
||||||
expect(sendPrompt).toHaveBeenCalledWith("Check my services");
|
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";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
interface AssistantPromptInteractionOptions {
|
interface AssistantPromptInteractionOptions {
|
||||||
sendPrompt: (prompt: string) => void;
|
sendPrompt: (prompt: string) => boolean;
|
||||||
|
onPromptAccepted: () => void;
|
||||||
prompt?: string;
|
prompt?: string;
|
||||||
|
canSend?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AssistantPromptDetail = () => {
|
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();
|
const trimmedPrompt = prompt.trim();
|
||||||
if (trimmedPrompt.length > 0) {
|
if (trimmedPrompt.length > 0) {
|
||||||
return {
|
return {
|
||||||
type: "javaScript" as const,
|
type: "javaScript" as const,
|
||||||
onSelect: () => sendPrompt(trimmedPrompt),
|
onSelect: () => {
|
||||||
closeSpotlightOnTrigger: true,
|
if (sendPrompt(trimmedPrompt)) onPromptAccepted();
|
||||||
|
},
|
||||||
|
closeSpotlightOnTrigger: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,9 +74,9 @@ export const createAssistantPromptInteraction = ({ sendPrompt, prompt = "" }: As
|
|||||||
useInteraction: () => ({
|
useInteraction: () => ({
|
||||||
type: "javaScript" as const,
|
type: "javaScript" as const,
|
||||||
onSelect: () => {
|
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