fix: harden assistant integration
This commit is contained in:
1 parent
00e8790ec2
commit
36ae9f93ee
17 files changed
+321
-160
No files matched your search
@@ -1,3 +1,5 @@
|
||||
import { hkdfSync } from "node:crypto";
|
||||
|
||||
import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
|
||||
import type { ToolSet, UIMessage } from "ai";
|
||||
import { convertToModelMessages, jsonSchema, stepCountIs, streamText, tool } from "ai";
|
||||
@@ -13,11 +15,17 @@ import { and, eq } from "@homarr/db";
|
||||
import { db } from "@homarr/db";
|
||||
import { assistantConfigurations, assistantThreads } from "@homarr/db/schema";
|
||||
|
||||
import { browserToolContracts } from "~/components/assistant/assistant-tool-contracts";
|
||||
|
||||
import { extractMcpTools } from "../../mcp/_extract-tools";
|
||||
|
||||
export const maxDuration = 60;
|
||||
|
||||
const logger = createLogger({ module: "assistant" });
|
||||
const toolApprovalSecret = Buffer.from(
|
||||
hkdfSync("sha256", Buffer.from(env.SECRET_ENCRYPTION_KEY, "hex"), "", "assistant-tool-approval", 32),
|
||||
).toString("base64url");
|
||||
const safeStreamError = "The model endpoint stopped the response. Check its URL, model, and credentials.";
|
||||
|
||||
const requestSchema = z.object({
|
||||
id: z.string().min(1).max(64),
|
||||
@@ -45,26 +53,6 @@ const requestSchema = z.object({
|
||||
.optional(),
|
||||
});
|
||||
|
||||
const browserToolDefinitions = {
|
||||
navigate_to_route: {
|
||||
description: "Navigate the current Homarr tab to a safe internal route.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: { path: { type: "string", description: "An internal Homarr path beginning with /" } },
|
||||
required: ["path"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
open_command_menu: {
|
||||
description: "Open Homarr's command and search menu.",
|
||||
parameters: { type: "object", properties: {}, additionalProperties: false },
|
||||
},
|
||||
open_media_request_search: {
|
||||
description: "Open Homarr's media request search interface.",
|
||||
parameters: { type: "object", properties: {}, additionalProperties: false },
|
||||
},
|
||||
} as const;
|
||||
|
||||
const assistantInstructions = `You are Homarr Assistant, embedded in the user's self-hosted Homarr dashboard.
|
||||
|
||||
Use the available Homarr tools whenever live instance data is needed. Never invent integrations, boards, apps, users, media, system status, or action results.
|
||||
@@ -164,11 +152,10 @@ export async function POST(request: Request) {
|
||||
const context = createTRPCContext({ headers: request.headers, session });
|
||||
const caller = mcpRouter.createCaller(context);
|
||||
const procedureTypes = getProcedureTypeMap();
|
||||
const mcpTools = extractMcpTools();
|
||||
|
||||
const homarrTools = Object.fromEntries(
|
||||
extractMcpTools().map((mcpTool) => {
|
||||
const procedurePath = mcpTool.pathInRouter.join(".");
|
||||
const isMutation = procedureTypes.get(procedurePath) === "mutation";
|
||||
mcpTools.map((mcpTool) => {
|
||||
return [
|
||||
mcpTool.name,
|
||||
tool({
|
||||
@@ -176,7 +163,6 @@ export async function POST(request: Request) {
|
||||
inputSchema: jsonSchema(
|
||||
(mcpTool.inputSchema ?? { type: "object", properties: {} }) as Parameters<typeof jsonSchema>[0],
|
||||
),
|
||||
needsApproval: isMutation,
|
||||
execute: async (input) => {
|
||||
try {
|
||||
const procedure = mcpTool.pathInRouter.reduce<unknown>(
|
||||
@@ -205,17 +191,24 @@ export async function POST(request: Request) {
|
||||
];
|
||||
}),
|
||||
) satisfies ToolSet;
|
||||
const toolApproval = Object.fromEntries(
|
||||
mcpTools.flatMap((mcpTool) =>
|
||||
procedureTypes.get(mcpTool.pathInRouter.join(".")) === "mutation"
|
||||
? [[mcpTool.name, "user-approval" as const]]
|
||||
: [],
|
||||
),
|
||||
);
|
||||
|
||||
const frontendTools = Object.fromEntries(
|
||||
Object.keys(parsed.data.tools ?? {}).flatMap((name) => {
|
||||
if (!(name in browserToolDefinitions)) return [];
|
||||
const definition = browserToolDefinitions[name as keyof typeof browserToolDefinitions];
|
||||
if (!(name in browserToolContracts)) return [];
|
||||
const definition = browserToolContracts[name as keyof typeof browserToolContracts];
|
||||
return [
|
||||
[
|
||||
name,
|
||||
tool({
|
||||
description: definition.description,
|
||||
inputSchema: jsonSchema(definition.parameters as Parameters<typeof jsonSchema>[0]),
|
||||
inputSchema: jsonSchema(z.toJSONSchema(definition.parameters) as Parameters<typeof jsonSchema>[0]),
|
||||
}),
|
||||
] as const,
|
||||
];
|
||||
@@ -249,16 +242,29 @@ export async function POST(request: Request) {
|
||||
abortSignal: request.signal,
|
||||
timeout: { totalMs: 55_000, stepMs: 30_000, toolMs: 30_000 },
|
||||
maxRetries: 2,
|
||||
experimental_toolApprovalSecret: env.SECRET_ENCRYPTION_KEY,
|
||||
toolApproval,
|
||||
experimental_toolApprovalSecret: toolApprovalSecret,
|
||||
onError: ({ error }) => {
|
||||
logger.error("Assistant response stream failed", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
},
|
||||
onFinish: async () => {
|
||||
await db
|
||||
.update(assistantThreads)
|
||||
.set({ modelId: configuration.modelId, updatedAt: new Date() })
|
||||
.where(eq(assistantThreads.id, thread.id));
|
||||
try {
|
||||
await db
|
||||
.update(assistantThreads)
|
||||
.set({ modelId: configuration.modelId, updatedAt: new Date() })
|
||||
.where(eq(assistantThreads.id, thread.id));
|
||||
} catch (error) {
|
||||
logger.error("Failed to update assistant conversation metadata", {
|
||||
threadId: thread.id,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return result.toUIMessageStreamResponse();
|
||||
return result.toUIMessageStreamResponse({ onError: () => safeStreamError });
|
||||
} catch {
|
||||
return Response.json(
|
||||
{ error: "The configured model endpoint could not start this response. Check its URL, model, and credentials." },
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
.drawer {
|
||||
--assistant-panel-width: min(32rem, 100vw);
|
||||
}
|
||||
|
||||
.content {
|
||||
height: 100dvh;
|
||||
background: var(--mantine-color-body);
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
useAuiState,
|
||||
} from "@assistant-ui/react";
|
||||
import { ActionIcon, Box, Button, Drawer, Group, ScrollArea, Stack, Text, ThemeIcon, Tooltip } from "@mantine/core";
|
||||
import { useMediaQuery } from "@mantine/hooks";
|
||||
import {
|
||||
IconArchive,
|
||||
IconArrowUp,
|
||||
@@ -206,8 +207,17 @@ const ArchivedThreadListItem = () => {
|
||||
|
||||
const ThreadHistory = ({ opened, onClose }: { opened: boolean; onClose: () => void }) => {
|
||||
const t = useScopedI18n("common.assistant");
|
||||
const isMobile = useMediaQuery("(max-width: 48em)");
|
||||
const hidden = isMobile && !opened;
|
||||
return (
|
||||
<Stack className={classes.history} gap="xs" p="sm" data-opened={opened}>
|
||||
<Stack
|
||||
className={classes.history}
|
||||
gap="xs"
|
||||
p="sm"
|
||||
data-opened={opened}
|
||||
inert={hidden ? true : undefined}
|
||||
aria-hidden={hidden || undefined}
|
||||
>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<ThreadListPrimitive.New asChild>
|
||||
<Button variant="light" leftSection={<IconPlus size={16} />} fullWidth onClick={onClose}>
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { createContext, useCallback, useContext, useMemo, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useChat } from "@ai-sdk/react";
|
||||
import type { MessageFormatAdapter, RemoteThreadListAdapter, ThreadHistoryAdapter, Toolkit } from "@assistant-ui/react";
|
||||
import {
|
||||
AssistantRuntimeProvider,
|
||||
@@ -13,12 +12,11 @@ import {
|
||||
useAuiState,
|
||||
useRemoteThreadListRuntime,
|
||||
} from "@assistant-ui/react";
|
||||
import { AssistantChatTransport, useAISDKRuntime } from "@assistant-ui/react-ai-sdk";
|
||||
import { AssistantChatTransport, useChatRuntime } from "@assistant-ui/react-ai-sdk";
|
||||
import { useHotkeys } from "@mantine/hooks";
|
||||
import { createAssistantStream } from "assistant-stream";
|
||||
import type { UIMessage } from "ai";
|
||||
import { lastAssistantMessageIsCompleteWithApprovalResponses } from "ai";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { clientApi, fetchApi } from "@homarr/api/client";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
@@ -32,6 +30,7 @@ import {
|
||||
} from "@homarr/spotlight";
|
||||
|
||||
import { AssistantPanel } from "./assistant-panel";
|
||||
import { browserToolContracts } from "./assistant-tool-contracts";
|
||||
|
||||
interface AssistantContextValue {
|
||||
enabled: boolean;
|
||||
@@ -178,22 +177,15 @@ const createHistoryAdapter = (threadId: string | undefined): ThreadHistoryAdapte
|
||||
});
|
||||
|
||||
const AssistantThreadRuntime = () => {
|
||||
const localThreadId = useAuiState((state) => state.threadListItem.id);
|
||||
const threadId = useAuiState((state) => state.threadListItem.remoteId);
|
||||
const aui = useAui();
|
||||
const transport = useMemo(() => new AssistantChatTransport({ api: "/api/assistant/chat" }), []);
|
||||
const chat = useChat<UIMessage>({
|
||||
id: localThreadId,
|
||||
const history = useMemo(() => createHistoryAdapter(threadId), [threadId]);
|
||||
|
||||
return useChatRuntime<UIMessage>({
|
||||
transport,
|
||||
sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithApprovalResponses,
|
||||
adapters: { history },
|
||||
});
|
||||
const history = useMemo(() => createHistoryAdapter(threadId), [threadId]);
|
||||
const runtime = useAISDKRuntime(chat, { adapters: { history } });
|
||||
|
||||
transport.setRuntime(runtime);
|
||||
transport["__internal_setGetThreadListItem"](() => (aui.threadListItem.source ? aui.threadListItem() : undefined));
|
||||
|
||||
return runtime;
|
||||
};
|
||||
|
||||
const AssistantRuntime = ({ children }: PropsWithChildren) => {
|
||||
@@ -208,17 +200,20 @@ const AssistantRuntime = ({ children }: PropsWithChildren) => {
|
||||
defineToolkit({
|
||||
navigate_to_route: {
|
||||
type: "frontend",
|
||||
description:
|
||||
"Navigate the current Homarr tab to a safe internal route. Only paths beginning with a single slash are accepted.",
|
||||
parameters: z.object({
|
||||
path: z.string().describe("An internal Homarr route, for example /manage/apps"),
|
||||
}),
|
||||
...browserToolContracts.navigate_to_route,
|
||||
execute: async ({ path }) => {
|
||||
if (!path.startsWith("/") || path.startsWith("//")) {
|
||||
const target = URL.canParse(path, window.location.origin) ? new URL(path, window.location.origin) : null;
|
||||
if (
|
||||
!path.startsWith("/") ||
|
||||
path.startsWith("/\\") ||
|
||||
target === null ||
|
||||
target.origin !== window.location.origin
|
||||
) {
|
||||
return { success: false, error: "Only internal Homarr paths are allowed." };
|
||||
}
|
||||
router.push(path);
|
||||
return { success: true, path };
|
||||
const internalPath = `${target.pathname}${target.search}${target.hash}`;
|
||||
router.push(internalPath);
|
||||
return { success: true, path: internalPath };
|
||||
},
|
||||
renderText: {
|
||||
running: ({ args }) => `Opening ${args.path}…`,
|
||||
@@ -227,8 +222,7 @@ const AssistantRuntime = ({ children }: PropsWithChildren) => {
|
||||
},
|
||||
open_command_menu: {
|
||||
type: "frontend",
|
||||
description: "Open Homarr's command and search menu.",
|
||||
parameters: z.object({}),
|
||||
...browserToolContracts.open_command_menu,
|
||||
execute: async () => {
|
||||
openSpotlight();
|
||||
return { success: true };
|
||||
@@ -237,8 +231,7 @@ const AssistantRuntime = ({ children }: PropsWithChildren) => {
|
||||
},
|
||||
open_media_request_search: {
|
||||
type: "frontend",
|
||||
description: "Open Homarr's media request search interface.",
|
||||
parameters: z.object({}),
|
||||
...browserToolContracts.open_media_request_search,
|
||||
execute: async () => {
|
||||
openMediaRequestSearch();
|
||||
return { success: true };
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
export const browserToolContracts = {
|
||||
navigate_to_route: {
|
||||
description:
|
||||
"Navigate the current Homarr tab to a safe internal route. Only same-origin paths beginning with a single slash are accepted.",
|
||||
parameters: z.object({
|
||||
path: z.string().describe("An internal Homarr route, for example /manage/apps"),
|
||||
}),
|
||||
},
|
||||
open_command_menu: {
|
||||
description: "Open Homarr's command and search menu.",
|
||||
parameters: z.object({}),
|
||||
},
|
||||
open_media_request_search: {
|
||||
description: "Open Homarr's media request search interface.",
|
||||
parameters: z.object({}),
|
||||
},
|
||||
} as const;
|
||||
@@ -195,15 +195,22 @@ const ownedThreadAsync = async (db: Database, threadId: string, userId: string)
|
||||
};
|
||||
|
||||
export const assistantRouter = createTRPCRouter({
|
||||
getAvailability: protectedProcedure.query(async ({ ctx }) => {
|
||||
const configuration = await getConfigurationAsync(ctx.db);
|
||||
const requiresApiKey = configuration?.provider === "openrouter" || configuration?.provider === "openai";
|
||||
return {
|
||||
enabled: Boolean(
|
||||
configuration?.enabled && configuration.modelId && (!requiresApiKey || configuration.encryptedApiKey),
|
||||
),
|
||||
};
|
||||
}),
|
||||
getAvailability: protectedProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
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 requiresApiKey = configuration?.provider === "openrouter" || configuration?.provider === "openai";
|
||||
return {
|
||||
enabled: Boolean(
|
||||
configuration?.enabled && configuration.modelId && (!requiresApiKey || configuration.encryptedApiKey),
|
||||
),
|
||||
};
|
||||
}),
|
||||
|
||||
getAdminConfiguration: adminProcedure.query(async ({ ctx }) => {
|
||||
const configuration = await getConfigurationAsync(ctx.db);
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { expect, test, vi } from "vitest";
|
||||
import { extractToolsFromProcedures } from "trpc-to-mcp";
|
||||
|
||||
import { assistantRouter } from "../router/assistant";
|
||||
import { appRouter } from "../router/app";
|
||||
import { boardRouter } from "../router/board";
|
||||
import { infoRouter } from "../router/info";
|
||||
@@ -9,8 +12,21 @@ import { serverSettingsRouter } from "../router/serverSettings";
|
||||
import { createTRPCRouter } from "../trpc";
|
||||
|
||||
vi.mock("@homarr/auth", () => ({}));
|
||||
vi.mock("@homarr/common/server", () => ({
|
||||
decryptSecret: vi.fn(),
|
||||
encryptSecret: vi.fn(),
|
||||
}));
|
||||
vi.mock("@homarr/core/infrastructure/logs", () => ({
|
||||
createLogger: () => ({
|
||||
error: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
vi.mock("@homarr/core/infrastructure/db/env", () => ({
|
||||
dbEnv: { DRIVER: "better-sqlite3" },
|
||||
}));
|
||||
|
||||
const mcpTestRouter = createTRPCRouter({
|
||||
assistant: assistantRouter,
|
||||
app: appRouter,
|
||||
board: boardRouter,
|
||||
info: infoRouter,
|
||||
@@ -29,6 +45,7 @@ test("MCP tools should contain expected procedures", () => {
|
||||
const toolNames = tools.map((tool) => tool.name);
|
||||
|
||||
expect(tools.length).toBeGreaterThan(0);
|
||||
expect(toolNames).toContain("assistant_getAvailability");
|
||||
expect(toolNames).toContain("app_all");
|
||||
expect(toolNames).toContain("app_byId");
|
||||
expect(toolNames).toContain("app_create");
|
||||
|
||||
@@ -6,6 +6,8 @@ import { dbEnv } from "@homarr/core/infrastructure/db/env";
|
||||
import type { HomarrDatabase, HomarrDatabaseMysql, HomarrDatabasePostgresql } from "./driver";
|
||||
import * as schema from "./schema";
|
||||
|
||||
const schemaTables = { ...schema };
|
||||
|
||||
type TableKey = {
|
||||
[K in keyof typeof schema]: (typeof schema)[K] extends { _: { brand: "Table" } } ? K : never;
|
||||
}[keyof typeof schema];
|
||||
@@ -36,7 +38,7 @@ export const createDbInsertCollectionForTransaction = <TTableKey extends TableKe
|
||||
for (const [key, values] of objectEntries(context)) {
|
||||
if (values.length >= 1) {
|
||||
transaction
|
||||
.insert(schema[key])
|
||||
.insert(schemaTables[key])
|
||||
.values(values as never)
|
||||
.run();
|
||||
}
|
||||
@@ -50,7 +52,7 @@ export const createDbInsertCollectionForTransaction = <TTableKey extends TableKe
|
||||
for (const [key, values] of objectEntries(context)) {
|
||||
if (values.length >= 1) {
|
||||
// Below is actually the mysqlSchema when the driver is mysql
|
||||
await transaction.insert(schema[key] as never).values(values as never);
|
||||
await transaction.insert(schemaTables[key] as never).values(values as never);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -23,9 +23,9 @@ CREATE TABLE `assistant_thread` (
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `assistant_message` (
|
||||
`id` varchar(64) NOT NULL,
|
||||
`id` varchar(128) NOT NULL,
|
||||
`thread_id` varchar(64) NOT NULL,
|
||||
`parent_id` varchar(64),
|
||||
`parent_id` varchar(128),
|
||||
`format` varchar(64) DEFAULT 'ai-sdk/v6' NOT NULL,
|
||||
`content` text DEFAULT ('{"json": {}}') NOT NULL,
|
||||
`created_at` timestamp DEFAULT (now()) NOT NULL,
|
||||
@@ -35,3 +35,7 @@ CREATE TABLE `assistant_message` (
|
||||
ALTER TABLE `assistant_thread` ADD CONSTRAINT `assistant_thread_user_id_user_id_fk` FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON DELETE cascade ON UPDATE no action;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `assistant_message` ADD CONSTRAINT `assistant_message_thread_id_assistant_thread_id_fk` FOREIGN KEY (`thread_id`) REFERENCES `assistant_thread`(`id`) ON DELETE cascade ON UPDATE no action;
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `assistant_message__thread_id_created_at_idx` ON `assistant_message` (`thread_id`,`created_at`);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `assistant_thread__user_id_updated_at_idx` ON `assistant_thread` (`user_id`,`updated_at`);
|
||||
@@ -11,9 +11,9 @@ CREATE TABLE "assistant_configuration" (
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "assistant_message" (
|
||||
"id" varchar(64) PRIMARY KEY NOT NULL,
|
||||
"id" varchar(128) PRIMARY KEY NOT NULL,
|
||||
"thread_id" varchar(64) NOT NULL,
|
||||
"parent_id" varchar(64),
|
||||
"parent_id" varchar(128),
|
||||
"format" varchar(64) DEFAULT 'ai-sdk/v6' NOT NULL,
|
||||
"content" text DEFAULT '{"json": {}}' NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL
|
||||
@@ -30,4 +30,6 @@ CREATE TABLE "assistant_thread" (
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "assistant_message" ADD CONSTRAINT "assistant_message_thread_id_assistant_thread_id_fk" FOREIGN KEY ("thread_id") REFERENCES "public"."assistant_thread"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "assistant_thread" ADD CONSTRAINT "assistant_thread_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "assistant_thread" ADD CONSTRAINT "assistant_thread_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "assistant_message__thread_id_created_at_idx" ON "assistant_message" USING btree ("thread_id","created_at");--> statement-breakpoint
|
||||
CREATE INDEX "assistant_thread__user_id_updated_at_idx" ON "assistant_thread" USING btree ("user_id","updated_at");
|
||||
@@ -294,7 +294,7 @@
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "varchar(64)",
|
||||
"type": "varchar(128)",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
@@ -306,7 +306,7 @@
|
||||
},
|
||||
"parent_id": {
|
||||
"name": "parent_id",
|
||||
"type": "varchar(64)",
|
||||
"type": "varchar(128)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
@@ -332,7 +332,29 @@
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"indexes": {
|
||||
"assistant_message__thread_id_created_at_idx": {
|
||||
"name": "assistant_message__thread_id_created_at_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "thread_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "created_at",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"assistant_message_thread_id_assistant_thread_id_fk": {
|
||||
"name": "assistant_message_thread_id_assistant_thread_id_fk",
|
||||
@@ -404,7 +426,29 @@
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"indexes": {
|
||||
"assistant_thread__user_id_updated_at_idx": {
|
||||
"name": "assistant_thread__user_id_updated_at_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "user_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "updated_at",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"assistant_thread_user_id_user_id_fk": {
|
||||
"name": "assistant_thread_user_id_user_id_fk",
|
||||
@@ -2687,4 +2731,4 @@
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -30,3 +30,7 @@ CREATE TABLE `assistant_thread` (
|
||||
`updated_at` integer DEFAULT (unixepoch()) NOT NULL,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `assistant_message__thread_id_created_at_idx` ON `assistant_message` (`thread_id`,`created_at`);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `assistant_thread__user_id_updated_at_idx` ON `assistant_thread` (`user_id`,`updated_at`);
|
||||
@@ -346,7 +346,16 @@
|
||||
"default": "(unixepoch())"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"indexes": {
|
||||
"assistant_message__thread_id_created_at_idx": {
|
||||
"name": "assistant_message__thread_id_created_at_idx",
|
||||
"columns": [
|
||||
"thread_id",
|
||||
"created_at"
|
||||
],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"assistant_message_thread_id_assistant_thread_id_fk": {
|
||||
"name": "assistant_message_thread_id_assistant_thread_id_fk",
|
||||
@@ -422,7 +431,16 @@
|
||||
"default": "(unixepoch())"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"indexes": {
|
||||
"assistant_thread__user_id_updated_at_idx": {
|
||||
"name": "assistant_thread__user_id_updated_at_idx",
|
||||
"columns": [
|
||||
"user_id",
|
||||
"updated_at"
|
||||
],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"assistant_thread_user_id_user_id_fk": {
|
||||
"name": "assistant_thread_user_id_user_id_fk",
|
||||
@@ -2744,4 +2762,4 @@
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
+33
-21
@@ -496,28 +496,40 @@ export const assistantConfigurations = mysqlTable("assistant_configuration", {
|
||||
updatedAt: timestamp().notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export const assistantThreads = mysqlTable("assistant_thread", {
|
||||
id: varchar({ length: 64 }).notNull().primaryKey(),
|
||||
userId: varchar({ length: 64 })
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
title: varchar({ length: 256 }),
|
||||
modelId: varchar({ length: 256 }),
|
||||
status: varchar({ length: 16 }).$type<"regular" | "archived">().notNull().default("regular"),
|
||||
createdAt: timestamp().notNull().defaultNow(),
|
||||
updatedAt: timestamp().notNull().defaultNow(),
|
||||
});
|
||||
export const assistantThreads = mysqlTable(
|
||||
"assistant_thread",
|
||||
{
|
||||
id: varchar({ length: 64 }).notNull().primaryKey(),
|
||||
userId: varchar({ length: 64 })
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
title: varchar({ length: 256 }),
|
||||
modelId: varchar({ length: 256 }),
|
||||
status: varchar({ length: 16 }).$type<"regular" | "archived">().notNull().default("regular"),
|
||||
createdAt: timestamp().notNull().defaultNow(),
|
||||
updatedAt: timestamp().notNull().defaultNow(),
|
||||
},
|
||||
(thread) => ({
|
||||
userUpdatedAtIdx: index("assistant_thread__user_id_updated_at_idx").on(thread.userId, thread.updatedAt),
|
||||
}),
|
||||
);
|
||||
|
||||
export const assistantMessages = mysqlTable("assistant_message", {
|
||||
id: varchar({ length: 64 }).notNull().primaryKey(),
|
||||
threadId: varchar({ length: 64 })
|
||||
.notNull()
|
||||
.references(() => assistantThreads.id, { onDelete: "cascade" }),
|
||||
parentId: varchar({ length: 64 }),
|
||||
format: varchar({ length: 64 }).notNull().default("ai-sdk/v6"),
|
||||
content: text().default(emptySuperJSON).notNull(),
|
||||
createdAt: timestamp().notNull().defaultNow(),
|
||||
});
|
||||
export const assistantMessages = mysqlTable(
|
||||
"assistant_message",
|
||||
{
|
||||
id: varchar({ length: 128 }).notNull().primaryKey(),
|
||||
threadId: varchar({ length: 64 })
|
||||
.notNull()
|
||||
.references(() => assistantThreads.id, { onDelete: "cascade" }),
|
||||
parentId: varchar({ length: 128 }),
|
||||
format: varchar({ length: 64 }).notNull().default("ai-sdk/v6"),
|
||||
content: text().default(emptySuperJSON).notNull(),
|
||||
createdAt: timestamp().notNull().defaultNow(),
|
||||
},
|
||||
(message) => ({
|
||||
threadCreatedAtIdx: index("assistant_message__thread_id_created_at_idx").on(message.threadId, message.createdAt),
|
||||
}),
|
||||
);
|
||||
|
||||
export const apiKeyRelations = relations(apiKeys, ({ one }) => ({
|
||||
user: one(users, {
|
||||
|
||||
@@ -495,28 +495,40 @@ export const assistantConfigurations = pgTable("assistant_configuration", {
|
||||
updatedAt: timestamp().notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export const assistantThreads = pgTable("assistant_thread", {
|
||||
id: varchar({ length: 64 }).notNull().primaryKey(),
|
||||
userId: varchar({ length: 64 })
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
title: varchar({ length: 256 }),
|
||||
modelId: varchar({ length: 256 }),
|
||||
status: varchar({ length: 16 }).$type<"regular" | "archived">().notNull().default("regular"),
|
||||
createdAt: timestamp().notNull().defaultNow(),
|
||||
updatedAt: timestamp().notNull().defaultNow(),
|
||||
});
|
||||
export const assistantThreads = pgTable(
|
||||
"assistant_thread",
|
||||
{
|
||||
id: varchar({ length: 64 }).notNull().primaryKey(),
|
||||
userId: varchar({ length: 64 })
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
title: varchar({ length: 256 }),
|
||||
modelId: varchar({ length: 256 }),
|
||||
status: varchar({ length: 16 }).$type<"regular" | "archived">().notNull().default("regular"),
|
||||
createdAt: timestamp().notNull().defaultNow(),
|
||||
updatedAt: timestamp().notNull().defaultNow(),
|
||||
},
|
||||
(thread) => ({
|
||||
userUpdatedAtIdx: index("assistant_thread__user_id_updated_at_idx").on(thread.userId, thread.updatedAt),
|
||||
}),
|
||||
);
|
||||
|
||||
export const assistantMessages = pgTable("assistant_message", {
|
||||
id: varchar({ length: 64 }).notNull().primaryKey(),
|
||||
threadId: varchar({ length: 64 })
|
||||
.notNull()
|
||||
.references(() => assistantThreads.id, { onDelete: "cascade" }),
|
||||
parentId: varchar({ length: 64 }),
|
||||
format: varchar({ length: 64 }).notNull().default("ai-sdk/v6"),
|
||||
content: text().default(emptySuperJSON).notNull(),
|
||||
createdAt: timestamp().notNull().defaultNow(),
|
||||
});
|
||||
export const assistantMessages = pgTable(
|
||||
"assistant_message",
|
||||
{
|
||||
id: varchar({ length: 128 }).notNull().primaryKey(),
|
||||
threadId: varchar({ length: 64 })
|
||||
.notNull()
|
||||
.references(() => assistantThreads.id, { onDelete: "cascade" }),
|
||||
parentId: varchar({ length: 128 }),
|
||||
format: varchar({ length: 64 }).notNull().default("ai-sdk/v6"),
|
||||
content: text().default(emptySuperJSON).notNull(),
|
||||
createdAt: timestamp().notNull().defaultNow(),
|
||||
},
|
||||
(message) => ({
|
||||
threadCreatedAtIdx: index("assistant_message__thread_id_created_at_idx").on(message.threadId, message.createdAt),
|
||||
}),
|
||||
);
|
||||
|
||||
export const apiKeyRelations = relations(apiKeys, ({ one }) => ({
|
||||
user: one(users, {
|
||||
|
||||
@@ -480,34 +480,46 @@ export const assistantConfigurations = sqliteTable("assistant_configuration", {
|
||||
.default(sql`(unixepoch())`),
|
||||
});
|
||||
|
||||
export const assistantThreads = sqliteTable("assistant_thread", {
|
||||
id: text().notNull().primaryKey(),
|
||||
userId: text()
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
title: text(),
|
||||
modelId: text(),
|
||||
status: text().$type<"regular" | "archived">().notNull().default("regular"),
|
||||
createdAt: int({ mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
updatedAt: int({ mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
});
|
||||
export const assistantThreads = sqliteTable(
|
||||
"assistant_thread",
|
||||
{
|
||||
id: text().notNull().primaryKey(),
|
||||
userId: text()
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
title: text(),
|
||||
modelId: text(),
|
||||
status: text().$type<"regular" | "archived">().notNull().default("regular"),
|
||||
createdAt: int({ mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
updatedAt: int({ mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
},
|
||||
(thread) => ({
|
||||
userUpdatedAtIdx: index("assistant_thread__user_id_updated_at_idx").on(thread.userId, thread.updatedAt),
|
||||
}),
|
||||
);
|
||||
|
||||
export const assistantMessages = sqliteTable("assistant_message", {
|
||||
id: text().notNull().primaryKey(),
|
||||
threadId: text()
|
||||
.notNull()
|
||||
.references(() => assistantThreads.id, { onDelete: "cascade" }),
|
||||
parentId: text(),
|
||||
format: text().notNull().default("ai-sdk/v6"),
|
||||
content: text().default(emptySuperJSON).notNull(),
|
||||
createdAt: int({ mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
});
|
||||
export const assistantMessages = sqliteTable(
|
||||
"assistant_message",
|
||||
{
|
||||
id: text().notNull().primaryKey(),
|
||||
threadId: text()
|
||||
.notNull()
|
||||
.references(() => assistantThreads.id, { onDelete: "cascade" }),
|
||||
parentId: text(),
|
||||
format: text().notNull().default("ai-sdk/v6"),
|
||||
content: text().default(emptySuperJSON).notNull(),
|
||||
createdAt: int({ mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
},
|
||||
(message) => ({
|
||||
threadCreatedAtIdx: index("assistant_message__thread_id_created_at_idx").on(message.threadId, message.createdAt),
|
||||
}),
|
||||
);
|
||||
|
||||
export const apiKeyRelations = relations(apiKeys, ({ one }) => ({
|
||||
user: one(users, {
|
||||
|
||||
@@ -11,6 +11,9 @@ import * as mysqlSchema from "../schema/mysql";
|
||||
import * as postgresqlSchema from "../schema/postgresql";
|
||||
import * as sqliteSchema from "../schema/sqlite";
|
||||
|
||||
const mysqlTables = { ...mysqlSchema };
|
||||
const postgresqlTables = { ...postgresqlSchema };
|
||||
|
||||
// We need the following three types as there is currently no support for Buffer in mysql & pg and
|
||||
// so we use a custom type which results in the config beeing different
|
||||
type FixedMysqlConfig = {
|
||||
@@ -63,7 +66,7 @@ test("schemas should match", () => {
|
||||
if (!("uniqueName" in sqliteColumn)) return;
|
||||
if (!("primary" in sqliteColumn)) return;
|
||||
|
||||
const mysqlTable = mysqlSchema[tableName];
|
||||
const mysqlTable = mysqlTables[tableName];
|
||||
|
||||
const mysqlColumn = mysqlTable[columnName as keyof typeof mysqlTable] as object;
|
||||
if (!("isUnique" in mysqlColumn)) return;
|
||||
@@ -84,7 +87,7 @@ test("schemas should match", () => {
|
||||
).toEqual(mysqlColumn.primary);
|
||||
});
|
||||
|
||||
const mysqlTable = mysqlSchema[tableName];
|
||||
const mysqlTable = mysqlTables[tableName];
|
||||
const sqliteForeignKeys = sqliteTable[Symbol.for("drizzle:SQLiteInlineForeignKeys") as keyof typeof sqliteTable] as
|
||||
| SqliteForeignKey[]
|
||||
| undefined;
|
||||
@@ -144,7 +147,7 @@ test("schemas should match for postgresql", () => {
|
||||
objectEntries(sqliteSchema).forEach(([tableName, sqliteTable]) => {
|
||||
// keys of sqliteSchema and postgresqlSchema are the same, so we can safely use tableName as key
|
||||
// skipcq: JS-E1007
|
||||
const postgresqlTable = postgresqlSchema[tableName];
|
||||
const postgresqlTable = postgresqlTables[tableName];
|
||||
Object.entries(sqliteTable).forEach(([columnName, sqliteColumn]: [string, object]) => {
|
||||
if (!("isUnique" in sqliteColumn)) return;
|
||||
if (!("uniqueName" in sqliteColumn)) return;
|
||||
|
||||
Reference in new issue
Block a user