feat: polish release v2 experience (#6800)
This commit is contained in:
1 parent
925b0fb801
commit
a4a903d7c3
295 files changed
+10627
-7115
No files matched your search
@@ -1,3 +1,5 @@
|
||||
import { isRecord } from "@homarr/common";
|
||||
|
||||
export type AssistantMentionReference = {
|
||||
type: "app" | "integration" | "board" | "widget";
|
||||
id: string;
|
||||
@@ -20,9 +22,8 @@ const mentionPattern = /:(app|integration|board|widget)\[([^\]\n]{1,1024})\](?:\
|
||||
const getMessageText = (message: { parts: unknown[] }) =>
|
||||
message.parts
|
||||
.flatMap((part) => {
|
||||
if (!part || typeof part !== "object" || Array.isArray(part)) return [];
|
||||
const value = part as Record<string, unknown>;
|
||||
return value.type === "text" && typeof value.text === "string" ? [value.text] : [];
|
||||
if (!isRecord(part)) return [];
|
||||
return part.type === "text" && typeof part.text === "string" ? [part.text] : [];
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { convertToModelMessages, pruneMessages } from "ai";
|
||||
import type { ModelMessage, UIMessage } from "ai";
|
||||
|
||||
import { isRecord } from "@homarr/common";
|
||||
import { isCustomWidgetToolName } from "@homarr/custom-widgets/core";
|
||||
|
||||
import { getAssistantToolOutputMaxCharacters, toAssistantToolOutput } from "./assistant-tool-output";
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
|
||||
const reloadableCustomWidgetResourceToolNames = [
|
||||
"customWidget_getSkill",
|
||||
"customWidget_schema",
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { isRecord } from "@homarr/common";
|
||||
|
||||
const openRouterWebSearchTool = {
|
||||
type: "openrouter:web_search",
|
||||
parameters: {
|
||||
@@ -16,8 +18,7 @@ export interface OpenRouterWebSearchSource {
|
||||
|
||||
const maximumWebSearchSources = 12;
|
||||
|
||||
const asRecord = (value: unknown): Record<string, unknown> | undefined =>
|
||||
typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : undefined;
|
||||
const asRecord = (value: unknown): Record<string, unknown> | undefined => (isRecord(value) ? value : undefined);
|
||||
|
||||
const getSafeWebSourceUrl = (value: unknown) => {
|
||||
if (typeof value !== "string" || !URL.canParse(value)) return undefined;
|
||||
@@ -100,7 +101,7 @@ export const withOpenRouterToolRequestOptions = (
|
||||
});
|
||||
|
||||
export const getOpenRouterWebSearchRequests = (value: unknown) => {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
|
||||
if (!isRecord(value)) return undefined;
|
||||
const usage = "usage" in value && typeof value.usage === "object" && value.usage !== null ? value.usage : undefined;
|
||||
const serverToolUse =
|
||||
usage && "server_tool_use" in usage && typeof usage.server_tool_use === "object" && usage.server_tool_use !== null
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
const asRecord = (value: unknown): Record<string, unknown> | null =>
|
||||
typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : null;
|
||||
import { isRecord } from "@homarr/common";
|
||||
|
||||
const asRecord = (value: unknown): Record<string, unknown> | null => (isRecord(value) ? value : null);
|
||||
|
||||
const getStatusCode = (error: unknown): number | undefined => {
|
||||
const record = asRecord(error);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
const asErrorRecord = (value: unknown): Record<string, unknown> | undefined =>
|
||||
typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : undefined;
|
||||
import { isRecord } from "@homarr/common";
|
||||
|
||||
const asErrorRecord = (value: unknown): Record<string, unknown> | undefined => (isRecord(value) ? value : undefined);
|
||||
|
||||
const getErrorChain = (error: unknown) => {
|
||||
const chain: Record<string, unknown>[] = [];
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { isRecord } from "@homarr/common";
|
||||
import { normalizeCustomWidgetLifecycleToolInput } from "@homarr/custom-widgets/core";
|
||||
|
||||
interface AssistantToolCallInput {
|
||||
@@ -97,7 +98,7 @@ const repairMultilineToolInput = <T extends AssistantToolCallInput>(toolCall: T)
|
||||
if (customWidgetNoInputToolNames.has(toolCall.toolName)) {
|
||||
try {
|
||||
const input = JSON.parse(toolCall.input) as unknown;
|
||||
if (typeof input === "object" && input !== null && !Array.isArray(input) && Object.keys(input).length === 0) {
|
||||
if (isRecord(input) && Object.keys(input).length === 0) {
|
||||
return null;
|
||||
}
|
||||
} catch {
|
||||
@@ -124,8 +125,8 @@ const repairCustomWidgetLifecycleInput = <T extends AssistantToolCallInput>(tool
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (typeof input !== "object" || input === null || Array.isArray(input)) return null;
|
||||
const normalized = normalizeCustomWidgetLifecycleToolInput(toolCall.toolName, input as Record<string, unknown>);
|
||||
if (!isRecord(input)) return null;
|
||||
const normalized = normalizeCustomWidgetLifecycleToolInput(toolCall.toolName, input);
|
||||
if (normalized === input) return null;
|
||||
return { ...toolCall, input: JSON.stringify(normalized) };
|
||||
};
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { serialize } from "superjson";
|
||||
|
||||
import { isRecord } from "@homarr/common";
|
||||
|
||||
export const customWidgetPreviewQueryOutputMaxCharacters = 8_000;
|
||||
export const assistantToolOutputMaxCharacters = 24_000;
|
||||
const customWidgetAuthoringResourceOutputMaxCharacters = 60_000;
|
||||
@@ -38,10 +40,7 @@ export const toAssistantToolOutput = (value: unknown, options: AssistantToolOutp
|
||||
const serialized = JSON.stringify(output);
|
||||
if (serialized === undefined || serialized.length <= options.maxCharacters) return output;
|
||||
|
||||
const outputRecord =
|
||||
typeof output === "object" && output !== null && !Array.isArray(output)
|
||||
? (output as Record<string, unknown>)
|
||||
: undefined;
|
||||
const outputRecord = isRecord(output) ? output : undefined;
|
||||
const metadata = outputRecord
|
||||
? Object.fromEntries(
|
||||
["ok", "status", "statusText", "error"].flatMap((key) =>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { isRecord } from "@homarr/common";
|
||||
|
||||
const compactTemplateToolNames = new Set(["customWidget_validateTemplate", "customWidget_previewReviseTemplate"]);
|
||||
|
||||
const asRecord = (value: unknown): Record<string, unknown> | null =>
|
||||
typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : null;
|
||||
const asRecord = (value: unknown): Record<string, unknown> | null => (isRecord(value) ? value : null);
|
||||
|
||||
export const getAssistantToolInputSchema = (toolName: string, inputSchema: Record<string, unknown>) => {
|
||||
if (!compactTemplateToolNames.has(toolName)) return inputSchema;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { UIMessage } from "ai";
|
||||
|
||||
import { isRecord } from "@homarr/common";
|
||||
import { getCustomWidgetPhaseToolNames } from "@homarr/custom-widgets/core";
|
||||
|
||||
export { getCustomWidgetPhaseToolNames };
|
||||
@@ -17,9 +18,6 @@ const customWidgetContextToolBudgets: Readonly<Record<string, number>> = {
|
||||
customWidget_getExample: 1,
|
||||
};
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
|
||||
export const createCustomWidgetDiscoveryPhaseController = (limit = maxFocusedComponentSearchesPerPhase) => {
|
||||
const calls = new Map<string, number>();
|
||||
const reset = () => {
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
getSelectedModelDetailsAsync,
|
||||
} from "@homarr/api/assistant";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { isRecord } from "@homarr/common";
|
||||
import { env } from "@homarr/common/env";
|
||||
import { decryptSecret } from "@homarr/common/server";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
@@ -168,8 +169,7 @@ const webSearchInstructions = `
|
||||
|
||||
OpenRouter web search is available. Use it only when current external information or unsupplied API documentation is needed. Prefer primary documentation, keep the search focused, and cite the sources that support the answer or generated artifact.`;
|
||||
|
||||
const asRecord = (value: unknown): Record<string, unknown> | null =>
|
||||
typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : null;
|
||||
const asRecord = (value: unknown): Record<string, unknown> | null => (isRecord(value) ? value : null);
|
||||
|
||||
const asFiniteNumber = (value: unknown) => {
|
||||
const number = typeof value === "number" ? value : typeof value === "string" ? Number(value) : Number.NaN;
|
||||
@@ -527,13 +527,8 @@ export async function POST(request: Request) {
|
||||
};
|
||||
}
|
||||
let executionInput = input;
|
||||
if (
|
||||
mcpTool.name.startsWith("customWidget_") &&
|
||||
typeof input === "object" &&
|
||||
input !== null &&
|
||||
!Array.isArray(input)
|
||||
) {
|
||||
executionInput = normalizeCustomWidgetLifecycleToolInput(mcpTool.name, input as Record<string, unknown>);
|
||||
if (mcpTool.name.startsWith("customWidget_") && isRecord(input)) {
|
||||
executionInput = normalizeCustomWidgetLifecycleToolInput(mcpTool.name, input);
|
||||
}
|
||||
const contextRequestKey = getCustomWidgetContextRequestKey(mcpTool.name, executionInput);
|
||||
if (contextRequestKey !== null && loadedCustomWidgetContextRequests.has(contextRequestKey)) {
|
||||
|
||||
@@ -48,7 +48,7 @@ const readBoundedRequestBodyAsync = async (request: Request): Promise<Uint8Array
|
||||
throw new Error("Invalid Content-Length header");
|
||||
}
|
||||
if (parsedLength > MAX_MULTIPART_REQUEST_BYTES) {
|
||||
throw new BackupTooLargeError("Backup upload exceeds the 256 MB limit");
|
||||
throw new BackupTooLargeError("Backup upload exceeds the 256 MiB limit");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@ const readBoundedRequestBodyAsync = async (request: Request): Promise<Uint8Array
|
||||
totalBytes += value.byteLength;
|
||||
if (totalBytes > MAX_MULTIPART_REQUEST_BYTES) {
|
||||
await reader.cancel();
|
||||
throw new BackupTooLargeError("Backup upload exceeds the 256 MB limit");
|
||||
throw new BackupTooLargeError("Backup upload exceeds the 256 MiB limit");
|
||||
}
|
||||
chunks.push(value);
|
||||
}
|
||||
@@ -103,18 +103,18 @@ const assertArchiveSizeLimits = (zip: AdmZip) => {
|
||||
}
|
||||
totalSize += entrySize;
|
||||
if (totalSize > MAX_UNCOMPRESSED_ARCHIVE_BYTES) {
|
||||
throw new BackupTooLargeError("Uncompressed backup archive exceeds the 514 MB limit");
|
||||
throw new BackupTooLargeError("Uncompressed backup archive exceeds the 514 MiB limit");
|
||||
}
|
||||
}
|
||||
|
||||
const dbEntry = zip.getEntry("db.sqlite");
|
||||
if (dbEntry && dbEntry.header.size > MAX_UNCOMPRESSED_DATABASE_BYTES) {
|
||||
throw new BackupTooLargeError("Uncompressed SQLite database exceeds the 512 MB limit");
|
||||
throw new BackupTooLargeError("Uncompressed SQLite database exceeds the 512 MiB limit");
|
||||
}
|
||||
|
||||
const metadataEntry = zip.getEntry("metadata.json");
|
||||
if (metadataEntry && metadataEntry.header.size > MAX_METADATA_BYTES) {
|
||||
throw new BackupTooLargeError("Backup metadata exceeds the 1 MB limit");
|
||||
throw new BackupTooLargeError("Backup metadata exceeds the 1 MiB limit");
|
||||
}
|
||||
|
||||
const encryptionKeyEntry = zip.getEntry("encryption-key.txt");
|
||||
@@ -255,7 +255,7 @@ export async function POST(req: Request) {
|
||||
return NextResponse.json({ error: "No file provided" }, { status: 400 });
|
||||
}
|
||||
if (file.size > MAX_COMPRESSED_BACKUP_BYTES) {
|
||||
return NextResponse.json({ error: "Backup upload exceeds the 256 MB limit" }, { status: 413 });
|
||||
return NextResponse.json({ error: "Backup upload exceeds the 256 MiB limit" }, { status: 413 });
|
||||
}
|
||||
|
||||
let zip: AdmZip;
|
||||
|
||||
@@ -6,20 +6,17 @@ import { db } from "@homarr/db";
|
||||
import { handshakeAsync } from "@homarr/redis";
|
||||
|
||||
const logger = createLogger({ module: "healthLiveRoute" });
|
||||
const noStoreHeaders = { "Cache-Control": "no-store" };
|
||||
const healthStatusCodes = { healthy: 200, unhealthy: 500 } as const;
|
||||
|
||||
export async function GET() {
|
||||
const timeBeforeHealthCheck = performance.now();
|
||||
const response = await executeAndAggregateAllHealthChecksAsync();
|
||||
logger.info("Completed healthcheck", { elapsed: `${performance.now() - timeBeforeHealthCheck}ms` });
|
||||
logger.debug("Completed healthcheck", { elapsed: `${performance.now() - timeBeforeHealthCheck}ms` });
|
||||
|
||||
if (response.status === "healthy") {
|
||||
return new Response(JSON.stringify(response), {
|
||||
status: 200,
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify(response), {
|
||||
status: 500,
|
||||
return Response.json(response, {
|
||||
status: healthStatusCodes[response.status],
|
||||
headers: noStoreHeaders,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -63,8 +60,8 @@ const executeHealthCheckSafelyAsync = async (
|
||||
name: string,
|
||||
callback: () => Promise<object>,
|
||||
): Promise<HealthCheckResult> => {
|
||||
const currentTimeBeforeCallback = performance.now();
|
||||
try {
|
||||
const currentTimeBeforeCallback = performance.now();
|
||||
const values = await callback();
|
||||
return {
|
||||
name,
|
||||
@@ -79,7 +76,7 @@ const executeHealthCheckSafelyAsync = async (
|
||||
return {
|
||||
status: "unhealthy",
|
||||
values: {
|
||||
error,
|
||||
latency: performance.now() - currentTimeBeforeCallback,
|
||||
},
|
||||
name,
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user