fix: address assistant review feedback

This commit is contained in:
ajnart committed 2026-08-14 11:32:10 +02:00
1 parent 33695753dd
commit 5ceef06b4b
26 files changed
+340 -49

No files matched your search

+1 -1
View File
@@ -18,7 +18,7 @@ export const assistantWidget: WidgetDefinition = {
name: "Pinned conversation", name: "Pinned conversation",
description: "Search for a conversation. This field is hidden when Current conversation is selected.", description: "Search for a conversation. This field is hidden when Current conversation is selected.",
values: { type: "string" }, values: { type: "string" },
defaultValue: "Hidden", defaultValue: "",
}, },
], ],
}, },
@@ -101,8 +101,8 @@ const ConfigurationSection = ({ number, title, description, status, actions, chi
</section> </section>
); );
const ConfigurationSkeleton = () => ( const ConfigurationSkeleton = ({ label }: { label: string }) => (
<Stack className={classes.configuration} gap="md" aria-label="Loading assistant configuration"> <Stack className={classes.configuration} gap="md" aria-label={label}>
<Paper withBorder p="md"> <Paper withBorder p="md">
<Group justify="space-between"> <Group justify="space-between">
<Group> <Group>
@@ -357,7 +357,7 @@ export const AssistantConfiguration = () => {
}; };
if (isLoading) { if (isLoading) {
return <ConfigurationSkeleton />; return <ConfigurationSkeleton label={t("loading")} />;
} }
return ( return (
@@ -376,12 +376,12 @@ export const AssistantConfiguration = () => {
<Badge <Badge
size="sm" size="sm"
variant="light" variant="light"
color={connectionReady ? "green" : connectionChanged ? "yellow" : "gray"} color={connectionChanged ? "yellow" : connectionReady ? "green" : "gray"}
> >
{connectionReady {connectionChanged
? t("overview.connectionReady") ? t("overview.unsavedChanges")
: connectionChanged : connectionReady
? t("overview.unsavedChanges") ? t("overview.connectionReady")
: t("overview.setupRequired")} : t("overview.setupRequired")}
</Badge> </Badge>
</Group> </Group>
@@ -815,9 +815,7 @@ export const AssistantConfiguration = () => {
</Box> </Box>
<Group className={classes.selectedModelBadges} gap="xs" justify="flex-end"> <Group className={classes.selectedModelBadges} gap="xs" justify="flex-end">
{selectedModel.contextLength && ( {selectedModel.contextLength && (
<Badge variant="light"> <Badge variant="light">{t("model.context", { count: selectedModel.contextLength })}</Badge>
{t("model.context", { count: selectedModel.contextLength.toLocaleString() })}
</Badge>
)} )}
<Badge <Badge
variant="light" variant="light"
@@ -0,0 +1,49 @@
import { describe, expect, test } from "vitest";
import { getAssistantModelLookupStatus } from "./assistant-model-lookup";
describe("getAssistantModelLookupStatus", () => {
test("keeps the configured default usable when discovery is temporarily unreachable", () => {
expect(
getAssistantModelLookupStatus({
configuredModelId: "provider/default",
requestedModelId: "provider/default",
hasModel: false,
failed: true,
}),
).toBe("available");
});
test("treats an alternate model discovery failure as retryable", () => {
expect(
getAssistantModelLookupStatus({
configuredModelId: "provider/default",
requestedModelId: "provider/alternate",
hasModel: false,
failed: true,
}),
).toBe("unreachable");
});
test("rejects an alternate model only after discovery confirms it is absent", () => {
expect(
getAssistantModelLookupStatus({
configuredModelId: "provider/default",
requestedModelId: "provider/alternate",
hasModel: false,
failed: false,
}),
).toBe("unavailable");
});
test("accepts a discovered alternate model", () => {
expect(
getAssistantModelLookupStatus({
configuredModelId: "provider/default",
requestedModelId: "provider/alternate",
hasModel: true,
failed: false,
}),
).toBe("available");
});
});
@@ -0,0 +1,17 @@
export type AssistantModelLookupStatus = "available" | "unavailable" | "unreachable";
export const getAssistantModelLookupStatus = ({
configuredModelId,
requestedModelId,
hasModel,
failed,
}: {
configuredModelId: string;
requestedModelId: string;
hasModel: boolean;
failed: boolean;
}): AssistantModelLookupStatus => {
if (requestedModelId === configuredModelId) return "available";
if (failed) return "unreachable";
return hasModel ? "available" : "unavailable";
};
@@ -31,6 +31,7 @@ import type {
import { extractMcpTools } from "../../mcp/_extract-tools"; import { extractMcpTools } from "../../mcp/_extract-tools";
import { getRequestedMentionIds, sanitizeAttachmentFilename } from "./assistant-chat-input"; import { getRequestedMentionIds, sanitizeAttachmentFilename } from "./assistant-chat-input";
import { getAssistantModelLookupStatus } from "./assistant-model-lookup";
import { getAssistantStreamErrorMessage } from "./assistant-stream-error"; import { getAssistantStreamErrorMessage } from "./assistant-stream-error";
import { getForcedAssistantToolName, withAssistantToolPolicy } from "./assistant-tool-policy"; import { getForcedAssistantToolName, withAssistantToolPolicy } from "./assistant-tool-policy";
@@ -361,11 +362,35 @@ export async function POST(request: Request) {
const requestStartedAt = Date.now(); const requestStartedAt = Date.now();
const requestId = crypto.randomUUID(); const requestId = crypto.randomUUID();
const requestedModelId = parsed.data.modelId ?? configuration.modelId; const requestedModelId = parsed.data.modelId ?? configuration.modelId;
const [selectedModel, mentionContext] = await Promise.all([ const [modelLookup, mentionContext] = await Promise.all([
getSelectedModelDetailsAsync(configuration, requestedModelId).catch(() => null), getSelectedModelDetailsAsync(configuration, requestedModelId).then(
(model) => ({ model, error: null }),
(error: unknown) => ({ model: null, error }),
),
getMentionContextAsync(context, parsed.data.messages), getMentionContextAsync(context, parsed.data.messages),
]); ]);
if (requestedModelId !== configuration.modelId && !selectedModel) { if (modelLookup.error !== null) {
logger.warn("Assistant model discovery failed before the response started", {
requestId,
provider: configuration.provider,
modelId: requestedModelId,
error: modelLookup.error,
});
}
const selectedModel = modelLookup.model;
const modelLookupStatus = getAssistantModelLookupStatus({
configuredModelId: configuration.modelId,
requestedModelId,
hasModel: selectedModel !== null,
failed: modelLookup.error !== null,
});
if (modelLookupStatus === "unreachable") {
return Response.json(
{ error: "The configured model endpoint could not be reached. Try again in a moment." },
{ status: 503 },
);
}
if (modelLookupStatus === "unavailable") {
return Response.json( return Response.json(
{ error: "The selected model is not available from the configured provider." }, { error: "The selected model is not available from the configured provider." },
{ status: 400 }, { status: 400 },
@@ -96,4 +96,31 @@ describe("assistant automatic approval tracker", () => {
await act(async () => root.unmount()); await act(async () => root.unmount());
}); });
test("returns a failed approval to the caller and releases its claim", async () => {
const container = document.createElement("div");
containers.push(container);
document.body.append(container);
const root = createRoot(container);
await act(async () => {
root.render(
createElement(
AssistantAutoApprovalProvider,
{ conversationId: "local-thread-1" },
createElement(AutoApprovalProbe),
),
);
});
act(() => currentAutoApproval?.setEnabled(true));
expect(
currentAutoApproval?.requestApproval("app-create-1", () => {
throw new Error("Approval transport failed");
}),
).toBe(false);
expect(currentAutoApproval?.requestApproval("app-create-1", () => undefined)).toBe(true);
await act(async () => root.unmount());
});
}); });
@@ -58,9 +58,9 @@ export const AssistantAutoApprovalProvider = ({
try { try {
approve(); approve();
return true; return true;
} catch (error) { } catch {
trackerRef.current.release(toolCallId); trackerRef.current.release(toolCallId);
throw error; return false;
} }
}, },
[enabled], [enabled],
@@ -43,7 +43,11 @@ import { useZodForm } from "@homarr/form";
import { useI18n, useScopedI18n } from "@homarr/translation/client"; import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { boardSavePartialSettingsSchema } from "@homarr/validation/board"; import { boardSavePartialSettingsSchema } from "@homarr/validation/board";
import { getChangedBoardSettings, getCustomCssWarnings } from "./assistant-board-settings"; import {
getAssistantBoardSettingsDefaultTab,
getChangedBoardSettings,
getCustomCssWarnings,
} from "./assistant-board-settings";
import { hasCompleteAssistantToolArguments, hasFailedAssistantToolArguments } from "./assistant-human-tool-status"; import { hasCompleteAssistantToolArguments, hasFailedAssistantToolArguments } from "./assistant-human-tool-status";
import { AssistantHumanToolError } from "./assistant-human-tools"; import { AssistantHumanToolError } from "./assistant-human-tools";
import classes from "./assistant-panel.module.css"; import classes from "./assistant-panel.module.css";
@@ -183,8 +187,7 @@ const BoardSettingsForm = ({
const changedCount = Object.keys(changes).length; const changedCount = Object.keys(changes).length;
const cssWarnings = getCustomCssWarnings(form.values.customCss); const cssWarnings = getCustomCssWarnings(form.values.customCss);
const proposedKeys = Object.keys(args.changes); const proposedKeys = Object.keys(args.changes);
const defaultTab = const defaultTab = getAssistantBoardSettingsDefaultTab(args.changes);
args.changes.customCss !== undefined ? "css" : proposedKeys.some(isAppearanceKey) ? "appearance" : "general";
const optionData = <TValue extends string>( const optionData = <TValue extends string>(
values: readonly TValue[], values: readonly TValue[],
key: "backgroundImageAttachment" | "backgroundImageRepeat" | "backgroundImageSize", key: "backgroundImageAttachment" | "backgroundImageRepeat" | "backgroundImageSize",
@@ -404,6 +407,3 @@ const BoardSettingsForm = ({
</Box> </Box>
); );
}; };
const isAppearanceKey = (key: string) =>
["primaryColor", "secondaryColor", "opacity", "iconColor", "itemRadius"].includes(key);
@@ -1,6 +1,10 @@
import { describe, expect, test } from "vitest"; import { describe, expect, test } from "vitest";
import { getChangedBoardSettings, getCustomCssWarnings } from "./assistant-board-settings"; import {
getAssistantBoardSettingsDefaultTab,
getChangedBoardSettings,
getCustomCssWarnings,
} from "./assistant-board-settings";
describe("getChangedBoardSettings", () => { describe("getChangedBoardSettings", () => {
test("returns only changed settings and treats empty nullable values equally", () => { test("returns only changed settings and treats empty nullable values equally", () => {
@@ -27,3 +31,12 @@ describe("getCustomCssWarnings", () => {
}); });
}); });
}); });
describe("getAssistantBoardSettingsDefaultTab", () => {
test("opens the tab containing the proposed settings", () => {
expect(getAssistantBoardSettingsDefaultTab({ pageTitle: "Home" })).toBe("general");
expect(getAssistantBoardSettingsDefaultTab({ primaryColor: "#112233" })).toBe("appearance");
expect(getAssistantBoardSettingsDefaultTab({ backgroundImageSize: "cover" })).toBe("background");
expect(getAssistantBoardSettingsDefaultTab({ customCss: ".item { opacity: 0.9; }" })).toBe("css");
});
});
@@ -31,3 +31,27 @@ export const getCustomCssWarnings = (css: string) => ({
importsStylesheet: /@import\b/iu.test(css), importsStylesheet: /@import\b/iu.test(css),
loadsRemoteResource: /url\(\s*["']?https?:\/\//iu.test(css), loadsRemoteResource: /url\(\s*["']?https?:\/\//iu.test(css),
}); });
const appearanceKeys = new Set<keyof AssistantBoardSettingsChanges>([
"primaryColor",
"secondaryColor",
"opacity",
"iconColor",
"itemRadius",
]);
const backgroundKeys = new Set<keyof AssistantBoardSettingsChanges>([
"backgroundImageUrl",
"backgroundImageAttachment",
"backgroundImageRepeat",
"backgroundImageSize",
]);
export const getAssistantBoardSettingsDefaultTab = (
changes: AssistantBoardSettingsChanges,
): "general" | "appearance" | "background" | "css" => {
if (changes.customCss !== undefined) return "css";
const keys = Object.keys(changes) as (keyof AssistantBoardSettingsChanges)[];
if (keys.some((key) => appearanceKeys.has(key))) return "appearance";
if (keys.some((key) => backgroundKeys.has(key))) return "background";
return "general";
};
@@ -357,6 +357,8 @@ const ToolPart = ({
const autoApproval = useAssistantAutoApproval(); const autoApproval = useAssistantAutoApproval();
const [opened, setOpened] = useState(false); const [opened, setOpened] = useState(false);
const [approvalResponse, setApprovalResponse] = useState<"approve" | "deny" | null>(null); const [approvalResponse, setApprovalResponse] = useState<"approve" | "deny" | null>(null);
const [autoApprovalInProgress, setAutoApprovalInProgress] = useState(false);
const autoApprovalAttemptRef = useRef<string | null>(null);
const completed = status?.type === "complete"; const completed = status?.type === "complete";
const awaitingApproval = approval !== undefined && approval.approved === undefined && !approval.resolution; const awaitingApproval = approval !== undefined && approval.approved === undefined && !approval.resolution;
const denied = approval?.approved === false; const denied = approval?.approved === false;
@@ -369,12 +371,23 @@ const ToolPart = ({
const duration = timing?.completedAt !== undefined ? Math.max(0, timing.completedAt - timing.startedAt) : undefined; const duration = timing?.completedAt !== undefined ? Math.max(0, timing.completedAt - timing.startedAt) : undefined;
useEffect(() => { useEffect(() => {
if (!awaitingApproval || !autoApproval.enabled) return; if (!awaitingApproval) {
autoApprovalAttemptRef.current = null;
setAutoApprovalInProgress(false);
return;
}
if (!autoApproval.enabled) {
setAutoApprovalInProgress(false);
return;
}
if (autoApprovalAttemptRef.current === toolCallId) return;
autoApprovalAttemptRef.current = toolCallId;
autoApproval.requestApproval(toolCallId, () => { const requested = autoApproval.requestApproval(toolCallId, () => {
setApprovalResponse("approve");
respondToApproval({ approved: true, reason: "Approved automatically by the user." }); respondToApproval({ approved: true, reason: "Approved automatically by the user." });
setApprovalResponse("approve");
}); });
setAutoApprovalInProgress(requested);
}, [autoApproval, awaitingApproval, respondToApproval, toolCallId]); }, [autoApproval, awaitingApproval, respondToApproval, toolCallId]);
return ( return (
@@ -429,7 +442,7 @@ const ToolPart = ({
{t("approvalDescription")} {t("approvalDescription")}
</Text> </Text>
<ToolResultPreview result={args} /> <ToolResultPreview result={args} />
{autoApproval.enabled ? ( {autoApprovalInProgress ? (
<Group className={classes.autoApprovalProgress} gap="sm" wrap="nowrap"> <Group className={classes.autoApprovalProgress} gap="sm" wrap="nowrap">
<Loader type="bars" size="sm" color="green" /> <Loader type="bars" size="sm" color="green" />
<Text size="sm" fw={600}> <Text size="sm" fw={600}>
@@ -1,6 +1,6 @@
import { describe, expect, test } from "vitest"; import { describe, expect, test } from "vitest";
import { resolveAssistantPreferenceModelId } from "./assistant-preferences"; import { resolveAssistantPreferenceModelId, resolveAssistantThreadPreferenceModelId } from "./assistant-preferences";
const models = [ const models = [
{ id: "provider/default", name: "Default", inputModalities: ["text"] }, { id: "provider/default", name: "Default", inputModalities: ["text"] },
@@ -52,3 +52,41 @@ describe("resolveAssistantPreferenceModelId", () => {
).toBe("provider/default"); ).toBe("provider/default");
}); });
}); });
describe("resolveAssistantThreadPreferenceModelId", () => {
test("waits for an existing remote conversation's metadata", () => {
expect(
resolveAssistantThreadPreferenceModelId({
isRemote: true,
metadataLoaded: false,
threadModelId: undefined,
defaultModelId: "provider/default",
models,
}),
).toBeUndefined();
});
test("uses the default immediately for a new local conversation", () => {
expect(
resolveAssistantThreadPreferenceModelId({
isRemote: false,
metadataLoaded: false,
threadModelId: undefined,
defaultModelId: "provider/default",
models,
}),
).toBe("provider/default");
});
test("restores a valid persisted conversation model", () => {
expect(
resolveAssistantThreadPreferenceModelId({
isRemote: true,
metadataLoaded: true,
threadModelId: "provider/alternate",
defaultModelId: "provider/default",
models,
}),
).toBe("provider/alternate");
});
});
@@ -21,3 +21,21 @@ export const resolveAssistantPreferenceModelId = ({
if (currentModelId && models.some((model) => model.id === currentModelId)) return currentModelId; if (currentModelId && models.some((model) => model.id === currentModelId)) return currentModelId;
return defaultModelId; return defaultModelId;
}; };
export const resolveAssistantThreadPreferenceModelId = ({
isRemote,
metadataLoaded,
threadModelId,
defaultModelId,
models,
}: {
isRemote: boolean;
metadataLoaded: boolean;
threadModelId: unknown;
defaultModelId: string | null;
models: readonly AssistantRuntimeModelOption[];
}) => {
if (isRemote && !metadataLoaded) return undefined;
if (typeof threadModelId === "string" && models.some((model) => model.id === threadModelId)) return threadModelId;
return defaultModelId;
};
@@ -32,7 +32,7 @@ import { AssistantAskUserTool, AssistantConfigureAppTool } from "./assistant-hum
import { AssistantPanel } from "./assistant-panel"; import { AssistantPanel } from "./assistant-panel";
import { getPendingAssistantAction } from "./assistant-pending-action"; import { getPendingAssistantAction } from "./assistant-pending-action";
import type { AssistantReasoningMode, AssistantRuntimeModelOption } from "./assistant-preferences"; import type { AssistantReasoningMode, AssistantRuntimeModelOption } from "./assistant-preferences";
import { resolveAssistantPreferenceModelId } 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 sendPromptThroughComposer } from "./assistant-send";
import { createAssistantPromptInteraction } from "./assistant-spotlight"; import { createAssistantPromptInteraction } from "./assistant-spotlight";
@@ -481,7 +481,9 @@ const AssistantPreferenceSync = () => {
const preferences = useAssistantPreferences(); const preferences = useAssistantPreferences();
// The local id distinguishes every thread immediately, including threads that do not have a remote id yet. // The local id distinguishes every thread immediately, including threads that do not have a remote id yet.
const conversationId = useAuiState((state) => state.threadListItem.id); const conversationId = useAuiState((state) => state.threadListItem.id);
const threadModelId = useAuiState((state) => state.threadListItem.custom?.modelId); const remoteId = useAuiState((state) => state.threadListItem.remoteId);
const threadCustom = useAuiState((state) => state.threadListItem.custom);
const threadModelId = threadCustom?.modelId;
const previousSyncKeyRef = useRef<string | null>(null); const previousSyncKeyRef = useRef<string | null>(null);
const modelCatalogKey = preferences.models.map((model) => model.id).join("\0"); const modelCatalogKey = preferences.models.map((model) => model.id).join("\0");
@@ -489,14 +491,17 @@ const AssistantPreferenceSync = () => {
if (preferences.models.length === 0) return; if (preferences.models.length === 0) return;
const syncKey = `${conversationId}:${modelCatalogKey}`; const syncKey = `${conversationId}:${modelCatalogKey}`;
if (previousSyncKeyRef.current === syncKey) return; if (previousSyncKeyRef.current === syncKey) return;
const nextModelId = resolveAssistantThreadPreferenceModelId({
isRemote: remoteId !== undefined,
metadataLoaded: threadCustom !== undefined,
threadModelId,
defaultModelId: preferences.defaultModelId,
models: preferences.models,
});
if (nextModelId === undefined) return;
previousSyncKeyRef.current = syncKey; 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); if (nextModelId) preferences.setModelId(nextModelId);
}, [conversationId, modelCatalogKey, preferences, threadModelId]); }, [conversationId, modelCatalogKey, preferences, remoteId, threadCustom, threadModelId]);
return null; return null;
}; };
@@ -113,5 +113,10 @@ describe("assistant human tool contracts", () => {
expect(normalizeAssistantAppIconUrl("youtube")).toBe(""); expect(normalizeAssistantAppIconUrl("youtube")).toBe("");
expect(normalizeAssistantAppIconUrl("/api/media/youtube.svg")).toBe("/api/media/youtube.svg"); expect(normalizeAssistantAppIconUrl("/api/media/youtube.svg")).toBe("/api/media/youtube.svg");
expect(normalizeAssistantAppIconUrl("https://icons.example/youtube.svg")).toBe("https://icons.example/youtube.svg"); expect(normalizeAssistantAppIconUrl("https://icons.example/youtube.svg")).toBe("https://icons.example/youtube.svg");
expect(normalizeAssistantAppIconUrl("http://icons.example/youtube.svg")).toBe("http://icons.example/youtube.svg");
expect(normalizeAssistantAppIconUrl("//icons.example/youtube.svg")).toBe("");
expect(normalizeAssistantAppIconUrl("javascript:alert(1)")).toBe("");
expect(normalizeAssistantAppIconUrl("data:image/svg+xml,<svg />")).toBe("");
expect(normalizeAssistantAppIconUrl("file:///etc/passwd")).toBe("");
}); });
}); });
@@ -111,5 +111,8 @@ export type ConfigureBoardSettingsResult =
export const normalizeAssistantAppIconUrl = (value: string | undefined) => { export const normalizeAssistantAppIconUrl = (value: string | undefined) => {
if (!value) return ""; if (!value) return "";
return value.startsWith("/") || URL.canParse(value) ? value : ""; if (value.startsWith("/") && !value.startsWith("//")) return value;
if (!URL.canParse(value)) return "";
const protocol = new URL(value).protocol;
return protocol === "http:" || protocol === "https:" ? value : "";
}; };
@@ -73,6 +73,30 @@ describe("getToolResultPresentation", () => {
}); });
}); });
test("removes URL credentials and query parameters from inline descriptions", () => {
expect(
getToolResultPresentation({
apps: [
{
name: "Sonarr",
url: "https://user:password@sonarr.example/api/v3/series?apikey=secret#calendar",
},
],
}),
).toEqual({
type: "collection",
totalCount: 1,
items: [
{
title: "Sonarr",
description: "https://sonarr.example/api/v3/series#calendar",
badges: [],
fields: [],
},
],
});
});
test("limits large collections while preserving their total", () => { test("limits large collections while preserving their total", () => {
const result = getToolResultPresentation({ const result = getToolResultPresentation({
items: Array.from({ length: 8 }, (_, index) => ({ name: `Item ${index + 1}` })), items: Array.from({ length: 8 }, (_, index) => ({ name: `Item ${index + 1}` })),
@@ -31,6 +31,17 @@ const maximumFields = 6;
type ToolResultPrimitive = string | number | boolean; type ToolResultPrimitive = string | number | boolean;
const redactUrlCredentials = (value: string) => {
if (!URL.canParse(value)) return value;
const url = new URL(value);
if (url.protocol !== "http:" && url.protocol !== "https:") return value;
if (!url.username && !url.password && !url.search) return value;
url.username = "";
url.password = "";
url.search = "";
return url.toString();
};
export interface ToolResultField { export interface ToolResultField {
label: string; label: string;
value: ToolResultPrimitive; value: ToolResultPrimitive;
@@ -65,7 +76,7 @@ const toDisplayValue = (value: unknown): ToolResultPrimitive | undefined => {
if (typeof value === "string") { if (typeof value === "string") {
const trimmed = value.trim(); const trimmed = value.trim();
if (trimmed.length === 0 || trimmed.length > 160) return undefined; if (trimmed.length === 0 || trimmed.length > 160) return undefined;
return trimmed; return redactUrlCredentials(trimmed);
} }
if (typeof value === "number" && Number.isFinite(value)) return value; if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "boolean") return value; if (typeof value === "boolean") return value;
@@ -18,6 +18,7 @@ describe("get first empty position", () => {
0, 0,
], ],
[[["a", "a", "a", "a"]], [1, 1], 0, 1], [[["a", "a", "a", "a"]], [1, 1], 0, 1],
[[["a", " ", " ", " "]], [4, 1], 0, 1],
[ [
[ [
["a", "a", "a", "a"], ["a", "a", "a", "a"],
+2 -1
View File
@@ -237,8 +237,9 @@ const fetchModelsAsync = async (configuration: AssistantConfiguration) => {
.flatMap((model) => { .flatMap((model) => {
const id = typeof model.id === "string" ? model.id : typeof model.model === "string" ? model.model : null; const id = typeof model.id === "string" ? model.id : typeof model.model === "string" ? model.model : null;
if (!id || id.length > 256) return []; if (!id || id.length > 256) return [];
const name = const rawName =
typeof model.name === "string" ? model.name : typeof model.display_name === "string" ? model.display_name : id; typeof model.name === "string" ? model.name : typeof model.display_name === "string" ? model.display_name : id;
const name = rawName.trim() || id;
const contextLength = const contextLength =
typeof model.context_length === "number" && typeof model.context_length === "number" &&
Number.isSafeInteger(model.context_length) && Number.isSafeInteger(model.context_length) &&
@@ -170,6 +170,12 @@ describe("assistant conversation features", () => {
supported_parameters: [], supported_parameters: [],
architecture: { output_modalities: ["text"] }, architecture: { output_modalities: ["text"] },
}, },
{
id: "example/unnamed",
name: "",
supported_parameters: ["tools"],
architecture: { output_modalities: ["text"] },
},
], ],
}), }),
), ),
@@ -187,6 +193,10 @@ describe("assistant conversation features", () => {
name: "Example Model", name: "Example Model",
inputModalities: ["text", "image"], inputModalities: ["text", "image"],
}), }),
expect.objectContaining({
id: "example/unnamed",
name: "example/unnamed",
}),
], ],
}); });
expect(JSON.stringify(options)).not.toContain("encrypted"); expect(JSON.stringify(options)).not.toContain("encrypted");
+8 -2
View File
@@ -1970,6 +1970,7 @@
"management": "Management", "management": "Management",
"assistant": "Homarr Assistant", "assistant": "Homarr Assistant",
"assistantThinking": "Thinking", "assistantThinking": "Thinking",
"modifier": "Mod",
"docker": "Docker", "docker": "Docker",
"preferences": "Your preferences", "preferences": "Your preferences",
"logout": "Logout", "logout": "Logout",
@@ -2493,6 +2494,10 @@
} }
}, },
"widget": { "widget": {
"dynamicSelect": {
"noResults": "No results found",
"loadError": "Failed to load options"
},
"integrationSelect": { "integrationSelect": {
"configureIntegrationFirst": "Connect an integration first", "configureIntegrationFirst": "Connect an integration first",
"loading": "Loading...", "loading": "Loading...",
@@ -6084,6 +6089,7 @@
} }
}, },
"assistant": { "assistant": {
"loading": "Loading assistant configuration",
"title": "Assistant", "title": "Assistant",
"description": "Connect an OpenAI-compatible model endpoint for the instance-wide assistant.", "description": "Connect an OpenAI-compatible model endpoint for the instance-wide assistant.",
"security": { "security": {
@@ -6245,9 +6251,9 @@
"manualFallback": "Discovery is unavailable. You can still enter a model ID manually.", "manualFallback": "Discovery is unavailable. You can still enter a model ID manually.",
"saveConnectionFirst": "Save the provider connection before choosing a model.", "saveConnectionFirst": "Save the provider connection before choosing a model.",
"discovering": "Refreshing the models available from this provider…", "discovering": "Refreshing the models available from this provider…",
"discovered": "Discovered {count} models. Tool support depends on the selected endpoint and model.", "discovered": "{count, plural, one {Discovered # model. Tool support depends on the selected endpoint and model.} other {Discovered # models. Tool support depends on the selected endpoint and model.}}",
"discoveryFailed": "Model discovery did not complete", "discoveryFailed": "Model discovery did not complete",
"context": "{count} token context", "context": "{count, number} token context",
"toolsConfirmed": "Tools advertised", "toolsConfirmed": "Tools advertised",
"toolsUnknown": "Verify tool support" "toolsUnknown": "Verify tool support"
}, },
@@ -6,7 +6,7 @@ import { useDebouncedValue } from "@mantine/hooks";
import { IconCheck } from "@tabler/icons-react"; import { IconCheck } from "@tabler/icons-react";
import { translateIfNecessary } from "@homarr/translation"; import { translateIfNecessary } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client"; import { useI18n, useScopedI18n } from "@homarr/translation/client";
import type { CommonWidgetInputProps } from "./common"; import type { CommonWidgetInputProps } from "./common";
import { useWidgetInputTranslation } from "./common"; import { useWidgetInputTranslation } from "./common";
@@ -25,6 +25,7 @@ export const WidgetDynamicSelectInput = ({
boardId, boardId,
}: CommonWidgetInputProps<"dynamicSelect">) => { }: CommonWidgetInputProps<"dynamicSelect">) => {
const t = useI18n(); const t = useI18n();
const tSelect = useScopedI18n("widget.dynamicSelect");
const tWidget = useWidgetInputTranslation(kind, property); const tWidget = useWidgetInputTranslation(kind, property);
const form = useFormContext(); const form = useFormContext();
const inputProps = form.getInputProps(`options.${property}`); const inputProps = form.getInputProps(`options.${property}`);
@@ -77,7 +78,7 @@ export const WidgetDynamicSelectInput = ({
searchValue={search} searchValue={search}
onSearchChange={setSearch} onSearchChange={setSearch}
placeholder={tWidget("placeholder")} placeholder={tWidget("placeholder")}
nothingFoundMessage={t("common.noResults")} nothingFoundMessage={tSelect("noResults")}
leftSection={isPending && <Loader size="xs" />} leftSection={isPending && <Loader size="xs" />}
renderOption={({ option, checked }) => { renderOption={({ option, checked }) => {
return ( return (
+4 -4
View File
@@ -3,14 +3,14 @@
import { Center, Stack, Text, ThemeIcon } from "@mantine/core"; import { Center, Stack, Text, ThemeIcon } from "@mantine/core";
import { IconRobot } from "@tabler/icons-react"; import { IconRobot } from "@tabler/icons-react";
import { useI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition"; import type { WidgetComponentProps } from "../definition";
import { useAssistantWidgetRenderer } from "./context"; import { useAssistantWidgetRenderer } from "./context";
export default function AssistantWidget(props: WidgetComponentProps<"assistant">) { export default function AssistantWidget(props: WidgetComponentProps<"assistant">) {
const Renderer = useAssistantWidgetRenderer(); const Renderer = useAssistantWidgetRenderer();
const t = useI18n(); const t = useScopedI18n("widget.assistant.unavailable");
if (Renderer) return <Renderer {...props} />; if (Renderer) return <Renderer {...props} />;
@@ -21,10 +21,10 @@ export default function AssistantWidget(props: WidgetComponentProps<"assistant">
<IconRobot size={20} /> <IconRobot size={20} />
</ThemeIcon> </ThemeIcon>
<Text size="sm" fw={600}> <Text size="sm" fw={600}>
{t("widget.assistant.unavailable.title")} {t("title")}
</Text> </Text>
<Text size="xs" c="dimmed"> <Text size="xs" c="dimmed">
{t("widget.assistant.unavailable.description")} {t("description")}
</Text> </Text>
</Stack> </Stack>
</Center> </Center>
+2 -1
View File
@@ -21,10 +21,11 @@ export const { definition, componentLoader } = createWidgetDefinition("assistant
conversation: factory.dynamicSelect({ conversation: factory.dynamicSelect({
withDescription: true, withDescription: true,
useOptions(query) { useOptions(query) {
const { data: threads = [], isPending } = clientApi.assistant.listThreads.useQuery(); const { data: threads = [], isPending, isError } = clientApi.assistant.listThreads.useQuery();
const normalizedQuery = query.trim().toLocaleLowerCase(); const normalizedQuery = query.trim().toLocaleLowerCase();
return { return {
isPending, isPending,
isError,
options: threads options: threads
.filter((thread) => { .filter((thread) => {
if (normalizedQuery.length === 0) return true; if (normalizedQuery.length === 0) return true;
+1
View File
@@ -61,6 +61,7 @@ interface DynamicSelectInput extends CommonInput<DynamicSelectOption | null> {
) => { ) => {
error?: string; error?: string;
isPending: boolean; isPending: boolean;
isError?: boolean;
options: DynamicSelectOption[]; options: DynamicSelectOption[];
}; };
} }