Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd8f0a51c3 | ||
|
|
db9aecb1cd | ||
|
|
974dc1609c | ||
|
|
f61e3591bd |
No files matched your search
@@ -1,5 +1,6 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
|
import { notFound, redirect } from "next/navigation";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
|
|
||||||
// Placed here because gridstack styles are used for board content
|
// Placed here because gridstack styles are used for board content
|
||||||
@@ -49,8 +50,24 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
|
|||||||
page: async ({ params }: { params: Promise<TParams> }) => {
|
page: async ({ params }: { params: Promise<TParams> }) => {
|
||||||
const resolvedParams = await params;
|
const resolvedParams = await params;
|
||||||
const queryClient = getQueryClient();
|
const queryClient = getQueryClient();
|
||||||
|
const session = await auth();
|
||||||
|
|
||||||
const [board, session] = await Promise.all([getInitialBoard(resolvedParams), auth()]);
|
const board = await getInitialBoard(resolvedParams).catch((error) => {
|
||||||
|
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
|
||||||
|
if (!session) {
|
||||||
|
logger.debug("No home board found for anonymous user, redirecting to login");
|
||||||
|
redirect("/auth/login");
|
||||||
|
}
|
||||||
|
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof TRPCError && error.code === "BAD_REQUEST") {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
|
||||||
const itemsMap = board.items.reduce((acc, item) => {
|
const itemsMap = board.items.reduce((acc, item) => {
|
||||||
const existing = acc.get(item.kind);
|
const existing = acc.get(item.kind);
|
||||||
@@ -110,8 +127,11 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Ignore not found errors and return empty metadata
|
// Ignore not found and bad-request errors and return empty metadata
|
||||||
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
|
if (
|
||||||
|
error instanceof TRPCError &&
|
||||||
|
(error.code === "NOT_FOUND" || error.code === "BAD_REQUEST")
|
||||||
|
) {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+9
-9
@@ -462,7 +462,7 @@ export const AssistantConfiguration = () => {
|
|||||||
</Badge>
|
</Badge>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SimpleGrid cols={{ base: 1, sm: 2 }}>
|
<Stack gap="md">
|
||||||
<Select
|
<Select
|
||||||
label={t("provider.title")}
|
label={t("provider.title")}
|
||||||
description={t(`provider.options.${provider}.description`)}
|
description={t(`provider.options.${provider}.description`)}
|
||||||
@@ -503,14 +503,14 @@ export const AssistantConfiguration = () => {
|
|||||||
onChange={(event) => changeBaseUrl(event.currentTarget.value)}
|
onChange={(event) => changeBaseUrl(event.currentTarget.value)}
|
||||||
placeholder="https://provider.example/v1"
|
placeholder="https://provider.example/v1"
|
||||||
/>
|
/>
|
||||||
</SimpleGrid>
|
<TextInput
|
||||||
<TextInput
|
label={t("model.discoveryPath")}
|
||||||
label={t("model.discoveryPath")}
|
description={t("model.discoveryPathDescription")}
|
||||||
description={t("model.discoveryPathDescription")}
|
value={modelDiscoveryPath}
|
||||||
value={modelDiscoveryPath}
|
onChange={(event) => changeModelDiscoveryPath(event.currentTarget.value)}
|
||||||
onChange={(event) => changeModelDiscoveryPath(event.currentTarget.value)}
|
placeholder="/models"
|
||||||
placeholder="/models"
|
/>
|
||||||
/>
|
</Stack>
|
||||||
|
|
||||||
{destinationChanged && (
|
{destinationChanged && (
|
||||||
<Alert icon={<IconAlertTriangle size={18} />} color="yellow" title={t("destinationChanged.title")}>
|
<Alert icon={<IconAlertTriangle size={18} />} color="yellow" title={t("destinationChanged.title")}>
|
||||||
|
|||||||
@@ -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 { createOpenAICompatible } from "@ai-sdk/openai-compatible";
|
||||||
import type { MetadataExtractor } from "@ai-sdk/openai-compatible";
|
import type { MetadataExtractor } from "@ai-sdk/openai-compatible";
|
||||||
import type { ToolSet, UIMessage } from "ai";
|
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 { parse, stringify } from "superjson";
|
||||||
import { z } from "zod/v4";
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
} from "@homarr/definitions";
|
} from "@homarr/definitions";
|
||||||
|
|
||||||
import { browserToolContracts } from "~/components/assistant/assistant-tool-contracts";
|
import { browserToolContracts } from "~/components/assistant/assistant-tool-contracts";
|
||||||
|
import { env as appEnv } from "~/env";
|
||||||
import type {
|
import type {
|
||||||
AssistantMessageMetadata,
|
AssistantMessageMetadata,
|
||||||
AssistantRequestStep,
|
AssistantRequestStep,
|
||||||
@@ -43,6 +44,7 @@ import {
|
|||||||
normalizeOpenRouterWebSearchSources,
|
normalizeOpenRouterWebSearchSources,
|
||||||
withOpenRouterWebSearch,
|
withOpenRouterWebSearch,
|
||||||
} from "./assistant-openrouter";
|
} from "./assistant-openrouter";
|
||||||
|
import { toProviderOptionsKey } from "./assistant-provider-options";
|
||||||
import { assistantExecutionPolicy } from "./assistant-execution-policy";
|
import { assistantExecutionPolicy } from "./assistant-execution-policy";
|
||||||
import { getAssistantStreamErrorMessage } from "./assistant-stream-error";
|
import { getAssistantStreamErrorMessage } from "./assistant-stream-error";
|
||||||
import { getSafeAssistantToolError } from "./assistant-tool-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) {
|
export async function POST(request: Request) {
|
||||||
const contentLength = Number(request.headers.get("content-length") ?? 0);
|
const contentLength = Number(request.headers.get("content-length") ?? 0);
|
||||||
if (contentLength > 2_000_000) {
|
if (contentLength > 2_000_000) {
|
||||||
@@ -359,10 +386,7 @@ export async function POST(request: Request) {
|
|||||||
const configuration = await db.query.assistantConfigurations.findFirst({
|
const configuration = await db.query.assistantConfigurations.findFirst({
|
||||||
where: eq(assistantConfigurations.id, "default"),
|
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({
|
const thread = await db.query.assistantThreads.findFirst({
|
||||||
where: and(eq(assistantThreads.id, parsed.data.id), eq(assistantThreads.userId, session.user.id)),
|
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 });
|
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 context = createTRPCContext({ headers: request.headers, session });
|
||||||
const requestStartedAt = Date.now();
|
const requestStartedAt = Date.now();
|
||||||
const requestId = crypto.randomUUID();
|
const requestId = crypto.randomUUID();
|
||||||
@@ -543,7 +576,7 @@ export async function POST(request: Request) {
|
|||||||
reasoning: parsed.data.reasoning === "auto" ? undefined : parsed.data.reasoning,
|
reasoning: parsed.data.reasoning === "auto" ? undefined : parsed.data.reasoning,
|
||||||
providerOptions:
|
providerOptions:
|
||||||
configuration.provider === "openrouter" || openRouterServerToolsEnabled
|
configuration.provider === "openrouter" || openRouterServerToolsEnabled
|
||||||
? { [providerName]: { usage: { include: true } } }
|
? { [toProviderOptionsKey(providerName)]: { usage: { include: true } } }
|
||||||
: undefined,
|
: undefined,
|
||||||
toolApproval,
|
toolApproval,
|
||||||
experimental_toolApprovalSecret: getToolApprovalSecret(),
|
experimental_toolApprovalSecret: getToolApprovalSecret(),
|
||||||
|
|||||||
@@ -923,7 +923,8 @@ const RequestTelemetry = () => {
|
|||||||
const metadata = useAuiState((state) => state.message.metadata);
|
const metadata = useAuiState((state) => state.message.metadata);
|
||||||
const messageId = useAuiState((state) => state.message.id);
|
const messageId = useAuiState((state) => state.message.id);
|
||||||
const threadId = useAuiState((state) => state.threadListItem.remoteId);
|
const threadId = useAuiState((state) => state.threadListItem.remoteId);
|
||||||
const threadMessageMetadata = useAuiState((state) => state.thread.messages.map((message) => message.metadata));
|
const threadMessages = useAuiState((state) => state.thread.messages);
|
||||||
|
const threadMessageMetadata = useMemo(() => threadMessages.map((message) => message.metadata), [threadMessages]);
|
||||||
const persistedTelemetry = getAssistantTelemetry(metadata);
|
const persistedTelemetry = getAssistantTelemetry(metadata);
|
||||||
const persistedUsage = getAssistantUsage(metadata);
|
const persistedUsage = getAssistantUsage(metadata);
|
||||||
const generationQuery = clientApi.assistant.getGenerationTelemetry.useQuery(
|
const generationQuery = clientApi.assistant.getGenerationTelemetry.useQuery(
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ import {
|
|||||||
import type { createTRPCContext } from "../trpc";
|
import type { createTRPCContext } from "../trpc";
|
||||||
import { fetchOpenRouterGenerationTelemetryAsync } from "../assistant-generation-telemetry";
|
import { fetchOpenRouterGenerationTelemetryAsync } from "../assistant-generation-telemetry";
|
||||||
import { verifyAssistantGenerationAccessToken } from "../assistant-generation-access";
|
import { verifyAssistantGenerationAccessToken } from "../assistant-generation-access";
|
||||||
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure } from "../trpc";
|
import { createTRPCRouter, isDemoMode, permissionRequiredProcedure, protectedProcedure } from "../trpc";
|
||||||
import { boardRouter } from "./board";
|
import { boardRouter } from "./board";
|
||||||
|
|
||||||
const adminProcedure = permissionRequiredProcedure.requiresPermission("admin");
|
const adminProcedure = permissionRequiredProcedure.requiresPermission("admin");
|
||||||
@@ -440,14 +440,10 @@ const addFeedbackToMessageContent = (serializedContent: string, type: "positive"
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const assistantRouter = createTRPCRouter({
|
export const assistantRouter = createTRPCRouter({
|
||||||
getAvailability: protectedProcedure
|
getAvailability: protectedProcedure.query(async ({ ctx }) => {
|
||||||
.meta({
|
if (isDemoMode) {
|
||||||
mcp: {
|
return { enabled: true };
|
||||||
enabled: true,
|
}
|
||||||
description: "Check whether Homarr Assistant is enabled and configured for the current user",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.query(async ({ ctx }) => {
|
|
||||||
const configuration = await getConfigurationAsync(ctx.db);
|
const configuration = await getConfigurationAsync(ctx.db);
|
||||||
const requiresApiKey = configuration ? assistantProviderRequiresApiKey(configuration.provider) : false;
|
const requiresApiKey = configuration ? assistantProviderRequiresApiKey(configuration.provider) : false;
|
||||||
return {
|
return {
|
||||||
@@ -457,26 +453,11 @@ export const assistantRouter = createTRPCRouter({
|
|||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
|
||||||
getContextEntities: protectedProcedure
|
getContextEntities: protectedProcedure.query(async ({ ctx }) => {
|
||||||
.meta({
|
|
||||||
mcp: {
|
|
||||||
enabled: true,
|
|
||||||
description:
|
|
||||||
"Lists the apps, integrations, boards, and widgets the signed-in user may reference. Integration entries are permission-filtered. Use returned IDs as inputs for other tools.",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.query(async ({ ctx }) => {
|
|
||||||
return await getAssistantContextEntitiesAsync(ctx);
|
return await getAssistantContextEntitiesAsync(ctx);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
getModelCapabilities: protectedProcedure
|
getModelCapabilities: protectedProcedure
|
||||||
.meta({
|
|
||||||
mcp: {
|
|
||||||
enabled: true,
|
|
||||||
description:
|
|
||||||
"Returns the configured assistant model's discovered input modalities and whether image input is supported, unsupported, or unknown.",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.input(z.object({ modelId: z.string().trim().min(1).max(256).optional() }).optional())
|
.input(z.object({ modelId: z.string().trim().min(1).max(256).optional() }).optional())
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const configuration = await getConfigurationAsync(ctx.db);
|
const configuration = await getConfigurationAsync(ctx.db);
|
||||||
@@ -495,6 +476,24 @@ export const assistantRouter = createTRPCRouter({
|
|||||||
|
|
||||||
getRuntimeOptions: protectedProcedure.query(async ({ ctx }) => {
|
getRuntimeOptions: protectedProcedure.query(async ({ ctx }) => {
|
||||||
const configuration = await getConfigurationAsync(ctx.db);
|
const configuration = await getConfigurationAsync(ctx.db);
|
||||||
|
if (isDemoMode) {
|
||||||
|
return {
|
||||||
|
provider: "homarr",
|
||||||
|
defaultModelId: "homarr-assistant",
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
id: "homarr-assistant",
|
||||||
|
name: "Homarr Assistant (Demo)",
|
||||||
|
description: null,
|
||||||
|
contextLength: null,
|
||||||
|
promptPrice: null,
|
||||||
|
completionPrice: null,
|
||||||
|
inputModalities: [],
|
||||||
|
toolSupport: "unknown" as const,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
if (!configuration?.enabled || !configuration.modelId) {
|
if (!configuration?.enabled || !configuration.modelId) {
|
||||||
throw new TRPCError({ code: "PRECONDITION_FAILED", message: "Homarr Assistant is not configured." });
|
throw new TRPCError({ code: "PRECONDITION_FAILED", message: "Homarr Assistant is not configured." });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
import { expect, test, vi } from "vitest";
|
import { expect, test, vi } from "vitest";
|
||||||
import { extractToolsFromProcedures } from "trpc-to-mcp";
|
import { extractToolsFromProcedures } from "trpc-to-mcp";
|
||||||
|
|
||||||
import { assistantRouter } from "../router/assistant";
|
|
||||||
import { appRouter } from "../router/app";
|
import { appRouter } from "../router/app";
|
||||||
import { boardRouter } from "../router/board";
|
import { boardRouter } from "../router/board";
|
||||||
import { customWidgetRouter } from "../router/custom-widget/custom-widget-router";
|
import { customWidgetRouter } from "../router/custom-widget/custom-widget-router";
|
||||||
@@ -28,7 +27,6 @@ vi.mock("@homarr/core/infrastructure/db/env", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const mcpTestRouter = createTRPCRouter({
|
const mcpTestRouter = createTRPCRouter({
|
||||||
assistant: assistantRouter,
|
|
||||||
app: appRouter,
|
app: appRouter,
|
||||||
board: boardRouter,
|
board: boardRouter,
|
||||||
customWidget: customWidgetRouter,
|
customWidget: customWidgetRouter,
|
||||||
@@ -49,9 +47,6 @@ test("MCP tools should contain expected procedures", () => {
|
|||||||
const toolNames = tools.map((tool) => tool.name);
|
const toolNames = tools.map((tool) => tool.name);
|
||||||
|
|
||||||
expect(tools.length).toBeGreaterThan(0);
|
expect(tools.length).toBeGreaterThan(0);
|
||||||
expect(toolNames).toContain("assistant_getAvailability");
|
|
||||||
expect(toolNames).toContain("assistant_getContextEntities");
|
|
||||||
expect(toolNames).toContain("assistant_getModelCapabilities");
|
|
||||||
expect(toolNames).toContain("app_all");
|
expect(toolNames).toContain("app_all");
|
||||||
expect(toolNames).toContain("app_byId");
|
expect(toolNames).toContain("app_byId");
|
||||||
expect(toolNames).toContain("app_create");
|
expect(toolNames).toContain("app_create");
|
||||||
|
|||||||
Reference in new issue
Block a user