feat: mock Homarr Assistant in demo mode and fix board page crash (#6524)
This commit is contained in:
1 parent
f991dc6334
commit
7bf22639ba
6 files changed
+127
-43
No files matched your search
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { toProviderOptionsKey } from "./assistant-provider-options";
|
||||
|
||||
describe("toProviderOptionsKey", () => {
|
||||
test("converts the hyphenated provider name to the camelCase AI SDK provider option key", () => {
|
||||
expect(toProviderOptionsKey("homarr-openrouter")).toBe("homarrOpenrouter");
|
||||
});
|
||||
|
||||
test("matches the @ai-sdk/openai-compatible camelCase conversion", () => {
|
||||
expect(toProviderOptionsKey("homarr-anthropic")).toBe("homarrAnthropic");
|
||||
expect(toProviderOptionsKey("homarr-google-gemini")).toBe("homarrGoogleGemini");
|
||||
});
|
||||
|
||||
test("leaves names without separators unchanged", () => {
|
||||
expect(toProviderOptionsKey("homarr")).toBe("homarr");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* The AI SDK resolves provider options by the provider name registered on the
|
||||
* language model. The @ai-sdk/openai-compatible provider converts the raw
|
||||
* provider name (for example `homarr-openrouter`) to camelCase
|
||||
* (`homarrOpenrouter`) and warns when the raw key is still used. This helper
|
||||
* mirrors that conversion so providerOptions reach the provider without a
|
||||
* deprecation warning.
|
||||
*/
|
||||
export const toProviderOptionsKey = (name: string) =>
|
||||
name
|
||||
.split("-")
|
||||
.filter(Boolean)
|
||||
.map((segment, index) => {
|
||||
if (index === 0) return segment;
|
||||
return segment.charAt(0).toUpperCase() + segment.slice(1);
|
||||
})
|
||||
.join("");
|
||||
@@ -3,7 +3,7 @@ import { hkdfSync } from "node:crypto";
|
||||
import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
|
||||
import type { MetadataExtractor } from "@ai-sdk/openai-compatible";
|
||||
import type { ToolSet, UIMessage } from "ai";
|
||||
import { convertToModelMessages, jsonSchema, stepCountIs, streamText, tool } from "ai";
|
||||
import { convertToModelMessages, createUIMessageStream, createUIMessageStreamResponse, jsonSchema, stepCountIs, streamText, tool } from "ai";
|
||||
import { parse, stringify } from "superjson";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
} from "@homarr/definitions";
|
||||
|
||||
import { browserToolContracts } from "~/components/assistant/assistant-tool-contracts";
|
||||
import { env as appEnv } from "~/env";
|
||||
import type {
|
||||
AssistantMessageMetadata,
|
||||
AssistantRequestStep,
|
||||
@@ -43,6 +44,7 @@ import {
|
||||
normalizeOpenRouterWebSearchSources,
|
||||
withOpenRouterWebSearch,
|
||||
} from "./assistant-openrouter";
|
||||
import { toProviderOptionsKey } from "./assistant-provider-options";
|
||||
import { assistantExecutionPolicy } from "./assistant-execution-policy";
|
||||
import { getAssistantStreamErrorMessage } from "./assistant-stream-error";
|
||||
import { getSafeAssistantToolError } from "./assistant-tool-error";
|
||||
@@ -322,6 +324,31 @@ const getProcedureTypeMap = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const createDemoAssistantResponse = (request: z.infer<typeof requestSchema>) => {
|
||||
const delay = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
|
||||
const text =
|
||||
"This is the Homarr demo assistant. It is running in preview/mock mode, so it responds with a canned answer instead of calling a real language model. In a real deployment, an administrator configures a provider and a model for full conversations.";
|
||||
const responseId = crypto.randomUUID();
|
||||
const partId = crypto.randomUUID();
|
||||
const chunkTexts = text.match(/.{1,3}/gs) ?? [text];
|
||||
|
||||
const stream = createUIMessageStream<UIMessage>({
|
||||
originalMessages: request.messages as UIMessage[],
|
||||
execute: async ({ writer }) => {
|
||||
writer.write({ type: "start", messageId: responseId });
|
||||
writer.write({ type: "text-start", id: partId });
|
||||
for (const chunk of chunkTexts) {
|
||||
writer.write({ type: "text-delta", id: partId, delta: chunk });
|
||||
await delay(18);
|
||||
}
|
||||
writer.write({ type: "text-end", id: partId });
|
||||
writer.write({ type: "finish", finishReason: "stop" });
|
||||
},
|
||||
});
|
||||
|
||||
return createUIMessageStreamResponse({ stream });
|
||||
};
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const contentLength = Number(request.headers.get("content-length") ?? 0);
|
||||
if (contentLength > 2_000_000) {
|
||||
@@ -359,10 +386,7 @@ export async function POST(request: Request) {
|
||||
const configuration = await db.query.assistantConfigurations.findFirst({
|
||||
where: eq(assistantConfigurations.id, "default"),
|
||||
});
|
||||
const requiresApiKey = configuration ? assistantProviderRequiresApiKey(configuration.provider) : false;
|
||||
if (!configuration?.enabled || !configuration.modelId || (requiresApiKey && !configuration.encryptedApiKey)) {
|
||||
return Response.json({ error: "Homarr Assistant is not configured." }, { status: 503 });
|
||||
}
|
||||
|
||||
const thread = await db.query.assistantThreads.findFirst({
|
||||
where: and(eq(assistantThreads.id, parsed.data.id), eq(assistantThreads.userId, session.user.id)),
|
||||
});
|
||||
@@ -370,6 +394,15 @@ export async function POST(request: Request) {
|
||||
return Response.json({ error: "Conversation not found." }, { status: 404 });
|
||||
}
|
||||
|
||||
if (appEnv.DEMO_MODE) {
|
||||
return createDemoAssistantResponse(parsed.data);
|
||||
}
|
||||
|
||||
const requiresApiKey = configuration ? assistantProviderRequiresApiKey(configuration.provider) : false;
|
||||
if (!configuration?.enabled || !configuration.modelId || (requiresApiKey && !configuration.encryptedApiKey)) {
|
||||
return Response.json({ error: "Homarr Assistant is not configured." }, { status: 503 });
|
||||
}
|
||||
|
||||
const context = createTRPCContext({ headers: request.headers, session });
|
||||
const requestStartedAt = Date.now();
|
||||
const requestId = crypto.randomUUID();
|
||||
@@ -543,7 +576,7 @@ export async function POST(request: Request) {
|
||||
reasoning: parsed.data.reasoning === "auto" ? undefined : parsed.data.reasoning,
|
||||
providerOptions:
|
||||
configuration.provider === "openrouter" || openRouterServerToolsEnabled
|
||||
? { [providerName]: { usage: { include: true } } }
|
||||
? { [toProviderOptionsKey(providerName)]: { usage: { include: true } } }
|
||||
: undefined,
|
||||
toolApproval,
|
||||
experimental_toolApprovalSecret: getToolApprovalSecret(),
|
||||
|
||||
Reference in new issue
Block a user