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
@@ -96,4 +96,31 @@ describe("assistant automatic approval tracker", () => {
|
||||
|
||||
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 {
|
||||
approve();
|
||||
return true;
|
||||
} catch (error) {
|
||||
} catch {
|
||||
trackerRef.current.release(toolCallId);
|
||||
throw error;
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[enabled],
|
||||
|
||||
@@ -43,7 +43,11 @@ import { useZodForm } from "@homarr/form";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
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 { AssistantHumanToolError } from "./assistant-human-tools";
|
||||
import classes from "./assistant-panel.module.css";
|
||||
@@ -183,8 +187,7 @@ const BoardSettingsForm = ({
|
||||
const changedCount = Object.keys(changes).length;
|
||||
const cssWarnings = getCustomCssWarnings(form.values.customCss);
|
||||
const proposedKeys = Object.keys(args.changes);
|
||||
const defaultTab =
|
||||
args.changes.customCss !== undefined ? "css" : proposedKeys.some(isAppearanceKey) ? "appearance" : "general";
|
||||
const defaultTab = getAssistantBoardSettingsDefaultTab(args.changes);
|
||||
const optionData = <TValue extends string>(
|
||||
values: readonly TValue[],
|
||||
key: "backgroundImageAttachment" | "backgroundImageRepeat" | "backgroundImageSize",
|
||||
@@ -404,6 +407,3 @@ const BoardSettingsForm = ({
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const isAppearanceKey = (key: string) =>
|
||||
["primaryColor", "secondaryColor", "opacity", "iconColor", "itemRadius"].includes(key);
|
||||
@@ -1,6 +1,10 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { getChangedBoardSettings, getCustomCssWarnings } from "./assistant-board-settings";
|
||||
import {
|
||||
getAssistantBoardSettingsDefaultTab,
|
||||
getChangedBoardSettings,
|
||||
getCustomCssWarnings,
|
||||
} from "./assistant-board-settings";
|
||||
|
||||
describe("getChangedBoardSettings", () => {
|
||||
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),
|
||||
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 [opened, setOpened] = useState(false);
|
||||
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 awaitingApproval = approval !== undefined && approval.approved === undefined && !approval.resolution;
|
||||
const denied = approval?.approved === false;
|
||||
@@ -369,12 +371,23 @@ const ToolPart = ({
|
||||
const duration = timing?.completedAt !== undefined ? Math.max(0, timing.completedAt - timing.startedAt) : undefined;
|
||||
|
||||
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, () => {
|
||||
setApprovalResponse("approve");
|
||||
const requested = autoApproval.requestApproval(toolCallId, () => {
|
||||
respondToApproval({ approved: true, reason: "Approved automatically by the user." });
|
||||
setApprovalResponse("approve");
|
||||
});
|
||||
setAutoApprovalInProgress(requested);
|
||||
}, [autoApproval, awaitingApproval, respondToApproval, toolCallId]);
|
||||
|
||||
return (
|
||||
@@ -429,7 +442,7 @@ const ToolPart = ({
|
||||
{t("approvalDescription")}
|
||||
</Text>
|
||||
<ToolResultPreview result={args} />
|
||||
{autoApproval.enabled ? (
|
||||
{autoApprovalInProgress ? (
|
||||
<Group className={classes.autoApprovalProgress} gap="sm" wrap="nowrap">
|
||||
<Loader type="bars" size="sm" color="green" />
|
||||
<Text size="sm" fw={600}>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { resolveAssistantPreferenceModelId } from "./assistant-preferences";
|
||||
import { resolveAssistantPreferenceModelId, resolveAssistantThreadPreferenceModelId } from "./assistant-preferences";
|
||||
|
||||
const models = [
|
||||
{ id: "provider/default", name: "Default", inputModalities: ["text"] },
|
||||
@@ -52,3 +52,41 @@ describe("resolveAssistantPreferenceModelId", () => {
|
||||
).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;
|
||||
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 { getPendingAssistantAction } from "./assistant-pending-action";
|
||||
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 { sendAssistantPrompt as sendPromptThroughComposer } from "./assistant-send";
|
||||
import { createAssistantPromptInteraction } from "./assistant-spotlight";
|
||||
@@ -481,7 +481,9 @@ const AssistantPreferenceSync = () => {
|
||||
const preferences = useAssistantPreferences();
|
||||
// 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 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 modelCatalogKey = preferences.models.map((model) => model.id).join("\0");
|
||||
|
||||
@@ -489,14 +491,17 @@ const AssistantPreferenceSync = () => {
|
||||
if (preferences.models.length === 0) return;
|
||||
const syncKey = `${conversationId}:${modelCatalogKey}`;
|
||||
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;
|
||||
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);
|
||||
}, [conversationId, modelCatalogKey, preferences, threadModelId]);
|
||||
}, [conversationId, modelCatalogKey, preferences, remoteId, threadCustom, threadModelId]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -113,5 +113,10 @@ describe("assistant human tool contracts", () => {
|
||||
expect(normalizeAssistantAppIconUrl("youtube")).toBe("");
|
||||
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("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) => {
|
||||
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", () => {
|
||||
const result = getToolResultPresentation({
|
||||
items: Array.from({ length: 8 }, (_, index) => ({ name: `Item ${index + 1}` })),
|
||||
|
||||
@@ -31,6 +31,17 @@ const maximumFields = 6;
|
||||
|
||||
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 {
|
||||
label: string;
|
||||
value: ToolResultPrimitive;
|
||||
@@ -65,7 +76,7 @@ const toDisplayValue = (value: unknown): ToolResultPrimitive | undefined => {
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim();
|
||||
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 === "boolean") return value;
|
||||
|
||||
Reference in new issue
Block a user