fix: address assistant review feedback
This commit is contained in:
1 parent
33695753dd
commit
5ceef06b4b
26 files changed
+340
-49
No files matched your search
@@ -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: "",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
+9
-11
@@ -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"],
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ interface DynamicSelectInput extends CommonInput<DynamicSelectOption | null> {
|
|||||||
) => {
|
) => {
|
||||||
error?: string;
|
error?: string;
|
||||||
isPending: boolean;
|
isPending: boolean;
|
||||||
|
isError?: boolean;
|
||||||
options: DynamicSelectOption[];
|
options: DynamicSelectOption[];
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user