feat: improve assistant background controls
This commit is contained in:
1 parent
0c5fc1d331
commit
41389b61e2
13 files changed
+644
-86
No files matched your search
@@ -15,7 +15,8 @@ It is available to signed-in users from the user menu and the `Cmd/Ctrl + K` com
|
||||
|
||||
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
|
||||
responses add an unread count to the user menu until they are opened or dismissed.
|
||||
responses add an unread count to the user menu until they are opened or dismissed. The expanded
|
||||
conversation has a minimize action that returns it to the activity bar without interrupting a response.
|
||||
|
||||
`Cmd/Ctrl + Shift + K` opens the compact conversation panel directly.
|
||||
|
||||
@@ -83,7 +84,11 @@ as part of the prompt.
|
||||
|
||||
Type `@` in the composer to add permission-checked context. The nested menu contains apps, integrations,
|
||||
boards, widgets, and tools available to the current user. Homarr validates every referenced ID again on
|
||||
the server before adding it to the model context. Type `/` for common prompt shortcuts.
|
||||
the server before adding it to the model context. Type `/` for common prompt shortcuts. The compact
|
||||
controls below the composer can switch between models discovered from the configured endpoint. Model
|
||||
selection is stored on the conversation after a completed request. Thinking mode can be left on
|
||||
**Auto**, disabled, or set from minimal through high; explicit levels are sent using the AI SDK's
|
||||
provider-neutral reasoning setting and still depend on support from the chosen model and endpoint.
|
||||
|
||||
Each completed response includes a circular context-window meter. Select it to open request details when
|
||||
the provider reports them: input, output, cached-input and reasoning tokens, used and remaining context,
|
||||
|
||||
@@ -16,7 +16,7 @@ import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { and, eq } from "@homarr/db";
|
||||
import { db } from "@homarr/db";
|
||||
import { assistantConfigurations, assistantThreads } from "@homarr/db/schema";
|
||||
import { assistantProviderRequiresApiKey } from "@homarr/definitions";
|
||||
import { assistantProviderRequiresApiKey, assistantReasoningModes } from "@homarr/definitions";
|
||||
|
||||
import { browserToolContracts } from "~/components/assistant/assistant-tool-contracts";
|
||||
import type {
|
||||
@@ -50,6 +50,8 @@ const allowedAttachmentMediaTypes = new Set([
|
||||
]);
|
||||
const requestSchema = z.object({
|
||||
id: z.string().min(1).max(64),
|
||||
modelId: z.string().trim().min(1).max(256).optional(),
|
||||
reasoning: z.enum(assistantReasoningModes).default("auto"),
|
||||
messages: z
|
||||
.array(
|
||||
z
|
||||
@@ -324,10 +326,17 @@ export async function POST(request: Request) {
|
||||
const context = createTRPCContext({ headers: request.headers, session });
|
||||
const requestStartedAt = Date.now();
|
||||
const requestId = crypto.randomUUID();
|
||||
const requestedModelId = parsed.data.modelId ?? configuration.modelId;
|
||||
const [selectedModel, mentionContext] = await Promise.all([
|
||||
getSelectedModelDetailsAsync(configuration).catch(() => null),
|
||||
getSelectedModelDetailsAsync(configuration, requestedModelId).catch(() => null),
|
||||
getMentionContextAsync(context, parsed.data.messages),
|
||||
]);
|
||||
if (requestedModelId !== configuration.modelId && !selectedModel) {
|
||||
return Response.json(
|
||||
{ error: "The selected model is not available from the configured provider." },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
const modelId = selectedModel?.id ?? configuration.modelId;
|
||||
if (
|
||||
selectedModel &&
|
||||
@@ -436,6 +445,7 @@ export async function POST(request: Request) {
|
||||
abortSignal: request.signal,
|
||||
timeout: { totalMs: 55_000, stepMs: 30_000, toolMs: 30_000 },
|
||||
maxRetries: 2,
|
||||
reasoning: parsed.data.reasoning === "auto" ? undefined : parsed.data.reasoning,
|
||||
toolApproval,
|
||||
experimental_toolApprovalSecret: getToolApprovalSecret(),
|
||||
onChunk: ({ chunk }) => {
|
||||
|
||||
@@ -675,6 +675,42 @@
|
||||
padding-inline: 0.45rem;
|
||||
}
|
||||
|
||||
.composerFooter {
|
||||
min-width: 0;
|
||||
padding-inline: 0.2rem;
|
||||
}
|
||||
|
||||
.runtimeControls {
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.modelSelect {
|
||||
width: min(15rem, 52%);
|
||||
min-width: 8rem;
|
||||
}
|
||||
|
||||
.reasoningSelect {
|
||||
width: 8.75rem;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.runtimeSelectInput {
|
||||
border-color: transparent;
|
||||
border-radius: var(--mantine-radius-md);
|
||||
background: var(--mantine-color-body);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.runtimeSelectInput:hover {
|
||||
background: var(--mantine-color-default-hover);
|
||||
}
|
||||
|
||||
.runtimeSelectInput:focus,
|
||||
.runtimeSelectInput:focus-within {
|
||||
border-color: var(--mantine-primary-color-filled);
|
||||
}
|
||||
|
||||
.triggerPopover {
|
||||
position: absolute;
|
||||
z-index: 5;
|
||||
@@ -815,6 +851,24 @@
|
||||
padding: 0.6rem 0.55rem;
|
||||
}
|
||||
|
||||
.composerFooter {
|
||||
padding-inline: 0.1rem;
|
||||
}
|
||||
|
||||
.composerHints {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.runtimeControls {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.modelSelect {
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.suggestions {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
@@ -861,8 +915,8 @@
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.composerHints {
|
||||
display: none;
|
||||
.reasoningSelect {
|
||||
width: 7.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
Popover,
|
||||
RingProgress,
|
||||
ScrollArea,
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
@@ -56,6 +57,7 @@ import {
|
||||
IconApps,
|
||||
IconArrowUp,
|
||||
IconAt,
|
||||
IconBrain,
|
||||
IconCheck,
|
||||
IconChevronDown,
|
||||
IconChevronLeft,
|
||||
@@ -68,6 +70,7 @@ import {
|
||||
IconFileExport,
|
||||
IconLink,
|
||||
IconMessage,
|
||||
IconMinus,
|
||||
IconPaperclip,
|
||||
IconPencil,
|
||||
IconHistory,
|
||||
@@ -77,6 +80,7 @@ import {
|
||||
IconRefresh,
|
||||
IconRobot,
|
||||
IconSearch,
|
||||
IconSparkles,
|
||||
IconThumbDown,
|
||||
IconThumbUp,
|
||||
IconTool,
|
||||
@@ -87,23 +91,32 @@ import remarkBreaks from "remark-breaks";
|
||||
import remarkGfm from "remark-gfm";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { assistantReasoningModes } from "@homarr/definitions";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import classes from "./assistant-panel.module.css";
|
||||
import { normalizeAssistantMarkdown } from "./assistant-markdown";
|
||||
import { getAssistantTelemetry, getAssistantUsage } from "./assistant-message-metadata";
|
||||
import type { AssistantReasoningMode, AssistantRuntimeModelOption } from "./assistant-preferences";
|
||||
import { getToolResultPresentation } from "./assistant-tool-result";
|
||||
|
||||
interface AssistantPanelProps {
|
||||
opened: boolean;
|
||||
onOpen: () => void;
|
||||
onClose: () => void;
|
||||
onMarkRead: () => void;
|
||||
onDismissActivity: () => void;
|
||||
activityDismissed: boolean;
|
||||
isRunning: boolean;
|
||||
unreadCount: number;
|
||||
latestAssistantText: string;
|
||||
latestUserText: string;
|
||||
latestStatus: MessageStatus | undefined;
|
||||
modelId: string | null;
|
||||
models: AssistantRuntimeModelOption[];
|
||||
modelOptionsLoading: boolean;
|
||||
reasoning: AssistantReasoningMode;
|
||||
onModelChange: (modelId: string) => void;
|
||||
onReasoningChange: (reasoning: AssistantReasoningMode) => void;
|
||||
}
|
||||
|
||||
const markdownRemarkPlugins = [remarkGfm, remarkBreaks];
|
||||
@@ -1171,7 +1184,64 @@ const ComposerTriggers = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const Composer = () => {
|
||||
type ComposerProps = Pick<
|
||||
AssistantPanelProps,
|
||||
"modelId" | "models" | "modelOptionsLoading" | "reasoning" | "onModelChange" | "onReasoningChange"
|
||||
>;
|
||||
|
||||
const RuntimeControls = ({
|
||||
modelId,
|
||||
models,
|
||||
modelOptionsLoading,
|
||||
reasoning,
|
||||
onModelChange,
|
||||
onReasoningChange,
|
||||
}: ComposerProps) => {
|
||||
const t = useScopedI18n("common.assistant");
|
||||
return (
|
||||
<Group className={classes.runtimeControls} gap={6} wrap="nowrap">
|
||||
<Select
|
||||
className={classes.modelSelect}
|
||||
classNames={{ input: classes.runtimeSelectInput }}
|
||||
size="xs"
|
||||
value={modelId}
|
||||
onChange={(value) => {
|
||||
if (value) onModelChange(value);
|
||||
}}
|
||||
data={models.map((model) => ({ value: model.id, label: model.name }))}
|
||||
searchable
|
||||
allowDeselect={false}
|
||||
disabled={modelOptionsLoading || models.length === 0}
|
||||
leftSection={modelOptionsLoading ? <Loader size={13} /> : <IconSparkles size={14} />}
|
||||
aria-label={t("runtime.model")}
|
||||
placeholder={t("runtime.model")}
|
||||
nothingFoundMessage={t("runtime.noModels")}
|
||||
comboboxProps={{ withinPortal: true, position: "top-start" }}
|
||||
/>
|
||||
<Select
|
||||
className={classes.reasoningSelect}
|
||||
classNames={{ input: classes.runtimeSelectInput }}
|
||||
size="xs"
|
||||
value={reasoning}
|
||||
onChange={(value) => {
|
||||
if (value && assistantReasoningModes.includes(value as AssistantReasoningMode)) {
|
||||
onReasoningChange(value as AssistantReasoningMode);
|
||||
}
|
||||
}}
|
||||
data={assistantReasoningModes.map((mode) => ({
|
||||
value: mode,
|
||||
label: t(`runtime.reasoning.${mode}`),
|
||||
}))}
|
||||
allowDeselect={false}
|
||||
leftSection={<IconBrain size={14} />}
|
||||
aria-label={t("runtime.thinking")}
|
||||
comboboxProps={{ withinPortal: true, position: "top-end" }}
|
||||
/>
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
const Composer = (props: ComposerProps) => {
|
||||
const t = useScopedI18n("common.assistant");
|
||||
const running = useAuiState((state) => state.thread.isRunning);
|
||||
return (
|
||||
@@ -1220,13 +1290,16 @@ const Composer = () => {
|
||||
</ComposerPrimitive.Send>
|
||||
)}
|
||||
</Group>
|
||||
<Group className={classes.composerHints} gap="xs">
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("mentions.hint")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("commands.hint")}
|
||||
</Text>
|
||||
<Group className={classes.composerFooter} justify="space-between" gap="xs" wrap="nowrap">
|
||||
<RuntimeControls {...props} />
|
||||
<Group className={classes.composerHints} gap="xs" wrap="nowrap">
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("mentions.hint")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("commands.hint")}
|
||||
</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
</ComposerPrimitive.Root>
|
||||
</ComposerPrimitive.AttachmentDropzone>
|
||||
@@ -1242,12 +1315,25 @@ const AssistantActivityBar = ({
|
||||
latestUserText,
|
||||
latestStatus,
|
||||
onOpen,
|
||||
onMarkRead,
|
||||
}: Omit<AssistantPanelProps, "opened" | "onClose">) => {
|
||||
onDismissActivity,
|
||||
activityDismissed,
|
||||
}: Pick<
|
||||
AssistantPanelProps,
|
||||
| "isRunning"
|
||||
| "unreadCount"
|
||||
| "latestAssistantText"
|
||||
| "latestUserText"
|
||||
| "latestStatus"
|
||||
| "onOpen"
|
||||
| "onDismissActivity"
|
||||
| "activityDismissed"
|
||||
>) => {
|
||||
const t = useScopedI18n("common.assistant");
|
||||
const needsApproval = latestStatus?.type === "requires-action";
|
||||
const failed = latestStatus?.type === "incomplete" && latestStatus.reason !== "cancelled";
|
||||
const visible = isRunning || unreadCount > 0 || needsApproval;
|
||||
const visible =
|
||||
!activityDismissed &&
|
||||
(isRunning || unreadCount > 0 || needsApproval || latestStatus !== undefined || latestAssistantText.length > 0);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
@@ -1304,7 +1390,7 @@ const AssistantActivityBar = ({
|
||||
className={classes.activityDismiss}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={onMarkRead}
|
||||
onClick={onDismissActivity}
|
||||
aria-label={t("activity.dismiss")}
|
||||
>
|
||||
<IconX size={15} />
|
||||
@@ -1318,12 +1404,19 @@ export const AssistantPanel = ({
|
||||
opened,
|
||||
onOpen,
|
||||
onClose,
|
||||
onMarkRead,
|
||||
onDismissActivity,
|
||||
activityDismissed,
|
||||
isRunning,
|
||||
unreadCount,
|
||||
latestAssistantText,
|
||||
latestUserText,
|
||||
latestStatus,
|
||||
modelId,
|
||||
models,
|
||||
modelOptionsLoading,
|
||||
reasoning,
|
||||
onModelChange,
|
||||
onReasoningChange,
|
||||
}: AssistantPanelProps) => {
|
||||
const t = useScopedI18n("common.assistant");
|
||||
const previousFocusRef = useRef<HTMLElement | null>(null);
|
||||
@@ -1354,7 +1447,8 @@ export const AssistantPanel = ({
|
||||
{!opened && (
|
||||
<AssistantActivityBar
|
||||
onOpen={onOpen}
|
||||
onMarkRead={onMarkRead}
|
||||
onDismissActivity={onDismissActivity}
|
||||
activityDismissed={activityDismissed}
|
||||
isRunning={isRunning}
|
||||
unreadCount={unreadCount}
|
||||
latestAssistantText={latestAssistantText}
|
||||
@@ -1393,15 +1487,17 @@ export const AssistantPanel = ({
|
||||
</ActionIcon>
|
||||
</ThreadListPrimitive.New>
|
||||
</Tooltip>
|
||||
<ActionIcon
|
||||
className={classes.panelAction}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={onClose}
|
||||
aria-label={t("close")}
|
||||
>
|
||||
<IconX size={18} />
|
||||
</ActionIcon>
|
||||
<Tooltip label={t("minimize")}>
|
||||
<ActionIcon
|
||||
className={classes.panelAction}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={onClose}
|
||||
aria-label={t("minimize")}
|
||||
>
|
||||
<IconMinus size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Group>
|
||||
<ThreadPrimitive.Root className={classes.thread}>
|
||||
@@ -1428,7 +1524,14 @@ export const AssistantPanel = ({
|
||||
</ActionIcon>
|
||||
</ThreadPrimitive.ScrollToBottom>
|
||||
</ThreadPrimitive.Viewport>
|
||||
<Composer />
|
||||
<Composer
|
||||
modelId={modelId}
|
||||
models={models}
|
||||
modelOptionsLoading={modelOptionsLoading}
|
||||
reasoning={reasoning}
|
||||
onModelChange={onModelChange}
|
||||
onReasoningChange={onReasoningChange}
|
||||
/>
|
||||
</ThreadPrimitive.Root>
|
||||
</dialog>
|
||||
</FocusTrap>
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
export type { AssistantReasoningMode } from "@homarr/definitions";
|
||||
|
||||
export type AssistantRuntimeModelOption = {
|
||||
id: string;
|
||||
name: string;
|
||||
inputModalities: string[];
|
||||
};
|
||||
@@ -39,6 +39,8 @@ import {
|
||||
} from "@homarr/spotlight";
|
||||
|
||||
import { AssistantPanel } from "./assistant-panel";
|
||||
import type { AssistantReasoningMode, AssistantRuntimeModelOption } from "./assistant-preferences";
|
||||
import { sendAssistantPrompt as sendPromptThroughComposer } from "./assistant-send";
|
||||
import { createAssistantPromptInteraction } from "./assistant-spotlight";
|
||||
import { browserToolContracts } from "./assistant-tool-contracts";
|
||||
import type { AssistantUIMessage } from "./assistant-message-metadata";
|
||||
@@ -55,6 +57,18 @@ interface AssistantContextValue {
|
||||
}
|
||||
|
||||
const AssistantContext = createContext<AssistantContextValue | null>(null);
|
||||
interface AssistantPreferencesContextValue {
|
||||
defaultModelId: string | null;
|
||||
modelId: string | null;
|
||||
models: AssistantRuntimeModelOption[];
|
||||
reasoning: AssistantReasoningMode;
|
||||
isLoading: boolean;
|
||||
setModelId: (modelId: string) => void;
|
||||
setReasoning: (reasoning: AssistantReasoningMode) => void;
|
||||
getRequestBody: () => { modelId?: string; reasoning: AssistantReasoningMode };
|
||||
}
|
||||
|
||||
const AssistantPreferencesContext = createContext<AssistantPreferencesContextValue | null>(null);
|
||||
const ignoreUnsupportedArchiveAction = () => Promise.resolve();
|
||||
const assistantImageAttachmentTypes = ["image/gif", "image/jpeg", "image/png", "image/webp"];
|
||||
const assistantDocumentAttachmentTypes = [
|
||||
@@ -143,6 +157,67 @@ export const useHomarrAssistant = () => {
|
||||
|
||||
export const useOptionalHomarrAssistant = () => useContext(AssistantContext);
|
||||
|
||||
const useAssistantPreferences = () => {
|
||||
const value = useContext(AssistantPreferencesContext);
|
||||
if (!value) throw new Error("useAssistantPreferences must be used within AssistantPreferencesProvider");
|
||||
return value;
|
||||
};
|
||||
|
||||
const AssistantPreferencesProvider = ({ children }: PropsWithChildren) => {
|
||||
const { data, isLoading } = clientApi.assistant.getRuntimeOptions.useQuery(undefined, {
|
||||
staleTime: 10 * 60_000,
|
||||
});
|
||||
const [modelId, setModelIdState] = useState<string | null>(null);
|
||||
const [reasoning, setReasoningState] = useState<AssistantReasoningMode>("auto");
|
||||
const preferencesRef = useRef<{ modelId: string | null; reasoning: AssistantReasoningMode }>({
|
||||
modelId: null,
|
||||
reasoning: "auto",
|
||||
});
|
||||
const models = useMemo<AssistantRuntimeModelOption[]>(
|
||||
() => data?.models.map(({ id, name, inputModalities }) => ({ id, name, inputModalities })) ?? [],
|
||||
[data?.models],
|
||||
);
|
||||
|
||||
const setModelId = useCallback((nextModelId: string) => {
|
||||
preferencesRef.current.modelId = nextModelId;
|
||||
setModelIdState(nextModelId);
|
||||
}, []);
|
||||
const setReasoning = useCallback((nextReasoning: AssistantReasoningMode) => {
|
||||
preferencesRef.current.reasoning = nextReasoning;
|
||||
setReasoningState(nextReasoning);
|
||||
}, []);
|
||||
const getRequestBody = useCallback(() => {
|
||||
const current = preferencesRef.current;
|
||||
return {
|
||||
...(current.modelId ? { modelId: current.modelId } : {}),
|
||||
reasoning: current.reasoning,
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!data) return;
|
||||
const currentModelId = preferencesRef.current.modelId;
|
||||
if (currentModelId && data.models.some((model) => model.id === currentModelId)) return;
|
||||
setModelId(data.defaultModelId);
|
||||
}, [data, setModelId]);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
defaultModelId: data?.defaultModelId ?? null,
|
||||
modelId,
|
||||
models,
|
||||
reasoning,
|
||||
isLoading,
|
||||
setModelId,
|
||||
setReasoning,
|
||||
getRequestBody,
|
||||
}),
|
||||
[data?.defaultModelId, getRequestBody, isLoading, modelId, models, reasoning, setModelId, setReasoning],
|
||||
);
|
||||
|
||||
return <AssistantPreferencesContext.Provider value={value}>{children}</AssistantPreferencesContext.Provider>;
|
||||
};
|
||||
|
||||
const threadAdapter: RemoteThreadListAdapter = {
|
||||
async list() {
|
||||
const threads = await fetchApi.assistant.listThreads.query();
|
||||
@@ -173,6 +248,11 @@ const threadAdapter: RemoteThreadListAdapter = {
|
||||
async rename(threadId, title) {
|
||||
await fetchApi.assistant.renameThread.mutate({ threadId, title });
|
||||
},
|
||||
async updateCustom(threadId, custom) {
|
||||
const modelId = custom?.modelId;
|
||||
if (typeof modelId !== "string") return;
|
||||
await fetchApi.assistant.updateThreadModel.mutate({ threadId, modelId });
|
||||
},
|
||||
// assistant-ui requires these callbacks even when the product does not expose archiving.
|
||||
archive: ignoreUnsupportedArchiveAction,
|
||||
unarchive: ignoreUnsupportedArchiveAction,
|
||||
@@ -267,14 +347,25 @@ const createHistoryAdapter = (threadId: string | undefined): ThreadHistoryAdapte
|
||||
const AssistantThreadRuntime = () => {
|
||||
const t = useScopedI18n("common.assistant");
|
||||
const threadId = useAuiState((state) => state.threadListItem.remoteId);
|
||||
const transport = useMemo(() => new AssistantChatTransport({ api: "/api/assistant/chat" }), []);
|
||||
const preferences = useAssistantPreferences();
|
||||
const transport = useMemo(
|
||||
() =>
|
||||
new AssistantChatTransport({
|
||||
api: "/api/assistant/chat",
|
||||
body: preferences.getRequestBody,
|
||||
}),
|
||||
[preferences.getRequestBody],
|
||||
);
|
||||
const history = useMemo(() => createHistoryAdapter(threadId), [threadId]);
|
||||
const { data: modelCapabilities } = clientApi.assistant.getModelCapabilities.useQuery(undefined, {
|
||||
staleTime: 10 * 60_000,
|
||||
});
|
||||
const selectedModel = preferences.models.find((model) => model.id === preferences.modelId);
|
||||
const attachments = useMemo(
|
||||
() => createAssistantAttachmentAdapter(modelCapabilities?.imageInput !== "unsupported"),
|
||||
[modelCapabilities?.imageInput],
|
||||
() =>
|
||||
createAssistantAttachmentAdapter(
|
||||
selectedModel === undefined ||
|
||||
selectedModel.inputModalities.length === 0 ||
|
||||
selectedModel.inputModalities.includes("image"),
|
||||
),
|
||||
[selectedModel],
|
||||
);
|
||||
const feedback = useMemo(
|
||||
() => ({
|
||||
@@ -367,11 +458,35 @@ const AssistantRuntime = ({ children }: PropsWithChildren) => {
|
||||
<AssistantRuntimeProvider runtime={runtime}>
|
||||
<AssistantTools toolkit={toolkit} />
|
||||
<AssistantRuntimeEvents />
|
||||
<AssistantPreferenceSync />
|
||||
{children}
|
||||
</AssistantRuntimeProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const AssistantPreferenceSync = () => {
|
||||
const preferences = useAssistantPreferences();
|
||||
const threadId = useAuiState((state) => state.threadListItem.remoteId);
|
||||
const threadModelId = useAuiState((state) => state.threadListItem.custom?.modelId);
|
||||
const previousSyncKeyRef = useRef<string | null>(null);
|
||||
const modelCatalogKey = preferences.models.map((model) => model.id).join("\0");
|
||||
|
||||
useEffect(() => {
|
||||
if (preferences.models.length === 0) return;
|
||||
const syncKey = `${threadId ?? "new"}:${modelCatalogKey}`;
|
||||
if (previousSyncKeyRef.current === syncKey) return;
|
||||
previousSyncKeyRef.current = syncKey;
|
||||
const storedModelId = typeof threadModelId === "string" ? threadModelId : null;
|
||||
const nextModelId =
|
||||
storedModelId && preferences.models.some((model) => model.id === storedModelId)
|
||||
? storedModelId
|
||||
: preferences.defaultModelId;
|
||||
if (nextModelId) preferences.setModelId(nextModelId);
|
||||
}, [modelCatalogKey, preferences, threadId, threadModelId]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const AssistantTools = ({ toolkit }: { toolkit: Toolkit }) => {
|
||||
useAui({ tools: Tools({ toolkit }) });
|
||||
return null;
|
||||
@@ -407,8 +522,11 @@ const getNotificationKey = (message: ThreadMessage | undefined) => {
|
||||
const EnabledAssistantProvider = ({ children }: PropsWithChildren) => {
|
||||
const t = useScopedI18n("common.assistant");
|
||||
const [opened, setOpened] = useState(false);
|
||||
const [activityDismissed, setActivityDismissed] = useState(true);
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
const [queuedPrompt, setQueuedPrompt] = useState<string | null>(null);
|
||||
const aui = useAui();
|
||||
const preferences = useAssistantPreferences();
|
||||
const messages = useAuiState((state) => state.thread.messages);
|
||||
const isRunning = useAuiState((state) => state.thread.isRunning);
|
||||
const isLoading = useAuiState((state) => state.thread.isLoading);
|
||||
@@ -417,6 +535,7 @@ const EnabledAssistantProvider = ({ children }: PropsWithChildren) => {
|
||||
const latestAssistantText = getMessageText(latestAssistantMessage);
|
||||
const latestUserText = getMessageText(latestUserMessage);
|
||||
const latestStatus = latestAssistantMessage?.role === "assistant" ? latestAssistantMessage.status : undefined;
|
||||
const assistantIsRunning = isRunning || queuedPrompt !== null;
|
||||
const notificationKey = getNotificationKey(latestAssistantMessage);
|
||||
const initializedRef = useRef(false);
|
||||
const lastNotifiedKeyRef = useRef<string | null>(null);
|
||||
@@ -427,30 +546,54 @@ const EnabledAssistantProvider = ({ children }: PropsWithChildren) => {
|
||||
}, [notificationKey]);
|
||||
const open = useCallback(() => {
|
||||
markRead();
|
||||
setActivityDismissed(false);
|
||||
setOpened(true);
|
||||
}, [markRead]);
|
||||
const close = useCallback(() => setOpened(false), []);
|
||||
const toggle = useCallback(() => {
|
||||
setOpened((current) => {
|
||||
if (!current) markRead();
|
||||
return !current;
|
||||
});
|
||||
}, [markRead]);
|
||||
if (opened) close();
|
||||
else open();
|
||||
}, [close, open, opened]);
|
||||
const sendPrompt = useCallback(
|
||||
(prompt: string) => {
|
||||
const text = prompt.trim();
|
||||
if (text.length === 0) return;
|
||||
if (isRunning || latestStatus?.type === "requires-action") {
|
||||
if (assistantIsRunning || latestStatus?.type === "requires-action") {
|
||||
showWarningNotification({
|
||||
title: t("busy.title"),
|
||||
message: t("busy.description"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
aui.thread().append({ role: "user", content: [{ type: "text", text }] });
|
||||
setActivityDismissed(false);
|
||||
if (isLoading) {
|
||||
setQueuedPrompt(text);
|
||||
return;
|
||||
}
|
||||
sendPromptThroughComposer(aui.composer(), text);
|
||||
},
|
||||
[aui, isRunning, latestStatus?.type, t],
|
||||
[assistantIsRunning, aui, isLoading, latestStatus?.type, t],
|
||||
);
|
||||
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],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (assistantIsRunning) setActivityDismissed(false);
|
||||
}, [assistantIsRunning]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading || isRunning || queuedPrompt === null) return;
|
||||
const prompt = queuedPrompt;
|
||||
setQueuedPrompt(null);
|
||||
sendPromptThroughComposer(aui.composer(), prompt);
|
||||
}, [aui, isLoading, isRunning, queuedPrompt]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading) return;
|
||||
@@ -462,6 +605,7 @@ const EnabledAssistantProvider = ({ children }: PropsWithChildren) => {
|
||||
}
|
||||
|
||||
if (notificationKey === null || notificationKey === lastNotifiedKeyRef.current) return;
|
||||
setActivityDismissed(false);
|
||||
|
||||
if (opened) {
|
||||
lastNotifiedKeyRef.current = notificationKey;
|
||||
@@ -489,8 +633,8 @@ const EnabledAssistantProvider = ({ children }: PropsWithChildren) => {
|
||||
useRegisterSpotlightContextActions("homarr-assistant", [spotlightItem], [spotlightItem]);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ enabled: true, opened, isRunning, unreadCount, open, close, toggle, sendPrompt }),
|
||||
[close, isRunning, open, opened, sendPrompt, toggle, unreadCount],
|
||||
() => ({ enabled: true, opened, isRunning: assistantIsRunning, unreadCount, open, close, toggle, sendPrompt }),
|
||||
[assistantIsRunning, close, open, opened, sendPrompt, toggle, unreadCount],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -500,12 +644,22 @@ const EnabledAssistantProvider = ({ children }: PropsWithChildren) => {
|
||||
opened={opened}
|
||||
onOpen={open}
|
||||
onClose={close}
|
||||
onMarkRead={markRead}
|
||||
isRunning={isRunning}
|
||||
onDismissActivity={() => {
|
||||
markRead();
|
||||
setActivityDismissed(true);
|
||||
}}
|
||||
activityDismissed={activityDismissed}
|
||||
isRunning={assistantIsRunning}
|
||||
unreadCount={unreadCount}
|
||||
latestAssistantText={latestAssistantText}
|
||||
latestUserText={latestUserText}
|
||||
latestUserText={queuedPrompt ?? latestUserText}
|
||||
latestStatus={latestStatus}
|
||||
modelId={preferences.modelId}
|
||||
models={preferences.models}
|
||||
modelOptionsLoading={preferences.isLoading}
|
||||
reasoning={preferences.reasoning}
|
||||
onModelChange={selectModel}
|
||||
onReasoningChange={preferences.setReasoning}
|
||||
/>
|
||||
</AssistantContext.Provider>
|
||||
);
|
||||
@@ -559,9 +713,11 @@ export const AssistantProvider = ({ children }: PropsWithChildren) => {
|
||||
|
||||
if (enabled) {
|
||||
return (
|
||||
<AssistantRuntime>
|
||||
<EnabledAssistantProvider>{children}</EnabledAssistantProvider>
|
||||
</AssistantRuntime>
|
||||
<AssistantPreferencesProvider>
|
||||
<AssistantRuntime>
|
||||
<EnabledAssistantProvider>{children}</EnabledAssistantProvider>
|
||||
</AssistantRuntime>
|
||||
</AssistantPreferencesProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
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")),
|
||||
};
|
||||
|
||||
expect(sendAssistantPrompt(composer, " Check my services ")).toBe(true);
|
||||
expect(calls).toEqual(["set:Check my services", "send"]);
|
||||
});
|
||||
|
||||
test("ignores empty prompts", () => {
|
||||
const composer = { setText: vi.fn(), send: vi.fn() };
|
||||
|
||||
expect(sendAssistantPrompt(composer, " ")).toBe(false);
|
||||
expect(composer.setText).not.toHaveBeenCalled();
|
||||
expect(composer.send).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
type AssistantComposerActions = {
|
||||
setText: (text: string) => void;
|
||||
send: () => void;
|
||||
};
|
||||
|
||||
export const sendAssistantPrompt = (composer: AssistantComposerActions, prompt: string) => {
|
||||
const text = prompt.trim();
|
||||
if (text.length === 0) return false;
|
||||
|
||||
composer.setText(text);
|
||||
composer.send();
|
||||
return true;
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
export {
|
||||
getAssistantContextEntitiesAsync,
|
||||
getAssistantModelsAsync,
|
||||
getSelectedModelDetailsAsync,
|
||||
type AssistantContextEntity,
|
||||
} from "./router/assistant";
|
||||
@@ -34,13 +34,13 @@ const configurationId = "default";
|
||||
const providerSchema = z.enum(assistantProviderIds);
|
||||
|
||||
const modelSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
description: z.string().nullable(),
|
||||
contextLength: z.number().nullable(),
|
||||
promptPrice: z.string().nullable(),
|
||||
completionPrice: z.string().nullable(),
|
||||
inputModalities: z.array(z.string()),
|
||||
id: z.string().min(1).max(256),
|
||||
name: z.string().min(1).max(256),
|
||||
description: z.string().max(2048).nullable(),
|
||||
contextLength: z.number().int().positive().max(Number.MAX_SAFE_INTEGER).nullable(),
|
||||
promptPrice: z.string().max(64).nullable(),
|
||||
completionPrice: z.string().max(64).nullable(),
|
||||
inputModalities: z.array(z.string().max(32)).max(16),
|
||||
toolSupport: z.enum(["confirmed", "unknown"]),
|
||||
});
|
||||
|
||||
@@ -71,7 +71,7 @@ export type AssistantContextEntity = {
|
||||
boardId?: string;
|
||||
};
|
||||
|
||||
const selectedModelCache = new Map<string, { expiresAt: number; value: z.infer<typeof modelSchema> | null }>();
|
||||
const modelListCache = new Map<string, { expiresAt: number; value: z.infer<typeof modelSchema>[] }>();
|
||||
|
||||
const customHeadersSchema = z
|
||||
.record(
|
||||
@@ -197,7 +197,7 @@ const fetchModelsAsync = async (configuration: AssistantConfiguration) => {
|
||||
} catch {
|
||||
throw new TRPCError({ code: "BAD_GATEWAY", message: "The model endpoint returned invalid JSON." });
|
||||
}
|
||||
const models = Array.isArray(body) ? body : (body.data ?? body.models ?? []);
|
||||
const models = (Array.isArray(body) ? body : (body.data ?? body.models ?? [])).slice(0, 1_000);
|
||||
return models
|
||||
.filter((model) => {
|
||||
if (configuration.provider !== "openrouter") return true;
|
||||
@@ -209,15 +209,21 @@ const fetchModelsAsync = async (configuration: AssistantConfiguration) => {
|
||||
})
|
||||
.flatMap((model) => {
|
||||
const id = typeof model.id === "string" ? model.id : typeof model.model === "string" ? model.model : null;
|
||||
if (!id) return [];
|
||||
if (!id || id.length > 256) return [];
|
||||
const name =
|
||||
typeof model.name === "string" ? model.name : typeof model.display_name === "string" ? model.display_name : id;
|
||||
const contextLength =
|
||||
typeof model.context_length === "number"
|
||||
typeof model.context_length === "number" &&
|
||||
Number.isSafeInteger(model.context_length) &&
|
||||
model.context_length > 0
|
||||
? model.context_length
|
||||
: typeof model.max_context_length === "number"
|
||||
: typeof model.max_context_length === "number" &&
|
||||
Number.isSafeInteger(model.max_context_length) &&
|
||||
model.max_context_length > 0
|
||||
? model.max_context_length
|
||||
: typeof model.max_input_tokens === "number"
|
||||
: typeof model.max_input_tokens === "number" &&
|
||||
Number.isSafeInteger(model.max_input_tokens) &&
|
||||
model.max_input_tokens > 0
|
||||
? model.max_input_tokens
|
||||
: null;
|
||||
const toolSupport =
|
||||
@@ -231,15 +237,17 @@ const fetchModelsAsync = async (configuration: AssistantConfiguration) => {
|
||||
: Array.isArray(model.input_modalities)
|
||||
? model.input_modalities
|
||||
: [];
|
||||
const inputModalities = rawInputModalities.filter((modality): modality is string => typeof modality === "string");
|
||||
const inputModalities = rawInputModalities
|
||||
.filter((modality): modality is string => typeof modality === "string" && modality.length <= 32)
|
||||
.slice(0, 16);
|
||||
return [
|
||||
{
|
||||
id,
|
||||
name,
|
||||
description: typeof model.description === "string" ? model.description : null,
|
||||
name: name.slice(0, 256),
|
||||
description: typeof model.description === "string" ? model.description.slice(0, 2048) : null,
|
||||
contextLength,
|
||||
promptPrice: typeof model.pricing?.prompt === "string" ? model.pricing.prompt : null,
|
||||
completionPrice: typeof model.pricing?.completion === "string" ? model.pricing.completion : null,
|
||||
promptPrice: typeof model.pricing?.prompt === "string" ? model.pricing.prompt.slice(0, 64) : null,
|
||||
completionPrice: typeof model.pricing?.completion === "string" ? model.pricing.completion.slice(0, 64) : null,
|
||||
inputModalities,
|
||||
toolSupport,
|
||||
},
|
||||
@@ -248,27 +256,36 @@ const fetchModelsAsync = async (configuration: AssistantConfiguration) => {
|
||||
.toSorted((left, right) => left.name.localeCompare(right.name));
|
||||
};
|
||||
|
||||
export const getSelectedModelDetailsAsync = async (configuration: AssistantConfiguration) => {
|
||||
if (!configuration.modelId || !configuration.modelDiscoveryPath) return null;
|
||||
|
||||
const cacheKey = [
|
||||
const getModelListCacheKey = (configuration: AssistantConfiguration) =>
|
||||
[
|
||||
configuration.provider,
|
||||
configuration.baseUrl,
|
||||
configuration.modelDiscoveryPath,
|
||||
configuration.modelId,
|
||||
configuration.updatedAt.getTime(),
|
||||
].join(":");
|
||||
const cached = selectedModelCache.get(cacheKey);
|
||||
if (cached && cached.expiresAt > Date.now()) return cached.value;
|
||||
|
||||
const models = await fetchModelsAsync(configuration);
|
||||
const resolvedModelId = resolveAssistantModelId(models, configuration.modelId);
|
||||
const value = models.find((model) => model.id === resolvedModelId) ?? null;
|
||||
selectedModelCache.clear();
|
||||
selectedModelCache.set(cacheKey, { expiresAt: Date.now() + 10 * 60_000, value });
|
||||
export const getAssistantModelsAsync = async (configuration: AssistantConfiguration, refresh = false) => {
|
||||
if (!configuration.modelDiscoveryPath) return [];
|
||||
const cacheKey = getModelListCacheKey(configuration);
|
||||
const cached = modelListCache.get(cacheKey);
|
||||
if (!refresh && cached && cached.expiresAt > Date.now()) return cached.value;
|
||||
|
||||
const value = await fetchModelsAsync(configuration);
|
||||
modelListCache.clear();
|
||||
modelListCache.set(cacheKey, { expiresAt: Date.now() + 10 * 60_000, value });
|
||||
return value;
|
||||
};
|
||||
|
||||
export const getSelectedModelDetailsAsync = async (
|
||||
configuration: AssistantConfiguration,
|
||||
requestedModelId = configuration.modelId,
|
||||
) => {
|
||||
if (!requestedModelId || !configuration.modelDiscoveryPath) return null;
|
||||
const models = await getAssistantModelsAsync(configuration);
|
||||
const resolvedModelId = resolveAssistantModelId(models, requestedModelId);
|
||||
return models.find((model) => model.id === resolvedModelId) ?? null;
|
||||
};
|
||||
|
||||
export const getAssistantContextEntitiesAsync = async (ctx: AssistantContext): Promise<AssistantContextEntity[]> => {
|
||||
if (!ctx.session) return [];
|
||||
|
||||
@@ -427,12 +444,13 @@ export const assistantRouter = createTRPCRouter({
|
||||
"Returns the configured assistant model's discovered input modalities and whether image input is supported, unsupported, or unknown.",
|
||||
},
|
||||
})
|
||||
.query(async ({ ctx }) => {
|
||||
.input(z.object({ modelId: z.string().trim().min(1).max(256).optional() }).optional())
|
||||
.query(async ({ ctx, input }) => {
|
||||
const configuration = await getConfigurationAsync(ctx.db);
|
||||
if (!configuration?.enabled || !configuration.modelId) {
|
||||
return { imageInput: "unsupported" as const, inputModalities: [] as string[] };
|
||||
}
|
||||
const model = await getSelectedModelDetailsAsync(configuration).catch(() => null);
|
||||
const model = await getSelectedModelDetailsAsync(configuration, input?.modelId).catch(() => null);
|
||||
if (!model || model.inputModalities.length === 0) {
|
||||
return { imageInput: "unknown" as const, inputModalities: [] as string[] };
|
||||
}
|
||||
@@ -442,6 +460,30 @@ export const assistantRouter = createTRPCRouter({
|
||||
};
|
||||
}),
|
||||
|
||||
getRuntimeOptions: protectedProcedure.query(async ({ ctx }) => {
|
||||
const configuration = await getConfigurationAsync(ctx.db);
|
||||
if (!configuration?.enabled || !configuration.modelId) {
|
||||
throw new TRPCError({ code: "PRECONDITION_FAILED", message: "Homarr Assistant is not configured." });
|
||||
}
|
||||
const discoveredModels = await getAssistantModelsAsync(configuration).catch(() => []);
|
||||
const resolvedDefaultId = resolveAssistantModelId(discoveredModels, configuration.modelId) ?? configuration.modelId;
|
||||
const defaultModel = discoveredModels.find((model) => model.id === resolvedDefaultId) ?? {
|
||||
id: resolvedDefaultId,
|
||||
name: resolvedDefaultId,
|
||||
description: null,
|
||||
contextLength: null,
|
||||
promptPrice: null,
|
||||
completionPrice: null,
|
||||
inputModalities: [],
|
||||
toolSupport: "unknown" as const,
|
||||
};
|
||||
return {
|
||||
provider: configuration.provider,
|
||||
defaultModelId: defaultModel.id,
|
||||
models: [defaultModel, ...discoveredModels.filter((model) => model.id !== defaultModel.id)],
|
||||
};
|
||||
}),
|
||||
|
||||
getAdminConfiguration: adminProcedure.query(async ({ ctx }) => {
|
||||
const configuration = await getConfigurationAsync(ctx.db);
|
||||
return {
|
||||
@@ -540,7 +582,7 @@ export const assistantRouter = createTRPCRouter({
|
||||
if (!configuration) {
|
||||
throw new TRPCError({ code: "PRECONDITION_FAILED", message: "Save a provider connection first." });
|
||||
}
|
||||
return await fetchModelsAsync(configuration);
|
||||
return await getAssistantModelsAsync(configuration, true);
|
||||
}),
|
||||
|
||||
updateConfiguration: adminProcedure
|
||||
@@ -590,6 +632,31 @@ export const assistantRouter = createTRPCRouter({
|
||||
return { id };
|
||||
}),
|
||||
|
||||
updateThreadModel: protectedProcedure
|
||||
.input(z.object({ threadId: z.string().max(64), modelId: z.string().trim().min(1).max(256) }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const [thread, configuration] = await Promise.all([
|
||||
ownedThreadAsync(ctx.db, input.threadId, ctx.session.user.id),
|
||||
getConfigurationAsync(ctx.db),
|
||||
]);
|
||||
if (!configuration?.enabled || !configuration.modelId) {
|
||||
throw new TRPCError({ code: "PRECONDITION_FAILED", message: "Homarr Assistant is not configured." });
|
||||
}
|
||||
const selectedModel = await getSelectedModelDetailsAsync(configuration, input.modelId).catch(() => null);
|
||||
if (input.modelId !== configuration.modelId && !selectedModel) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "The selected model is not available from the configured provider.",
|
||||
});
|
||||
}
|
||||
const modelId = selectedModel?.id ?? configuration.modelId;
|
||||
await ctx.db
|
||||
.update(assistantThreads)
|
||||
.set({ modelId, updatedAt: new Date() })
|
||||
.where(eq(assistantThreads.id, thread.id));
|
||||
return { modelId };
|
||||
}),
|
||||
|
||||
getThread: protectedProcedure.input(z.object({ threadId: z.string().max(64) })).query(async ({ ctx, input }) => {
|
||||
const thread = await ownedThreadAsync(ctx.db, input.threadId, ctx.session.user.id);
|
||||
const messages = await ctx.db.query.assistantMessages.findMany({
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
apps,
|
||||
assistantConfigurations,
|
||||
assistantMessages,
|
||||
assistantThreads,
|
||||
boards,
|
||||
integrations,
|
||||
integrationUserPermissions,
|
||||
@@ -141,6 +142,59 @@ describe("assistantRouter.updateConfiguration", () => {
|
||||
});
|
||||
|
||||
describe("assistant conversation features", () => {
|
||||
test("returns safe runtime model options to signed-in users", async () => {
|
||||
const db = await createConfiguredAssistantAsync();
|
||||
await db
|
||||
.update(assistantConfigurations)
|
||||
.set({
|
||||
baseUrl: "https://models.example/v1",
|
||||
encryptedHeaders: null,
|
||||
updatedAt: new Date("2026-07-30T14:00:00.000Z"),
|
||||
})
|
||||
.where(eq(assistantConfigurations.id, "default"));
|
||||
const caller = assistantRouter.createCaller({ db, deviceType: undefined, session: adminSession });
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue(
|
||||
Response.json({
|
||||
data: [
|
||||
{
|
||||
id: "example/model",
|
||||
name: "Example Model",
|
||||
supported_parameters: ["tools"],
|
||||
architecture: { input_modalities: ["text", "image"], output_modalities: ["text"] },
|
||||
},
|
||||
{
|
||||
id: "example/no-tools",
|
||||
name: "No tools",
|
||||
supported_parameters: [],
|
||||
architecture: { output_modalities: ["text"] },
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
const options = await caller.getRuntimeOptions();
|
||||
|
||||
expect(options).toEqual({
|
||||
provider: "openrouter",
|
||||
defaultModelId: "example/model",
|
||||
models: [
|
||||
expect.objectContaining({
|
||||
id: "example/model",
|
||||
name: "Example Model",
|
||||
inputModalities: ["text", "image"],
|
||||
}),
|
||||
],
|
||||
});
|
||||
expect(JSON.stringify(options)).not.toContain("encrypted");
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
test("returns permission-checked entities for composer mentions", async () => {
|
||||
const db = await createConfiguredAssistantAsync();
|
||||
await db.insert(users).values({ id: adminSession.user.id });
|
||||
@@ -172,6 +226,53 @@ describe("assistant conversation features", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("persists a discovered model choice on the owned conversation", async () => {
|
||||
const db = await createConfiguredAssistantAsync();
|
||||
await db.insert(users).values({ id: adminSession.user.id });
|
||||
await db
|
||||
.update(assistantConfigurations)
|
||||
.set({
|
||||
baseUrl: "https://thread-models.example/v1",
|
||||
encryptedHeaders: null,
|
||||
updatedAt: new Date("2026-07-30T15:00:00.000Z"),
|
||||
})
|
||||
.where(eq(assistantConfigurations.id, "default"));
|
||||
const caller = assistantRouter.createCaller({ db, deviceType: undefined, session: adminSession });
|
||||
const thread = await caller.createThread();
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue(
|
||||
Response.json({
|
||||
data: [
|
||||
{
|
||||
id: "example/model",
|
||||
name: "Default",
|
||||
supported_parameters: ["tools"],
|
||||
architecture: { output_modalities: ["text"] },
|
||||
},
|
||||
{
|
||||
id: "example/reasoning",
|
||||
name: "Reasoning",
|
||||
supported_parameters: ["tools"],
|
||||
architecture: { output_modalities: ["text"] },
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
await caller.updateThreadModel({ threadId: thread.id, modelId: "example/reasoning" });
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
|
||||
const storedThread = await db.query.assistantThreads.findFirst({
|
||||
where: eq(assistantThreads.id, thread.id),
|
||||
});
|
||||
expect(storedThread?.modelId).toBe("example/reasoning");
|
||||
});
|
||||
|
||||
test("persists feedback on an owned assistant message", async () => {
|
||||
const db = await createConfiguredAssistantAsync();
|
||||
await db.insert(users).values({ id: adminSession.user.id });
|
||||
|
||||
@@ -16,6 +16,9 @@ export const assistantProviderIds = [
|
||||
export type AssistantProvider = (typeof assistantProviderIds)[number];
|
||||
export type AssistantProviderCategory = "hosted" | "local" | "custom";
|
||||
|
||||
export const assistantReasoningModes = ["auto", "none", "minimal", "low", "medium", "high"] as const;
|
||||
export type AssistantReasoningMode = (typeof assistantReasoningModes)[number];
|
||||
|
||||
export interface AssistantModelOption {
|
||||
id: string;
|
||||
name: string;
|
||||
|
||||
@@ -1984,6 +1984,7 @@
|
||||
"subtitle": "Connected to this Homarr instance",
|
||||
"open": "Open Homarr Assistant",
|
||||
"close": "Close assistant",
|
||||
"minimize": "Minimize assistant",
|
||||
"spotlight": "Ask Homarr Assistant",
|
||||
"spotlightDescription": "Type and send a prompt without leaving the command menu.",
|
||||
"spotlightPrompt": {
|
||||
@@ -2024,6 +2025,19 @@
|
||||
}
|
||||
},
|
||||
"composerPlaceholder": "Ask Homarr Assistant…",
|
||||
"runtime": {
|
||||
"model": "Model",
|
||||
"noModels": "No models available",
|
||||
"thinking": "Thinking mode",
|
||||
"reasoning": {
|
||||
"auto": "Thinking: Auto",
|
||||
"none": "Thinking: Off",
|
||||
"minimal": "Thinking: Minimal",
|
||||
"low": "Thinking: Low",
|
||||
"medium": "Thinking: Medium",
|
||||
"high": "Thinking: High"
|
||||
}
|
||||
},
|
||||
"stop": "Stop response",
|
||||
"send": "Send message",
|
||||
"copy": "Copy response",
|
||||
|
||||
Reference in new issue
Block a user