getThread ordered purely by created_at, which is second-granular on MySQL and SQLite. A user turn and the assistant reply routinely land in the same second, so their relative order was whatever the storage engine returned. The client rebuilds the conversation tree from parent_id, so a child ahead of its parent corrupts the thread. Adds an id tiebreaker for a deterministic database order, then a stable topological pass that guarantees parents precede children while preserving sibling order. Dangling parent references are treated as roots and cycles are appended, so no message is ever dropped.
912 lines
34 KiB
TypeScript
912 lines
34 KiB
TypeScript
import { TRPCError } from "@trpc/server";
|
|
import { parse, stringify } from "superjson";
|
|
import { z } from "zod/v4";
|
|
|
|
import { constructIntegrationPermissions } from "@homarr/auth/shared";
|
|
import { createId } from "@homarr/common";
|
|
import { decryptSecret, encryptSecret } from "@homarr/common/server";
|
|
import { and, asc, desc, eq, handleTransactionsAsync, inArray } from "@homarr/db";
|
|
import type { Database } from "@homarr/db";
|
|
import {
|
|
apps,
|
|
assistantConfigurations,
|
|
assistantMessages,
|
|
assistantThreads,
|
|
groupMembers,
|
|
integrationGroupPermissions,
|
|
integrations,
|
|
integrationUserPermissions,
|
|
items,
|
|
} from "@homarr/db/schema";
|
|
import {
|
|
assistantProviderCanUseOpenRouterServerTools,
|
|
assistantProviderIds,
|
|
assistantProviderPresets,
|
|
assistantProviderRequiresApiKey,
|
|
getIconUrl,
|
|
resolveAssistantModelId,
|
|
} from "@homarr/definitions";
|
|
|
|
import type { createTRPCContext } from "../trpc";
|
|
import { fetchOpenRouterGenerationTelemetryAsync } from "../assistant-generation-telemetry";
|
|
import { orderMessagesByParent } from "../assistant-message-order";
|
|
import { verifyAssistantGenerationAccessToken } from "../assistant-generation-access";
|
|
import { createTRPCRouter, isDemoMode, permissionRequiredProcedure, protectedProcedure } from "../trpc";
|
|
import { boardRouter } from "./board";
|
|
|
|
const adminProcedure = permissionRequiredProcedure.requiresPermission("admin");
|
|
const configurationId = "default";
|
|
const providerSchema = z.enum(assistantProviderIds);
|
|
|
|
const modelSchema = z.object({
|
|
id: z.string().min(1).max(256),
|
|
name: z.string().min(1).max(256),
|
|
description: z.string().max(2048).nullable(),
|
|
contextLength: z.number().int().positive().max(Number.MAX_SAFE_INTEGER).nullable(),
|
|
promptPrice: z.string().max(64).nullable(),
|
|
completionPrice: z.string().max(64).nullable(),
|
|
inputModalities: z.array(z.string().max(32)).max(16),
|
|
toolSupport: z.enum(["confirmed", "unknown"]),
|
|
});
|
|
|
|
type CompatibleModel = {
|
|
id?: unknown;
|
|
name?: unknown;
|
|
display_name?: unknown;
|
|
model?: unknown;
|
|
description?: unknown;
|
|
context_length?: unknown;
|
|
max_context_length?: unknown;
|
|
max_input_tokens?: unknown;
|
|
supported_parameters?: unknown;
|
|
input_modalities?: unknown;
|
|
architecture?: { input_modalities?: unknown; output_modalities?: unknown };
|
|
pricing?: { prompt?: unknown; completion?: unknown };
|
|
capabilities?: { function_calling?: unknown };
|
|
};
|
|
|
|
type AssistantConfiguration = typeof assistantConfigurations.$inferSelect;
|
|
type AssistantContext = Awaited<ReturnType<typeof createTRPCContext>>;
|
|
|
|
export type AssistantContextEntity = {
|
|
id: string;
|
|
type: "app" | "integration" | "board" | "widget";
|
|
label: string;
|
|
description: string;
|
|
iconUrl?: string;
|
|
boardId?: string;
|
|
};
|
|
|
|
const modelListCache = new Map<string, { expiresAt: number; value: z.infer<typeof modelSchema>[] }>();
|
|
|
|
const customHeadersSchema = z
|
|
.record(
|
|
z
|
|
.string()
|
|
.trim()
|
|
.min(1)
|
|
.max(128)
|
|
.regex(/^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/),
|
|
z
|
|
.string()
|
|
.max(2048)
|
|
.refine((value) => !value.includes("\r") && !value.includes("\n")),
|
|
)
|
|
.refine((headers) => Object.keys(headers).length <= 20)
|
|
.refine(
|
|
(headers) =>
|
|
!Object.keys(headers).some((name) =>
|
|
["connection", "content-length", "host", "transfer-encoding"].includes(name.toLowerCase()),
|
|
),
|
|
"Hop-by-hop and transport headers cannot be configured.",
|
|
);
|
|
|
|
const normalizeBaseUrl = (value: string) => {
|
|
let url: URL;
|
|
try {
|
|
url = new URL(value);
|
|
} catch {
|
|
throw new TRPCError({ code: "BAD_REQUEST", message: "Enter a valid provider URL." });
|
|
}
|
|
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || url.search || url.hash) {
|
|
throw new TRPCError({
|
|
code: "BAD_REQUEST",
|
|
message: "The provider URL must use HTTP or HTTPS without credentials, query parameters, or fragments.",
|
|
});
|
|
}
|
|
return url.toString().replace(/\/$/, "");
|
|
};
|
|
|
|
const normalizeDiscoveryPath = (value: string | null) => {
|
|
if (value === null || value.trim() === "") return null;
|
|
const path = value.trim();
|
|
if (!path.startsWith("/") || path.startsWith("//") || path.includes("?") || path.includes("#")) {
|
|
throw new TRPCError({
|
|
code: "BAD_REQUEST",
|
|
message: "The model discovery path must be an absolute path such as /models.",
|
|
});
|
|
}
|
|
return path;
|
|
};
|
|
|
|
const getConfigurationAsync = async (db: Database) => {
|
|
return await db.query.assistantConfigurations.findFirst({
|
|
where: eq(assistantConfigurations.id, configurationId),
|
|
});
|
|
};
|
|
|
|
const decryptCustomHeaders = (encryptedHeaders: `${string}.${string}` | null) => {
|
|
if (!encryptedHeaders) return {};
|
|
try {
|
|
return customHeadersSchema.parse(JSON.parse(decryptSecret(encryptedHeaders)));
|
|
} catch {
|
|
throw new TRPCError({
|
|
code: "INTERNAL_SERVER_ERROR",
|
|
message: "The stored provider headers could not be decrypted.",
|
|
});
|
|
}
|
|
};
|
|
|
|
const getProviderHeaders = (configuration: AssistantConfiguration) => {
|
|
const headers: Record<string, string> = {};
|
|
if (configuration.encryptedApiKey) {
|
|
const apiKey = decryptSecret(configuration.encryptedApiKey);
|
|
if (assistantProviderPresets[configuration.provider].discoveryAuthentication === "anthropic") {
|
|
headers["X-Api-Key"] = apiKey;
|
|
headers["anthropic-version"] = "2023-06-01";
|
|
} else {
|
|
headers.Authorization = `Bearer ${apiKey}`;
|
|
}
|
|
}
|
|
if (configuration.provider === "openrouter") {
|
|
headers["HTTP-Referer"] ??= "https://homarr.dev";
|
|
headers["X-Title"] ??= "Homarr Assistant";
|
|
}
|
|
return { ...headers, ...decryptCustomHeaders(configuration.encryptedHeaders) };
|
|
};
|
|
|
|
const getGenerationIdsFromMessageContent = (content: unknown) => {
|
|
if (!content || typeof content !== "object" || Array.isArray(content)) return [];
|
|
const metadata = (content as { metadata?: unknown }).metadata;
|
|
if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) return [];
|
|
const custom = (metadata as { custom?: unknown }).custom;
|
|
if (!custom || typeof custom !== "object" || Array.isArray(custom)) return [];
|
|
const telemetry = (custom as { telemetry?: unknown }).telemetry;
|
|
if (!telemetry || typeof telemetry !== "object" || Array.isArray(telemetry)) return [];
|
|
const steps = (telemetry as { steps?: unknown }).steps;
|
|
if (!Array.isArray(steps)) return [];
|
|
return steps
|
|
.flatMap((step) => {
|
|
if (!step || typeof step !== "object" || Array.isArray(step)) return [];
|
|
const generationId = (step as { generationId?: unknown }).generationId;
|
|
const accessToken = (step as { generationAccessToken?: unknown }).generationAccessToken;
|
|
return typeof generationId === "string" &&
|
|
/^gen-[A-Za-z0-9_-]{1,128}$/.test(generationId) &&
|
|
typeof accessToken === "string" &&
|
|
/^[A-Za-z0-9_-]{43}$/.test(accessToken)
|
|
? [{ generationId, accessToken }]
|
|
: [];
|
|
})
|
|
.slice(0, 8);
|
|
};
|
|
|
|
const fetchModelsAsync = async (configuration: AssistantConfiguration) => {
|
|
if (!configuration.modelDiscoveryPath) {
|
|
throw new TRPCError({
|
|
code: "PRECONDITION_FAILED",
|
|
message: "Model discovery is disabled for this provider. Enter a model ID manually.",
|
|
});
|
|
}
|
|
|
|
const endpoint = `${configuration.baseUrl}${configuration.modelDiscoveryPath}`;
|
|
let response: Response;
|
|
try {
|
|
response = await fetch(endpoint, {
|
|
headers: getProviderHeaders(configuration),
|
|
signal: AbortSignal.timeout(15_000),
|
|
});
|
|
} catch {
|
|
throw new TRPCError({
|
|
code: "BAD_GATEWAY",
|
|
message: "The model endpoint could not be reached from the Homarr server.",
|
|
});
|
|
}
|
|
|
|
if (!response.ok) {
|
|
if (response.status === 401 || response.status === 403) {
|
|
throw new TRPCError({ code: "UNAUTHORIZED", message: "The provider rejected the configured credentials." });
|
|
}
|
|
throw new TRPCError({
|
|
code: "BAD_GATEWAY",
|
|
message: `Model discovery failed with status ${response.status}.`,
|
|
});
|
|
}
|
|
|
|
let body: { data?: CompatibleModel[]; models?: CompatibleModel[] } | CompatibleModel[];
|
|
try {
|
|
body = (await response.json()) as typeof body;
|
|
} catch {
|
|
throw new TRPCError({ code: "BAD_GATEWAY", message: "The model endpoint returned invalid JSON." });
|
|
}
|
|
const models = (Array.isArray(body) ? body : (body.data ?? body.models ?? [])).slice(0, 1_000);
|
|
return models
|
|
.filter((model) => {
|
|
if (configuration.provider !== "openrouter") return true;
|
|
const parameters = Array.isArray(model.supported_parameters) ? model.supported_parameters : [];
|
|
const outputModalities = Array.isArray(model.architecture?.output_modalities)
|
|
? model.architecture.output_modalities
|
|
: [];
|
|
return parameters.includes("tools") && (outputModalities.length === 0 || outputModalities.includes("text"));
|
|
})
|
|
.flatMap((model) => {
|
|
const id = typeof model.id === "string" ? model.id : typeof model.model === "string" ? model.model : null;
|
|
if (!id || id.length > 256) return [];
|
|
const rawName =
|
|
typeof model.name === "string" ? model.name : typeof model.display_name === "string" ? model.display_name : id;
|
|
const name = rawName.trim() || id;
|
|
const contextLength =
|
|
typeof model.context_length === "number" &&
|
|
Number.isSafeInteger(model.context_length) &&
|
|
model.context_length > 0
|
|
? model.context_length
|
|
: typeof model.max_context_length === "number" &&
|
|
Number.isSafeInteger(model.max_context_length) &&
|
|
model.max_context_length > 0
|
|
? model.max_context_length
|
|
: typeof model.max_input_tokens === "number" &&
|
|
Number.isSafeInteger(model.max_input_tokens) &&
|
|
model.max_input_tokens > 0
|
|
? model.max_input_tokens
|
|
: null;
|
|
const toolSupport =
|
|
configuration.provider === "openrouter" ||
|
|
model.capabilities?.function_calling === true ||
|
|
configuration.provider === "anthropic"
|
|
? ("confirmed" as const)
|
|
: ("unknown" as const);
|
|
const rawInputModalities = Array.isArray(model.architecture?.input_modalities)
|
|
? model.architecture.input_modalities
|
|
: Array.isArray(model.input_modalities)
|
|
? model.input_modalities
|
|
: [];
|
|
const inputModalities = rawInputModalities
|
|
.filter((modality): modality is string => typeof modality === "string" && modality.length <= 32)
|
|
.slice(0, 16);
|
|
return [
|
|
{
|
|
id,
|
|
name: name.slice(0, 256),
|
|
description: typeof model.description === "string" ? model.description.slice(0, 2048) : null,
|
|
contextLength,
|
|
promptPrice: typeof model.pricing?.prompt === "string" ? model.pricing.prompt.slice(0, 64) : null,
|
|
completionPrice: typeof model.pricing?.completion === "string" ? model.pricing.completion.slice(0, 64) : null,
|
|
inputModalities,
|
|
toolSupport,
|
|
},
|
|
];
|
|
})
|
|
.toSorted((left, right) => left.name.localeCompare(right.name));
|
|
};
|
|
|
|
const getModelListCacheKey = (configuration: AssistantConfiguration) =>
|
|
[
|
|
configuration.provider,
|
|
configuration.baseUrl,
|
|
configuration.modelDiscoveryPath,
|
|
configuration.updatedAt.getTime(),
|
|
].join(":");
|
|
|
|
export const getAssistantModelsAsync = async (configuration: AssistantConfiguration, refresh = false) => {
|
|
if (!configuration.modelDiscoveryPath) return [];
|
|
const cacheKey = getModelListCacheKey(configuration);
|
|
const cached = modelListCache.get(cacheKey);
|
|
if (!refresh && cached && cached.expiresAt > Date.now()) return cached.value;
|
|
|
|
const value = await fetchModelsAsync(configuration);
|
|
modelListCache.clear();
|
|
modelListCache.set(cacheKey, { expiresAt: Date.now() + 10 * 60_000, value });
|
|
return value;
|
|
};
|
|
|
|
export const getSelectedModelDetailsAsync = async (
|
|
configuration: AssistantConfiguration,
|
|
requestedModelId = configuration.modelId,
|
|
) => {
|
|
if (!requestedModelId || !configuration.modelDiscoveryPath) return null;
|
|
const models = await getAssistantModelsAsync(configuration);
|
|
const resolvedModelId = resolveAssistantModelId(models, requestedModelId);
|
|
return models.find((model) => model.id === resolvedModelId) ?? null;
|
|
};
|
|
|
|
export const getAssistantContextEntitiesAsync = async (ctx: AssistantContext): Promise<AssistantContextEntity[]> => {
|
|
if (!ctx.session) return [];
|
|
|
|
const groupsOfCurrentUser = await ctx.db.query.groupMembers.findMany({
|
|
where: eq(groupMembers.userId, ctx.session.user.id),
|
|
});
|
|
const [availableBoards, availableIntegrations, availableApps] = await Promise.all([
|
|
boardRouter.createCaller(ctx).getAllBoards(),
|
|
ctx.db.query.integrations.findMany({
|
|
columns: { id: true, name: true, kind: true },
|
|
with: {
|
|
userPermissions: {
|
|
where: eq(integrationUserPermissions.userId, ctx.session.user.id),
|
|
},
|
|
groupPermissions: {
|
|
where: inArray(
|
|
integrationGroupPermissions.groupId,
|
|
groupsOfCurrentUser.map((group) => group.groupId).concat(""),
|
|
),
|
|
},
|
|
},
|
|
orderBy: asc(integrations.name),
|
|
limit: 250,
|
|
}),
|
|
ctx.db.query.apps.findMany({
|
|
columns: { id: true, name: true, description: true, iconUrl: true },
|
|
orderBy: asc(apps.name),
|
|
limit: 250,
|
|
}),
|
|
]);
|
|
const boardsById = new Map(availableBoards.map((board) => [board.id, board]));
|
|
const availableItems =
|
|
availableBoards.length === 0
|
|
? []
|
|
: await ctx.db.query.items.findMany({
|
|
columns: { id: true, boardId: true, kind: true },
|
|
where: inArray(
|
|
items.boardId,
|
|
availableBoards.map((board) => board.id),
|
|
),
|
|
orderBy: asc(items.kind),
|
|
limit: 500,
|
|
});
|
|
|
|
return [
|
|
...availableApps.map(
|
|
(app): AssistantContextEntity => ({
|
|
id: app.id,
|
|
type: "app",
|
|
label: app.name,
|
|
description: app.description ?? "Homarr app",
|
|
iconUrl: app.iconUrl,
|
|
}),
|
|
),
|
|
...availableIntegrations
|
|
.filter((integration) => constructIntegrationPermissions(integration, ctx.session).hasUseAccess)
|
|
.map(
|
|
(integration): AssistantContextEntity => ({
|
|
id: integration.id,
|
|
type: "integration",
|
|
label: integration.name,
|
|
description: `${integration.kind} integration`,
|
|
iconUrl: getIconUrl(integration.kind),
|
|
}),
|
|
),
|
|
...availableBoards.map(
|
|
(board): AssistantContextEntity => ({
|
|
id: board.id,
|
|
type: "board",
|
|
label: board.name,
|
|
description: board.isHome ? "Home board" : board.isMobileHome ? "Mobile home board" : "Homarr board",
|
|
}),
|
|
),
|
|
...availableItems.flatMap((item): AssistantContextEntity[] => {
|
|
const board = boardsById.get(item.boardId);
|
|
if (!board) return [];
|
|
return [
|
|
{
|
|
id: item.id,
|
|
type: "widget",
|
|
label: `${item.kind} · ${board.name}`,
|
|
description: `${item.kind} widget on ${board.name}`,
|
|
boardId: board.id,
|
|
},
|
|
];
|
|
}),
|
|
];
|
|
};
|
|
|
|
const ownedThreadAsync = async (db: Database, threadId: string, userId: string) => {
|
|
const thread = await db.query.assistantThreads.findFirst({
|
|
where: and(eq(assistantThreads.id, threadId), eq(assistantThreads.userId, userId)),
|
|
});
|
|
if (!thread) {
|
|
throw new TRPCError({ code: "NOT_FOUND", message: "Conversation not found." });
|
|
}
|
|
return thread;
|
|
};
|
|
|
|
const addFeedbackToMessageContent = (serializedContent: string, type: "positive" | "negative") => {
|
|
let content: unknown;
|
|
try {
|
|
content = parse(serializedContent);
|
|
} catch {
|
|
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "The stored message is invalid." });
|
|
}
|
|
if (!content || typeof content !== "object" || Array.isArray(content)) {
|
|
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "The stored message is invalid." });
|
|
}
|
|
const metadata =
|
|
"metadata" in content &&
|
|
content.metadata &&
|
|
typeof content.metadata === "object" &&
|
|
!Array.isArray(content.metadata)
|
|
? content.metadata
|
|
: {};
|
|
return stringify({
|
|
...content,
|
|
metadata: { ...metadata, submittedFeedback: { type } },
|
|
});
|
|
};
|
|
|
|
export const assistantRouter = createTRPCRouter({
|
|
getAvailability: protectedProcedure.query(async ({ ctx }) => {
|
|
if (isDemoMode) {
|
|
return { enabled: true };
|
|
}
|
|
const configuration = await getConfigurationAsync(ctx.db);
|
|
const requiresApiKey = configuration ? assistantProviderRequiresApiKey(configuration.provider) : false;
|
|
return {
|
|
enabled: Boolean(
|
|
configuration?.enabled && configuration.modelId && (!requiresApiKey || configuration.encryptedApiKey),
|
|
),
|
|
};
|
|
}),
|
|
|
|
getContextEntities: protectedProcedure.query(async ({ ctx }) => {
|
|
return await getAssistantContextEntitiesAsync(ctx);
|
|
}),
|
|
|
|
getModelCapabilities: protectedProcedure
|
|
.input(z.object({ modelId: z.string().trim().min(1).max(256).optional() }).optional())
|
|
.query(async ({ ctx, input }) => {
|
|
const configuration = await getConfigurationAsync(ctx.db);
|
|
if (!configuration?.enabled || !configuration.modelId) {
|
|
return { imageInput: "unsupported" as const, inputModalities: [] as string[] };
|
|
}
|
|
const model = await getSelectedModelDetailsAsync(configuration, input?.modelId).catch(() => null);
|
|
if (!model || model.inputModalities.length === 0) {
|
|
return { imageInput: "unknown" as const, inputModalities: [] as string[] };
|
|
}
|
|
return {
|
|
imageInput: model.inputModalities.includes("image") ? ("supported" as const) : ("unsupported" as const),
|
|
inputModalities: model.inputModalities,
|
|
};
|
|
}),
|
|
|
|
getRuntimeOptions: protectedProcedure.query(async ({ ctx }) => {
|
|
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) {
|
|
throw new TRPCError({ code: "PRECONDITION_FAILED", message: "Homarr Assistant is not configured." });
|
|
}
|
|
const discoveredModels = await getAssistantModelsAsync(configuration).catch(() => []);
|
|
const resolvedDefaultId = resolveAssistantModelId(discoveredModels, configuration.modelId) ?? configuration.modelId;
|
|
const defaultModel = discoveredModels.find((model) => model.id === resolvedDefaultId) ?? {
|
|
id: resolvedDefaultId,
|
|
name: resolvedDefaultId,
|
|
description: null,
|
|
contextLength: null,
|
|
promptPrice: null,
|
|
completionPrice: null,
|
|
inputModalities: [],
|
|
toolSupport: "unknown" as const,
|
|
};
|
|
return {
|
|
provider: configuration.provider,
|
|
defaultModelId: defaultModel.id,
|
|
models: [defaultModel, ...discoveredModels.filter((model) => model.id !== defaultModel.id)],
|
|
};
|
|
}),
|
|
|
|
getAdminConfiguration: adminProcedure.query(async ({ ctx }) => {
|
|
const configuration = await getConfigurationAsync(ctx.db);
|
|
return {
|
|
connectionConfigured: Boolean(configuration),
|
|
enabled: configuration?.enabled ?? false,
|
|
webSearchEnabled: configuration?.webSearchEnabled ?? false,
|
|
provider: configuration?.provider ?? "openrouter",
|
|
baseUrl: configuration?.baseUrl ?? "https://openrouter.ai/api/v1",
|
|
modelDiscoveryPath: configuration?.modelDiscoveryPath ?? "/models",
|
|
apiKeyConfigured: Boolean(configuration?.encryptedApiKey),
|
|
customHeadersConfigured: Boolean(configuration?.encryptedHeaders),
|
|
modelId: configuration?.modelId ?? null,
|
|
updatedAt: configuration?.updatedAt ?? null,
|
|
};
|
|
}),
|
|
|
|
updateConnection: adminProcedure
|
|
.input(
|
|
z.object({
|
|
provider: providerSchema,
|
|
baseUrl: z.string().trim().min(1).max(2048),
|
|
modelDiscoveryPath: z.string().trim().max(512).nullable(),
|
|
apiKey: z.string().trim().min(1).max(2048).optional(),
|
|
clearApiKey: z.boolean().default(false),
|
|
customHeaders: customHeadersSchema.optional(),
|
|
clearCustomHeaders: z.boolean().default(false),
|
|
}),
|
|
)
|
|
.mutation(async ({ ctx, input }) => {
|
|
const baseUrl = normalizeBaseUrl(input.baseUrl);
|
|
const modelDiscoveryPath = normalizeDiscoveryPath(input.modelDiscoveryPath);
|
|
const existing = await getConfigurationAsync(ctx.db);
|
|
const destinationChanged =
|
|
existing !== undefined && (existing.provider !== input.provider || existing.baseUrl !== baseUrl);
|
|
const connectionChanged =
|
|
existing !== undefined && (destinationChanged || existing.modelDiscoveryPath !== modelDiscoveryPath);
|
|
const encryptedApiKey = input.apiKey
|
|
? encryptSecret(input.apiKey)
|
|
: input.clearApiKey || destinationChanged
|
|
? null
|
|
: (existing?.encryptedApiKey ?? null);
|
|
const encryptedHeaders = input.customHeaders
|
|
? encryptSecret(JSON.stringify(input.customHeaders))
|
|
: input.clearCustomHeaders || destinationChanged
|
|
? null
|
|
: (existing?.encryptedHeaders ?? null);
|
|
|
|
if (existing) {
|
|
await ctx.db
|
|
.update(assistantConfigurations)
|
|
.set({
|
|
provider: input.provider,
|
|
baseUrl,
|
|
modelDiscoveryPath,
|
|
encryptedApiKey,
|
|
encryptedHeaders,
|
|
modelId: connectionChanged ? null : existing.modelId,
|
|
enabled: connectionChanged ? false : existing.enabled,
|
|
webSearchEnabled:
|
|
destinationChanged || !assistantProviderCanUseOpenRouterServerTools(input.provider)
|
|
? false
|
|
: existing.webSearchEnabled,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(assistantConfigurations.id, configurationId));
|
|
} else {
|
|
await ctx.db.insert(assistantConfigurations).values({
|
|
id: configurationId,
|
|
provider: input.provider,
|
|
baseUrl,
|
|
modelDiscoveryPath,
|
|
encryptedApiKey,
|
|
encryptedHeaders,
|
|
});
|
|
}
|
|
|
|
return {
|
|
apiKeyConfigured: Boolean(encryptedApiKey),
|
|
customHeadersConfigured: Boolean(encryptedHeaders),
|
|
credentialsClearedForDestinationChange: destinationChanged && !input.apiKey && !input.customHeaders,
|
|
};
|
|
}),
|
|
|
|
clearCredentials: adminProcedure.mutation(async ({ ctx }) => {
|
|
const existing = await getConfigurationAsync(ctx.db);
|
|
if (!existing) return;
|
|
await ctx.db
|
|
.update(assistantConfigurations)
|
|
.set({
|
|
encryptedApiKey: null,
|
|
encryptedHeaders: null,
|
|
modelId: null,
|
|
enabled: false,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(assistantConfigurations.id, configurationId));
|
|
}),
|
|
|
|
discoverModels: adminProcedure.output(z.array(modelSchema)).query(async ({ ctx }) => {
|
|
const configuration = await getConfigurationAsync(ctx.db);
|
|
if (!configuration) {
|
|
throw new TRPCError({ code: "PRECONDITION_FAILED", message: "Save a provider connection first." });
|
|
}
|
|
return await getAssistantModelsAsync(configuration, true);
|
|
}),
|
|
|
|
updateConfiguration: adminProcedure
|
|
.input(
|
|
z.object({
|
|
enabled: z.boolean(),
|
|
modelId: z.string().trim().min(1).max(256),
|
|
webSearchEnabled: z.boolean().default(false),
|
|
}),
|
|
)
|
|
.mutation(async ({ ctx, input }) => {
|
|
const configuration = await getConfigurationAsync(ctx.db);
|
|
if (!configuration) {
|
|
throw new TRPCError({ code: "PRECONDITION_FAILED", message: "Save a provider connection first." });
|
|
}
|
|
const requiresApiKey = assistantProviderRequiresApiKey(configuration.provider);
|
|
if (input.enabled && requiresApiKey && !configuration.encryptedApiKey) {
|
|
throw new TRPCError({ code: "PRECONDITION_FAILED", message: "This provider requires an API key." });
|
|
}
|
|
if (input.webSearchEnabled && !assistantProviderCanUseOpenRouterServerTools(configuration.provider)) {
|
|
throw new TRPCError({
|
|
code: "BAD_REQUEST",
|
|
message: "Web search requires OpenRouter or an OpenRouter-compatible proxy endpoint.",
|
|
});
|
|
}
|
|
const discoveredModels = configuration.modelDiscoveryPath
|
|
? await fetchModelsAsync(configuration).catch(() => null)
|
|
: null;
|
|
const modelId = discoveredModels
|
|
? (resolveAssistantModelId(discoveredModels, input.modelId) ?? input.modelId)
|
|
: input.modelId;
|
|
await ctx.db
|
|
.update(assistantConfigurations)
|
|
.set({ enabled: input.enabled, modelId, webSearchEnabled: input.webSearchEnabled, updatedAt: new Date() })
|
|
.where(eq(assistantConfigurations.id, configurationId));
|
|
}),
|
|
|
|
listThreads: protectedProcedure.query(async ({ ctx }) => {
|
|
return await ctx.db.query.assistantThreads.findMany({
|
|
where: eq(assistantThreads.userId, ctx.session.user.id),
|
|
orderBy: desc(assistantThreads.updatedAt),
|
|
limit: 100,
|
|
});
|
|
}),
|
|
|
|
createThread: protectedProcedure
|
|
.input(z.object({ localId: z.string().max(128).optional() }).optional())
|
|
.mutation(async ({ ctx }) => {
|
|
const configuration = await getConfigurationAsync(ctx.db);
|
|
const id = createId();
|
|
const now = new Date();
|
|
await ctx.db.insert(assistantThreads).values({
|
|
id,
|
|
userId: ctx.session.user.id,
|
|
modelId: configuration?.modelId ?? null,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
});
|
|
return { id };
|
|
}),
|
|
|
|
updateThreadModel: protectedProcedure
|
|
.input(z.object({ threadId: z.string().max(64), modelId: z.string().trim().min(1).max(256) }))
|
|
.mutation(async ({ ctx, input }) => {
|
|
const [thread, configuration] = await Promise.all([
|
|
ownedThreadAsync(ctx.db, input.threadId, ctx.session.user.id),
|
|
getConfigurationAsync(ctx.db),
|
|
]);
|
|
if (!configuration?.enabled || !configuration.modelId) {
|
|
throw new TRPCError({ code: "PRECONDITION_FAILED", message: "Homarr Assistant is not configured." });
|
|
}
|
|
const selectedModel = await getSelectedModelDetailsAsync(configuration, input.modelId).catch(() => null);
|
|
if (input.modelId !== configuration.modelId && !selectedModel) {
|
|
throw new TRPCError({
|
|
code: "BAD_REQUEST",
|
|
message: "The selected model is not available from the configured provider.",
|
|
});
|
|
}
|
|
const modelId = selectedModel?.id ?? configuration.modelId;
|
|
await ctx.db
|
|
.update(assistantThreads)
|
|
.set({ modelId, updatedAt: new Date() })
|
|
.where(eq(assistantThreads.id, thread.id));
|
|
return { modelId };
|
|
}),
|
|
|
|
getThread: protectedProcedure.input(z.object({ threadId: z.string().max(64) })).query(async ({ ctx, input }) => {
|
|
const thread = await ownedThreadAsync(ctx.db, input.threadId, ctx.session.user.id);
|
|
const messages = await ctx.db.query.assistantMessages.findMany({
|
|
where: eq(assistantMessages.threadId, thread.id),
|
|
// `created_at` is only second-granular on MySQL and SQLite, so sibling messages written in the
|
|
// same second tie. The id tiebreaker makes the database order at least deterministic.
|
|
orderBy: [asc(assistantMessages.createdAt), asc(assistantMessages.id)],
|
|
});
|
|
return {
|
|
thread,
|
|
messages: orderMessagesByParent(messages).map((message) => ({
|
|
id: message.id,
|
|
parentId: message.parentId,
|
|
format: message.format,
|
|
content: parse(message.content),
|
|
})),
|
|
};
|
|
}),
|
|
|
|
getGenerationTelemetry: protectedProcedure
|
|
.input(
|
|
z.object({
|
|
threadId: z.string().max(64),
|
|
messageId: z.string().min(1).max(128),
|
|
}),
|
|
)
|
|
.query(async ({ ctx, input }) => {
|
|
const thread = await ownedThreadAsync(ctx.db, input.threadId, ctx.session.user.id);
|
|
const [message, configuration] = await Promise.all([
|
|
ctx.db.query.assistantMessages.findFirst({
|
|
where: and(eq(assistantMessages.id, input.messageId), eq(assistantMessages.threadId, thread.id)),
|
|
}),
|
|
getConfigurationAsync(ctx.db),
|
|
]);
|
|
if (!message) return { complete: true, generations: [] };
|
|
if (configuration?.provider !== "openrouter" || !configuration.encryptedApiKey) {
|
|
return { complete: true, generations: [] };
|
|
}
|
|
|
|
const generationReferences = getGenerationIdsFromMessageContent(parse(message.content)).filter((reference) =>
|
|
verifyAssistantGenerationAccessToken(
|
|
{
|
|
userId: ctx.session.user.id,
|
|
threadId: thread.id,
|
|
generationId: reference.generationId,
|
|
},
|
|
reference.accessToken,
|
|
),
|
|
);
|
|
if (generationReferences.length === 0) return { complete: true, generations: [] };
|
|
const generations = await Promise.all(
|
|
generationReferences.map(({ generationId }) =>
|
|
fetchOpenRouterGenerationTelemetryAsync({
|
|
baseUrl: configuration.baseUrl,
|
|
generationId,
|
|
headers: getProviderHeaders(configuration),
|
|
}),
|
|
),
|
|
);
|
|
return {
|
|
complete: generations.every((generation) => generation !== null),
|
|
generations: generations.filter((generation) => generation !== null),
|
|
};
|
|
}),
|
|
|
|
renameThread: protectedProcedure
|
|
.input(z.object({ threadId: z.string().max(64), title: z.string().trim().min(1).max(120) }))
|
|
.mutation(async ({ ctx, input }) => {
|
|
await ownedThreadAsync(ctx.db, input.threadId, ctx.session.user.id);
|
|
await ctx.db
|
|
.update(assistantThreads)
|
|
.set({ title: input.title, updatedAt: new Date() })
|
|
.where(eq(assistantThreads.id, input.threadId));
|
|
}),
|
|
|
|
deleteThread: protectedProcedure
|
|
.input(z.object({ threadId: z.string().max(64) }))
|
|
.mutation(async ({ ctx, input }) => {
|
|
await ownedThreadAsync(ctx.db, input.threadId, ctx.session.user.id);
|
|
await ctx.db.delete(assistantThreads).where(eq(assistantThreads.id, input.threadId));
|
|
}),
|
|
|
|
appendMessage: protectedProcedure
|
|
.input(
|
|
z.object({
|
|
threadId: z.string().max(64),
|
|
id: z.string().min(1).max(128),
|
|
parentId: z.string().max(128).nullable(),
|
|
format: z.literal("ai-sdk/v6"),
|
|
content: z.unknown(),
|
|
}),
|
|
)
|
|
.mutation(async ({ ctx, input }) => {
|
|
const thread = await ownedThreadAsync(ctx.db, input.threadId, ctx.session.user.id);
|
|
const existing = await ctx.db.query.assistantMessages.findFirst({
|
|
where: and(eq(assistantMessages.id, input.id), eq(assistantMessages.threadId, thread.id)),
|
|
});
|
|
if (existing) {
|
|
await ctx.db
|
|
.update(assistantMessages)
|
|
.set({ parentId: input.parentId, format: input.format, content: stringify(input.content) })
|
|
.where(eq(assistantMessages.id, input.id));
|
|
} else {
|
|
await ctx.db.insert(assistantMessages).values({
|
|
id: input.id,
|
|
threadId: thread.id,
|
|
parentId: input.parentId,
|
|
format: input.format,
|
|
content: stringify(input.content),
|
|
});
|
|
}
|
|
|
|
const title =
|
|
thread.title ??
|
|
(typeof input.content === "object" &&
|
|
input.content !== null &&
|
|
"role" in input.content &&
|
|
input.content.role === "user" &&
|
|
"parts" in input.content &&
|
|
Array.isArray(input.content.parts)
|
|
? input.content.parts
|
|
.filter(
|
|
(part): part is { type: "text"; text: string } =>
|
|
typeof part === "object" &&
|
|
part !== null &&
|
|
"type" in part &&
|
|
part.type === "text" &&
|
|
"text" in part &&
|
|
typeof part.text === "string",
|
|
)
|
|
.map((part) => part.text)
|
|
.join(" ")
|
|
.trim()
|
|
.slice(0, 80) || null
|
|
: null);
|
|
|
|
await ctx.db
|
|
.update(assistantThreads)
|
|
.set({ title, updatedAt: new Date() })
|
|
.where(eq(assistantThreads.id, thread.id));
|
|
}),
|
|
|
|
submitFeedback: protectedProcedure
|
|
.input(
|
|
z.object({
|
|
threadId: z.string().max(64),
|
|
messageId: z.string().min(1).max(128),
|
|
type: z.enum(["positive", "negative"]),
|
|
}),
|
|
)
|
|
.mutation(async ({ ctx, input }) => {
|
|
const thread = await ownedThreadAsync(ctx.db, input.threadId, ctx.session.user.id);
|
|
await handleTransactionsAsync(ctx.db, {
|
|
handleAsync: async (db, schema) => {
|
|
await db.transaction(async (transaction) => {
|
|
const table = schema.assistantMessages;
|
|
const [message] = await transaction
|
|
.select({ content: table.content })
|
|
.from(table)
|
|
.where(and(eq(table.id, input.messageId), eq(table.threadId, thread.id)))
|
|
.for("update");
|
|
if (!message) throw new TRPCError({ code: "NOT_FOUND", message: "Message not found." });
|
|
await transaction
|
|
.update(table)
|
|
.set({ content: addFeedbackToMessageContent(message.content, input.type) })
|
|
.where(and(eq(table.id, input.messageId), eq(table.threadId, thread.id)));
|
|
});
|
|
},
|
|
handleSync: (db) => {
|
|
db.transaction((transaction) => {
|
|
const message = transaction
|
|
.select({ content: assistantMessages.content })
|
|
.from(assistantMessages)
|
|
.where(and(eq(assistantMessages.id, input.messageId), eq(assistantMessages.threadId, thread.id)))
|
|
.get();
|
|
if (!message) throw new TRPCError({ code: "NOT_FOUND", message: "Message not found." });
|
|
transaction
|
|
.update(assistantMessages)
|
|
.set({ content: addFeedbackToMessageContent(message.content, input.type) })
|
|
.where(and(eq(assistantMessages.id, input.messageId), eq(assistantMessages.threadId, thread.id)))
|
|
.run();
|
|
});
|
|
},
|
|
});
|
|
}),
|
|
|
|
deleteMessages: protectedProcedure
|
|
.input(z.object({ threadId: z.string().max(64), ids: z.array(z.string().max(128)).max(100) }))
|
|
.mutation(async ({ ctx, input }) => {
|
|
await ownedThreadAsync(ctx.db, input.threadId, ctx.session.user.id);
|
|
if (input.ids.length === 0) return;
|
|
await ctx.db
|
|
.delete(assistantMessages)
|
|
.where(and(eq(assistantMessages.threadId, input.threadId), inArray(assistantMessages.id, input.ids)));
|
|
await ctx.db
|
|
.update(assistantThreads)
|
|
.set({ updatedAt: new Date() })
|
|
.where(eq(assistantThreads.id, input.threadId));
|
|
}),
|
|
});
|
|
|
|
export { fetchModelsAsync, getConfigurationAsync, ownedThreadAsync };
|