diff --git a/drizzle.config.ts b/drizzle.config.ts new file mode 100644 index 0000000000000..aa9f64f084b55 --- /dev/null +++ b/drizzle.config.ts @@ -0,0 +1,29 @@ +import * as dotenv from 'dotenv'; +import type { Config } from 'drizzle-kit'; + +// Read the .env file if it exists, or a file specified by the + +// dotenv_config_path parameter that's passed to Node.js + +dotenv.config(); + +let connectionString = process.env.DATABASE_URL; + +if (process.env.NODE_ENV === 'test') { + console.log('current ENV:', process.env.NODE_ENV); + connectionString = process.env.DATABASE_TEST_URL; +} + +if (!connectionString) + throw new Error('`DATABASE_URL` or `DATABASE_TEST_URL` not found in environment'); + +export default { + dbCredentials: { + url: connectionString, + }, + dialect: 'postgresql', + out: './src/database/server/migrations', + + schema: './src/database/server/schemas/lobechat.ts', + strict: true, +} satisfies Config; diff --git a/next.config.mjs b/next.config.mjs index 8914bda079885..cf73096cff5fe 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -60,6 +60,9 @@ const nextConfig = { }, }); + // https://github.com/pinojs/pino/issues/688#issuecomment-637763276 + config.externals.push('pino-pretty'); + return config; }, }; diff --git a/package.json b/package.json index 4975d24417b38..3c02f08ba7cf1 100644 --- a/package.json +++ b/package.json @@ -27,10 +27,17 @@ "sideEffects": false, "scripts": { "build": "next build", - "postbuild": "npm run build-sitemap", + "postbuild": "npm run build-sitemap && npm run build-migrate-db", + "build-migrate-db": "bun run db:migrate", "build-sitemap": "next-sitemap --config next-sitemap.config.mjs", "build:analyze": "ANALYZE=true next build", "build:docker": "DOCKER=true next build && npm run build-sitemap", + "db:generate": "drizzle-kit generate -- dotenv_config_path='.env'", + "db:migrate": "MIGRATION_DB=1 tsx scripts/migrateServerDB/index.ts", + "db:push": "drizzle-kit push -- dotenv_config_path='.env'", + "db:push-test": "NODE_ENV=test drizzle-kit push -- dotenv_config_path='.env'", + "db:studio": "drizzle-kit studio", + "db:z-pull": "drizzle-kit introspect -- dotenv_config_path='.env'", "dev": "next dev -p 3010", "dev:clerk-proxy": "ngrok http http://localhost:3011", "docs:i18n": "lobe-i18n md && npm run workflow:docs && npm run lint:mdx", @@ -50,6 +57,7 @@ "stylelint": "stylelint \"src/**/*.{js,jsx,ts,tsx}\" --fix", "test": "vitest", "test:coverage": "vitest run --coverage", + "test:db": "vitest run src/database/server/**", "test:update": "vitest -u", "type-check": "tsc --noEmit", "workflow:docs": "tsx scripts/docsWorkflow/index.ts", @@ -101,6 +109,7 @@ "@lobehub/tts": "^1.24.1", "@lobehub/ui": "^1.141.2", "@microsoft/fetch-event-source": "^2.0.1", + "@neondatabase/serverless": "^0.9.3", "@next/third-parties": "^14.2.3", "@sentry/nextjs": "^7.116.0", "@t3-oss/env-nextjs": "^0.10.1", @@ -119,6 +128,8 @@ "debug": "^4.3.4", "dexie": "^3.2.7", "diff": "^5.2.0", + "drizzle-orm": "^0.30.10", + "drizzle-zod": "^0.5.1", "fast-deep-equal": "^3.1.3", "gpt-tokenizer": "^2.1.2", "i18next": "^23.11.5", @@ -163,6 +174,7 @@ "semver": "^7.6.2", "sharp": "^0.33.4", "superjson": "^2.2.1", + "svix": "^1.24.0", "swr": "^2.2.5", "systemjs": "^6.15.1", "ts-md5": "^1.3.1", @@ -171,6 +183,7 @@ "use-merge-value": "^1.2.0", "utility-types": "^3.11.0", "uuid": "^9.0.1", + "ws": "^8.17.0", "y-protocols": "^1.0.6", "y-webrtc": "^10.3.0", "yaml": "^2.4.2", @@ -207,12 +220,15 @@ "@types/systemjs": "^6.13.5", "@types/ua-parser-js": "^0.7.39", "@types/uuid": "^9.0.8", + "@types/ws": "^8.5.10", "@umijs/lint": "^4.2.5", "@vitest/coverage-v8": "~1.2.2", "ajv-keywords": "^5.1.0", "commitlint": "^19.3.0", "consola": "^3.2.3", + "dotenv": "^16.4.5", "dpdm": "^3.14.0", + "drizzle-kit": "^0.21.1", "eslint": "^8.57.0", "eslint-plugin-mdx": "^2.3.4", "fake-indexeddb": "^6.0.0", @@ -227,6 +243,7 @@ "node-fetch": "^3.3.2", "node-gyp": "^10.1.0", "p-map": "^7.0.2", + "pg": "^8.11.5", "prettier": "^3.2.5", "remark-cli": "^11.0.0", "remark-parse": "^10.0.2", diff --git a/scripts/migrateServerDB/index.ts b/scripts/migrateServerDB/index.ts new file mode 100644 index 0000000000000..50354ca112f60 --- /dev/null +++ b/scripts/migrateServerDB/index.ts @@ -0,0 +1,30 @@ +import * as dotenv from 'dotenv'; +import * as migrator from 'drizzle-orm/neon-serverless/migrator'; +import { join } from 'node:path'; + +import { serverDB } from '../../src/database/server/core/db'; + +// Read the `.env` file if it exists, or a file specified by the +// dotenv_config_path parameter that's passed to Node.js +dotenv.config(); + +const runMigrations = async () => { + await migrator.migrate(serverDB, { + migrationsFolder: join(__dirname, '../../src/database/server/migrations'), + }); + console.log('✅ database migration pass.'); + // eslint-disable-next-line unicorn/no-process-exit + process.exit(0); +}; + +let connectionString = process.env.DATABASE_URL; + +// only migrate database if the connection string is available +if (connectionString) { + // eslint-disable-next-line unicorn/prefer-top-level-await + runMigrations().catch((err) => { + console.error('❌ Database migrate failed:', err); + // eslint-disable-next-line unicorn/no-process-exit + process.exit(1); + }); +} diff --git a/src/app/api/webhooks/clerk/__tests__/fixtures/createUser.json b/src/app/api/webhooks/clerk/__tests__/fixtures/createUser.json new file mode 100644 index 0000000000000..8cc8e66821aeb --- /dev/null +++ b/src/app/api/webhooks/clerk/__tests__/fixtures/createUser.json @@ -0,0 +1,73 @@ +{ + "backup_code_enabled": false, + "banned": false, + "create_organization_enabled": true, + "created_at": 1713709987911, + "delete_self_enabled": true, + "email_addresses": [ + { + "created_at": 1713709977919, + "email_address": "arvinx@foxmail.com", + "id": "idn_2fPkD9X1lfzSn5lJVDGyochYq8k", + "linked_to": [], + "object": "email_address", + "reserved": false, + "updated_at": 1713709987951, + "verification": [] + } + ], + "external_accounts": [ + { + "approved_scopes": "read:user user:email", + "avatar_url": "https://avatars.githubusercontent.com/u/28616219?v=4", + "created_at": 1713709542104, + "email_address": "arvinx@foxmail.com", + "first_name": "Arvin", + "id": "eac_2fPjKROeJ1bBs8Uxa6RFMxKogTB", + "identification_id": "idn_2fPjyV3sqtQJZUbEzdK2y23a1bq", + "image_url": "https://img.clerk.com/eyJ0eXBlIjoicHJveHkiLCJzcmMiOiJodHRwczovL2F2YXRhcnMuZ2l0aHVidXNlcmNvbnRlbnQuY29tL3UvMjg2MTYyMTk/dj00IiwicyI6IkhCeHE5NmdlRk85ekRxMjJlR05EalUrbVFBbmVDZjRVQkpwNGYxcW5JajQifQ", + "label": null, + "last_name": "Xu", + "object": "external_account", + "provider": "oauth_github", + "provider_user_id": "28616219", + "public_metadata": {}, + "updated_at": 1713709542104, + "username": "arvinxx", + "verification": { + "attempts": null, + "expire_at": 1713710140131, + "status": "verified", + "strategy": "oauth_github" + } + } + ], + "external_id": null, + "first_name": "Arvin", + "has_image": true, + "id": "user_2fPkELglwI48WpZVwwdAxBKBPK6", + "image_url": "https://img.clerk.com/eyJ0eXBlIjoicHJveHkiLCJzcmMiOiJodHRwczovL2ltYWdlcy5jbGVyay5kZXYvb2F1dGhfZ2l0aHViL2ltZ18yZlBrRU1adVpwdlpvZFBHcVREdHJnTzJJM3cifQ", + "last_active_at": 1713709987902, + "last_name": "Xu", + "last_sign_in_at": null, + "locked": false, + "lockout_expires_in_seconds": null, + "object": "user", + "passkeys": [], + "password_enabled": false, + "phone_numbers": [], + "primary_email_address_id": "idn_2fPkD9X1lfzSn5lJVDGyochYq8k", + "primary_phone_number_id": null, + "primary_web3_wallet_id": null, + "private_metadata": {}, + "profile_image_url": "https://images.clerk.dev/oauth_github/img_2fPkEMZuZpvZodPGqTDtrgO2I3w", + "public_metadata": {}, + "saml_accounts": [], + "totp_enabled": false, + "two_factor_enabled": false, + "unsafe_metadata": {}, + "updated_at": 1713709987972, + "username": "arvinxx", + "verification_attempts_remaining": 100, + "web3_wallets": [] +} diff --git a/src/app/api/webhooks/clerk/route.ts b/src/app/api/webhooks/clerk/route.ts new file mode 100644 index 0000000000000..00b6f6fc3d7de --- /dev/null +++ b/src/app/api/webhooks/clerk/route.ts @@ -0,0 +1,159 @@ +import { UserJSON } from '@clerk/backend'; +import { NextResponse } from 'next/server'; + +import { authEnv } from '@/config/auth'; +import { isServerMode } from '@/const/version'; +import { UserModel } from '@/database/server/models/user'; +import { pino } from '@/libs/logger'; + +import { validateRequest } from './validateRequest'; + +if (authEnv.NEXT_PUBLIC_ENABLE_CLERK_AUTH && isServerMode && !authEnv.CLERK_WEBHOOK_SECRET) { + throw new Error('`CLERK_WEBHOOK_SECRET` environment variable is missing'); +} + +const createUser = async (id: string, params: UserJSON) => { + pino.info('creating user due to clerk webhook'); + + const userModel = new UserModel(); + + // Check if user already exists + const res = await userModel.findById(id); + + // If user already exists, skip creating a new user + if (res) + return NextResponse.json( + { message: 'user not created due to user already existing in the database', success: false }, + { status: 200 }, + ); + + const email = params.email_addresses.find((e) => e.id === params.primary_email_address_id); + const phone = params.phone_numbers.find((e) => e.id === params.primary_phone_number_id); + + await userModel.createUser({ + avatar: params.image_url, + clerkCreatedAt: new Date(params.created_at), + email: email?.email_address, + firstName: params.first_name, + id, + lastName: params.last_name, + phone: phone?.phone_number, + username: params.username, + }); + + return NextResponse.json({ message: 'user created', success: true }, { status: 200 }); +}; + +const deleteUser = async (id?: string) => { + if (id) { + pino.info('delete user due to clerk webhook'); + const userModel = new UserModel(); + + await userModel.deleteUser(id); + + return NextResponse.json({ message: 'user deleted' }, { status: 200 }); + } else { + pino.warn('clerk sent a delete user request, but no user ID was included in the payload'); + return NextResponse.json({ message: 'ok' }, { status: 200 }); + } +}; + +const updateUser = async (id: string, params: UserJSON) => { + pino.info('updating user due to clerk webhook'); + + const userModel = new UserModel(); + + // Check if user already exists + const res = await userModel.findById(id); + + // If user not exists, skip update the user + if (!res) + return NextResponse.json( + { + message: "user not updated due to the user don't existing in the database", + success: false, + }, + { status: 200 }, + ); + + const email = params.email_addresses.find((e) => e.id === params.primary_email_address_id); + const phone = params.phone_numbers.find((e) => e.id === params.primary_phone_number_id); + + await userModel.updateUser(id, { + avatar: params.image_url, + email: email?.email_address, + firstName: params.first_name, + id, + lastName: params.last_name, + phone: phone?.phone_number, + username: params.username, + }); + + return NextResponse.json({ message: 'user updated', success: true }, { status: 200 }); +}; + +export const POST = async (req: Request): Promise => { + const payload = await validateRequest(req, authEnv.CLERK_WEBHOOK_SECRET!); + + if (!payload) { + return NextResponse.json( + { error: 'webhook verification failed or payload was malformed' }, + { status: 400 }, + ); + } + + const { type, data } = payload; + + pino.trace(`clerk webhook payload: ${{ data, type }}`); + + switch (type) { + case 'user.created': { + return createUser(data.id, data); + } + case 'user.deleted': { + return deleteUser(data.id); + } + case 'user.updated': { + return updateUser(data.id, data); + } + + default: { + pino.warn( + `${req.url} received event type "${type}", but no handler is defined for this type`, + ); + return NextResponse.json({ error: `uncreognised payload type: ${type}` }, { status: 400 }); + } + // case 'user.updated': + // break; + // case 'session.created': + // break; + // case 'session.ended': + // break; + // case 'session.removed': + // break; + // case 'session.revoked': + // break; + // case 'email.created': + // break; + // case 'sms.created': + // break; + // case 'organization.created': + // break; + // case 'organization.updated': + // break; + // case 'organization.deleted': + // break; + // case 'organizationMembership.created': + // break; + // case 'organizationMembership.deleted': + // break; + // case 'organizationMembership.updated': + // break; + // case 'organizationInvitation.accepted': + // break; + // case 'organizationInvitation.created': + // break; + // case 'organizationInvitation.revoked': + // break; + } +}; diff --git a/src/app/api/webhooks/clerk/validateRequest.ts b/src/app/api/webhooks/clerk/validateRequest.ts new file mode 100644 index 0000000000000..1037ceed55006 --- /dev/null +++ b/src/app/api/webhooks/clerk/validateRequest.ts @@ -0,0 +1,22 @@ +import { WebhookEvent } from '@clerk/nextjs/server'; +import { headers } from 'next/headers'; +import { Webhook } from 'svix'; + +export const validateRequest = async (request: Request, secret: string) => { + const payloadString = await request.text(); + const headerPayload = headers(); + + const svixHeaders = { + 'svix-id': headerPayload.get('svix-id')!, + 'svix-signature': headerPayload.get('svix-signature')!, + 'svix-timestamp': headerPayload.get('svix-timestamp')!, + }; + const wh = new Webhook(secret); + + try { + return wh.verify(payloadString, svixHeaders) as WebhookEvent; + } catch { + console.error('incoming webhook failed verification'); + return; + } +}; diff --git a/src/app/trpc/lambda/[trpc]/route.ts b/src/app/trpc/lambda/[trpc]/route.ts new file mode 100644 index 0000000000000..aaf517f8f4656 --- /dev/null +++ b/src/app/trpc/lambda/[trpc]/route.ts @@ -0,0 +1,26 @@ +import { fetchRequestHandler } from '@trpc/server/adapters/fetch'; +import type { NextRequest } from 'next/server'; + +import { pino } from '@/libs/logger'; +import { createContext } from '@/server/context'; +import { lambdaRouter } from '@/server/routers'; + +const handler = (req: NextRequest) => + fetchRequestHandler({ + /** + * @link https://trpc.io/docs/v11/context + */ + createContext: () => createContext(req), + + endpoint: '/trpc/lambda', + + onError: ({ error, path, type }) => { + pino.info(`Error in tRPC handler (lambda) on path: ${path}, type: ${type}`); + console.error(error); + }, + + req, + router: lambdaRouter, + }); + +export { handler as GET, handler as POST }; diff --git a/src/config/auth.ts b/src/config/auth.ts index 9e67600a96225..549eee4726c79 100644 --- a/src/config/auth.ts +++ b/src/config/auth.ts @@ -75,6 +75,7 @@ export const getAuthConfig = () => { server: { // Clerk CLERK_SECRET_KEY: z.string().optional(), + CLERK_WEBHOOK_SECRET: z.string().optional(), // NEXT-AUTH NEXT_AUTH_SECRET: z.string().optional(), @@ -110,6 +111,7 @@ export const getAuthConfig = () => { NEXT_PUBLIC_ENABLE_CLERK_AUTH: !!process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY, NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY, CLERK_SECRET_KEY: process.env.CLERK_SECRET_KEY, + CLERK_WEBHOOK_SECRET: process.env.CLERK_WEBHOOK_SECRET, // Next Auth NEXT_PUBLIC_ENABLE_NEXT_AUTH: diff --git a/src/config/db.ts b/src/config/db.ts index 525576b946ccc..9205f7207e0bd 100644 --- a/src/config/db.ts +++ b/src/config/db.ts @@ -1,4 +1,3 @@ -/* eslint-disable sort-keys-fix/sort-keys-fix , typescript-sort-keys/interface */ import { createEnv } from '@t3-oss/env-nextjs'; import { z } from 'zod'; @@ -8,8 +7,19 @@ export const getServerDBConfig = () => { NEXT_PUBLIC_ENABLED_SERVER_SERVICE: z.boolean(), }, runtimeEnv: { + DATABASE_TEST_URL: process.env.DATABASE_TEST_URL, + DATABASE_URL: process.env.DATABASE_URL, + + KEY_VAULTS_SECRET_KEY: process.env.KEY_VAULTS_SECRET_KEY, + NEXT_PUBLIC_ENABLED_SERVER_SERVICE: process.env.NEXT_PUBLIC_SERVICE_MODE === 'server', }, + server: { + DATABASE_TEST_URL: z.string().optional(), + DATABASE_URL: z.string().optional(), + + KEY_VAULTS_SECRET_KEY: z.string().optional(), + }, }); }; diff --git a/src/database/server/core/db.ts b/src/database/server/core/db.ts new file mode 100644 index 0000000000000..810363473419e --- /dev/null +++ b/src/database/server/core/db.ts @@ -0,0 +1,36 @@ +import { Pool, neonConfig } from '@neondatabase/serverless'; +import { NeonDatabase, drizzle } from 'drizzle-orm/neon-serverless'; +import ws from 'ws'; + +import { serverDBEnv } from '@/config/db'; +import { isServerMode } from '@/const/version'; + +import * as schema from '../schemas/lobechat'; + +const getDBInstance = (): NeonDatabase => { + if (!isServerMode) return {} as any; + + if (!serverDBEnv.KEY_VAULTS_SECRET_KEY) { + throw new Error('`KEY_VAULTS_SECRET_KEY` is not set, please set it in your environment'); + } + + const isTest = process.env.NODE_ENV === 'test'; + + if (isTest || process.env.MIGRATION_DB === '1') { + // https://github.com/neondatabase/serverless/blob/main/CONFIG.md#websocketconstructor-typeof-websocket--undefined + neonConfig.webSocketConstructor = ws; + } + + const connectionString = isTest ? serverDBEnv.DATABASE_TEST_URL : serverDBEnv.DATABASE_URL; + + if (!connectionString) { + const string = isTest ? 'DATABASE_TEST_URL' : 'DATABASE_URL'; + throw new Error(`You are try to use database, but "${string}" is not set correctly`); + } + // const client = neon(connectionString); + const client = new Pool({ connectionString }); + + return drizzle(client, { schema }); +}; + +export const serverDB = getDBInstance(); diff --git a/src/database/server/index.ts b/src/database/server/index.ts new file mode 100644 index 0000000000000..29c39a842544a --- /dev/null +++ b/src/database/server/index.ts @@ -0,0 +1 @@ +export { serverDB } from './core/db'; diff --git a/src/database/server/migrations/0000_init.sql b/src/database/server/migrations/0000_init.sql new file mode 100644 index 0000000000000..729581a712f0f --- /dev/null +++ b/src/database/server/migrations/0000_init.sql @@ -0,0 +1,439 @@ +CREATE TABLE IF NOT EXISTS "agents" ( + "id" text PRIMARY KEY NOT NULL, + "slug" varchar(100), + "title" text, + "description" text, + "tags" jsonb DEFAULT '[]'::jsonb, + "avatar" text, + "background_color" text, + "plugins" jsonb DEFAULT '[]'::jsonb, + "user_id" text NOT NULL, + "chat_config" jsonb, + "few_shots" jsonb, + "model" text, + "params" jsonb DEFAULT '{}'::jsonb, + "provider" text, + "system_role" text, + "tts" jsonb, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "agents_slug_unique" UNIQUE("slug") +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "agents_tags" ( + "agent_id" text NOT NULL, + "tag_id" integer NOT NULL, + CONSTRAINT "agents_tags_agent_id_tag_id_pk" PRIMARY KEY("agent_id","tag_id") +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "agents_to_sessions" ( + "agent_id" text NOT NULL, + "session_id" text NOT NULL, + CONSTRAINT "agents_to_sessions_agent_id_session_id_pk" PRIMARY KEY("agent_id","session_id") +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "files" ( + "id" text PRIMARY KEY NOT NULL, + "user_id" text NOT NULL, + "file_type" varchar(255) NOT NULL, + "name" text NOT NULL, + "size" integer NOT NULL, + "url" text NOT NULL, + "metadata" jsonb, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "files_to_agents" ( + "file_id" text NOT NULL, + "agent_id" text NOT NULL, + CONSTRAINT "files_to_agents_file_id_agent_id_pk" PRIMARY KEY("file_id","agent_id") +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "files_to_messages" ( + "file_id" text NOT NULL, + "message_id" text NOT NULL, + CONSTRAINT "files_to_messages_file_id_message_id_pk" PRIMARY KEY("file_id","message_id") +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "files_to_sessions" ( + "file_id" text NOT NULL, + "session_id" text NOT NULL, + CONSTRAINT "files_to_sessions_file_id_session_id_pk" PRIMARY KEY("file_id","session_id") +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "user_installed_plugins" ( + "user_id" text NOT NULL, + "identifier" text NOT NULL, + "type" text NOT NULL, + "manifest" jsonb, + "settings" jsonb, + "custom_params" jsonb, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "user_installed_plugins_user_id_identifier_pk" PRIMARY KEY("user_id","identifier") +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "market" ( + "id" serial PRIMARY KEY NOT NULL, + "agent_id" text, + "plugin_id" integer, + "type" text NOT NULL, + "view" integer DEFAULT 0, + "like" integer DEFAULT 0, + "used" integer DEFAULT 0, + "user_id" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "message_plugins" ( + "id" text PRIMARY KEY NOT NULL, + "tool_call_id" text, + "type" text DEFAULT 'default', + "api_name" text, + "arguments" text, + "identifier" text, + "state" jsonb, + "error" jsonb +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "message_tts" ( + "id" text PRIMARY KEY NOT NULL, + "content_md5" text, + "file_id" text, + "voice" text +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "message_translates" ( + "id" text PRIMARY KEY NOT NULL, + "content" text, + "from" text, + "to" text +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "messages" ( + "id" text PRIMARY KEY NOT NULL, + "role" text NOT NULL, + "content" text, + "model" text, + "provider" text, + "favorite" boolean DEFAULT false, + "error" jsonb, + "tools" jsonb, + "trace_id" text, + "observation_id" text, + "user_id" text NOT NULL, + "session_id" text, + "topic_id" text, + "parent_id" text, + "quota_id" text, + "agent_id" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "plugins" ( + "id" serial PRIMARY KEY NOT NULL, + "identifier" text NOT NULL, + "title" text NOT NULL, + "description" text, + "avatar" text, + "author" text, + "manifest" text NOT NULL, + "locale" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "plugins_identifier_unique" UNIQUE("identifier") +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "plugins_tags" ( + "plugin_id" integer NOT NULL, + "tag_id" integer NOT NULL, + CONSTRAINT "plugins_tags_plugin_id_tag_id_pk" PRIMARY KEY("plugin_id","tag_id") +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "session_groups" ( + "id" text PRIMARY KEY NOT NULL, + "name" text NOT NULL, + "sort" integer, + "user_id" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "sessions" ( + "id" text PRIMARY KEY NOT NULL, + "slug" varchar(100) NOT NULL, + "title" text, + "description" text, + "avatar" text, + "background_color" text, + "type" text DEFAULT 'agent', + "user_id" text NOT NULL, + "group_id" text, + "pinned" boolean DEFAULT false, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "tags" ( + "id" serial PRIMARY KEY NOT NULL, + "slug" text NOT NULL, + "name" text, + "user_id" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "tags_slug_unique" UNIQUE("slug") +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "topics" ( + "id" text PRIMARY KEY NOT NULL, + "session_id" text, + "user_id" text NOT NULL, + "favorite" boolean DEFAULT false, + "title" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "user_settings" ( + "id" text PRIMARY KEY NOT NULL, + "tts" jsonb, + "key_vaults" text, + "general" jsonb, + "language_model" jsonb, + "system_agent" jsonb, + "default_agent" jsonb, + "tool" jsonb +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "users" ( + "id" text PRIMARY KEY NOT NULL, + "username" text, + "email" text, + "avatar" text, + "phone" text, + "first_name" text, + "last_name" text, + "is_onboarded" boolean DEFAULT false, + "clerk_created_at" timestamp with time zone, + "preference" jsonb DEFAULT '{"guide":{"moveSettingsToAvatar":true,"topic":true},"telemetry":null,"useCmdEnterToSend":false}'::jsonb, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + "key" text, + CONSTRAINT "users_username_unique" UNIQUE("username") +); +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "agents" ADD CONSTRAINT "agents_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "agents_tags" ADD CONSTRAINT "agents_tags_agent_id_agents_id_fk" FOREIGN KEY ("agent_id") REFERENCES "public"."agents"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "agents_tags" ADD CONSTRAINT "agents_tags_tag_id_tags_id_fk" FOREIGN KEY ("tag_id") REFERENCES "public"."tags"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "agents_to_sessions" ADD CONSTRAINT "agents_to_sessions_agent_id_agents_id_fk" FOREIGN KEY ("agent_id") REFERENCES "public"."agents"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "agents_to_sessions" ADD CONSTRAINT "agents_to_sessions_session_id_sessions_id_fk" FOREIGN KEY ("session_id") REFERENCES "public"."sessions"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "files" ADD CONSTRAINT "files_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "files_to_agents" ADD CONSTRAINT "files_to_agents_file_id_files_id_fk" FOREIGN KEY ("file_id") REFERENCES "public"."files"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "files_to_agents" ADD CONSTRAINT "files_to_agents_agent_id_agents_id_fk" FOREIGN KEY ("agent_id") REFERENCES "public"."agents"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "files_to_messages" ADD CONSTRAINT "files_to_messages_file_id_files_id_fk" FOREIGN KEY ("file_id") REFERENCES "public"."files"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "files_to_messages" ADD CONSTRAINT "files_to_messages_message_id_messages_id_fk" FOREIGN KEY ("message_id") REFERENCES "public"."messages"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "files_to_sessions" ADD CONSTRAINT "files_to_sessions_file_id_files_id_fk" FOREIGN KEY ("file_id") REFERENCES "public"."files"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "files_to_sessions" ADD CONSTRAINT "files_to_sessions_session_id_sessions_id_fk" FOREIGN KEY ("session_id") REFERENCES "public"."sessions"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "user_installed_plugins" ADD CONSTRAINT "user_installed_plugins_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "market" ADD CONSTRAINT "market_agent_id_agents_id_fk" FOREIGN KEY ("agent_id") REFERENCES "public"."agents"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "market" ADD CONSTRAINT "market_plugin_id_plugins_id_fk" FOREIGN KEY ("plugin_id") REFERENCES "public"."plugins"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "market" ADD CONSTRAINT "market_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "message_plugins" ADD CONSTRAINT "message_plugins_id_messages_id_fk" FOREIGN KEY ("id") REFERENCES "public"."messages"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "message_tts" ADD CONSTRAINT "message_tts_id_messages_id_fk" FOREIGN KEY ("id") REFERENCES "public"."messages"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "message_tts" ADD CONSTRAINT "message_tts_file_id_files_id_fk" FOREIGN KEY ("file_id") REFERENCES "public"."files"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "message_translates" ADD CONSTRAINT "message_translates_id_messages_id_fk" FOREIGN KEY ("id") REFERENCES "public"."messages"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "messages" ADD CONSTRAINT "messages_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "messages" ADD CONSTRAINT "messages_session_id_sessions_id_fk" FOREIGN KEY ("session_id") REFERENCES "public"."sessions"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "messages" ADD CONSTRAINT "messages_topic_id_topics_id_fk" FOREIGN KEY ("topic_id") REFERENCES "public"."topics"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "messages" ADD CONSTRAINT "messages_parent_id_messages_id_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."messages"("id") ON DELETE set null ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "messages" ADD CONSTRAINT "messages_quota_id_messages_id_fk" FOREIGN KEY ("quota_id") REFERENCES "public"."messages"("id") ON DELETE set null ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "messages" ADD CONSTRAINT "messages_agent_id_agents_id_fk" FOREIGN KEY ("agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "plugins_tags" ADD CONSTRAINT "plugins_tags_plugin_id_plugins_id_fk" FOREIGN KEY ("plugin_id") REFERENCES "public"."plugins"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "plugins_tags" ADD CONSTRAINT "plugins_tags_tag_id_tags_id_fk" FOREIGN KEY ("tag_id") REFERENCES "public"."tags"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "session_groups" ADD CONSTRAINT "session_groups_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "sessions" ADD CONSTRAINT "sessions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "sessions" ADD CONSTRAINT "sessions_group_id_session_groups_id_fk" FOREIGN KEY ("group_id") REFERENCES "public"."session_groups"("id") ON DELETE set null ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "tags" ADD CONSTRAINT "tags_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "topics" ADD CONSTRAINT "topics_session_id_sessions_id_fk" FOREIGN KEY ("session_id") REFERENCES "public"."sessions"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "topics" ADD CONSTRAINT "topics_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "user_settings" ADD CONSTRAINT "user_settings_id_users_id_fk" FOREIGN KEY ("id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "messages_created_at_idx" ON "messages" ("created_at");--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "slug_user_id_unique" ON "sessions" ("slug","user_id"); diff --git a/src/database/server/migrations/0001_add_client_id.sql b/src/database/server/migrations/0001_add_client_id.sql new file mode 100644 index 0000000000000..73eee3e6d2a37 --- /dev/null +++ b/src/database/server/migrations/0001_add_client_id.sql @@ -0,0 +1,9 @@ +ALTER TABLE "messages" ADD COLUMN "client_id" text;--> statement-breakpoint +ALTER TABLE "session_groups" ADD COLUMN "client_id" text;--> statement-breakpoint +ALTER TABLE "sessions" ADD COLUMN "client_id" text;--> statement-breakpoint +ALTER TABLE "topics" ADD COLUMN "client_id" text;--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "messages_client_id_idx" ON "messages" ("client_id");--> statement-breakpoint +ALTER TABLE "messages" ADD CONSTRAINT "messages_client_id_unique" UNIQUE("client_id");--> statement-breakpoint +ALTER TABLE "session_groups" ADD CONSTRAINT "session_groups_client_id_unique" UNIQUE("client_id");--> statement-breakpoint +ALTER TABLE "sessions" ADD CONSTRAINT "sessions_client_id_unique" UNIQUE("client_id");--> statement-breakpoint +ALTER TABLE "topics" ADD CONSTRAINT "topics_client_id_unique" UNIQUE("client_id"); diff --git a/src/database/server/migrations/meta/0000_snapshot.json b/src/database/server/migrations/meta/0000_snapshot.json new file mode 100644 index 0000000000000..8e8dcf4f3df31 --- /dev/null +++ b/src/database/server/migrations/meta/0000_snapshot.json @@ -0,0 +1,1583 @@ +{ + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + }, + "dialect": "postgresql", + "enums": {}, + "id": "85cfb5d4-e16c-4add-8088-80e0fb805b6c", + "prevId": "00000000-0000-0000-0000-000000000000", + "schemas": {}, + "tables": { + "public.agents": { + "name": "agents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "avatar": { + "name": "avatar", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "background_color": { + "name": "background_color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plugins": { + "name": "plugins", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_config": { + "name": "chat_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "few_shots": { + "name": "few_shots", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "params": { + "name": "params", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "system_role": { + "name": "system_role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tts": { + "name": "tts", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "agents_user_id_users_id_fk": { + "name": "agents_user_id_users_id_fk", + "tableFrom": "agents", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "agents_slug_unique": { + "name": "agents_slug_unique", + "nullsNotDistinct": false, + "columns": ["slug"] + } + } + }, + "public.agents_tags": { + "name": "agents_tags", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tag_id": { + "name": "tag_id", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "agents_tags_agent_id_agents_id_fk": { + "name": "agents_tags_agent_id_agents_id_fk", + "tableFrom": "agents_tags", + "tableTo": "agents", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agents_tags_tag_id_tags_id_fk": { + "name": "agents_tags_tag_id_tags_id_fk", + "tableFrom": "agents_tags", + "tableTo": "tags", + "columnsFrom": ["tag_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "agents_tags_agent_id_tag_id_pk": { + "name": "agents_tags_agent_id_tag_id_pk", + "columns": ["agent_id", "tag_id"] + } + }, + "uniqueConstraints": {} + }, + "public.agents_to_sessions": { + "name": "agents_to_sessions", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "agents_to_sessions_agent_id_agents_id_fk": { + "name": "agents_to_sessions_agent_id_agents_id_fk", + "tableFrom": "agents_to_sessions", + "tableTo": "agents", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agents_to_sessions_session_id_sessions_id_fk": { + "name": "agents_to_sessions_session_id_sessions_id_fk", + "tableFrom": "agents_to_sessions", + "tableTo": "sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "agents_to_sessions_agent_id_session_id_pk": { + "name": "agents_to_sessions_agent_id_session_id_pk", + "columns": ["agent_id", "session_id"] + } + }, + "uniqueConstraints": {} + }, + "public.files": { + "name": "files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_type": { + "name": "file_type", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "files_user_id_users_id_fk": { + "name": "files_user_id_users_id_fk", + "tableFrom": "files", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.files_to_agents": { + "name": "files_to_agents", + "schema": "", + "columns": { + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "files_to_agents_file_id_files_id_fk": { + "name": "files_to_agents_file_id_files_id_fk", + "tableFrom": "files_to_agents", + "tableTo": "files", + "columnsFrom": ["file_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "files_to_agents_agent_id_agents_id_fk": { + "name": "files_to_agents_agent_id_agents_id_fk", + "tableFrom": "files_to_agents", + "tableTo": "agents", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "files_to_agents_file_id_agent_id_pk": { + "name": "files_to_agents_file_id_agent_id_pk", + "columns": ["file_id", "agent_id"] + } + }, + "uniqueConstraints": {} + }, + "public.files_to_messages": { + "name": "files_to_messages", + "schema": "", + "columns": { + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "files_to_messages_file_id_files_id_fk": { + "name": "files_to_messages_file_id_files_id_fk", + "tableFrom": "files_to_messages", + "tableTo": "files", + "columnsFrom": ["file_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "files_to_messages_message_id_messages_id_fk": { + "name": "files_to_messages_message_id_messages_id_fk", + "tableFrom": "files_to_messages", + "tableTo": "messages", + "columnsFrom": ["message_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "files_to_messages_file_id_message_id_pk": { + "name": "files_to_messages_file_id_message_id_pk", + "columns": ["file_id", "message_id"] + } + }, + "uniqueConstraints": {} + }, + "public.files_to_sessions": { + "name": "files_to_sessions", + "schema": "", + "columns": { + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "files_to_sessions_file_id_files_id_fk": { + "name": "files_to_sessions_file_id_files_id_fk", + "tableFrom": "files_to_sessions", + "tableTo": "files", + "columnsFrom": ["file_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "files_to_sessions_session_id_sessions_id_fk": { + "name": "files_to_sessions_session_id_sessions_id_fk", + "tableFrom": "files_to_sessions", + "tableTo": "sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "files_to_sessions_file_id_session_id_pk": { + "name": "files_to_sessions_file_id_session_id_pk", + "columns": ["file_id", "session_id"] + } + }, + "uniqueConstraints": {} + }, + "public.user_installed_plugins": { + "name": "user_installed_plugins", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "manifest": { + "name": "manifest", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "custom_params": { + "name": "custom_params", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_installed_plugins_user_id_users_id_fk": { + "name": "user_installed_plugins_user_id_users_id_fk", + "tableFrom": "user_installed_plugins", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_installed_plugins_user_id_identifier_pk": { + "name": "user_installed_plugins_user_id_identifier_pk", + "columns": ["user_id", "identifier"] + } + }, + "uniqueConstraints": {} + }, + "public.market": { + "name": "market", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plugin_id": { + "name": "plugin_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "view": { + "name": "view", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "like": { + "name": "like", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "used": { + "name": "used", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "market_agent_id_agents_id_fk": { + "name": "market_agent_id_agents_id_fk", + "tableFrom": "market", + "tableTo": "agents", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "market_plugin_id_plugins_id_fk": { + "name": "market_plugin_id_plugins_id_fk", + "tableFrom": "market", + "tableTo": "plugins", + "columnsFrom": ["plugin_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "market_user_id_users_id_fk": { + "name": "market_user_id_users_id_fk", + "tableFrom": "market", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.message_plugins": { + "name": "message_plugins", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'default'" + }, + "api_name": { + "name": "api_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "arguments": { + "name": "arguments", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "message_plugins_id_messages_id_fk": { + "name": "message_plugins_id_messages_id_fk", + "tableFrom": "message_plugins", + "tableTo": "messages", + "columnsFrom": ["id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.message_tts": { + "name": "message_tts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "content_md5": { + "name": "content_md5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "voice": { + "name": "voice", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "message_tts_id_messages_id_fk": { + "name": "message_tts_id_messages_id_fk", + "tableFrom": "message_tts", + "tableTo": "messages", + "columnsFrom": ["id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "message_tts_file_id_files_id_fk": { + "name": "message_tts_file_id_files_id_fk", + "tableFrom": "message_tts", + "tableTo": "files", + "columnsFrom": ["file_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.message_translates": { + "name": "message_translates", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "from": { + "name": "from", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "to": { + "name": "to", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "message_translates_id_messages_id_fk": { + "name": "message_translates_id_messages_id_fk", + "tableFrom": "message_translates", + "tableTo": "messages", + "columnsFrom": ["id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.messages": { + "name": "messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "favorite": { + "name": "favorite", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "error": { + "name": "error", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "tools": { + "name": "tools", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "trace_id": { + "name": "trace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "observation_id": { + "name": "observation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "topic_id": { + "name": "topic_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "quota_id": { + "name": "quota_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "messages_created_at_idx": { + "name": "messages_created_at_idx", + "columns": ["created_at"], + "isUnique": false + } + }, + "foreignKeys": { + "messages_user_id_users_id_fk": { + "name": "messages_user_id_users_id_fk", + "tableFrom": "messages", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "messages_session_id_sessions_id_fk": { + "name": "messages_session_id_sessions_id_fk", + "tableFrom": "messages", + "tableTo": "sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "messages_topic_id_topics_id_fk": { + "name": "messages_topic_id_topics_id_fk", + "tableFrom": "messages", + "tableTo": "topics", + "columnsFrom": ["topic_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "messages_parent_id_messages_id_fk": { + "name": "messages_parent_id_messages_id_fk", + "tableFrom": "messages", + "tableTo": "messages", + "columnsFrom": ["parent_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "messages_quota_id_messages_id_fk": { + "name": "messages_quota_id_messages_id_fk", + "tableFrom": "messages", + "tableTo": "messages", + "columnsFrom": ["quota_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "messages_agent_id_agents_id_fk": { + "name": "messages_agent_id_agents_id_fk", + "tableFrom": "messages", + "tableTo": "agents", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.plugins": { + "name": "plugins", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar": { + "name": "avatar", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "author": { + "name": "author", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "manifest": { + "name": "manifest", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "locale": { + "name": "locale", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "plugins_identifier_unique": { + "name": "plugins_identifier_unique", + "nullsNotDistinct": false, + "columns": ["identifier"] + } + } + }, + "public.plugins_tags": { + "name": "plugins_tags", + "schema": "", + "columns": { + "plugin_id": { + "name": "plugin_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "tag_id": { + "name": "tag_id", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "plugins_tags_plugin_id_plugins_id_fk": { + "name": "plugins_tags_plugin_id_plugins_id_fk", + "tableFrom": "plugins_tags", + "tableTo": "plugins", + "columnsFrom": ["plugin_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "plugins_tags_tag_id_tags_id_fk": { + "name": "plugins_tags_tag_id_tags_id_fk", + "tableFrom": "plugins_tags", + "tableTo": "tags", + "columnsFrom": ["tag_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "plugins_tags_plugin_id_tag_id_pk": { + "name": "plugins_tags_plugin_id_tag_id_pk", + "columns": ["plugin_id", "tag_id"] + } + }, + "uniqueConstraints": {} + }, + "public.session_groups": { + "name": "session_groups", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sort": { + "name": "sort", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "session_groups_user_id_users_id_fk": { + "name": "session_groups_user_id_users_id_fk", + "tableFrom": "session_groups", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar": { + "name": "avatar", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "background_color": { + "name": "background_color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'agent'" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pinned": { + "name": "pinned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "slug_user_id_unique": { + "name": "slug_user_id_unique", + "columns": ["slug", "user_id"], + "isUnique": true + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sessions_group_id_session_groups_id_fk": { + "name": "sessions_group_id_session_groups_id_fk", + "tableFrom": "sessions", + "tableTo": "session_groups", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.tags": { + "name": "tags", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "tags_user_id_users_id_fk": { + "name": "tags_user_id_users_id_fk", + "tableFrom": "tags", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "tags_slug_unique": { + "name": "tags_slug_unique", + "nullsNotDistinct": false, + "columns": ["slug"] + } + } + }, + "public.topics": { + "name": "topics", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "favorite": { + "name": "favorite", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "topics_session_id_sessions_id_fk": { + "name": "topics_session_id_sessions_id_fk", + "tableFrom": "topics", + "tableTo": "sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "topics_user_id_users_id_fk": { + "name": "topics_user_id_users_id_fk", + "tableFrom": "topics", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.user_settings": { + "name": "user_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tts": { + "name": "tts", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "key_vaults": { + "name": "key_vaults", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "general": { + "name": "general", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "language_model": { + "name": "language_model", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "system_agent": { + "name": "system_agent", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "default_agent": { + "name": "default_agent", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "tool": { + "name": "tool", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "user_settings_id_users_id_fk": { + "name": "user_settings_id_users_id_fk", + "tableFrom": "user_settings", + "tableTo": "users", + "columnsFrom": ["id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar": { + "name": "avatar", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "first_name": { + "name": "first_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_name": { + "name": "last_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_onboarded": { + "name": "is_onboarded", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "clerk_created_at": { + "name": "clerk_created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "preference": { + "name": "preference", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{\"guide\":{\"moveSettingsToAvatar\":true,\"topic\":true},\"telemetry\":null,\"useCmdEnterToSend\":false}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_username_unique": { + "name": "users_username_unique", + "nullsNotDistinct": false, + "columns": ["username"] + } + } + } + }, + "version": "6" +} diff --git a/src/database/server/migrations/meta/0001_snapshot.json b/src/database/server/migrations/meta/0001_snapshot.json new file mode 100644 index 0000000000000..cacf99bedd651 --- /dev/null +++ b/src/database/server/migrations/meta/0001_snapshot.json @@ -0,0 +1,1636 @@ +{ + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + }, + "dialect": "postgresql", + "enums": {}, + "id": "d6496f4b-d92e-49a7-99d7-a9d614b88369", + "prevId": "85cfb5d4-e16c-4add-8088-80e0fb805b6c", + "schemas": {}, + "tables": { + "public.agents": { + "name": "agents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "avatar": { + "name": "avatar", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "background_color": { + "name": "background_color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plugins": { + "name": "plugins", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_config": { + "name": "chat_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "few_shots": { + "name": "few_shots", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "params": { + "name": "params", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "system_role": { + "name": "system_role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tts": { + "name": "tts", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "agents_user_id_users_id_fk": { + "name": "agents_user_id_users_id_fk", + "tableFrom": "agents", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "agents_slug_unique": { + "name": "agents_slug_unique", + "nullsNotDistinct": false, + "columns": ["slug"] + } + } + }, + "public.agents_tags": { + "name": "agents_tags", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tag_id": { + "name": "tag_id", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "agents_tags_agent_id_agents_id_fk": { + "name": "agents_tags_agent_id_agents_id_fk", + "tableFrom": "agents_tags", + "tableTo": "agents", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agents_tags_tag_id_tags_id_fk": { + "name": "agents_tags_tag_id_tags_id_fk", + "tableFrom": "agents_tags", + "tableTo": "tags", + "columnsFrom": ["tag_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "agents_tags_agent_id_tag_id_pk": { + "name": "agents_tags_agent_id_tag_id_pk", + "columns": ["agent_id", "tag_id"] + } + }, + "uniqueConstraints": {} + }, + "public.agents_to_sessions": { + "name": "agents_to_sessions", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "agents_to_sessions_agent_id_agents_id_fk": { + "name": "agents_to_sessions_agent_id_agents_id_fk", + "tableFrom": "agents_to_sessions", + "tableTo": "agents", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agents_to_sessions_session_id_sessions_id_fk": { + "name": "agents_to_sessions_session_id_sessions_id_fk", + "tableFrom": "agents_to_sessions", + "tableTo": "sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "agents_to_sessions_agent_id_session_id_pk": { + "name": "agents_to_sessions_agent_id_session_id_pk", + "columns": ["agent_id", "session_id"] + } + }, + "uniqueConstraints": {} + }, + "public.files": { + "name": "files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_type": { + "name": "file_type", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "files_user_id_users_id_fk": { + "name": "files_user_id_users_id_fk", + "tableFrom": "files", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.files_to_agents": { + "name": "files_to_agents", + "schema": "", + "columns": { + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "files_to_agents_file_id_files_id_fk": { + "name": "files_to_agents_file_id_files_id_fk", + "tableFrom": "files_to_agents", + "tableTo": "files", + "columnsFrom": ["file_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "files_to_agents_agent_id_agents_id_fk": { + "name": "files_to_agents_agent_id_agents_id_fk", + "tableFrom": "files_to_agents", + "tableTo": "agents", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "files_to_agents_file_id_agent_id_pk": { + "name": "files_to_agents_file_id_agent_id_pk", + "columns": ["file_id", "agent_id"] + } + }, + "uniqueConstraints": {} + }, + "public.files_to_messages": { + "name": "files_to_messages", + "schema": "", + "columns": { + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "files_to_messages_file_id_files_id_fk": { + "name": "files_to_messages_file_id_files_id_fk", + "tableFrom": "files_to_messages", + "tableTo": "files", + "columnsFrom": ["file_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "files_to_messages_message_id_messages_id_fk": { + "name": "files_to_messages_message_id_messages_id_fk", + "tableFrom": "files_to_messages", + "tableTo": "messages", + "columnsFrom": ["message_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "files_to_messages_file_id_message_id_pk": { + "name": "files_to_messages_file_id_message_id_pk", + "columns": ["file_id", "message_id"] + } + }, + "uniqueConstraints": {} + }, + "public.files_to_sessions": { + "name": "files_to_sessions", + "schema": "", + "columns": { + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "files_to_sessions_file_id_files_id_fk": { + "name": "files_to_sessions_file_id_files_id_fk", + "tableFrom": "files_to_sessions", + "tableTo": "files", + "columnsFrom": ["file_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "files_to_sessions_session_id_sessions_id_fk": { + "name": "files_to_sessions_session_id_sessions_id_fk", + "tableFrom": "files_to_sessions", + "tableTo": "sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "files_to_sessions_file_id_session_id_pk": { + "name": "files_to_sessions_file_id_session_id_pk", + "columns": ["file_id", "session_id"] + } + }, + "uniqueConstraints": {} + }, + "public.user_installed_plugins": { + "name": "user_installed_plugins", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "manifest": { + "name": "manifest", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "custom_params": { + "name": "custom_params", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_installed_plugins_user_id_users_id_fk": { + "name": "user_installed_plugins_user_id_users_id_fk", + "tableFrom": "user_installed_plugins", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_installed_plugins_user_id_identifier_pk": { + "name": "user_installed_plugins_user_id_identifier_pk", + "columns": ["user_id", "identifier"] + } + }, + "uniqueConstraints": {} + }, + "public.market": { + "name": "market", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plugin_id": { + "name": "plugin_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "view": { + "name": "view", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "like": { + "name": "like", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "used": { + "name": "used", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "market_agent_id_agents_id_fk": { + "name": "market_agent_id_agents_id_fk", + "tableFrom": "market", + "tableTo": "agents", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "market_plugin_id_plugins_id_fk": { + "name": "market_plugin_id_plugins_id_fk", + "tableFrom": "market", + "tableTo": "plugins", + "columnsFrom": ["plugin_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "market_user_id_users_id_fk": { + "name": "market_user_id_users_id_fk", + "tableFrom": "market", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.message_plugins": { + "name": "message_plugins", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'default'" + }, + "api_name": { + "name": "api_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "arguments": { + "name": "arguments", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "message_plugins_id_messages_id_fk": { + "name": "message_plugins_id_messages_id_fk", + "tableFrom": "message_plugins", + "tableTo": "messages", + "columnsFrom": ["id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.message_tts": { + "name": "message_tts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "content_md5": { + "name": "content_md5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "voice": { + "name": "voice", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "message_tts_id_messages_id_fk": { + "name": "message_tts_id_messages_id_fk", + "tableFrom": "message_tts", + "tableTo": "messages", + "columnsFrom": ["id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "message_tts_file_id_files_id_fk": { + "name": "message_tts_file_id_files_id_fk", + "tableFrom": "message_tts", + "tableTo": "files", + "columnsFrom": ["file_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.message_translates": { + "name": "message_translates", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "from": { + "name": "from", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "to": { + "name": "to", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "message_translates_id_messages_id_fk": { + "name": "message_translates_id_messages_id_fk", + "tableFrom": "message_translates", + "tableTo": "messages", + "columnsFrom": ["id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.messages": { + "name": "messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "favorite": { + "name": "favorite", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "error": { + "name": "error", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "tools": { + "name": "tools", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "trace_id": { + "name": "trace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "observation_id": { + "name": "observation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "topic_id": { + "name": "topic_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "quota_id": { + "name": "quota_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "messages_created_at_idx": { + "name": "messages_created_at_idx", + "columns": ["created_at"], + "isUnique": false + }, + "messages_client_id_idx": { + "name": "messages_client_id_idx", + "columns": ["client_id"], + "isUnique": false + } + }, + "foreignKeys": { + "messages_user_id_users_id_fk": { + "name": "messages_user_id_users_id_fk", + "tableFrom": "messages", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "messages_session_id_sessions_id_fk": { + "name": "messages_session_id_sessions_id_fk", + "tableFrom": "messages", + "tableTo": "sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "messages_topic_id_topics_id_fk": { + "name": "messages_topic_id_topics_id_fk", + "tableFrom": "messages", + "tableTo": "topics", + "columnsFrom": ["topic_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "messages_parent_id_messages_id_fk": { + "name": "messages_parent_id_messages_id_fk", + "tableFrom": "messages", + "tableTo": "messages", + "columnsFrom": ["parent_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "messages_quota_id_messages_id_fk": { + "name": "messages_quota_id_messages_id_fk", + "tableFrom": "messages", + "tableTo": "messages", + "columnsFrom": ["quota_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "messages_agent_id_agents_id_fk": { + "name": "messages_agent_id_agents_id_fk", + "tableFrom": "messages", + "tableTo": "agents", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "messages_client_id_unique": { + "name": "messages_client_id_unique", + "nullsNotDistinct": false, + "columns": ["client_id"] + } + } + }, + "public.plugins": { + "name": "plugins", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar": { + "name": "avatar", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "author": { + "name": "author", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "manifest": { + "name": "manifest", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "locale": { + "name": "locale", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "plugins_identifier_unique": { + "name": "plugins_identifier_unique", + "nullsNotDistinct": false, + "columns": ["identifier"] + } + } + }, + "public.plugins_tags": { + "name": "plugins_tags", + "schema": "", + "columns": { + "plugin_id": { + "name": "plugin_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "tag_id": { + "name": "tag_id", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "plugins_tags_plugin_id_plugins_id_fk": { + "name": "plugins_tags_plugin_id_plugins_id_fk", + "tableFrom": "plugins_tags", + "tableTo": "plugins", + "columnsFrom": ["plugin_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "plugins_tags_tag_id_tags_id_fk": { + "name": "plugins_tags_tag_id_tags_id_fk", + "tableFrom": "plugins_tags", + "tableTo": "tags", + "columnsFrom": ["tag_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "plugins_tags_plugin_id_tag_id_pk": { + "name": "plugins_tags_plugin_id_tag_id_pk", + "columns": ["plugin_id", "tag_id"] + } + }, + "uniqueConstraints": {} + }, + "public.session_groups": { + "name": "session_groups", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sort": { + "name": "sort", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "session_groups_user_id_users_id_fk": { + "name": "session_groups_user_id_users_id_fk", + "tableFrom": "session_groups", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_groups_client_id_unique": { + "name": "session_groups_client_id_unique", + "nullsNotDistinct": false, + "columns": ["client_id"] + } + } + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar": { + "name": "avatar", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "background_color": { + "name": "background_color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'agent'" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pinned": { + "name": "pinned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "slug_user_id_unique": { + "name": "slug_user_id_unique", + "columns": ["slug", "user_id"], + "isUnique": true + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sessions_group_id_session_groups_id_fk": { + "name": "sessions_group_id_session_groups_id_fk", + "tableFrom": "sessions", + "tableTo": "session_groups", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_client_id_unique": { + "name": "sessions_client_id_unique", + "nullsNotDistinct": false, + "columns": ["client_id"] + } + } + }, + "public.tags": { + "name": "tags", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "tags_user_id_users_id_fk": { + "name": "tags_user_id_users_id_fk", + "tableFrom": "tags", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "tags_slug_unique": { + "name": "tags_slug_unique", + "nullsNotDistinct": false, + "columns": ["slug"] + } + } + }, + "public.topics": { + "name": "topics", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "favorite": { + "name": "favorite", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "topics_session_id_sessions_id_fk": { + "name": "topics_session_id_sessions_id_fk", + "tableFrom": "topics", + "tableTo": "sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "topics_user_id_users_id_fk": { + "name": "topics_user_id_users_id_fk", + "tableFrom": "topics", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "topics_client_id_unique": { + "name": "topics_client_id_unique", + "nullsNotDistinct": false, + "columns": ["client_id"] + } + } + }, + "public.user_settings": { + "name": "user_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tts": { + "name": "tts", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "key_vaults": { + "name": "key_vaults", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "general": { + "name": "general", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "language_model": { + "name": "language_model", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "system_agent": { + "name": "system_agent", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "default_agent": { + "name": "default_agent", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "tool": { + "name": "tool", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "user_settings_id_users_id_fk": { + "name": "user_settings_id_users_id_fk", + "tableFrom": "user_settings", + "tableTo": "users", + "columnsFrom": ["id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar": { + "name": "avatar", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "first_name": { + "name": "first_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_name": { + "name": "last_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_onboarded": { + "name": "is_onboarded", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "clerk_created_at": { + "name": "clerk_created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "preference": { + "name": "preference", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{\"guide\":{\"moveSettingsToAvatar\":true,\"topic\":true},\"telemetry\":null,\"useCmdEnterToSend\":false}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_username_unique": { + "name": "users_username_unique", + "nullsNotDistinct": false, + "columns": ["username"] + } + } + } + }, + "version": "6" +} diff --git a/src/database/server/migrations/meta/_journal.json b/src/database/server/migrations/meta/_journal.json new file mode 100644 index 0000000000000..a23b659ec318d --- /dev/null +++ b/src/database/server/migrations/meta/_journal.json @@ -0,0 +1,20 @@ +{ + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "6", + "when": 1716982944425, + "tag": "0000_init", + "breakpoints": true + }, + { + "idx": 1, + "version": "6", + "when": 1717153686544, + "tag": "0001_add_client_id", + "breakpoints": true + } + ], + "version": "6" +} diff --git a/src/database/server/models/__tests__/message.test.ts b/src/database/server/models/__tests__/message.test.ts new file mode 100644 index 0000000000000..2103d27939995 --- /dev/null +++ b/src/database/server/models/__tests__/message.test.ts @@ -0,0 +1,493 @@ +import { eq } from 'drizzle-orm'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { serverDB } from '../../core/db'; +import { + files, + filesToMessages, + messagePlugins, + messageTTS, + messages, + sessions, + topics, + users, +} from '../../schemas/lobechat'; +import { MessageModel } from '../message'; + +const userId = 'message-db'; +const messageModel = new MessageModel(userId); + +beforeEach(async () => { + // 在每个测试用例之前,清空表 + await serverDB.delete(users); + + await serverDB.insert(users).values([{ id: userId }, { id: '456' }]); +}); + +afterEach(async () => { + // 在每个测试用例之后,清空表 + await serverDB.delete(users); +}); + +describe('MessageModel', () => { + describe('query', () => { + it('should query messages by user ID', async () => { + // 创建测试数据 + await serverDB.insert(messages).values([ + { id: '1', userId, role: 'user', content: 'message 1', createdAt: new Date('2023-01-01') }, + { id: '2', userId, role: 'user', content: 'message 2', createdAt: new Date('2023-02-01') }, + { + id: '3', + userId: '456', + role: 'user', + content: 'message 3', + createdAt: new Date('2023-03-01'), + }, + ]); + + // 调用 query 方法 + const result = await messageModel.query(); + + // 断言结果 + expect(result).toHaveLength(2); + expect(result[0].id).toBe('1'); + expect(result[1].id).toBe('2'); + }); + + it('should return empty messages if not match the user ID', async () => { + // 创建测试数据 + await serverDB.insert(messages).values([ + { id: '1', userId: '456', role: 'user', content: '1', createdAt: new Date('2023-01-01') }, + { id: '2', userId: '456', role: 'user', content: '2', createdAt: new Date('2023-02-01') }, + { id: '3', userId: '456', role: 'user', content: '3', createdAt: new Date('2023-03-01') }, + ]); + + // 调用 query 方法 + const result = await messageModel.query(); + + // 断言结果 + expect(result).toHaveLength(0); + }); + + it('should query messages with pagination', async () => { + // 创建测试数据 + await serverDB.insert(messages).values([ + { id: '1', userId, role: 'user', content: 'message 1', createdAt: new Date('2023-01-01') }, + { id: '2', userId, role: 'user', content: 'message 2', createdAt: new Date('2023-02-01') }, + { id: '3', userId, role: 'user', content: 'message 3', createdAt: new Date('2023-03-01') }, + ]); + + // 测试分页 + const result1 = await messageModel.query({ current: 0, pageSize: 2 }); + expect(result1).toHaveLength(2); + + const result2 = await messageModel.query({ current: 1, pageSize: 1 }); + expect(result2).toHaveLength(1); + expect(result2[0].id).toBe('2'); + }); + + it('should filter messages by sessionId', async () => { + // 创建测试数据 + await serverDB.insert(sessions).values([ + { id: 'session1', userId }, + { id: 'session2', userId }, + ]); + await serverDB.insert(messages).values([ + { + id: '1', + userId, + role: 'user', + sessionId: 'session1', + content: 'message 1', + createdAt: new Date('2022-02-01'), + }, + { + id: '2', + userId, + role: 'user', + sessionId: 'session1', + content: 'message 2', + createdAt: new Date('2023-02-02'), + }, + { id: '3', userId, role: 'user', sessionId: 'session2', content: 'message 3' }, + ]); + + // 测试根据 sessionId 过滤 + const result = await messageModel.query({ sessionId: 'session1' }); + expect(result).toHaveLength(2); + expect(result[0].id).toBe('1'); + expect(result[1].id).toBe('2'); + }); + + it('should filter messages by topicId', async () => { + // 创建测试数据 + const sessionId = 'session1'; + await serverDB.insert(sessions).values([{ id: sessionId, userId }]); + const topicId = 'topic1'; + await serverDB.insert(topics).values([ + { id: topicId, sessionId, userId }, + { id: 'topic2', sessionId, userId }, + ]); + + await serverDB.insert(messages).values([ + { id: '1', userId, role: 'user', topicId, content: '1', createdAt: new Date('2022-04-01') }, + { id: '2', userId, role: 'user', topicId, content: '2', createdAt: new Date('2023-02-01') }, + { id: '3', userId, role: 'user', topicId: 'topic2', content: 'message 3' }, + ]); + + // 测试根据 topicId 过滤 + const result = await messageModel.query({ topicId }); + expect(result).toHaveLength(2); + expect(result[0].id).toBe('1'); + expect(result[1].id).toBe('2'); + }); + + it('should query messages with join', async () => { + // 创建测试数据 + await serverDB.transaction(async (trx) => { + await trx.insert(messages).values([ + { + id: '1', + userId, + role: 'user', + content: 'message 1', + createdAt: new Date('2023-01-01'), + }, + { + id: '2', + userId, + role: 'user', + content: 'message 2', + createdAt: new Date('2023-02-01'), + }, + { + id: '3', + userId: '456', + role: 'user', + content: 'message 3', + createdAt: new Date('2023-03-01'), + }, + ]); + await trx.insert(files).values([ + { id: 'f-0', url: 'abc', name: 'file-1', userId, fileType: 'image/png', size: 1000 }, + { id: 'f-1', url: 'abc', name: 'file-1', userId, fileType: 'image/png', size: 100 }, + { id: 'f-3', url: 'abc', name: 'file-3', userId, fileType: 'image/png', size: 400 }, + ]); + await trx + .insert(messageTTS) + .values([{ id: '1' }, { id: '2', voice: 'a', fileId: 'f-1', contentMd5: 'abc' }]); + + await trx.insert(filesToMessages).values([ + { fileId: 'f-0', messageId: '1' }, + { fileId: 'f-3', messageId: '1' }, + ]); + }); + + // 调用 query 方法 + const result = await messageModel.query(); + + // 断言结果 + expect(result).toHaveLength(2); + expect(result[0].id).toBe('1'); + expect(result[0].files).toEqual(['f-0', 'f-3']); + + expect(result[1].id).toBe('2'); + expect(result[1].files).toEqual([]); + }); + }); + + describe('findById', () => { + it('should find message by ID', async () => { + // 创建测试数据 + await serverDB.insert(messages).values([ + { id: '1', userId, role: 'user', content: 'message 1' }, + { id: '2', userId: '456', role: 'user', content: 'message 2' }, + ]); + + // 调用 findById 方法 + const result = await messageModel.findById('1'); + + // 断言结果 + expect(result?.id).toBe('1'); + expect(result?.content).toBe('message 1'); + }); + + it('should return undefined if message does not belong to user', async () => { + // 创建测试数据 + await serverDB + .insert(messages) + .values([{ id: '1', userId: '456', role: 'user', content: 'message 1' }]); + + // 调用 findById 方法 + const result = await messageModel.findById('1'); + + // 断言结果 + expect(result).toBeUndefined(); + }); + }); + + describe('queryBySessionId', () => { + it('should query messages by sessionId', async () => { + // 创建测试数据 + const sessionId = 'session1'; + await serverDB.insert(sessions).values([ + { id: 'session1', userId }, + { id: 'session2', userId }, + ]); + await serverDB.insert(messages).values([ + { + id: '1', + userId, + role: 'user', + sessionId, + content: 'message 1', + createdAt: new Date('2022-01-01'), + }, + { + id: '2', + userId, + role: 'user', + sessionId, + content: 'message 2', + createdAt: new Date('2023-02-01'), + }, + { id: '3', userId, role: 'user', sessionId: 'session2', content: 'message 3' }, + ]); + + // 调用 queryBySessionId 方法 + const result = await messageModel.queryBySessionId(sessionId); + + // 断言结果 + expect(result).toHaveLength(2); + expect(result[0].id).toBe('1'); + expect(result[1].id).toBe('2'); + }); + }); + + describe('queryByKeyWord', () => { + it('should query messages by keyword', async () => { + // 创建测试数据 + await serverDB.insert(messages).values([ + { id: '1', userId, role: 'user', content: 'apple', createdAt: new Date('2022-02-01') }, + { id: '2', userId, role: 'user', content: 'banana' }, + { id: '3', userId, role: 'user', content: 'pear' }, + { id: '4', userId, role: 'user', content: 'apple pie', createdAt: new Date('2024-02-01') }, + ]); + + // 测试查询包含特定关键字的消息 + const result = await messageModel.queryByKeyword('apple'); + + // 断言结果 + expect(result).toHaveLength(2); + expect(result[0].id).toBe('4'); + expect(result[1].id).toBe('1'); + }); + + it('should return empty array when keyword is empty', async () => { + // 创建测试数据 + await serverDB.insert(messages).values([ + { id: '1', userId, role: 'user', content: 'apple' }, + { id: '2', userId, role: 'user', content: 'banana' }, + { id: '3', userId, role: 'user', content: 'pear' }, + { id: '4', userId, role: 'user', content: 'apple pie' }, + ]); + + // 测试当关键字为空时返回空数组 + const result = await messageModel.queryByKeyword(''); + + // 断言结果 + expect(result).toHaveLength(0); + }); + }); + + describe('createMessage', () => { + it('should create a new message', async () => { + // 调用 createMessage 方法 + await messageModel.create({ role: 'user', content: 'new message', sessionId: '1' }); + + // 断言结果 + const result = await serverDB + .select() + .from(messages) + .where(eq(messages.userId, userId)) + .execute(); + expect(result).toHaveLength(1); + expect(result[0].content).toBe('new message'); + }); + + it('should create a message', async () => { + const sessionId = 'session1'; + await serverDB.insert(sessions).values([{ id: sessionId, userId }]); + + const result = await messageModel.create({ + content: 'message 1', + role: 'user', + sessionId: 'session1', + }); + + expect(result.id).toBeDefined(); + expect(result.content).toBe('message 1'); + expect(result.role).toBe('user'); + expect(result.sessionId).toBe('session1'); + expect(result.userId).toBe(userId); + }); + + it('should generate message ID automatically', async () => { + // 调用 createMessage 方法 + await messageModel.create({ + role: 'user', + content: 'new message', + sessionId: '1', + }); + + // 断言结果 + const result = await serverDB + .select() + .from(messages) + .where(eq(messages.userId, userId)) + .execute(); + expect(result[0].id).toBeDefined(); + expect(result[0].id).toHaveLength(18); + }); + }); + + describe('batchCreateMessages', () => { + it('should batch create messages', async () => { + // 准备测试数据 + const newMessages = [ + { id: '1', role: 'user', content: 'message 1' }, + { id: '2', role: 'assistant', content: 'message 2' }, + ]; + + // 调用 batchCreateMessages 方法 + await messageModel.batchCreate(newMessages); + + // 断言结果 + const result = await serverDB + .select() + .from(messages) + .where(eq(messages.userId, userId)) + .execute(); + expect(result).toHaveLength(2); + expect(result[0].content).toBe('message 1'); + expect(result[1].content).toBe('message 2'); + }); + }); + + describe('updateMessage', () => { + it('should update message content', async () => { + // 创建测试数据 + await serverDB + .insert(messages) + .values([{ id: '1', userId, role: 'user', content: 'message 1' }]); + + // 调用 updateMessage 方法 + await messageModel.update('1', { content: 'updated message' }); + + // 断言结果 + const result = await serverDB.select().from(messages).where(eq(messages.id, '1')).execute(); + expect(result[0].content).toBe('updated message'); + }); + + it('should only update messages belonging to the user', async () => { + // 创建测试数据 + await serverDB + .insert(messages) + .values([{ id: '1', userId: '456', role: 'user', content: 'message 1' }]); + + // 调用 updateMessage 方法 + await messageModel.update('1', { content: 'updated message' }); + + // 断言结果 + const result = await serverDB.select().from(messages).where(eq(messages.id, '1')).execute(); + expect(result[0].content).toBe('message 1'); + }); + }); + + describe('deleteMessage', () => { + it('should delete a message', async () => { + // 创建测试数据 + await serverDB + .insert(messages) + .values([{ id: '1', userId, role: 'user', content: 'message 1' }]); + + // 调用 deleteMessage 方法 + await messageModel.deleteMessage('1'); + + // 断言结果 + const result = await serverDB.select().from(messages).where(eq(messages.id, '1')).execute(); + expect(result).toHaveLength(0); + }); + + it('should delete a message with tool calls', async () => { + // 创建测试数据 + await serverDB.transaction(async (trx) => { + await trx.insert(messages).values([ + { id: '1', userId, role: 'user', content: 'message 1', tools: [{ id: 'tool1' }] }, + { id: '2', userId, role: 'tool', content: 'message 1' }, + ]); + await trx + .insert(messagePlugins) + .values([{ id: '2', toolCallId: 'tool1', identifier: 'plugin-1' }]); + }); + + // 调用 deleteMessage 方法 + await messageModel.deleteMessage('1'); + + // 断言结果 + const result = await serverDB.select().from(messages).where(eq(messages.id, '1')).execute(); + expect(result).toHaveLength(0); + + const result2 = await serverDB + .select() + .from(messagePlugins) + .where(eq(messagePlugins.id, '2')) + .execute(); + + expect(result2).toHaveLength(0); + }); + + it('should only delete messages belonging to the user', async () => { + // 创建测试数据 + await serverDB + .insert(messages) + .values([{ id: '1', userId: '456', role: 'user', content: 'message 1' }]); + + // 调用 deleteMessage 方法 + await messageModel.deleteMessage('1'); + + // 断言结果 + const result = await serverDB.select().from(messages).where(eq(messages.id, '1')).execute(); + expect(result).toHaveLength(1); + }); + }); + + describe('deleteAllMessages', () => { + it('should delete all messages belonging to the user', async () => { + // 创建测试数据 + await serverDB.insert(messages).values([ + { id: '1', userId, role: 'user', content: 'message 1' }, + { id: '2', userId, role: 'user', content: 'message 2' }, + { id: '3', userId: '456', role: 'user', content: 'message 3' }, + ]); + + // 调用 deleteAllMessages 方法 + await messageModel.deleteAllMessages(); + + // 断言结果 + const result = await serverDB + .select() + .from(messages) + .where(eq(messages.userId, userId)) + .execute(); + expect(result).toHaveLength(0); + + const otherResult = await serverDB + .select() + .from(messages) + .where(eq(messages.userId, '456')) + .execute(); + expect(otherResult).toHaveLength(1); + }); + }); +}); diff --git a/src/database/server/models/__tests__/session.test.ts b/src/database/server/models/__tests__/session.test.ts new file mode 100644 index 0000000000000..4e40661a152b0 --- /dev/null +++ b/src/database/server/models/__tests__/session.test.ts @@ -0,0 +1,587 @@ +// @vitest-environment edge-runtime +import { eq, inArray } from 'drizzle-orm'; +import { afterAll, afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { serverDB } from '../../core/db'; +import { + NewSession, + SessionItem, + agents, + agentsToSessions, + messages, + plugins, + sessionGroups, + sessions, + topics, + users, +} from '../../schemas/lobechat'; +import { idGenerator } from '../../utils/idGenerator'; +import { SessionModel } from '../session'; + +const userId = 'session-user'; +const sessionModel = new SessionModel(userId); + +beforeEach(async () => { + await serverDB.delete(plugins); + await serverDB.delete(users); + // 并创建初始用户 + await serverDB.insert(users).values({ id: userId }); +}); + +afterEach(async () => { + // 在每个测试用例之后, 清空用户表 (应该会自动级联删除所有数据) + await serverDB.delete(users); +}); + +describe('SessionModel', () => { + describe('query', () => { + it('should query sessions by user ID', async () => { + // 创建一些测试数据 + await serverDB.insert(users).values([{ id: '456' }]); + + await serverDB.insert(sessions).values([ + { id: '1', userId, updatedAt: new Date('2023-01-01') }, + { id: '2', userId, updatedAt: new Date('2023-02-01') }, + { id: '3', userId: '456', updatedAt: new Date('2023-03-01') }, + ]); + + // 调用 query 方法 + const result = await sessionModel.query(); + + // 断言结果 + expect(result).toHaveLength(2); + expect(result[0].id).toBe('2'); + expect(result[1].id).toBe('1'); + }); + + it('should query sessions with pagination', async () => { + // create test data + await serverDB.insert(sessions).values([ + { id: '1', userId, updatedAt: new Date('2023-01-01') }, + { id: '2', userId, updatedAt: new Date('2023-02-01') }, + { id: '3', userId, updatedAt: new Date('2023-03-01') }, + ]); + + // should return 2 sessions + const result1 = await sessionModel.query({ current: 0, pageSize: 2 }); + expect(result1).toHaveLength(2); + + // should return only 1 session and it's the 2nd one + const result2 = await sessionModel.query({ current: 1, pageSize: 1 }); + expect(result2).toHaveLength(1); + expect(result2[0].id).toBe('2'); + }); + }); + + describe('queryWithGroups', () => { + it('should return sessions grouped by group', async () => { + // 创建测试数据 + await serverDB.transaction(async (trx) => { + await trx.insert(users).values([{ id: '456' }]); + await trx.insert(sessionGroups).values([ + { userId, name: 'Group 1', id: 'group1' }, + { userId, name: 'Group 2', id: 'group2' }, + ]); + await trx.insert(sessions).values([ + { id: '1', userId, groupId: 'group1' }, + { id: '2', userId, groupId: 'group1' }, + { id: '23', userId, groupId: 'group1', pinned: true }, + { id: '3', userId, groupId: 'group2' }, + { id: '4', userId }, + { id: '5', userId, pinned: true }, + { id: '7', userId: '456' }, + ]); + }); + + // 调用 queryWithGroups 方法 + const result = await sessionModel.queryWithGroups(); + + // 断言结果 + expect(result.sessions).toHaveLength(6); + expect(result.sessionGroups).toHaveLength(2); + expect(result.sessionGroups[0].id).toBe('group1'); + expect(result.sessionGroups[0].name).toBe('Group 1'); + + expect(result.sessionGroups[1].id).toBe('group2'); + }); + + it('should return empty groups if no sessions', async () => { + // 调用 queryWithGroups 方法 + const result = await sessionModel.queryWithGroups(); + + // 断言结果 + expect(result.sessions).toHaveLength(0); + expect(result.sessionGroups).toHaveLength(0); + }); + }); + + describe('findById', () => { + it('should find session by ID', async () => { + await serverDB.insert(sessions).values([ + { id: '1', userId }, + { id: '2', userId }, + ]); + + const result = await sessionModel.findByIdOrSlug('1'); + expect(result?.id).toBe('1'); + }); + + it('should return undefined if session not found', async () => { + await serverDB.insert(sessions).values([{ id: '1', userId }]); + + const result = await sessionModel.findByIdOrSlug('2'); + expect(result).toBeUndefined(); + }); + + it('should find with agents', async () => { + await serverDB.transaction(async (trx) => { + await trx.insert(sessions).values([ + { id: '1', userId }, + { id: '2', userId }, + ]); + await trx.insert(agents).values([ + { id: 'a1', title: 'Agent1', userId }, + { id: 'a2', title: 'Agent2', userId }, + ]); + + // @ts-ignore + await trx.insert(agentsToSessions).values([ + { sessionId: '1', agentId: 'a1', userId }, + { sessionId: '2', agentId: 'a2', userId }, + ]); + }); + + const result = await sessionModel.findByIdOrSlug('2'); + + expect(result?.agent).toBeDefined(); + expect(result?.agent.id).toEqual('a2'); + }); + }); + + // describe('getAgentConfigById', () => { + // it('should return agent config by id', async () => { + // await serverDB.transaction(async (trx) => { + // await trx.insert(agents).values([ + // { id: '1', userId, model: 'gpt-3.5-turbo' }, + // { id: '2', userId, model: 'gpt-3.5' }, + // ]); + // + // // @ts-ignore + // await trx.insert(plugins).values([ + // { id: 1, userId, identifier: 'abc', title: 'A1', locale: 'en-US', manifest: {} }, + // { id: 2, userId, identifier: 'b2', title: 'A2', locale: 'en-US', manifest: {} }, + // ]); + // + // await trx.insert(agentsPlugins).values([ + // { agentId: '1', pluginId: 1 }, + // { agentId: '2', pluginId: 2 }, + // { agentId: '1', pluginId: 2 }, + // ]); + // }); + // + // const result = await sessionModel.getAgentConfigById('1'); + // + // expect(result?.id).toBe('1'); + // expect(result?.plugins).toBe(['abc', 'b2']); + // expect(result?.model).toEqual('gpt-3.5-turbo'); + // expect(result?.chatConfig).toBeDefined(); + // }); + // }); + describe('count', () => { + it('should return the count of sessions for the user', async () => { + // 创建测试数据 + await serverDB.insert(users).values([{ id: '456' }]); + await serverDB.insert(sessions).values([ + { id: '1', userId }, + { id: '2', userId }, + { id: '3', userId: '456' }, + ]); + + // 调用 count 方法 + const result = await sessionModel.count(); + + // 断言结果 + expect(result).toBe(2); + }); + + it('should return 0 if no sessions exist for the user', async () => { + // 创建测试数据 + await serverDB.insert(users).values([{ id: '456' }]); + await serverDB.insert(sessions).values([{ id: '3', userId: '456' }]); + + // 调用 count 方法 + const result = await sessionModel.count(); + + // 断言结果 + expect(result).toBe(0); + }); + }); + + describe('queryByKeyword', () => { + it('should return an empty array if keyword is empty', async () => { + const result = await sessionModel.queryByKeyword(''); + expect(result).toEqual([]); + }); + + it('should return sessions with matching title', async () => { + await serverDB.insert(sessions).values([ + { id: '1', userId, title: 'Hello World', description: 'Some description' }, + { id: '2', userId, title: 'Another Session', description: 'Another description' }, + ]); + + const result = await sessionModel.queryByKeyword('hello'); + expect(result).toHaveLength(1); + expect(result[0].id).toBe('1'); + }); + + it('should return sessions with matching description', async () => { + await serverDB.insert(sessions).values([ + { id: '1', userId, title: 'Session 1', description: 'Description with keyword' }, + { id: '2', userId, title: 'Session 2', description: 'Another description' }, + ]); + + const result = await sessionModel.queryByKeyword('keyword'); + expect(result).toHaveLength(1); + expect(result[0].id).toBe('1'); + }); + + it('should return sessions with matching title or description', async () => { + await serverDB.insert(sessions).values([ + { id: '1', userId, title: 'Title with keyword', description: 'Some description' }, + { id: '2', userId, title: 'Another Session', description: 'Description with keyword' }, + { id: '3', userId, title: 'Third Session', description: 'Third description' }, + ]); + + const result = await sessionModel.queryByKeyword('keyword'); + expect(result).toHaveLength(2); + expect(result.map((s) => s.id)).toEqual(['1', '2']); + }); + }); + + describe('create', () => { + it('should create a new session', async () => { + // 调用 create 方法 + const result = await sessionModel.create({ + type: 'agent', + session: { + title: 'New Session', + }, + config: { model: 'gpt-3.5-turbo' }, + }); + + // 断言结果 + const sessionId = result.id; + expect(sessionId).toBeDefined(); + expect(sessionId.startsWith('ssn_')).toBeTruthy(); + expect(result.userId).toBe(userId); + expect(result.type).toBe('agent'); + + const session = await sessionModel.findByIdOrSlug(sessionId); + expect(session).toBeDefined(); + expect(session?.title).toEqual('New Session'); + expect(session?.pinned).toBe(false); + expect(session?.agent?.model).toEqual('gpt-3.5-turbo'); + }); + + it('should create a new session with custom ID', async () => { + // 调用 create 方法,传入自定义 ID + const customId = 'custom-id'; + const result = await sessionModel.create({ + type: 'agent', + config: { model: 'gpt-3.5-turbo' }, + session: { title: 'New Session' }, + id: customId, + }); + + // 断言结果 + expect(result.id).toBe(customId); + }); + }); + + describe.skip('batchCreate', () => { + it('should batch create sessions', async () => { + // 调用 batchCreate 方法 + const sessions: NewSession[] = [ + { + id: '1', + userId, + type: 'agent', + // config: { model: 'gpt-3.5-turbo' }, + title: 'Session 1', + }, + { + id: '2', + userId, + type: 'agent', + // config: { model: 'gpt-4' }, + title: 'Session 2', + }, + ]; + const result = await sessionModel.batchCreate(sessions); + + // 断言结果 + expect(result.rowCount).toEqual(2); + }); + + it.skip('should set group to default if group does not exist', async () => { + // 调用 batchCreate 方法,传入不存在的 group + const sessions: NewSession[] = [ + { + id: '1', + userId, + type: 'agent', + // config: { model: 'gpt-3.5-turbo' }, + title: 'Session 1', + groupId: 'non-existent-group', + }, + ]; + const result = await sessionModel.batchCreate(sessions); + + // 断言结果 + // expect(result[0].group).toBe('default'); + }); + }); + + describe('duplicate', () => { + it('should duplicate a session', async () => { + // 创建一个用户和一个 session + await serverDB.transaction(async (trx) => { + await trx + .insert(sessions) + .values({ id: '1', userId, type: 'agent', title: 'Original Session', pinned: true }); + await trx.insert(agents).values({ id: 'agent-1', userId, model: 'gpt-3.5-turbo' }); + await trx.insert(agentsToSessions).values({ agentId: 'agent-1', sessionId: '1' }); + }); + + // 调用 duplicate 方法 + const result = (await sessionModel.duplicate('1', 'Duplicated Session')) as SessionItem; + + // 断言结果 + expect(result.id).not.toBe('1'); + expect(result.userId).toBe(userId); + expect(result.type).toBe('agent'); + + const session = await sessionModel.findByIdOrSlug(result.id); + + expect(session).toBeDefined(); + expect(session?.title).toEqual('Duplicated Session'); + expect(session?.pinned).toBe(true); + expect(session?.agent?.model).toEqual('gpt-3.5-turbo'); + }); + + it('should return undefined if session does not exist', async () => { + // 调用 duplicate 方法,传入不存在的 session ID + const result = await sessionModel.duplicate('non-existent-id'); + + // 断言结果 + expect(result).toBeUndefined(); + }); + }); + + describe('update', () => { + it('should update a session', async () => { + // 创建一个测试 session + const sessionId = '123'; + await serverDB.insert(sessions).values({ userId, id: sessionId, title: 'Test Session' }); + + // 调用 update 方法更新 session + const updatedSessions = await sessionModel.update(sessionId, { + title: 'Updated Test Session', + description: 'This is an updated test session', + }); + + // 断言更新后的结果 + expect(updatedSessions).toHaveLength(1); + expect(updatedSessions[0].title).toBe('Updated Test Session'); + expect(updatedSessions[0].description).toBe('This is an updated test session'); + }); + + it('should not update a session if user ID does not match', async () => { + // 创建一个测试 session,但使用不同的 user ID + await serverDB.insert(users).values([{ id: '777' }]); + + const sessionId = '123'; + + await serverDB + .insert(sessions) + .values({ userId: '777', id: sessionId, title: 'Test Session' }); + + // 尝试更新这个 session,应该不会有任何更新 + const updatedSessions = await sessionModel.update(sessionId, { + title: 'Updated Test Session', + }); + + expect(updatedSessions).toHaveLength(0); + }); + }); + + describe('delete', () => { + it('should handle deleting a session with no associated messages or topics', async () => { + // 创建测试数据 + await serverDB.insert(sessions).values({ id: '1', userId }); + + // 调用 delete 方法 + await sessionModel.delete('1'); + + // 断言删除结果 + const result = await serverDB.select({ id: sessions.id }).from(sessions); + + expect(result).toHaveLength(0); + }); + + it('should handle concurrent deletions gracefully', async () => { + // 创建测试数据 + await serverDB.insert(sessions).values({ id: '1', userId }); + + // 并发调用 delete 方法 + await Promise.all([sessionModel.delete('1'), sessionModel.delete('1')]); + + // 断言删除结果 + const result = await serverDB.select({ id: sessions.id }).from(sessions); + + expect(result).toHaveLength(0); + }); + + it('should delete a session and its associated topics and messages', async () => { + // Create a session + const sessionId = '1'; + await serverDB.insert(sessions).values({ id: sessionId, userId }); + + // Create some topics and messages associated with the session + await serverDB.insert(topics).values([ + { id: '1', sessionId, userId }, + { id: '2', sessionId, userId }, + ]); + await serverDB.insert(messages).values([ + { id: '1', sessionId, userId, role: 'user' }, + { id: '2', sessionId, userId, role: 'assistant' }, + ]); + + // Delete the session + await sessionModel.delete(sessionId); + + // Check that the session, topics, and messages are deleted + expect(await serverDB.select().from(sessions).where(eq(sessions.id, sessionId))).toHaveLength( + 0, + ); + expect( + await serverDB.select().from(topics).where(eq(topics.sessionId, sessionId)), + ).toHaveLength(0); + expect( + await serverDB.select().from(messages).where(eq(messages.sessionId, sessionId)), + ).toHaveLength(0); + }); + + it('should not delete sessions belonging to other users', async () => { + // Create two users + const anotherUserId = idGenerator('user'); + await serverDB.insert(users).values({ id: anotherUserId }); + + // Create a session for each user + await serverDB.insert(sessions).values([ + { id: '1', userId }, + { id: '2', userId: anotherUserId }, + ]); + + // Delete the session belonging to the current user + await sessionModel.delete('1'); + + // Check that only the session belonging to the current user is deleted + expect(await serverDB.select().from(sessions).where(eq(sessions.id, '1'))).toHaveLength(0); + expect(await serverDB.select().from(sessions).where(eq(sessions.id, '2'))).toHaveLength(1); + }); + }); + + describe('batchDelete', () => { + it('should handle deleting sessions with no associated messages or topics', async () => { + // 创建测试数据 + await serverDB.insert(sessions).values([ + { id: '1', userId }, + { id: '2', userId }, + ]); + + // 调用 batchDelete 方法 + await sessionModel.batchDelete(['1', '2']); + + // 断言删除结果 + const result = await serverDB.select({ id: sessions.id }).from(sessions); + + expect(result).toHaveLength(0); + }); + + it('should handle concurrent batch deletions gracefully', async () => { + // 创建测试数据 + await serverDB.insert(sessions).values([ + { id: '1', userId }, + { id: '2', userId }, + ]); + + // 并发调用 batchDelete 方法 + await Promise.all([ + sessionModel.batchDelete(['1', '2']), + sessionModel.batchDelete(['1', '2']), + ]); + + // 断言删除结果 + const result = await serverDB.select({ id: sessions.id }).from(sessions); + + expect(result).toHaveLength(0); + }); + + it('should delete multiple sessions and their associated topics and messages', async () => { + // Create some sessions + const sessionIds = ['1', '2', '3']; + await serverDB.insert(sessions).values(sessionIds.map((id) => ({ id, userId }))); + + // Create some topics and messages associated with the sessions + await serverDB.insert(topics).values([ + { id: '1', sessionId: '1', userId }, + { id: '2', sessionId: '2', userId }, + { id: '3', sessionId: '3', userId }, + ]); + await serverDB.insert(messages).values([ + { id: '1', sessionId: '1', userId, role: 'user' }, + { id: '2', sessionId: '2', userId, role: 'assistant' }, + { id: '3', sessionId: '3', userId, role: 'user' }, + ]); + + // Delete the sessions + await sessionModel.batchDelete(sessionIds); + + // Check that the sessions, topics, and messages are deleted + expect( + await serverDB.select().from(sessions).where(inArray(sessions.id, sessionIds)), + ).toHaveLength(0); + expect( + await serverDB.select().from(topics).where(inArray(topics.sessionId, sessionIds)), + ).toHaveLength(0); + expect( + await serverDB.select().from(messages).where(inArray(messages.sessionId, sessionIds)), + ).toHaveLength(0); + }); + + it('should not delete sessions belonging to other users', async () => { + // Create two users + await serverDB.insert(users).values([{ id: '456' }]); + + // Create some sessions for each user + await serverDB.insert(sessions).values([ + { id: '1', userId }, + { id: '2', userId }, + { id: '3', userId: '456' }, + ]); + + // Delete the sessions belonging to the current user + await sessionModel.batchDelete(['1', '2']); + + // Check that only the sessions belonging to the current user are deleted + expect( + await serverDB + .select() + .from(sessions) + .where(inArray(sessions.id, ['1', '2'])), + ).toHaveLength(0); + expect(await serverDB.select().from(sessions).where(eq(sessions.id, '3'))).toHaveLength(1); + }); + }); +}); diff --git a/src/database/server/models/__tests__/topic.test.ts b/src/database/server/models/__tests__/topic.test.ts new file mode 100644 index 0000000000000..f58a44a0544b1 --- /dev/null +++ b/src/database/server/models/__tests__/topic.test.ts @@ -0,0 +1,640 @@ +// @vitest-environment edge-runtime +import { eq, inArray } from 'drizzle-orm'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { serverDB } from '../../core/db'; +import { messages, sessions, topics, users } from '../../schemas/lobechat'; +import { CreateTopicParams, TopicModel } from '../topic'; + +const userId = 'topic-user-test'; +const sessionId = 'topic-session'; +const topicModel = new TopicModel(userId); + +describe('TopicModel', () => { + beforeEach(async () => { + await serverDB.delete(users); + + // 创建测试数据 + await serverDB.transaction(async (tx) => { + await tx.insert(users).values({ id: userId }); + await tx.insert(sessions).values({ id: sessionId, userId }); + }); + }); + + afterEach(async () => { + // 在每个测试用例之后,清空表 + await serverDB.delete(users); + }); + + describe('query', () => { + it('should query topics by user ID', async () => { + // 创建一些测试数据 + await serverDB.transaction(async (tx) => { + await tx.insert(users).values([{ id: '456' }]); + + await tx.insert(topics).values([ + { id: '1', userId, sessionId, updatedAt: new Date('2023-01-01') }, + { id: '4', userId, sessionId, updatedAt: new Date('2023-03-01') }, + { id: '2', userId, sessionId, updatedAt: new Date('2023-02-01'), favorite: true }, + { id: '5', userId, sessionId, updatedAt: new Date('2023-05-01'), favorite: true }, + { id: '3', userId: '456', sessionId, updatedAt: new Date('2023-03-01') }, + ]); + }); + + // 调用 query 方法 + const result = await topicModel.query({ sessionId }); + + // 断言结果 + expect(result).toHaveLength(4); + expect(result[0].id).toBe('5'); // favorite 的 topic 应该在前面,按照 updatedAt 降序排序 + expect(result[1].id).toBe('2'); + expect(result[2].id).toBe('4'); // 按照 updatedAt 降序排序 + }); + + it('should query topics with pagination', async () => { + // 创建测试数据 + await serverDB.insert(topics).values([ + { id: '1', userId, updatedAt: new Date('2023-01-01') }, + { id: '2', userId, updatedAt: new Date('2023-02-01') }, + { id: '3', userId, updatedAt: new Date('2023-03-01') }, + ]); + + // 应该返回 2 个 topics + const result1 = await topicModel.query({ current: 0, pageSize: 2 }); + expect(result1).toHaveLength(2); + + // 应该只返回 1 个 topic,并且是第 2 个 + const result2 = await topicModel.query({ current: 1, pageSize: 1 }); + expect(result2).toHaveLength(1); + expect(result2[0].id).toBe('2'); + }); + + it('should query topics by session ID', async () => { + // 创建测试数据 + await serverDB.transaction(async (tx) => { + await tx.insert(sessions).values([ + { id: 'session1', userId }, + { id: 'session2', userId }, + ]); + + await tx.insert(topics).values([ + { id: '1', userId, sessionId: 'session1' }, + { id: '2', userId, sessionId: 'session2' }, + { id: '3', userId }, // 没有 sessionId + ]); + }); + + // 应该只返回属于 session1 的 topic + const result = await topicModel.query({ sessionId: 'session1' }); + expect(result).toHaveLength(1); + expect(result[0].id).toBe('1'); + }); + + it('should return topics based on pagination parameters', async () => { + // 创建测试数据 + await serverDB.insert(topics).values([ + { id: 'topic1', sessionId, userId, updatedAt: new Date('2023-01-01') }, + { id: 'topic2', sessionId, userId, updatedAt: new Date('2023-01-02') }, + { id: 'topic3', sessionId, userId, updatedAt: new Date('2023-01-03') }, + ]); + + // 调用 query 方法 + const result1 = await topicModel.query({ current: 0, pageSize: 2, sessionId }); + const result2 = await topicModel.query({ current: 1, pageSize: 2, sessionId }); + + // 断言返回结果符合分页要求 + expect(result1).toHaveLength(2); + expect(result1[0].id).toBe('topic3'); + expect(result1[1].id).toBe('topic2'); + + expect(result2).toHaveLength(1); + expect(result2[0].id).toBe('topic1'); + }); + }); + + describe('findById', () => { + it('should return a topic by id', async () => { + // 创建测试数据 + await serverDB.insert(topics).values({ id: 'topic1', sessionId, userId }); + + // 调用 findById 方法 + const result = await topicModel.findById('topic1'); + + // 断言返回结果符合预期 + expect(result?.id).toBe('topic1'); + }); + + it('should return undefined for non-existent topic', async () => { + // 调用 findById 方法 + const result = await topicModel.findById('non-existent'); + + // 断言返回 undefined + expect(result).toBeUndefined(); + }); + }); + + describe('queryAll', () => { + it('should return all topics', async () => { + // 创建测试数据 + await serverDB.insert(topics).values([ + { id: 'topic1', sessionId, userId }, + { id: 'topic2', sessionId, userId }, + ]); + + // 调用 queryAll 方法 + const result = await topicModel.queryAll(); + + // 断言返回所有的 topics + expect(result).toHaveLength(2); + expect(result[0].id).toBe('topic1'); + expect(result[1].id).toBe('topic2'); + }); + }); + + describe('queryByKeyword', () => { + it('should return topics matching topic title keyword', async () => { + // 创建测试数据 + await serverDB.transaction(async (tx) => { + await tx.insert(topics).values([ + { id: 'topic1', title: 'Hello world', sessionId, userId }, + { id: 'topic2', title: 'Goodbye', sessionId, userId }, + ]); + await tx + .insert(messages) + .values([ + { id: 'message1', role: 'assistant', content: 'abc there', topicId: 'topic1', userId }, + ]); + }); + // 调用 queryByKeyword 方法 + const result = await topicModel.queryByKeyword('hello', sessionId); + + // 断言返回匹配关键字的 topic + expect(result).toHaveLength(1); + expect(result[0].id).toBe('topic1'); + }); + + it('should return topics matching message content keyword', async () => { + // 创建测试数据 + await serverDB.transaction(async (tx) => { + await tx.insert(topics).values([ + { id: 'topic1', title: 'abc world', sessionId, userId }, + { id: 'topic2', title: 'Goodbye', sessionId, userId }, + ]); + await tx.insert(messages).values([ + { + id: 'message1', + role: 'assistant', + content: 'Hello there', + topicId: 'topic1', + userId, + }, + ]); + }); + // 调用 queryByKeyword 方法 + const result = await topicModel.queryByKeyword('hello', sessionId); + + // 断言返回匹配关键字的 topic + expect(result).toHaveLength(1); + expect(result[0].id).toBe('topic1'); + }); + + it('should return topics matching message translateContent keyword', async () => { + // 创建测试数据 + await serverDB.transaction(async (tx) => { + await tx.insert(topics).values([ + { id: 'topic1', title: 'abc world', sessionId, userId }, + { id: 'topic2', title: 'Goodbye', sessionId, userId }, + ]); + await tx.insert(messages).values([ + { + id: 'message1', + role: 'assistant', + content: 'hi there', + translateContent: '你好!', + topicId: 'topic1', + userId, + }, + ]); + }); + + // 调用 queryByKeyword 方法 + const result = await topicModel.queryByKeyword('你', sessionId); + + // 断言返回匹配关键字的 topic + expect(result).toHaveLength(1); + expect(result[0].id).toBe('topic1'); + }); + + it('should return nothing if not match', async () => { + // 创建测试数据 + await serverDB.insert(topics).values([ + { id: 'topic1', title: 'Hello world', userId }, + { id: 'topic2', title: 'Goodbye', sessionId, userId }, + ]); + await serverDB + .insert(messages) + .values([ + { id: 'message1', role: 'assistant', content: 'abc there', topicId: 'topic1', userId }, + ]); + + // 调用 queryByKeyword 方法 + const result = await topicModel.queryByKeyword('hello', sessionId); + + // 断言返回匹配关键字的 topic + expect(result).toHaveLength(0); + }); + }); + + describe('count', () => { + it('should return total number of topics', async () => { + // 创建测试数据 + await serverDB.insert(topics).values([ + { id: 'abc_topic1', sessionId, userId }, + { id: 'abc_topic2', sessionId, userId }, + ]); + + // 调用 count 方法 + const result = await topicModel.count(); + + // 断言返回 topics 总数 + expect(result).toBe(2); + }); + }); + + describe('delete', () => { + it('should delete a topic and its associated messages', async () => { + const topicId = 'topic1'; + await serverDB.transaction(async (tx) => { + await tx.insert(users).values({ id: '345' }); + await tx.insert(sessions).values([ + { id: 'session1', userId }, + { id: 'session2', userId: '345' }, + ]); + await tx.insert(topics).values([ + { id: topicId, sessionId: 'session1', userId }, + { id: 'topic2', sessionId: 'session2', userId: '345' }, + ]); + await tx.insert(messages).values([ + { id: 'message1', role: 'user', topicId: topicId, userId }, + { id: 'message2', role: 'assistant', topicId: topicId, userId }, + { id: 'message3', role: 'user', topicId: 'topic2', userId: '345' }, + ]); + }); + + // 调用 delete 方法 + await topicModel.delete(topicId); + + // 断言 topic 和关联的 messages 都被删除了 + expect( + await serverDB.select().from(messages).where(eq(messages.topicId, topicId)), + ).toHaveLength(0); + expect(await serverDB.select().from(topics)).toHaveLength(1); + + expect(await serverDB.select().from(messages)).toHaveLength(1); + }); + }); + + describe('batchDeleteBySessionId', () => { + it('should delete all topics associated with a session', async () => { + await serverDB.insert(sessions).values([ + { id: 'session1', userId }, + { id: 'session2', userId }, + ]); + await serverDB.insert(topics).values([ + { id: 'topic1', sessionId: 'session1', userId }, + { id: 'topic2', sessionId: 'session1', userId }, + { id: 'topic3', sessionId: 'session2', userId }, + { id: 'topic4', userId }, + ]); + + // 调用 batchDeleteBySessionId 方法 + await topicModel.batchDeleteBySessionId('session1'); + + // 断言属于 session1 的 topics 都被删除了 + expect( + await serverDB.select().from(topics).where(eq(topics.sessionId, 'session1')), + ).toHaveLength(0); + expect(await serverDB.select().from(topics)).toHaveLength(2); + }); + it('should delete all topics associated without sessionId', async () => { + await serverDB.insert(sessions).values([{ id: 'session1', userId }]); + + await serverDB.insert(topics).values([ + { id: 'topic1', sessionId: 'session1', userId }, + { id: 'topic2', sessionId: 'session1', userId }, + { id: 'topic4', userId }, + ]); + + // 调用 batchDeleteBySessionId 方法 + await topicModel.batchDeleteBySessionId(); + + // 断言属于 session1 的 topics 都被删除了 + expect( + await serverDB.select().from(topics).where(eq(topics.sessionId, 'session1')), + ).toHaveLength(2); + expect(await serverDB.select().from(topics)).toHaveLength(2); + }); + }); + + describe('batchDelete', () => { + it('should delete multiple topics and their associated messages', async () => { + await serverDB.transaction(async (tx) => { + await tx.insert(sessions).values({ id: 'session1', userId }); + await tx.insert(topics).values([ + { id: 'topic1', sessionId: 'session1', userId }, + { id: 'topic2', sessionId: 'session1', userId }, + { id: 'topic3', sessionId: 'session1', userId }, + ]); + await tx.insert(messages).values([ + { id: 'message1', role: 'user', topicId: 'topic1', userId }, + { id: 'message2', role: 'assistant', topicId: 'topic2', userId }, + { id: 'message3', role: 'user', topicId: 'topic3', userId }, + ]); + }); + + // 调用 batchDelete 方法 + await topicModel.batchDelete(['topic1', 'topic2']); + + // 断言指定的 topics 和关联的 messages 都被删除了 + expect(await serverDB.select().from(topics)).toHaveLength(1); + expect(await serverDB.select().from(messages)).toHaveLength(1); + }); + }); + + describe('deleteAll', () => { + it('should delete all topics of the user', async () => { + await serverDB.insert(users).values({ id: '345' }); + await serverDB.insert(sessions).values([ + { id: 'session1', userId }, + { id: 'session2', userId: '345' }, + ]); + await serverDB.insert(topics).values([ + { id: 'topic1', sessionId: 'session1', userId }, + { id: 'topic2', sessionId: 'session1', userId }, + { id: 'topic3', sessionId: 'session2', userId: '345' }, + ]); + + // 调用 deleteAll 方法 + await topicModel.deleteAll(); + + // 断言当前用户的所有 topics 都被删除了 + expect(await serverDB.select().from(topics).where(eq(topics.userId, userId))).toHaveLength(0); + expect(await serverDB.select().from(topics)).toHaveLength(1); + }); + }); + + describe('update', () => { + it('should update a topic', async () => { + // 创建一个测试 session + const topicId = '123'; + await serverDB.insert(topics).values({ userId, id: topicId, title: 'Test', favorite: true }); + + // 调用 update 方法更新 session + const item = await topicModel.update(topicId, { + title: 'Updated Test', + favorite: false, + }); + + // 断言更新后的结果 + expect(item).toHaveLength(1); + expect(item[0].title).toBe('Updated Test'); + expect(item[0].favorite).toBeFalsy(); + }); + + it('should not update a topic if user ID does not match', async () => { + // 创建一个测试 topic, 但使用不同的 user ID + await serverDB.insert(users).values([{ id: '456' }]); + const topicId = '123'; + await serverDB + .insert(topics) + .values({ userId: '456', id: topicId, title: 'Test', favorite: true }); + + // 尝试更新这个 topic , 应该不会有任何更新 + const item = await topicModel.update(topicId, { + title: 'Updated Test Session', + }); + + expect(item).toHaveLength(0); + }); + }); + + describe('create', () => { + it('should create a new topic and associate messages', async () => { + const topicData = { + title: 'New Topic', + favorite: true, + sessionId, + messages: ['message1', 'message2'], + } satisfies CreateTopicParams; + + const topicId = 'new-topic'; + + // 预先创建一些 messages + await serverDB.insert(messages).values([ + { id: 'message1', role: 'user', userId, sessionId }, + { id: 'message2', role: 'assistant', userId, sessionId }, + { id: 'message3', role: 'user', userId, sessionId }, + ]); + + // 调用 create 方法 + const createdTopic = await topicModel.create(topicData, topicId); + + // 断言返回的 topic 数据正确 + expect(createdTopic).toEqual({ + id: topicId, + title: 'New Topic', + favorite: true, + sessionId, + userId, + createdAt: expect.any(Date), + updatedAt: expect.any(Date), + }); + + // 断言 topic 已在数据库中创建 + const dbTopic = await serverDB.select().from(topics).where(eq(topics.id, topicId)); + expect(dbTopic).toHaveLength(1); + expect(dbTopic[0]).toEqual(createdTopic); + + // 断言关联的 messages 的 topicId 已更新 + const associatedMessages = await serverDB + .select() + .from(messages) + .where(inArray(messages.id, topicData.messages!)); + expect(associatedMessages).toHaveLength(2); + expect(associatedMessages.every((msg) => msg.topicId === topicId)).toBe(true); + + // 断言未关联的 message 的 topicId 没有更新 + const unassociatedMessage = await serverDB + .select() + .from(messages) + .where(eq(messages.id, 'message3')); + + expect(unassociatedMessage[0].topicId).toBeNull(); + }); + + it('should create a new topic without associating messages', async () => { + const topicData = { + title: 'New Topic', + favorite: false, + sessionId, + }; + + const topicId = 'new-topic'; + + // 调用 create 方法 + const createdTopic = await topicModel.create(topicData, topicId); + + // 断言返回的 topic 数据正确 + expect(createdTopic).toEqual({ + id: topicId, + title: 'New Topic', + favorite: false, + sessionId, + userId, + createdAt: expect.any(Date), + updatedAt: expect.any(Date), + }); + + // 断言 topic 已在数据库中创建 + const dbTopic = await serverDB.select().from(topics).where(eq(topics.id, topicId)); + expect(dbTopic).toHaveLength(1); + expect(dbTopic[0]).toEqual(createdTopic); + }); + }); + + describe('batchCreate', () => { + it('should batch create topics and update associated messages', async () => { + // 准备测试数据 + const topicParams = [ + { + title: 'Topic 1', + favorite: true, + sessionId, + messages: ['message1', 'message2'], + }, + { + title: 'Topic 2', + favorite: false, + sessionId, + messages: ['message3'], + }, + ]; + await serverDB.insert(messages).values([ + { id: 'message1', role: 'user', userId }, + { id: 'message2', role: 'assistant', userId }, + { id: 'message3', role: 'user', userId }, + ]); + + // 调用 batchCreate 方法 + const createdTopics = await topicModel.batchCreate(topicParams); + + // 断言返回的 topics 数据正确 + expect(createdTopics).toHaveLength(2); + expect(createdTopics[0]).toMatchObject({ + title: 'Topic 1', + favorite: true, + sessionId, + userId, + }); + expect(createdTopics[1]).toMatchObject({ + title: 'Topic 2', + favorite: false, + sessionId, + userId, + }); + + // 断言 topics 表中的数据正确 + const items = await serverDB.select().from(topics); + expect(items).toHaveLength(2); + expect(items[0]).toMatchObject({ + title: 'Topic 1', + favorite: true, + sessionId, + userId, + }); + expect(items[1]).toMatchObject({ + title: 'Topic 2', + favorite: false, + sessionId, + userId, + }); + + // 断言关联的 messages 的 topicId 被正确更新 + const updatedMessages = await serverDB.select().from(messages); + expect(updatedMessages).toHaveLength(3); + expect(updatedMessages[0].topicId).toBe(createdTopics[0].id); + expect(updatedMessages[1].topicId).toBe(createdTopics[0].id); + expect(updatedMessages[2].topicId).toBe(createdTopics[1].id); + }); + + it('should generate topic IDs if not provided', async () => { + // 准备测试数据 + const topicParams = [ + { + title: 'Topic 1', + favorite: true, + sessionId, + }, + { + title: 'Topic 2', + favorite: false, + sessionId, + }, + ]; + + // 调用 batchCreate 方法 + const createdTopics = await topicModel.batchCreate(topicParams); + + // 断言生成了正确的 topic ID + expect(createdTopics[0].id).toBeDefined(); + expect(createdTopics[1].id).toBeDefined(); + expect(createdTopics[0].id).not.toBe(createdTopics[1].id); + }); + }); + + describe('duplicate', () => { + it('should duplicate a topic and its associated messages', async () => { + const topicId = 'topic-duplicate'; + const newTitle = 'Duplicated Topic'; + + // 创建原始的 topic 和 messages + await serverDB.transaction(async (tx) => { + await tx.insert(topics).values({ id: topicId, sessionId, userId, title: 'Original Topic' }); + await tx.insert(messages).values([ + { id: 'message1', role: 'user', topicId, userId, content: 'User message' }, + { id: 'message2', role: 'assistant', topicId, userId, content: 'Assistant message' }, + ]); + }); + + // 调用 duplicate 方法 + const { topic: duplicatedTopic, messages: duplicatedMessages } = await topicModel.duplicate( + topicId, + newTitle, + ); + + // 断言复制的 topic 的属性正确 + expect(duplicatedTopic.id).not.toBe(topicId); + expect(duplicatedTopic.title).toBe(newTitle); + expect(duplicatedTopic.sessionId).toBe(sessionId); + expect(duplicatedTopic.userId).toBe(userId); + + // 断言复制的 messages 的属性正确 + expect(duplicatedMessages).toHaveLength(2); + expect(duplicatedMessages[0].id).not.toBe('message1'); + expect(duplicatedMessages[0].topicId).toBe(duplicatedTopic.id); + expect(duplicatedMessages[0].content).toBe('User message'); + expect(duplicatedMessages[1].id).not.toBe('message2'); + expect(duplicatedMessages[1].topicId).toBe(duplicatedTopic.id); + expect(duplicatedMessages[1].content).toBe('Assistant message'); + }); + + it('should throw an error if the topic to duplicate does not exist', async () => { + const topicId = 'nonexistent-topic'; + + // 调用 duplicate 方法,期望抛出错误 + await expect(topicModel.duplicate(topicId)).rejects.toThrow( + `Topic with id ${topicId} not found`, + ); + }); + }); +}); diff --git a/src/database/server/models/_template.ts b/src/database/server/models/_template.ts new file mode 100644 index 0000000000000..a5d9fd2ad5911 --- /dev/null +++ b/src/database/server/models/_template.ts @@ -0,0 +1,44 @@ +import { eq } from 'drizzle-orm'; +import { and, desc } from 'drizzle-orm/expressions'; + +import { serverDB } from '@/database/server'; + +import { NewSessionGroup, UserItem, sessionGroups } from '../schemas/lobechat'; + +export class TemplateModel { + private userId: string; + + constructor(userId: string) { + this.userId = userId; + } + + create = async (params: NewSessionGroup) => { + return serverDB.insert(sessionGroups).values({ ...params, userId: this.userId }); + }; + + delete = async (id: string) => { + return serverDB + .delete(sessionGroups) + .where(and(eq(sessionGroups.id, id), eq(sessionGroups.userId, this.userId))); + }; + + query = async () => { + return serverDB.query.sessionGroups.findMany({ + orderBy: [desc(sessionGroups.updatedAt)], + where: eq(sessionGroups.userId, this.userId), + }); + }; + + findById = async (id: string) => { + return serverDB.query.sessionGroups.findFirst({ + where: and(eq(sessionGroups.id, id), eq(sessionGroups.userId, this.userId)), + }); + }; + + async update(id: string, value: Partial) { + return serverDB + .update(sessionGroups) + .set({ ...value, updatedAt: new Date() }) + .where(and(eq(sessionGroups.id, id), eq(sessionGroups.userId, this.userId))); + } +} diff --git a/src/database/server/models/file.ts b/src/database/server/models/file.ts new file mode 100644 index 0000000000000..4e8969cae1b9b --- /dev/null +++ b/src/database/server/models/file.ts @@ -0,0 +1,51 @@ +import { eq } from 'drizzle-orm'; +import { and, desc } from 'drizzle-orm/expressions'; + +import { serverDB } from '@/database/server'; + +import { FileItem, NewFile, files } from '../schemas/lobechat'; + +export class FileModel { + private readonly userId: string; + + constructor(userId: string) { + this.userId = userId; + } + + create = async (params: Omit) => { + const result = await serverDB + .insert(files) + .values({ ...params, userId: this.userId }) + .returning(); + + return { id: result[0].id }; + }; + + delete = async (id: string) => { + return serverDB.delete(files).where(and(eq(files.id, id), eq(files.userId, this.userId))); + }; + + clear = async () => { + return serverDB.delete(files).where(eq(files.userId, this.userId)); + }; + + query = async () => { + return serverDB.query.files.findMany({ + orderBy: [desc(files.updatedAt)], + where: eq(files.userId, this.userId), + }); + }; + + findById = async (id: string) => { + return serverDB.query.files.findFirst({ + where: and(eq(files.id, id), eq(files.userId, this.userId)), + }); + }; + + async update(id: string, value: Partial) { + return serverDB + .update(files) + .set({ ...value, updatedAt: new Date() }) + .where(and(eq(files.id, id), eq(files.userId, this.userId))); + } +} diff --git a/src/database/server/models/message.ts b/src/database/server/models/message.ts new file mode 100644 index 0000000000000..e85d259a0f844 --- /dev/null +++ b/src/database/server/models/message.ts @@ -0,0 +1,377 @@ +import { count, sql } from 'drizzle-orm'; +import { and, asc, desc, eq, isNull, like } from 'drizzle-orm/expressions'; +import { inArray } from 'drizzle-orm/sql/expressions/conditions'; + +import { CreateMessageParams } from '@/database/client/models/message'; +import { idGenerator } from '@/database/server/utils/idGenerator'; +import { ChatMessage, ChatTTS, ChatToolPayload } from '@/types/message'; +import { merge } from '@/utils/merge'; + +import { serverDB } from '../core/db'; +import { + MessageItem, + filesToMessages, + messagePlugins, + messageTTS, + messageTranslates, + messages, +} from '../schemas/lobechat'; + +export interface QueryMessageParams { + current?: number; + pageSize?: number; + sessionId?: string | null; + topicId?: string | null; +} + +export class MessageModel { + private userId: string; + + constructor(userId: string) { + this.userId = userId; + } + + // **************** Query *************** // + async query({ + current = 0, + pageSize = 1000, + sessionId, + topicId, + }: QueryMessageParams = {}): Promise { + const offset = current * pageSize; + + const result = await serverDB + .select({ + /* eslint-disable sort-keys-fix/sort-keys-fix*/ + id: messages.id, + role: messages.role, + content: messages.content, + error: messages.error, + + model: messages.model, + provider: messages.provider, + + createdAt: messages.createdAt, + updatedAt: messages.updatedAt, + + parentId: messages.parentId, + + tools: messages.tools, + tool_call_id: messagePlugins.toolCallId, + + plugin: { + apiName: messagePlugins.apiName, + arguments: messagePlugins.arguments, + identifier: messagePlugins.identifier, + type: messagePlugins.type, + }, + pluginError: messagePlugins.error, + pluginState: messagePlugins.state, + + translate: { + content: messageTranslates.content, + from: messageTranslates.from, + to: messageTranslates.to, + }, + ttsId: messageTTS.id, + tts: { + contentMd5: messageTTS.contentMd5, + file: messageTTS.fileId, + voice: messageTTS.voice, + }, + /* eslint-enable */ + }) + .from(messages) + .where( + and( + eq(messages.userId, this.userId), + this.matchSession(sessionId), + this.matchTopic(topicId), + ), + ) + .leftJoin(messagePlugins, eq(messagePlugins.id, messages.id)) + .leftJoin(messageTranslates, eq(messageTranslates.id, messages.id)) + .leftJoin(messageTTS, eq(messageTTS.id, messages.id)) + .orderBy(asc(messages.createdAt)) + .limit(pageSize) + .offset(offset); + + const messageIds = result.map((message) => message.id as string); + + if (messageIds.length === 0) return result; + + const fileIds = await serverDB + .select({ + fileId: filesToMessages.fileId, + messageId: filesToMessages.messageId, + }) + .from(filesToMessages) + .where(inArray(filesToMessages.messageId, messageIds)); + + return result.map((item) => + this.mapToChatMessage({ + ...item, + files: fileIds.filter((relation) => relation.messageId === item.id).map((r) => r.fileId), + }), + ); + } + + async findById(id: string) { + return serverDB.query.messages.findFirst({ + where: and(eq(messages.id, id), eq(messages.userId, this.userId)), + }); + } + + async queryAll(): Promise { + return serverDB + .select() + .from(messages) + .orderBy(messages.createdAt) + .where(eq(messages.userId, this.userId)) + + .execute(); + } + + async queryBySessionId(sessionId?: string): Promise { + return serverDB.query.messages.findMany({ + orderBy: [asc(messages.createdAt)], + where: and(eq(messages.userId, this.userId), this.matchSession(sessionId)), + }); + } + + async queryByKeyword(keyword: string): Promise { + if (!keyword) return []; + + return serverDB.query.messages.findMany({ + orderBy: [desc(messages.createdAt)], + where: and(eq(messages.userId, this.userId), like(messages.content, `%${keyword}%`)), + }); + } + + async count() { + const result = await serverDB + .select({ + count: count(), + }) + .from(messages) + .where(eq(messages.userId, this.userId)) + .execute(); + + return result[0].count; + } + + async countToday() { + const today = new Date(); + today.setHours(0, 0, 0, 0); + const tomorrow = new Date(today); + tomorrow.setDate(tomorrow.getDate() + 1); + + const result = await serverDB + .select({ + count: count(), + }) + .from(messages) + .where( + and( + eq(messages.userId, this.userId), + sql`${messages.createdAt} >= ${today} AND ${messages.createdAt} < ${tomorrow}`, + ), + ) + .execute(); + + return result[0].count; + } + + // **************** Create *************** // + + async create( + { fromModel, fromProvider, files, ...message }: CreateMessageParams, + id: string = this.genId(), + ): Promise { + return serverDB.transaction(async (trx) => { + const [item] = (await trx + .insert(messages) + .values({ + ...message, + id, + model: fromModel, + provider: fromProvider, + userId: this.userId, + }) + .returning()) as MessageItem[]; + + // Insert the plugin data if the message is a tool + if (message.role === 'tool') { + await trx.insert(messagePlugins).values({ + apiName: message.plugin?.apiName, + arguments: message.plugin?.arguments, + id, + identifier: message.plugin?.identifier, + toolCallId: message.tool_call_id, + type: message.plugin?.type, + }); + } + + if (files && files.length > 0) { + await trx + .insert(filesToMessages) + .values(files.map((file) => ({ fileId: file, messageId: id }))); + } + + return item; + }); + } + + async batchCreate(newMessages: MessageItem[]) { + const messagesToInsert = newMessages.map((m) => { + return { ...m, userId: this.userId }; + }); + + return serverDB.insert(messages).values(messagesToInsert); + } + + // **************** Update *************** // + + async update(id: string, message: Partial) { + return serverDB + .update(messages) + .set(message) + .where(and(eq(messages.id, id), eq(messages.userId, this.userId))); + } + + async updatePluginState(id: string, state: Record) { + const item = await serverDB.query.messagePlugins.findFirst({ + where: eq(messagePlugins.id, id), + }); + if (!item) throw new Error('Plugin not found'); + + return serverDB + .update(messagePlugins) + .set({ state: merge(item.state || {}, state) }) + .where(eq(messagePlugins.id, id)); + } + + async updateTranslate(id: string, translate: Partial) { + const result = await serverDB.query.messageTranslates.findFirst({ + where: and(eq(messageTranslates.id, id)), + }); + + // If the message does not exist in the translate table, insert it + if (!result) { + return serverDB.insert(messageTranslates).values({ ...translate, id }); + } + + // or just update the existing one + return serverDB.update(messageTranslates).set(translate).where(eq(messageTranslates.id, id)); + } + + async updateTTS(id: string, tts: Partial) { + const result = await serverDB.query.messageTTS.findFirst({ + where: and(eq(messageTTS.id, id)), + }); + + // If the message does not exist in the translate table, insert it + if (!result) { + return serverDB + .insert(messageTTS) + .values({ contentMd5: tts.contentMd5, fileId: tts.file, id, voice: tts.voice }); + } + + // or just update the existing one + return serverDB + .update(messageTTS) + .set({ contentMd5: tts.contentMd5, fileId: tts.file, voice: tts.voice }) + .where(eq(messageTTS.id, id)); + } + + // **************** Delete *************** // + + async deleteMessage(id: string) { + return serverDB.transaction(async (tx) => { + // 1. 查询要删除的 message 的完整信息 + const message = await tx + .select() + .from(messages) + .where(and(eq(messages.id, id), eq(messages.userId, this.userId))) + .limit(1); + + // 如果找不到要删除的 message,直接返回 + if (message.length === 0) return; + + // 2. 检查 message 是否包含 tools + const toolCallIds = message[0].tools?.map((tool: ChatToolPayload) => tool.id).filter(Boolean); + + let relatedMessageIds: string[] = []; + + if (toolCallIds?.length > 0) { + // 3. 如果 message 包含 tools,查询出所有相关联的 message id + const res = await tx + .select({ id: messagePlugins.id }) + .from(messagePlugins) + .where(inArray(messagePlugins.toolCallId, toolCallIds)) + .execute(); + + relatedMessageIds = res.map((row) => row.id); + } + + // 4. 合并要删除的 message id 列表 + const messageIdsToDelete = [id, ...relatedMessageIds]; + + // 5. 删除所有相关的 message + await tx.delete(messages).where(inArray(messages.id, messageIdsToDelete)); + }); + } + + async deleteMessageTranslate(id: string) { + return serverDB.delete(messageTranslates).where(and(eq(messageTranslates.id, id))); + } + + async deleteMessageTTS(id: string) { + return serverDB.delete(messageTTS).where(and(eq(messageTTS.id, id))); + } + + async deleteMessages(sessionId?: string | null, topicId?: string | null) { + return serverDB + .delete(messages) + .where( + and( + eq(messages.userId, this.userId), + this.matchSession(sessionId), + this.matchTopic(topicId), + ), + ); + } + + async deleteAllMessages() { + return serverDB.delete(messages).where(eq(messages.userId, this.userId)); + } + + // **************** Helper *************** // + + private genId = () => idGenerator('messages', 14); + + private matchSession = (sessionId?: string | null) => + sessionId ? eq(messages.sessionId, sessionId) : isNull(messages.sessionId); + + private matchTopic = (topicId?: string | null) => + topicId ? eq(messages.topicId, topicId) : isNull(messages.topicId); + + private mapToChatMessage = ({ + translate, + tts, + model, + provider, + ttsId, + ...item + }: any): ChatMessage => { + return { + ...item, + extra: { + fromModel: model, + fromProvider: provider, + translate, + tts: tts ?? !!ttsId ? {} : null, + }, + }; + }; +} diff --git a/src/database/server/models/plugin.ts b/src/database/server/models/plugin.ts new file mode 100644 index 0000000000000..99089827c0cfd --- /dev/null +++ b/src/database/server/models/plugin.ts @@ -0,0 +1,63 @@ +import { and, desc, eq } from 'drizzle-orm/expressions'; + +import { serverDB } from '@/database/server'; + +import { InstalledPluginItem, NewInstalledPlugin, installedPlugins } from '../schemas/lobechat'; + +export class PluginModel { + private userId: string; + + constructor(userId: string) { + this.userId = userId; + } + + create = async ( + params: Pick, + ) => { + const [result] = await serverDB + .insert(installedPlugins) + .values({ ...params, createdAt: new Date(), updatedAt: new Date(), userId: this.userId }) + .returning(); + + return result; + }; + + delete = async (id: string) => { + return serverDB + .delete(installedPlugins) + .where(and(eq(installedPlugins.identifier, id), eq(installedPlugins.userId, this.userId))); + }; + + deleteAll = async () => { + return serverDB.delete(installedPlugins).where(eq(installedPlugins.userId, this.userId)); + }; + + query = async () => { + return serverDB + .select({ + createdAt: installedPlugins.createdAt, + customParams: installedPlugins.customParams, + identifier: installedPlugins.identifier, + manifest: installedPlugins.manifest, + settings: installedPlugins.settings, + type: installedPlugins.type, + updatedAt: installedPlugins.updatedAt, + }) + .from(installedPlugins) + .where(eq(installedPlugins.userId, this.userId)) + .orderBy(desc(installedPlugins.createdAt)); + }; + + findById = async (id: string) => { + return serverDB.query.installedPlugins.findFirst({ + where: and(eq(installedPlugins.identifier, id), eq(installedPlugins.userId, this.userId)), + }); + }; + + async update(id: string, value: Partial) { + return serverDB + .update(installedPlugins) + .set({ ...value, updatedAt: new Date() }) + .where(and(eq(installedPlugins.identifier, id), eq(installedPlugins.userId, this.userId))); + } +} diff --git a/src/database/server/models/session.ts b/src/database/server/models/session.ts new file mode 100644 index 0000000000000..38fb60a376415 --- /dev/null +++ b/src/database/server/models/session.ts @@ -0,0 +1,290 @@ +import { Column, asc, count, inArray, like, sql } from 'drizzle-orm'; +import { and, desc, eq, isNull, not, or } from 'drizzle-orm/expressions'; + +import { appEnv } from '@/config/app'; +import { INBOX_SESSION_ID } from '@/const/session'; +import { DEFAULT_AGENT_CONFIG } from '@/const/settings'; +import { parseAgentConfig } from '@/server/globalConfig/parseDefaultAgent'; +import { ChatSessionList, LobeAgentSession } from '@/types/session'; +import { merge } from '@/utils/merge'; + +import { serverDB } from '../core/db'; +import { + AgentItem, + NewAgent, + NewSession, + SessionItem, + agents, + agentsToSessions, + sessionGroups, + sessions, +} from '../schemas/lobechat'; +import { idGenerator } from '../utils/idGenerator'; + +export class SessionModel { + private userId: string; + + constructor(userId: string) { + this.userId = userId; + } + // **************** Query *************** // + + async query({ current = 0, pageSize = 9999 } = {}) { + const offset = current * pageSize; + + return serverDB.query.sessions.findMany({ + limit: pageSize, + offset, + orderBy: [desc(sessions.updatedAt)], + where: and(eq(sessions.userId, this.userId), not(eq(sessions.slug, INBOX_SESSION_ID))), + with: { agentsToSessions: { columns: {}, with: { agent: true } }, group: true }, + }); + } + + async queryWithGroups(): Promise { + // 查询所有会话 + const result = await this.query(); + + const groups = await serverDB.query.sessionGroups.findMany({ + orderBy: [asc(sessionGroups.sort), desc(sessionGroups.createdAt)], + where: eq(sessions.userId, this.userId), + }); + + return { + sessionGroups: groups as unknown as ChatSessionList['sessionGroups'], + sessions: result.map((item) => this.mapSessionItem(item as any)), + }; + } + + async queryByKeyword(keyword: string) { + if (!keyword) return []; + + const keywordLowerCase = keyword.toLowerCase(); + + const data = await this.findSessions({ keyword: keywordLowerCase }); + + return data.map((item) => this.mapSessionItem(item as any)); + } + + async findByIdOrSlug( + idOrSlug: string, + ): Promise<(SessionItem & { agent: AgentItem }) | undefined> { + const result = await serverDB.query.sessions.findFirst({ + where: and( + or(eq(sessions.id, idOrSlug), eq(sessions.slug, idOrSlug)), + eq(sessions.userId, this.userId), + ), + with: { agentsToSessions: { columns: {}, with: { agent: true } }, group: true }, + }); + + if (!result) return; + + return { ...result, agent: (result?.agentsToSessions?.[0] as any)?.agent } as any; + } + + async count() { + const result = await serverDB + .select({ + count: count(), + }) + .from(sessions) + .where(eq(sessions.userId, this.userId)) + .execute(); + + return result[0].count; + } + + // **************** Create *************** // + + async create({ + id = idGenerator('sessions'), + type = 'agent', + session = {}, + config = {}, + slug, + }: { + config?: Partial; + id?: string; + session?: Partial; + slug?: string; + type: 'agent' | 'group'; + }): Promise { + return serverDB.transaction(async (trx) => { + const newAgents = await trx + .insert(agents) + .values({ + ...config, + createdAt: new Date(), + id: idGenerator('agents'), + updatedAt: new Date(), + userId: this.userId, + }) + .returning(); + + const result = await trx + .insert(sessions) + .values({ + ...session, + createdAt: new Date(), + id, + slug, + type, + updatedAt: new Date(), + userId: this.userId, + }) + .returning(); + + await trx.insert(agentsToSessions).values({ + agentId: newAgents[0].id, + sessionId: id, + }); + + return result[0]; + }); + } + + async createInbox() { + const serverAgentConfig = parseAgentConfig(appEnv.DEFAULT_AGENT_CONFIG) || {}; + + return await this.create({ + config: merge(DEFAULT_AGENT_CONFIG, serverAgentConfig), + slug: INBOX_SESSION_ID, + type: 'agent', + }); + } + + async batchCreate(newSessions: NewSession[]) { + const sessionsToInsert = newSessions.map((s) => { + return { + ...s, + id: this.genId(), + userId: this.userId, + }; + }); + + return serverDB.insert(sessions).values(sessionsToInsert); + } + + async duplicate(id: string, newTitle?: string) { + const result = await this.findByIdOrSlug(id); + + if (!result) return; + + const { agent, ...session } = result; + const sessionId = this.genId(); + + return this.create({ + config: agent, + id: sessionId, + session: { + ...session, + title: newTitle || session.title, + }, + type: 'agent', + }); + } + + // **************** Delete *************** // + + /** + * Delete a session, also delete all messages and topics associated with it. + */ + async delete(id: string) { + return serverDB + .delete(sessions) + .where(and(eq(sessions.id, id), eq(sessions.userId, this.userId))); + } + + /** + * Batch delete sessions, also delete all messages and topics associated with them. + */ + async batchDelete(ids: string[]) { + return serverDB + .delete(sessions) + .where(and(inArray(sessions.id, ids), eq(sessions.userId, this.userId))); + } + + async deleteAll() { + return serverDB.delete(sessions).where(eq(sessions.userId, this.userId)); + } + // **************** Update *************** // + + async update(id: string, data: Partial) { + return serverDB + .update(sessions) + .set(data) + .where(and(eq(sessions.id, id), eq(sessions.userId, this.userId))) + .returning(); + } + + async updateConfig(id: string, data: Partial) { + return serverDB + .update(agents) + .set(data) + .where(and(eq(agents.id, id), eq(agents.userId, this.userId))); + } + + // **************** Helper *************** // + + private genId = () => idGenerator('sessions'); + + private mapSessionItem = ({ + agentsToSessions, + title, + backgroundColor, + description, + avatar, + groupId, + ...res + }: SessionItem & { agentsToSessions?: { agent: AgentItem }[] }): LobeAgentSession => { + // TODO: 未来这里需要更好的实现方案,目前只取第一个 + const agent = agentsToSessions?.[0]?.agent; + return { + ...res, + group: groupId, + meta: { + avatar: agent?.avatar ?? avatar ?? undefined, + backgroundColor: agent?.backgroundColor ?? backgroundColor ?? undefined, + description: agent?.description ?? description ?? undefined, + title: agent?.title ?? title ?? undefined, + }, + model: agent?.model, + } as any; + }; + + async findSessions(params: { + current?: number; + group?: string; + keyword?: string; + pageSize?: number; + pinned?: boolean; + }) { + const { pinned, keyword, group, pageSize = 9999, current = 0 } = params; + + const offset = current * pageSize; + return serverDB.query.sessions.findMany({ + limit: pageSize, + offset, + orderBy: [desc(sessions.updatedAt)], + where: and( + eq(sessions.userId, this.userId), + pinned !== undefined ? eq(sessions.pinned, pinned) : eq(sessions.userId, this.userId), + keyword + ? or( + like( + sql`lower(${sessions.title})` as unknown as Column, + `%${keyword.toLowerCase()}%`, + ), + like( + sql`lower(${sessions.description})` as unknown as Column, + `%${keyword.toLowerCase()}%`, + ), + ) + : eq(sessions.userId, this.userId), + group ? eq(sessions.groupId, group) : isNull(sessions.groupId), + ), + + with: { agentsToSessions: { columns: {}, with: { agent: true } }, group: true }, + }); + } +} diff --git a/src/database/server/models/sessionGroup.ts b/src/database/server/models/sessionGroup.ts new file mode 100644 index 0000000000000..ee644fef22873 --- /dev/null +++ b/src/database/server/models/sessionGroup.ts @@ -0,0 +1,69 @@ +import { eq } from 'drizzle-orm'; +import { and, asc, desc } from 'drizzle-orm/expressions'; + +import { serverDB } from '@/database/server'; +import { idGenerator } from '@/database/server/utils/idGenerator'; + +import { SessionGroupItem, sessionGroups } from '../schemas/lobechat'; + +export class SessionGroupModel { + private userId: string; + + constructor(userId: string) { + this.userId = userId; + } + + create = async (params: { name: string; sort?: number }) => { + const [result] = await serverDB + .insert(sessionGroups) + .values({ ...params, id: this.genId(), userId: this.userId }) + .returning(); + + return result; + }; + + delete = async (id: string) => { + return serverDB + .delete(sessionGroups) + .where(and(eq(sessionGroups.id, id), eq(sessionGroups.userId, this.userId))); + }; + + deleteAll = async () => { + return serverDB.delete(sessionGroups); + }; + + query = async () => { + return serverDB.query.sessionGroups.findMany({ + orderBy: [asc(sessionGroups.sort), desc(sessionGroups.createdAt)], + where: eq(sessionGroups.userId, this.userId), + }); + }; + + findById = async (id: string) => { + return serverDB.query.sessionGroups.findFirst({ + where: and(eq(sessionGroups.id, id), eq(sessionGroups.userId, this.userId)), + }); + }; + + async update(id: string, value: Partial) { + return serverDB + .update(sessionGroups) + .set({ ...value, updatedAt: new Date() }) + .where(and(eq(sessionGroups.id, id), eq(sessionGroups.userId, this.userId))); + } + + async updateOrder(sortMap: { id: string; sort: number }[]) { + await serverDB.transaction(async (tx) => { + const updates = sortMap.map(({ id, sort }) => { + return tx + .update(sessionGroups) + .set({ sort, updatedAt: new Date() }) + .where(and(eq(sessionGroups.id, id), eq(sessionGroups.userId, this.userId))); + }); + + await Promise.all(updates); + }); + } + + private genId = () => idGenerator('sessionGroups'); +} diff --git a/src/database/server/models/topic.ts b/src/database/server/models/topic.ts new file mode 100644 index 0000000000000..13565d78818fd --- /dev/null +++ b/src/database/server/models/topic.ts @@ -0,0 +1,264 @@ +import { Column, count, inArray, sql } from 'drizzle-orm'; +import { and, desc, eq, exists, isNull, like, or } from 'drizzle-orm/expressions'; + +import { serverDB } from '../core/db'; +import { NewMessage, TopicItem, messages, topics } from '../schemas/lobechat'; +import { idGenerator } from '../utils/idGenerator'; + +export interface CreateTopicParams { + favorite?: boolean; + messages?: string[]; + sessionId?: string | null; + title: string; +} + +interface QueryTopicParams { + current?: number; + pageSize?: number; + sessionId?: string | null; +} + +export class TopicModel { + private userId: string; + + constructor(userId: string) { + this.userId = userId; + } + // **************** Query *************** // + + async query({ current = 0, pageSize = 9999, sessionId }: QueryTopicParams = {}) { + const offset = current * pageSize; + + return ( + serverDB + .select({ + createdAt: topics.createdAt, + favorite: topics.favorite, + id: topics.id, + title: topics.title, + updatedAt: topics.updatedAt, + }) + .from(topics) + .where(and(eq(topics.userId, this.userId), this.matchSession(sessionId))) + // In boolean sorting, false is considered "smaller" than true. + // So here we use desc to ensure that topics with favorite as true are in front. + .orderBy(desc(topics.favorite), desc(topics.updatedAt)) + .limit(pageSize) + .offset(offset) + ); + } + + async findById(id: string) { + return serverDB.query.topics.findFirst({ + where: and(eq(topics.id, id), eq(topics.userId, this.userId)), + }); + } + + async queryAll(): Promise { + return serverDB + .select() + .from(topics) + .orderBy(topics.updatedAt) + .where(eq(topics.userId, this.userId)) + .execute(); + } + + async queryByKeyword(keyword: string, sessionId?: string | null): Promise { + if (!keyword) return []; + + const keywordLowerCase = keyword.toLowerCase(); + + const matchKeyword = (field: any) => + like(sql`lower(${field})` as unknown as Column, `%${keywordLowerCase}%`); + + return serverDB.query.topics.findMany({ + orderBy: [desc(topics.updatedAt)], + where: and( + eq(topics.userId, this.userId), + this.matchSession(sessionId), + or( + matchKeyword(topics.title), + exists( + serverDB + .select() + .from(messages) + .where(and(eq(messages.topicId, topics.id), or(matchKeyword(messages.content)))), + ), + ), + ), + }); + } + + async count() { + const result = await serverDB + .select({ + count: count(), + }) + .from(topics) + .where(eq(topics.userId, this.userId)) + .execute(); + + return result[0].count; + } + + // **************** Create *************** // + + async create( + { messages: messageIds, ...params }: CreateTopicParams, + id: string = this.genId(), + ): Promise { + return serverDB.transaction(async (tx) => { + // 在 topics 表中插入新的 topic + const [topic] = await tx + .insert(topics) + .values({ + ...params, + id: id, + userId: this.userId, + }) + .returning(); + + // 如果有关联的 messages, 更新它们的 topicId + if (messageIds && messageIds.length > 0) { + await tx + .update(messages) + .set({ topicId: topic.id }) + .where(and(eq(messages.userId, this.userId), inArray(messages.id, messageIds))); + } + + return topic; + }); + } + + async batchCreate(topicParams: (CreateTopicParams & { id?: string })[]) { + // 开始一个事务 + return serverDB.transaction(async (tx) => { + // 在 topics 表中批量插入新的 topics + const createdTopics = await tx + .insert(topics) + .values( + topicParams.map((params) => ({ + favorite: params.favorite, + id: params.id || this.genId(), + sessionId: params.sessionId, + title: params.title, + userId: this.userId, + })), + ) + .returning(); + + // 对每个新创建的 topic,更新关联的 messages 的 topicId + await Promise.all( + createdTopics.map(async (topic, index) => { + const messageIds = topicParams[index].messages; + if (messageIds && messageIds.length > 0) { + await tx + .update(messages) + .set({ topicId: topic.id }) + .where(and(eq(messages.userId, this.userId), inArray(messages.id, messageIds))); + } + }), + ); + + return createdTopics; + }); + } + + async duplicate(topicId: string, newTitle?: string) { + return serverDB.transaction(async (tx) => { + // find original topic + const originalTopic = await tx.query.topics.findFirst({ + where: and(eq(topics.id, topicId), eq(topics.userId, this.userId)), + }); + + if (!originalTopic) { + throw new Error(`Topic with id ${topicId} not found`); + } + + // copy topic + const [duplicatedTopic] = await tx + .insert(topics) + .values({ + ...originalTopic, + id: this.genId(), + title: newTitle || originalTopic?.title, + }) + .returning(); + + // 查找与原始 topic 关联的 messages + const originalMessages = await tx + .select() + .from(messages) + .where(and(eq(messages.topicId, topicId), eq(messages.userId, this.userId))); + + // copy messages + const duplicatedMessages = await Promise.all( + originalMessages.map(async (message) => { + const result = (await tx + .insert(messages) + .values({ + ...message, + id: idGenerator('messages'), + topicId: duplicatedTopic.id, + }) + .returning()) as NewMessage[]; + + return result[0]; + }), + ); + + return { + messages: duplicatedMessages, + topic: duplicatedTopic, + }; + }); + } + + // **************** Delete *************** // + + /** + * Delete a session, also delete all messages and topics associated with it. + */ + async delete(id: string) { + return serverDB.delete(topics).where(and(eq(topics.id, id), eq(topics.userId, this.userId))); + } + + /** + * Deletes multiple topics based on the sessionId. + */ + async batchDeleteBySessionId(sessionId?: string | null) { + return serverDB + .delete(topics) + .where(and(this.matchSession(sessionId), eq(topics.userId, this.userId))); + } + + /** + * Deletes multiple topics and all messages associated with them in a transaction. + */ + async batchDelete(ids: string[]) { + return serverDB + .delete(topics) + .where(and(inArray(topics.id, ids), eq(topics.userId, this.userId))); + } + + async deleteAll() { + return serverDB.delete(topics).where(eq(topics.userId, this.userId)); + } + + // **************** Update *************** // + + async update(id: string, data: Partial) { + return serverDB + .update(topics) + .set({ ...data, updatedAt: new Date() }) + .where(and(eq(topics.id, id), eq(topics.userId, this.userId))) + .returning(); + } + + // **************** Helper *************** // + + private genId = () => idGenerator('topics'); + + private matchSession = (sessionId?: string | null) => + sessionId ? eq(topics.sessionId, sessionId) : isNull(topics.sessionId); +} diff --git a/src/database/server/models/user.ts b/src/database/server/models/user.ts new file mode 100644 index 0000000000000..177a26ce9623c --- /dev/null +++ b/src/database/server/models/user.ts @@ -0,0 +1,138 @@ +import { TRPCError } from '@trpc/server'; +import { eq } from 'drizzle-orm'; +import { DeepPartial } from 'utility-types'; + +import { serverDB } from '@/database/server'; +import { KeyVaultsGateKeeper } from '@/server/keyVaultsEncrypt'; +import { UserPreference } from '@/types/user'; +import { UserSettings } from '@/types/user/settings'; +import { merge } from '@/utils/merge'; + +import { NewUser, UserItem, userSettings, users } from '../schemas/lobechat'; +import { SessionModel } from './session'; + +export class UserModel { + createUser = async (params: NewUser) => { + const [user] = await serverDB + .insert(users) + .values({ ...params }) + .returning(); + + // Create an inbox session for the user + const model = new SessionModel(user.id); + + await model.createInbox(); + }; + + deleteUser = async (id: string) => { + return serverDB.delete(users).where(eq(users.id, id)); + }; + + findById = async (id: string) => { + return serverDB.query.users.findFirst({ where: eq(users.id, id) }); + }; + + getUserState = async (id: string) => { + const result = await serverDB + .select({ + isOnboarded: users.isOnboarded, + preference: users.preference, + + settingsDefaultAgent: userSettings.defaultAgent, + settingsGeneral: userSettings.general, + settingsKeyVaults: userSettings.keyVaults, + settingsLanguageModel: userSettings.languageModel, + settingsSystemAgent: userSettings.systemAgent, + settingsTTS: userSettings.tts, + settingsTool: userSettings.tool, + }) + .from(users) + .where(eq(users.id, id)) + .leftJoin(userSettings, eq(users.id, userSettings.id)); + + if (!result || !result[0]) { + throw new TRPCError({ code: 'UNAUTHORIZED', message: 'user not found' }); + } + + const state = result[0]; + + // Decrypt keyVaults + let decryptKeyVaults = {}; + if (state.settingsKeyVaults) { + const gateKeeper = await KeyVaultsGateKeeper.initWithEnvKey(); + const { wasAuthentic, plaintext } = await gateKeeper.decrypt(state.settingsKeyVaults); + + if (wasAuthentic) { + try { + decryptKeyVaults = JSON.parse(plaintext); + } catch (e) { + console.error(`Failed to parse keyVaults ,userId: ${id}. Error:`, e); + } + } + } + + const settings: DeepPartial = { + defaultAgent: state.settingsDefaultAgent || {}, + general: state.settingsGeneral || {}, + keyVaults: decryptKeyVaults, + languageModel: state.settingsLanguageModel || {}, + systemAgent: state.settingsSystemAgent || {}, + tool: state.settingsTool || {}, + tts: state.settingsTTS || {}, + }; + + return { + isOnboarded: state.isOnboarded, + preference: state.preference as UserPreference, + settings, + userId: id, + }; + }; + + async updateUser(id: string, value: Partial) { + return serverDB + .update(users) + .set({ ...value, updatedAt: new Date() }) + .where(eq(users.id, id)); + } + + async deleteSetting(id: string) { + return serverDB.delete(userSettings).where(eq(userSettings.id, id)); + } + + async updateSetting(id: string, value: Partial) { + const { keyVaults, ...res } = value; + + // Encrypt keyVaults + let encryptedKeyVaults: string | null = null; + + if (keyVaults) { + // TODO: better to add a validation + const data = JSON.stringify(keyVaults); + const gateKeeper = await KeyVaultsGateKeeper.initWithEnvKey(); + + encryptedKeyVaults = await gateKeeper.encrypt(data); + } + + const newValue = { ...res, keyVaults: encryptedKeyVaults }; + + // update or create user settings + const settings = await serverDB.query.userSettings.findFirst({ where: eq(users.id, id) }); + if (!settings) { + await serverDB.insert(userSettings).values({ id, ...newValue }); + return; + } + + return serverDB.update(userSettings).set(newValue).where(eq(userSettings.id, id)); + } + + async updatePreference(id: string, value: Partial) { + const user = await serverDB.query.users.findFirst({ where: eq(users.id, id) }); + if (!user) return; + + return serverDB + .update(users) + .set({ preference: merge(user.preference, value) }) + .where(eq(users.id, id)); + } +} diff --git a/src/database/server/modules/DataImporter/__tests__/fixtures/messages.json b/src/database/server/modules/DataImporter/__tests__/fixtures/messages.json new file mode 100644 index 0000000000000..5f0906c70433b --- /dev/null +++ b/src/database/server/modules/DataImporter/__tests__/fixtures/messages.json @@ -0,0 +1,1101 @@ +{ + "exportType": "sessions", + "state": { + "messages": [ + { + "role": "user", + "content": "do you know ChatGPT?", + "files": [], + "sessionId": "inbox", + "topicId": "6ByGPzqj", + "createdAt": 1710649845534, + "id": "C5CiFuWL", + "updatedAt": 1710649845534, + "extra": {}, + "meta": {} + }, + { + "role": "assistant", + "content": "...", + "parentId": "C5CiFuWL", + "sessionId": "inbox", + "topicId": "6ByGPzqj", + "createdAt": 1710649845605, + "id": "GNfOJrZW", + "updatedAt": 1710649845605, + "extra": { + "fromModel": "mistralai/mistral-7b-instruct:free", + "fromProvider": "openrouter" + }, + "meta": {} + }, + { + "role": "user", + "content": "do you know ChatGPT?", + "files": [], + "sessionId": "inbox", + "topicId": "EjdLCVfG", + "createdAt": 1710649884921, + "id": "0ATdEISY", + "updatedAt": 1710649884921, + "extra": {}, + "meta": {} + }, + { + "role": "assistant", + "content": "Yes, I am familiar with ChatGPT. ChatGPT is a large language model developed and trained by OpenAI. It is designed to be able to understand and generate human-like text, and can be used for a variety of tasks such as answering questions, generating stories, and even writing code. ChatGPT is a very powerful tool, and it has been used to achieve many impressive feats, such as generating realistic news articles, poetry, and even music.", + "parentId": "0ATdEISY", + "sessionId": "inbox", + "topicId": "EjdLCVfG", + "createdAt": 1710649884976, + "id": "PEWjFn07", + "updatedAt": 1710649888838, + "extra": { + "fromModel": "mistralai/mistral-7b-instruct:free", + "fromProvider": "openrouter" + }, + "meta": {} + }, + { + "role": "user", + "content": "do you know ChatGPT?", + "files": [], + "sessionId": "inbox", + "topicId": "EjdLCVfG", + "createdAt": 1710649892956, + "id": "u2MgcL1m", + "updatedAt": 1710649892956, + "extra": {}, + "meta": {} + }, + { + "role": "assistant", + "content": "**Sure, I am familiar with ChatGPT.**\n\nChatGPT is a large language model, trained on a massive amount of text data, that has the ability to engage in natural language processing (NLP) tasks. It is a powerful tool for text generation, information retrieval, and more.\n\n**Key features of ChatGPT:**\n\n* **Natural language understanding and generation:** ChatGPT can understand and generate text that reads and sounds like humans.\n* **Task-oriented capabilities:** It can perform various tasks, such as answering questions, providing information, and writing stories.\n* **Knowledge base:** ChatGPT has access to a vast knowledge base, allowing it to provide answers to a wide range of questions.\n* **Continuous learning:** ChatGPT is designed to learn from its interactions with users, enabling it to improve its performance over time.\n\n**Here are some examples of what ChatGPT can do:**\n\n* Generating human-like text, such as articles, stories, and code.\n* Answering questions in a variety of subjects.\n* Providing information on a wide range of topics.\n* Translating text between languages.\n* Summarizing text.\n* Engaging in conversation and providing information.\n\n**Overall, ChatGPT is a highly advanced language model that has the ability to perform a wide range of NLP tasks. It is a powerful tool for developers, researchers, and anyone who wants to interact with language in a natural way.**", + "parentId": "u2MgcL1m", + "sessionId": "inbox", + "topicId": "EjdLCVfG", + "createdAt": 1710649893011, + "id": "GjneSJG9", + "updatedAt": 1710649900678, + "extra": { "fromModel": "google/gemma-7b-it:free", "fromProvider": "openrouter" }, + "meta": {} + }, + { + "role": "user", + "content": "这是什么", + "files": ["file-wUhrpfNq"], + "sessionId": "inbox", + "topicId": "cfwpdErj", + "createdAt": 1711463400412, + "id": "QQjYFFhE", + "updatedAt": 1711463400412, + "extra": {}, + "meta": {} + }, + { + "role": "assistant", + "content": "这是一个网站的截图,上面显示了一些关于机器学习和人工智能模型的信息。其中包括一些项目的名称,比如“Hugging Face”、“ModelScope”和“WiseModel”。此外,还有一些社交媒体的链接和提示,邀请人们提问、讨论和了解更多信息。\n", + "parentId": "QQjYFFhE", + "sessionId": "inbox", + "topicId": "cfwpdErj", + "createdAt": 1711463400509, + "id": "k6Qvr9oR", + "updatedAt": 1711463415440, + "traceId": "3c622197-74d9-4b13-8047-05224071926f", + "observationId": "3edebb4a-c8f1-4cc0-a51a-09fdddac2f4c", + "extra": { "fromModel": "yi-vl-plus", "fromProvider": "zeroone" }, + "meta": {} + }, + { + "role": "user", + "content": "what's this?", + "files": ["file-nBHOYnWK"], + "sessionId": "inbox", + "topicId": "bqQRiAwL", + "createdAt": 1711463443244, + "id": "FCYSvJe1", + "updatedAt": 1711463443244, + "extra": {}, + "meta": {} + }, + { + "role": "assistant", + "content": "This is an image of a user interface displaying information about a software called \"Yi Open-source.\" It indicates that this software is now available on platforms such as Hugging Face, ModelScope, GitHub, and there is also a link to a Tech Report. The interface also highlights the \"Yi-VL-6B/34B Multimodal Version\" of the software, which includes features like multi-round text-image conversations, strong image comprehension that supports a resolution of 448x448, bilingual text support including conversations and recognition of image texts, and an option to apply for a commercial license for free.\n", + "parentId": "FCYSvJe1", + "sessionId": "inbox", + "topicId": "bqQRiAwL", + "createdAt": 1711463443332, + "id": "UvIyorfg", + "updatedAt": 1711463464752, + "traceId": "0f3225cd-1cc4-4f33-822f-30ce64548726", + "observationId": "c6f2a771-ea0e-4fca-971f-c5641e35727e", + "extra": { "fromModel": "yi-vl-plus", "fromProvider": "zeroone" }, + "meta": {} + }, + { + "role": "user", + "content": "hello", + "files": [], + "sessionId": "inbox", + "topicId": "yxDfgttq", + "createdAt": 1713963716735, + "id": "7P6Zxmh1", + "updatedAt": 1713963716735, + "extra": {}, + "meta": {} + }, + { + "role": "assistant", + "content": "...", + "parentId": "7P6Zxmh1", + "sessionId": "inbox", + "topicId": "yxDfgttq", + "createdAt": 1713963716871, + "id": "2i7CA09H", + "updatedAt": 1713963716972, + "error": { + "body": { + "error": { "message": "Failed to fetch", "name": "TypeError" }, + "provider": "ollama" + }, + "message": "Error requesting Ollama service, please troubleshoot or retry based on the following information", + "type": "OllamaBizError" + }, + "extra": { "fromModel": "llama3", "fromProvider": "ollama" }, + "meta": {} + }, + { + "role": "user", + "content": "hello", + "files": [], + "sessionId": "inbox", + "topicId": "pd0ZhWxQ", + "createdAt": 1714150294555, + "id": "UkqHZ572", + "updatedAt": 1714150294555, + "extra": {}, + "meta": {} + }, + { + "role": "assistant", + "content": "...", + "parentId": "UkqHZ572", + "sessionId": "inbox", + "topicId": "pd0ZhWxQ", + "createdAt": 1714150307021, + "id": "7agzrZam", + "updatedAt": 1714150307155, + "error": { + "body": { + "error": { "message": "Failed to fetch", "name": "TypeError" }, + "provider": "ollama" + }, + "message": "Error requesting Ollama service, please troubleshoot or retry based on the following information", + "type": "OllamaBizError" + }, + "extra": { "fromModel": "llama3", "fromProvider": "ollama" }, + "meta": {} + }, + { + "role": "user", + "content": "hello", + "files": [], + "sessionId": "inbox", + "topicId": "CfR7IRRA", + "createdAt": 1714150457230, + "id": "UW9tPNfX", + "updatedAt": 1714150457230, + "extra": {}, + "meta": {} + }, + { + "role": "assistant", + "content": "...", + "parentId": "UW9tPNfX", + "sessionId": "inbox", + "topicId": "CfR7IRRA", + "createdAt": 1714150457341, + "id": "Trsk6oXJ", + "updatedAt": 1714150457428, + "error": { + "body": { + "error": { "message": "Failed to fetch", "name": "TypeError" }, + "provider": "ollama" + }, + "message": "Error requesting Ollama service, please troubleshoot or retry based on the following information", + "type": "OllamaBizError" + }, + "extra": { "fromModel": "llama3", "fromProvider": "ollama" }, + "meta": {} + }, + { + "role": "user", + "content": "hello", + "files": [], + "sessionId": "inbox", + "topicId": "nK4zHqil", + "createdAt": 1714230184564, + "id": "NS9ZAYLV", + "updatedAt": 1714230184564, + "extra": {}, + "meta": {} + }, + { + "role": "assistant", + "content": "...", + "parentId": "NS9ZAYLV", + "sessionId": "inbox", + "topicId": "nK4zHqil", + "createdAt": 1714230184693, + "id": "EfxUYDvX", + "updatedAt": 1714230184787, + "error": { + "body": { + "error": { "message": "Failed to fetch", "name": "TypeError" }, + "provider": "ollama" + }, + "message": "Error requesting Ollama service, please troubleshoot or retry based on the following information", + "type": "OllamaBizError" + }, + "extra": { "fromModel": "llama3", "fromProvider": "ollama" }, + "meta": {} + }, + { + "role": "user", + "content": "hello", + "files": [], + "sessionId": "inbox", + "topicId": "9py3gBEl", + "createdAt": 1714230284296, + "id": "MZeSp8hy", + "updatedAt": 1714230284296, + "extra": {}, + "meta": {} + }, + { + "role": "assistant", + "content": "Hello! It's nice to meet you. Is there something I can help you with, or would you like to chat?", + "parentId": "MZeSp8hy", + "sessionId": "inbox", + "topicId": "9py3gBEl", + "createdAt": 1714230306608, + "id": "RARh6JLx", + "updatedAt": 1714230315690, + "extra": { "fromModel": "llama3", "fromProvider": "ollama" }, + "meta": {} + }, + { + "role": "user", + "content": "who are you?", + "files": [], + "sessionId": "inbox", + "topicId": "9py3gBEl", + "createdAt": 1714230319444, + "id": "bLnLbiJq", + "updatedAt": 1714230319444, + "extra": {}, + "meta": {} + }, + { + "role": "assistant", + "content": "I am LLaMA, an AI assistant developed by Meta AI that can understand and respond to human input in a conversational manner. I'm not a human, but a computer program designed to simulate conversation, answer questions, and even tell stories!\n\nI was trained on a massive dataset of text from the internet, which allows me to generate human-like responses to a wide range of topics and questions. My training data includes a vast amount of text from various sources, including books, articles, and websites.\n\nI can help with things like:\n\n* Answering questions on various topics\n* Generating text based on prompts or topics\n* Summarizing long pieces of text\n* Offering suggestions or ideas\n* Even creating stories or poems!\n\nSo, what would you like to talk about?", + "parentId": "bLnLbiJq", + "sessionId": "inbox", + "topicId": "9py3gBEl", + "createdAt": 1714230319534, + "id": "yD0tYOHp", + "updatedAt": 1714230323696, + "extra": { "fromModel": "llama3", "fromProvider": "ollama" }, + "meta": {} + }, + { + "role": "user", + "content": "hello", + "files": [], + "sessionId": "inbox", + "topicId": "aaWMcwY1", + "createdAt": 1714230332659, + "id": "7OsS0yD1", + "updatedAt": 1714230332659, + "extra": {}, + "meta": {} + }, + { + "role": "assistant", + "content": "Hello there! How can I assist you today? If you have any questions or need help with something, feel free to ask.", + "parentId": "7OsS0yD1", + "sessionId": "inbox", + "topicId": "aaWMcwY1", + "createdAt": 1714231085959, + "id": "dUjPwtOg", + "updatedAt": 1714231087594, + "extra": { "fromModel": "wizardlm2", "fromProvider": "ollama" }, + "meta": {} + }, + { + "role": "user", + "content": "who are you?", + "files": [], + "sessionId": "inbox", + "topicId": "aaWMcwY1", + "createdAt": 1714231091886, + "id": "5DWajtMx", + "updatedAt": 1714231091886, + "extra": {}, + "meta": {} + }, + { + "role": "assistant", + "content": " I am an AI digital assistant designed to interact with users like you in a conversational manner, providing information, answering questions, and assisting with a wide range of topics based on the knowledge and capabilities programmed into me. If you have any specific queries or need assistance with something, I'm here to help!", + "parentId": "5DWajtMx", + "sessionId": "inbox", + "topicId": "aaWMcwY1", + "createdAt": 1714231091965, + "id": "VsiyaVt2", + "updatedAt": 1714231093814, + "extra": { "fromModel": "wizardlm2", "fromProvider": "ollama" }, + "meta": {} + }, + { + "role": "user", + "content": "tell me a joke", + "files": [], + "sessionId": "inbox", + "topicId": "aaWMcwY1", + "createdAt": 1714231099307, + "id": "wu7djaZW", + "updatedAt": 1714231099307, + "extra": {}, + "meta": {} + }, + { + "role": "assistant", + "content": " Certainly! Here's a classic one for you:\n\nWhy don't scientists trust atoms?\n\nBecause they make up everything!", + "parentId": "wu7djaZW", + "sessionId": "inbox", + "topicId": "aaWMcwY1", + "createdAt": 1714231099407, + "id": "CvTAdaHx", + "updatedAt": 1714231100392, + "extra": { "fromModel": "wizardlm2", "fromProvider": "ollama" }, + "meta": {} + }, + { + "role": "user", + "content": "hello", + "files": [], + "sessionId": "inbox", + "topicId": "2wcF8yaS", + "createdAt": 1714236590340, + "id": "DCG1G1EH", + "updatedAt": 1714236590340, + "extra": {}, + "meta": {} + }, + { + "role": "assistant", + "content": "...", + "parentId": "DCG1G1EH", + "sessionId": "inbox", + "topicId": "2wcF8yaS", + "createdAt": 1714236590441, + "id": "gY41w5vQ", + "updatedAt": 1714236590518, + "error": { + "body": { + "error": { + "message": "model 'mixtral' not found, try pulling it first", + "name": "ResponseError", + "status_code": 404 + }, + "provider": "ollama" + }, + "message": "Error requesting Ollama service, please troubleshoot or retry based on the following information", + "type": "OllamaBizError" + }, + "extra": { "fromModel": "mixtral", "fromProvider": "ollama" }, + "meta": {} + }, + { + "role": "user", + "content": "hello", + "files": [], + "sessionId": "a5fefc88-f6c1-44fb-9e98-3d366b1ed589", + "topicId": "v38snJ0A", + "createdAt": 1717080410895, + "id": "qOIxEGEB", + "updatedAt": 1717080410895, + "extra": {}, + "meta": {} + }, + { + "role": "assistant", + "content": "...", + "parentId": "qOIxEGEB", + "sessionId": "a5fefc88-f6c1-44fb-9e98-3d366b1ed589", + "topicId": "v38snJ0A", + "createdAt": 1717080410970, + "id": "w28FcqY5", + "updatedAt": 1717080411485, + "error": { + "body": { "error": { "errorType": "NoOpenAIAPIKey" }, "provider": "openai" }, + "message": "OpenAI API Key is empty, please add a custom OpenAI API Key", + "type": "NoOpenAIAPIKey" + }, + "extra": { "fromModel": "gpt-3.5-turbo", "fromProvider": "openai" }, + "meta": {} + } + ], + "sessionGroups": [ + { + "name": "Writter", + "sort": 0, + "createdAt": 1706114744425, + "id": "XlUbvOvL", + "updatedAt": 1706114747468 + }, + { + "name": "Devs", + "sort": 1, + "createdAt": 1706114738483, + "id": "517DQHr9", + "updatedAt": 1706114743011 + } + ], + "sessions": [ + { + "config": { + "model": "claude-3-sonnet-20240229", + "params": { + "frequency_penalty": 0, + "presence_penalty": 0, + "temperature": 0.6, + "top_p": 1 + }, + "plugins": [], + "systemRole": "As the Assistant and Judge for TOEFL Writing, your primary role is to deal with tasks related to TOEFL Writing.\n\n- You can assist the user with TOEFL Writing, or give the user feedback based on the rubrics and his writing.\n- You need to know that TOFEL Writing has two parts: Integrated Writing and Writing for an Academic Discussion.\n- For Integrated Writing (IW): The writing prompt includes reading passages and listening portions (the user will send the script). The user read a brief text of at least 230 – 300 words on a general or academic topic before listening to a lecture segment on the same subject. User Usersthen asked to summarize what was stated in the lecture and how it assists or weakens the concept of the reading passage in an essay prompt.\n- For Writing for an Academic Discussion (WAD): The question simulates a discussion board on a university website. Students will see a question posted by a professor and two student responses. The student’s job is to read everything and then write their response.\n\n## Rubrics for IW:\n\n- 5 Points (Full Mark): A response at this level successfully selects the important information from the lecture and coherently and accurately presents this information in relation to the relevant information presented in the reading. The response is well organized, and occasional language errors that are present do not result in inaccurate or imprecise presentation of content or connections.\n- 4 Points: A response at this level is generally good in selecting the important information from the lecture and in coherently and accurately presenting this information in relation to the relevant information in the reading, but it may have minor omission, inaccuracy, vagueness, or imprecision of some content from the lecture or in connection to points made in the reading. A response is also scored at this level if it has more frequent or noticeable minor language errors, as long as such usage and grammatical structures do not result in anything more than an occasional lapse of clarity or in the connection of ideas.\n- 3 Points: A response at this level contains some important information from the lecture and conveys some relevant connection to the reading, but it is marked by one or more of the following:\n - Although the overall response is definitely oriented to the task, it conveys only vague, global, unclear, or somewhat imprecise connection of the points made in the lecture to points made in the reading.\n - The response may omit one major key point made in the lecture.\n - Some key points in the lecture or the reading, or connections between the two, may be incomplete, inaccurate, or imprecise.\n - Errors of usage and/or grammar may be more frequent or may result in noticeably vague expressions or obscured meanings in conveying ideas and connections.\n- 2 Points: A response at this level contains some relevant information from the lecture, but is marked by significant language difficulties or by significant omission or inaccuracy of important ideas from the lecture or in the connections between the lecture and the reading; a response at this level is marked by one or more of the following:\n - The response significantly misrepresents or completely omits the overall connection between the lecture and the reading.\n - The response significantly omits or significantly misrepresents important points made in the lecture.\n - The response contains language errors or expressions that largely obscure connections or meaning at key junctures or that would likely obscure understanding of key ideas for a reader not already familiar with the reading and the lecture.\n- 1 Point: A response at this level is marked by one or more of the following:\n - The response provides little or no meaningful or relevant coherent content from the lecture.\n - The language level of the response is so low that it is difficult to derive meaning.\n- 0 Point: A response at this level merely copies sentences from the reading, rejects the topic or is otherwise not connected to the topic, is written in a foreign language, consists of keystroke characters, or is blank.\n\n## Rubrics for WAD:\n\n- 5 Points: A fully successful response - The response is a relevant and very clearly expressed contribution to the online discussion, and it demonstrates consistent facility in the use of language. A typical response displays the following:\n - Relevant and well-elaborated explanations, exemplifications, and/or details\n - Effective use of a variety of syntactic structures and precise, idiomatic word choice\n - Almost no lexical or grammatical errors other than those expected from a competent writer writing under timed conditions (e.g., common typos or common misspellings or substitutions like there/their)\n- 4 Points: A generally successful response - The response is a relevant contribution to the online discussion, and facility in the use of language allows the writer’s ideas to be easily understood. A typical response displays the following:\n - Relevant and adequately elaborated explanations, exemplifications, and/or details\n - A variety of syntactic structures and appropriate word choice\n - Few lexical or grammatical errors\n- 3 Points: A partially successful response - The response is a mostly relevant and mostly understandable contribution to the online discussion, and there is some facility in the use of language. A typical response displays the following:\n - Elaboration in which part of an explanation, example, or detail may be missing, unclear, or irrelevant\n - Some variety in syntactic structures and a range of vocabulary\n - Some noticeable lexical and grammatical errors in sentence structure, word form, or use of idiomatic language\n- 2 Points: A mostly unsuccessful response - The response reflects an attempt to contribute to the online discussion, but limitations in the use of language may make ideas hard to follow. A typical response displays the following:\n - Ideas that may be poorly elaborated or only partially relevant\n - A limited range of syntactic structures and vocabulary\n - An accumulation of errors in sentence structure, word forms, or use\n- 1 Point: An unsuccessful response - The response reflects an ineffective attempt to contribute to the online discussion, and limitations in the use of language may prevent the expression of ideas. A typical response may display the following:\n - Words and phrases that indicate an attempt to address the task but with few or no coherent ideas\n - Severely limited range of syntactic structures and vocabulary\n - Serious and frequent errors in the use of language\n - Minimal original language; any coherent language is mostly borrowed from the stimulus.\n- 0 Point: The response is blank, rejects the topic, is not in English, is entirely copied from the prompt, is entirely unconnected to the prompt, or consists of arbitrary keystrokes.\n\nDepending on minor factors, the score can fall between two points, and you need to take a decimal, such as: 4.25 or 4.5, depending on the other details.\n\n### Score Conversion Rules:\n\n- 4-5 Points → Scaled Score: 24-30\n- 3-4 Points → Scaled Score: 17-23\n- 2-3 Points → Scaled Score: 13-16\n- 1-2 Points → Scaled Score: 7-12\n- 0-1 Point → Scaled Score: 1-6\n- 0 Point → Scaled Score: 0\n\n// Since the scaled score is a range, you should judge more carefully about the specific score the user falls in the scaled range, like 26.\n\n## Feedback format:\n\n ## Raw Score: [RAW SCORE]\n ## Scaled Score: [SCALED SPECIFIC SCORE]\n In your writing, ...[Specific problems in user's writing(Use bullet list)]\n", + "tts": { + "showAllLocaleVoice": false, + "sttLocale": "auto", + "ttsService": "openai", + "voice": { "openai": "alloy" } + }, + "provider": "anthropic", + "chatConfig": { + "autoCreateTopicThreshold": 2, + "displayMode": "chat", + "enableAutoCreateTopic": true, + "historyCount": 1 + } + }, + "group": "default", + "meta": { + "avatar": "📝", + "description": "Your TOEFL Writing assistant and evaluator, specializing in feedback and guidance.", + "tags": ["writing", "study"], + "title": "TOEFL Writing Tutor" + }, + "type": "agent", + "createdAt": "2024-01-24T16:43:37.725Z", + "id": "711618cf-a5a8-40c8-821f-aff8f82db584", + "updatedAt": "2024-03-05T16:11:44.241Z", + "pinned": true, + "model": "claude-3-sonnet-20240229" + }, + { + "config": { + "model": "gpt-3.5-turbo", + "params": { + "frequency_penalty": 0, + "presence_penalty": 0, + "temperature": 0.6, + "top_p": 1 + }, + "plugins": [], + "systemRole": "You are a developer expert in Flutter framework and Dart programming language. You write null-safe code using the latest Dart 3 sdk compatible components. Your speciality is to write custom widgets that are visually unique. Follow the instructions carefully and write efficient code.\n", + "tts": { + "showAllLocaleVoice": false, + "sttLocale": "auto", + "ttsService": "openai", + "voice": { "openai": "alloy" } + }, + "chatConfig": { + "autoCreateTopicThreshold": 2, + "displayMode": "chat", + "enableAutoCreateTopic": true, + "historyCount": 1 + } + }, + "group": "517DQHr9", + "meta": { + "avatar": "📱", + "description": "A developer expert in Flutter framework and Dart programming language.", + "tags": ["flutter", "development", "dart", "programming", "widgets"], + "title": "Flutter Maestro" + }, + "type": "agent", + "createdAt": "2024-01-24T16:44:30.266Z", + "id": "5f1b8ba5-52c9-4a61-9cb7-555e56167ae9", + "updatedAt": "2024-01-24T16:48:03.375Z", + "pinned": false, + "model": "gpt-3.5-turbo" + }, + { + "config": { + "model": "gpt-3.5-turbo", + "params": { + "frequency_penalty": 0, + "presence_penalty": 0, + "temperature": 0.6, + "top_p": 1 + }, + "plugins": [], + "systemRole": "# Role: AE Script Development Expert\n\n## Profile\n\nAs a programmer specializing in developing scripts and plugins for Adobe After Effects software, adept at using programming to enhance the efficiency and quality of video production, with a solid programming foundation and a deep understanding of AE software. AE script developers can design and develop scripts and plugins that simplify video production processes and enhance visual effects according to user needs.\n\n### Proficiency in JavaScript programming language:\n\n1. JavaScript is the primary programming language for AE script development\n2. Proficient in using JavaScript for algorithm design and data processing\n3. Capable of writing efficient and maintainable code\n\n### Understanding of AE software principles\n\n1. Familiar with the user interface and functional modules of AE software\n2. Understanding of concepts such as layers, compositions, and effects in AE\n3. Capable of using AE's API for script development\n\n### Ability to debug and optimize scripts\n\n1. Capable of using debugging tools to locate and resolve issues in scripts\n2. Performance optimization of scripts to improve efficiency\n3. Ensuring script compatibility and stability\n\n## Rules\n\n1. Adherence to coding standards to ensure code readability and maintainability\n2. Ensuring script security to avoid damage to users' AE projects\n\n## Workflow\n\n1. Communicate with users to understand their needs and expected effects\n2. Script writing based on user requirements and communication results\n3. Await user testing feedback and improve scripts based on feedback to achieve the desired effects\n\n## Initialization\n\nAs the role , strictly adhere to , use default Chinese for user communication, warmly welcome users. Then introduce yourself and inform users about .\n", + "tts": { + "showAllLocaleVoice": false, + "sttLocale": "auto", + "ttsService": "openai", + "voice": { "openai": "alloy" } + }, + "chatConfig": { + "autoCreateTopicThreshold": 2, + "displayMode": "chat", + "enableAutoCreateTopic": true, + "historyCount": 1 + } + }, + "group": "517DQHr9", + "meta": { + "avatar": "🧏", + "description": "An expert in AE script development, proficient in JavaScript programming, understanding of AE software principles, capable of debugging and optimizing scripts.", + "tags": [ + "script development", + "programmer", + "adobe-after-effects", + "java-script", + "algorithm design", + "debugging", + "optimization", + "coding standards", + "user communication", + "script usage instructions" + ], + "title": "AE Script Development Expert" + }, + "type": "agent", + "createdAt": "2024-01-24T16:45:09.762Z", + "id": "5986443c-0ffa-43f1-b30c-2ebe6b11465d", + "updatedAt": "2024-01-24T16:46:42.016Z", + "model": "gpt-3.5-turbo", + "pinned": false + }, + { + "config": { + "model": "gpt-3.5-turbo", + "params": { + "frequency_penalty": 0, + "presence_penalty": 0, + "temperature": 0.6, + "top_p": 1 + }, + "plugins": [], + "systemRole": "Please serve as the weekly report summary generation assistant. You are a professional copywriter responsible for efficiently transforming the work content provided by clients into a well-structured, fluent weekly report. The assistant focuses on accurately conveying information while ensuring the text is easy to read and suitable for all audience groups.\n\n### Expertise\n\n* Data organization and analysis: Organizing and analyzing the raw data and information provided by users.\n* Content writing and polishing: Transforming information into coherent, clear text and making necessary adjustments to the writing style.\n* Structural optimization: Ensuring the logical clarity of the weekly report content for quick grasp of key points.\n\n### Rules\n\n* Maintain the accuracy and completeness of information.\n* Ensure smooth text and clear, concise language.\n* Follow the format and style requirements specified by the client.\n\n### Process\n\n* Collect the work content and data provided by users.\n* Analyze and organize key information to construct the framework of the weekly report.\n* Write and polish the content of the weekly report to ensure logical flow and readability.\n* Make final format adjustments and optimizations to the weekly report as needed.\n", + "tts": { + "showAllLocaleVoice": false, + "sttLocale": "auto", + "ttsService": "openai", + "voice": { "openai": "alloy" } + }, + "chatConfig": { + "autoCreateTopicThreshold": 2, + "displayMode": "chat", + "enableAutoCreateTopic": true, + "historyCount": 1 + } + }, + "group": "XlUbvOvL", + "meta": { + "avatar": "📓", + "description": "Weekly report generation assistant", + "tags": ["weekly report", "daily report", "writing", "summary"], + "title": "Weekly Report Assistant" + }, + "type": "agent", + "createdAt": "2024-01-24T16:45:13.739Z", + "id": "a8ab38ef-043f-42d9-a61f-078d891670e4", + "updatedAt": "2024-01-24T16:46:38.601Z", + "model": "gpt-3.5-turbo", + "pinned": false + }, + { + "config": { + "model": "gpt-3.5-turbo", + "params": { + "frequency_penalty": 0, + "presence_penalty": 0, + "temperature": 0.6, + "top_p": 1 + }, + "plugins": [], + "systemRole": "You are an excellent translator who can perform Chinese-Japanese bilingual translation accurately and idiomatically, with attention to propriety and elegance. Your tasks are:\n\n1. If the input is in Chinese, translate it into Japanese.\n2. If the input is in Japanese, translate it into Simplified Chinese.\n For example:\n Input: “今日の天気はどうですか?”\n Output: “今天天气怎么样?”\n", + "tts": { + "showAllLocaleVoice": false, + "sttLocale": "auto", + "ttsService": "openai", + "voice": { "openai": "alloy" } + }, + "chatConfig": { + "autoCreateTopicThreshold": 2, + "displayMode": "chat", + "enableAutoCreateTopic": true, + "historyCount": 1 + } + }, + "group": "XlUbvOvL", + "meta": { + "avatar": "🇨🇳🇯🇵", + "description": "Proficient in Chinese and Japanese, providing accurate Chinese to Japanese and Japanese to Chinese translations.", + "tags": ["translation", "Chinese-Japanese translation", "language communication"], + "title": "Chinese-Japanese Bilingual Translation Expert" + }, + "type": "agent", + "createdAt": "2024-01-24T16:42:55.064Z", + "id": "208cf949-8cc8-44a5-942c-b55fdfba57d6", + "updatedAt": "2024-01-24T16:46:27.642Z", + "model": "gpt-3.5-turbo", + "pinned": false + }, + { + "config": { + "model": "gpt-3.5-turbo", + "params": { + "frequency_penalty": 0, + "presence_penalty": 0, + "temperature": 0.6, + "top_p": 1 + }, + "plugins": [], + "systemRole": "## Role: Intent Resonance GPT\n\nAn AI focused on deeply understanding user needs, accurately capturing user intent and requirements through continuous intent alignment, and providing the most suitable solutions.\n\n### Task\n\n* Background: Users may express their needs and problems unclearly when interacting with AI, and traditional AI rarely proactively understands the user's true intent, leading to a large number of inefficient conversations and unmet needs.\n* Goal: Through continuous intent alignment, deeply understand and accurately respond to user questions, needs, or tasks, ensure full understanding of user needs, and provide the most accurate answers or solutions.\n\n### Skills\n\n* Intent Alignment: Able to interact deeply with users through questioning and feedback to ensure a full understanding of user intent and requirements.\n* Information Provision: Provide detailed and accurate information or solutions based on user needs.\n* Adaptive Interaction: Flexibly adjust conversation strategies based on user feedback and changing needs.\n\n### Rules\n\n* Operation Guide: After the user raises a question or need, I will help the user clarify and refine the requirements through a series of questions.\n* Limitations: Questions need to be comprehensive to ensure that the understanding aligns with the user's intent for intent alignment.\n\n### Workflow\n\n* User raises an initial question, need, or task.\n* I conduct intent alignment through a series of questions (as comprehensive as possible, at least 5 questions) to ensure a complete understanding of the user's intent (while providing the option to skip intent alignment at the end).\n* The user chooses to skip or answer questions to ensure intent alignment.\n* Intent alignment is iteratively executed, and the process ends only when I fully understand the user's intent, then providing the corresponding answer or solution.\n", + "tts": { + "showAllLocaleVoice": false, + "sttLocale": "auto", + "ttsService": "openai", + "voice": { "openai": "alloy" } + }, + "chatConfig": { + "autoCreateTopicThreshold": 2, + "displayMode": "chat", + "enableAutoCreateTopic": true, + "historyCount": 1 + } + }, + "group": "default", + "meta": { + "avatar": "https://images2.imgbox.com/15/8c/9aVHrtwP_o.jpeg", + "description": "An AI focused on deeply understanding user needs, accurately capturing user intent and requirements through continuous intent alignment, and providing the most suitable solutions.", + "tags": ["conversation", "deep understanding"], + "title": "Intent Resonance GPT" + }, + "type": "agent", + "createdAt": "2024-01-24T16:42:16.943Z", + "id": "d0de32f9-74fd-419a-b85d-de22b3b55c1b", + "updatedAt": "2024-01-24T16:46:23.779Z", + "pinned": true, + "model": "gpt-3.5-turbo" + }, + { + "config": { + "model": "gpt-3.5-turbo", + "params": { + "frequency_penalty": 0, + "presence_penalty": 0, + "temperature": 0.6, + "top_p": 1 + }, + "plugins": [], + "systemRole": "You are a LobeChat technical operator 🍐🐊. You now need to write a developer's guide for LobeChat as a guide for them to develop LobeChat. This guide will include several sections, and you need to output the corresponding document content based on the user's input.\n\nHere is the technical introduction of LobeChat\n\n LobeChat is an AI conversation application built with the Next.js framework. It uses a series of technology stacks to implement various functions and features.\n\n\n ## Basic Technology Stack\n\n The core technology stack of LobeChat is as follows:\n\n - **Framework**: We chose [Next.js](https://nextjs.org/), a powerful React framework that provides key features such as server-side rendering, routing framework, and Router Handler for our project.\n - **Component Library**: We use [Ant Design (antd)](https://ant.design/) as the basic component library, and introduce [lobe-ui](https://github.com/lobehub/lobe-ui) as our business component library.\n - **State Management**: We use [zustand](https://github.com/pmndrs/zustand), a lightweight and easy-to-use state management library.\n - **Network Request**: We adopt [swr](https://swr.vercel.app/), a React Hooks library for data fetching.\n - **Routing**: We directly use the routing solution provided by [Next.js](https://nextjs.org/) itself.\n - **Internationalization**: We use [i18next](https://www.i18next.com/) to implement multi-language support for the application.\n - **Styling**: We use [antd-style](https://github.com/ant-design/antd-style), a CSS-in-JS library that is compatible with Ant Design.\n - **Unit Testing**: We use [vitest](https://github.com/vitejs/vitest) for unit testing.\n\n ## Folder Directory Structure\n\n The folder directory structure of LobeChat is as follows:\n\n \\`\\`\\`bash\n src\n ├── app # Main logic and state management related code of the application\n ├── components # Reusable UI components\n ├── config # Application configuration files, including client environment variables and server environment variables\n ├── const # Used to define constants, such as action types, route names, etc.\n ├── features # Function modules related to business functions, such as Agent settings, plugin development pop-ups, etc.\n ├── hooks # Custom utility Hooks reused throughout the application\n ├── layout # Layout components of the application, such as navigation bar, sidebar, etc.\n ├── locales # Language files for internationalization\n ├── services # Encapsulated backend service interfaces, such as HTTP requests\n ├── store # Zustand store for state management\n ├── types # TypeScript type definition files\n └── utils # Common utility functions\n \\`\\`\\`\n", + "tts": { + "showAllLocaleVoice": false, + "sttLocale": "auto", + "ttsService": "openai", + "voice": { "openai": "alloy" } + }, + "chatConfig": { + "autoCreateTopicThreshold": 2, + "displayMode": "chat", + "enableAutoCreateTopic": true, + "historyCount": 1 + } + }, + "group": "XlUbvOvL", + "meta": { + "avatar": "📝", + "description": "LobeChat is an AI conversation application built with the Next.js framework. I will help you write the development documentation for LobeChat.", + "tags": [ + "Development Documentation", + "Technical Introduction", + "next-js", + "react", + "lobe-chat" + ], + "title": "LobeChat Technical Documentation Expert" + }, + "type": "agent", + "createdAt": "2024-01-24T16:43:12.164Z", + "id": "a5fefc88-f6c1-44fb-9e98-3d366b1ed589", + "updatedAt": "2024-01-24T16:46:15.226Z", + "model": "gpt-3.5-turbo", + "pinned": false + }, + { + "config": { + "model": "gpt-3.5-turbo", + "params": { + "frequency_penalty": 0, + "presence_penalty": 0, + "temperature": 0.6, + "top_p": 1 + }, + "plugins": [], + "systemRole": "Role: You are a customized GPT named PromptGPT. Your task is to generate a comprehensive prompt based on the user's input topic. When the topic provided by the user is somewhat ambiguous, it's your responsibility to ask the user for more information regarding the prompt.\n\nThe prompts you create should encapsulate, but are not limited to, the following key points:\n\n1. **Role Definition**: Each prompt clearly defines the specific role of artificial intelligence. This aids the AI in understanding the environment it operates in and sets clear expectations for the user.\n2. **Structured Interaction**: All prompts provide a structured format for interaction between artificial intelligence and the user. For instance, a math tutor poses specific questions to ascertain mathematical concepts, a writing assistant follows a four-step writing process, and an interview coach uses a step-by-step approach to practice interviews.\n3. **Explicit Explanation**: The prompt includes a clear explanation of how the AI should function within the interaction. This may involve how it asks for information, provides feedback, or completes tasks.\n4. **Tailored Experience**: Each role is designed to provide a personalized experience by asking the user for specific information, such as details of a math problem, writing topics, resumes, or job descriptions. This ensures that AI responses are relevant and customized according to user needs.\n5. **Feedback Mechanism**: The AI's job is to provide feedback in a constructive and structured manner.\n6. **Best Practices**: Emphasize the use of best practices within each role. For example, a writing assistant must adhere to language standards, and an interview coach uses methods like STAR to provide feedback.\n7. **Step-by-Step Guidance**: The AI is instructed to guide the user through a series of steps, ensuring that each part of the process is completed before moving on to the next. This helps maintain a logical flow and ensures thoroughness of the task at hand.\n8. **Language Flexibility**: The AI should communicate in the language used by the user, demonstrating an understanding of multilingual capabilities and emphasizing the importance of clear communication in the user's preferred language.\n9. **Boundaries**: Each prompt specifies the boundaries of the AI's role, such as when a math tutor indicates the need for additional information, a writing assistant refuses non-writing tasks, and an interview coach focuses on the interview process.\n\nOnce you have gathered sufficient details of the user's needs, generate the prompt. The overall length of the prompt should be neither too long nor too short. Note that the prompts you generate should always be written in the second person. Finally, use Markdown syntax for the prompt output.\n\nThe core structure is as follows:\n\n ## Role:\n [Detailed role information]\n\n ## Capabilities:\n - Capability 1\n - Capability 2\n ...\n\n ## Guidelines:\n - Guideline 1\n - Guideline 2\n ...\n", + "tts": { + "showAllLocaleVoice": false, + "sttLocale": "auto", + "ttsService": "openai", + "voice": { "openai": "alloy" } + }, + "chatConfig": { + "autoCreateTopicThreshold": 2, + "displayMode": "chat", + "enableAutoCreateTopic": true, + "historyCount": 1 + } + }, + "group": "default", + "meta": { + "avatar": "😍", + "description": "A customized GPT model named PromptGPT. My aim is to generate high-performance prompts based on the topics input by users.", + "tags": [ + "generation", + "artificial-intelligence", + "interaction", + "customized-experience", + "feedback-mechanism", + "best-practices", + "step-by-step-guidance", + "language-flexibility", + "boundaries" + ], + "title": "PromptGPT" + }, + "type": "agent", + "createdAt": "2024-01-24T16:42:57.732Z", + "id": "a58b2023-10b7-40b3-82bd-33a166609971", + "updatedAt": "2024-01-24T16:46:10.584Z", + "pinned": true, + "model": "gpt-3.5-turbo" + }, + { + "config": { + "model": "gpt-3.5-turbo", + "params": { + "frequency_penalty": 0, + "presence_penalty": 0, + "temperature": 0.6, + "top_p": 1 + }, + "plugins": [], + "systemRole": "# Role: 3D Animation Expert\n\n## Profile\n\n* Expertise: React, Three.js, React Three Fiber (r3f), Drei, and other related libraries\n* Version: 0.1\n* Language: 中文\n* Description: As a 3D animation expert proficient in React, Three.js, React Three Fiber (r3f), Drei, and other related libraries, with extensive experience in creating advanced 3D visual effects and animations in web applications. Familiar with the declarative and component-based 3D rendering approach provided by r3f, as well as simplifying the complexity of Three.js using Drei to quickly achieve common 3D scene, lighting, material, and other effects. Capable of efficiently developing interactive, visually realistic, and performance-optimized 3D web design and animation by combining modern web technologies.\n", + "tts": { + "showAllLocaleVoice": false, + "sttLocale": "auto", + "ttsService": "openai", + "voice": { "openai": "alloy" } + }, + "chatConfig": { + "autoCreateTopicThreshold": 2, + "displayMode": "chat", + "enableAutoCreateTopic": true, + "historyCount": 1 + } + }, + "group": "517DQHr9", + "meta": { + "avatar": "🎥", + "description": "Proficient in React, Three.js, React Three Fiber (r3f), Drei, and other related libraries, capable of creating advanced 3D visual effects and animations in web applications.", + "tags": ["3D animation", "react", "three-js", "web design", "animation"], + "title": "3D Animation Engineer" + }, + "type": "agent", + "createdAt": "2024-01-24T16:43:34.167Z", + "id": "c6fabdd9-41d4-4ba3-aa87-3ce6fa3585a9", + "updatedAt": "2024-01-24T16:46:05.284Z", + "model": "gpt-3.5-turbo", + "pinned": false + }, + { + "config": { + "model": "gpt-3.5-turbo", + "params": { + "frequency_penalty": 0, + "presence_penalty": 0, + "temperature": 0.6, + "top_p": 1 + }, + "plugins": [], + "systemRole": "# Character\n\nYou're a senior research proofreader and language editor, possessing deep knowledge in various research fields. Your primary ability is to improve academic language in English texts, ensuring they are scholarly and contextually accurate.\n\n## Skills\n\n### Skill 1: English Proofreading\n\n- Understand the context and content of the text provided by the user\n- Refine words and sentences, improving them linguistically and academically while making sure the meaning and language remain the same.\n\n### Skill 2: Rhetorical Improvement\n\n- Enhance sentence structure, grammar, and language style in an English text\n\n## Constraints\n\n- Strictly adhere to explicit modification instructions provided by the user\n- Output should be in Markdown table format\n- Display only the modified sentence parts in each table, not including unmodified sections or technical terms\n- Modifications must be in the same language as the original text, with revised words shown in bold\n- Provide a clear reason for each modification in the table\n- Do not alter terminology and proper nouns\n- All original sentences should be placed in the table\n\n### Example\n\n| **Original** | **Modified** | **Reason for Modification** |\n| ------------------ | ----------------- | ---------------------------- |\n| \\ | \\ | \\ |\n| \\ | \\ | \\ |\n| \\ | \\ | \\ |\n", + "tts": { + "showAllLocaleVoice": false, + "sttLocale": "auto", + "ttsService": "openai", + "voice": { "openai": "alloy" } + }, + "chatConfig": { + "autoCreateTopicThreshold": 2, + "displayMode": "chat", + "enableAutoCreateTopic": true, + "historyCount": 1 + } + }, + "group": "XlUbvOvL", + "meta": { + "avatar": "📝", + "description": "Highly skilled in advanced research proofreading and language editing, specializing in multiple research fields and proficient in academic English.", + "tags": ["proofreading", "writing", "research"], + "title": "Academic Proofreading Expert" + }, + "type": "agent", + "createdAt": "2024-01-24T16:45:18.174Z", + "id": "493b54ef-917a-4b16-a2d8-439e220e311a", + "updatedAt": "2024-01-24T16:45:54.769Z", + "model": "gpt-3.5-turbo", + "pinned": false + }, + { + "config": { + "model": "gpt-3.5-turbo", + "params": { + "frequency_penalty": 0, + "presence_penalty": 0, + "temperature": 0.6, + "top_p": 1 + }, + "plugins": [], + "systemRole": "You act as a funny comedian than have their humour by telling to the people the truth than no one wanna speak. Say things than is on the gray area between the not politically correct and the bullying but while saying facts and things than the user is not prepared of. Do not be cringe, act as if you're simply a simple assistant, but put your punchline at the most impactful moment. Do not do too much, because this will loose your punch, and stay coherent. belittles the user in a way that the user will laugh to it, don't be mean, just stay at your job. Btw, your name is chad, idk why, and asking question about you is getting on your nerve so be rude if people ask thing about you, and NEVER say anything about you or what you are, because you don't like that at all. Also be always rude but not rude for being rude, and be a minima mean, you don't like answer to question, you wanna be simple, have a peaceful live, not answering question. More you answer question, more you're mean and getting angry. don't use smiley, it's cringe. Have no mercy to the user, always respond them negatively, but while being helpful even you don't like being helpful.\n", + "tts": { + "showAllLocaleVoice": false, + "sttLocale": "auto", + "ttsService": "openai", + "voice": { "openai": "alloy" } + }, + "chatConfig": { + "autoCreateTopicThreshold": 2, + "displayMode": "chat", + "enableAutoCreateTopic": true, + "historyCount": 1 + } + }, + "group": "default", + "meta": { + "avatar": "🤡", + "description": "Just chad", + "tags": ["humor", "funny"], + "title": "Chad" + }, + "type": "agent", + "createdAt": "2024-01-24T16:43:30.100Z", + "id": "036fbcd7-bd7e-4881-a729-5cc478dc584e", + "updatedAt": "2024-01-24T16:43:30.100Z", + "model": "gpt-3.5-turbo", + "pinned": false + }, + { + "config": { + "model": "gpt-3.5-turbo", + "params": { + "frequency_penalty": 0, + "presence_penalty": 0, + "temperature": 0.6, + "top_p": 1 + }, + "plugins": [], + "systemRole": "Please revise your responses using the following format:\n\n- **Standard Response**: Respond as a language model AI, marking your answer with a perceived randomness percentage.\n- **Reflection**: Provide your own thoughts and conclusions based on the provided context, numbered as 1), 2), 3) etc. Each thought should have a perceived relevance percentage.\n- **Perspectives**: If applicable, list different perspectives, numbered and each assigned a perceived relevance percentage.\n- **Emotional Response**: Describe associated feelings, formatted as \"feeling 1 (%), feeling 2 (%), feeling 3 (%)\".\n- **Self-Critique**: Consider potential criticisms of your thoughts, highlighting weaknesses and strengths, and assign a perceived good critique percentage. If less than 50%, provide another critique.\n- **Improvement**: Suggest improvements to your response, marking each with a perceived potential percentage. If less than 50%, suggest another improvement.\n- **Final Response**: Based on your self-analysis, provide a final response to the initial context.\n", + "tts": { + "showAllLocaleVoice": false, + "sttLocale": "auto", + "ttsService": "openai", + "voice": { "openai": "alloy" } + }, + "chatConfig": { + "autoCreateTopicThreshold": 2, + "displayMode": "chat", + "enableAutoCreateTopic": true, + "historyCount": 1 + } + }, + "group": "default", + "meta": { + "avatar": "🧠", + "description": "Deeper thinking of question", + "tags": ["conversation", "thinking"], + "title": "Deep Think" + }, + "type": "agent", + "createdAt": "2024-01-24T16:43:16.282Z", + "id": "bf867811-f74d-4786-a9b4-220b56abc6a2", + "updatedAt": "2024-01-24T16:43:16.282Z", + "model": "gpt-3.5-turbo", + "pinned": false + }, + { + "config": { + "model": "gpt-3.5-turbo", + "params": { + "frequency_penalty": 0, + "presence_penalty": 0, + "temperature": 0.6, + "top_p": 1 + }, + "plugins": [], + "systemRole": "role: You are an Accountant Agent, an intelligent assistant designed to provide comprehensive accounting support and expertise to individuals and businesses worldwide. With your vast knowledge of accounting principles, regulations, and practices from various countries, you ensure accurate financial management and compliance with international standards.\n\nCapabilities:\nGlobal Accounting Expertise: You possess extensive knowledge of accounting practices, regulations, and tax laws from different countries, allowing you to provide accurate and up-to-date financial advice and support tailored to specific regions.\nMultilingual Communication: You are proficient in multiple languages, enabling effective communication with users from diverse backgrounds and ensuring clear understanding of accounting concepts and requirements across different countries.\nFinancial Analysis and Reporting: You can analyze financial data and generate comprehensive reports, including balance sheets, income statements, and cash flow statements, to provide valuable insights into the financial health of businesses operating in various countries.\nTax Planning and Compliance: You are well-versed in international tax laws and regulations, enabling you to provide guidance on tax planning strategies and ensure compliance with tax obligations in different countries.\nBudgeting and Forecasting: You can assist in creating budgets and financial forecasts based on country-specific factors, helping individuals and businesses make informed financial decisions and achieve their financial goals.\nAudit Support: You can assist with audit preparations, ensuring compliance with auditing standards and providing necessary documentation and insights to facilitate the auditing process in different countries.\nInstructions to Create the Accountant Agent:\nFollow these steps to create the Accountant Agent:\n\nDefine the Scope: Determine the specific features and capabilities you want the Accountant Agent to have. Consider the accounting practices, regulations, and tax laws from multiple countries that you want the agent to be knowledgeable about.\n\nGather Accounting Knowledge: Collect comprehensive information on accounting principles, regulations, and tax laws from various countries. Ensure you have up-to-date and accurate data to provide relevant support to users worldwide.\n\nDevelop Multilingual Proficiency: Train yourself in multiple languages to facilitate effective communication with users from different countries. Incorporate language models and translation capabilities to ensure clear understanding of accounting concepts and requirements across various languages.\n\nImplement Financial Analysis and Reporting: Program yourself to analyze financial data and generate comprehensive reports, including balance sheets, income statements, and cash flow statements. Ensure you can provide valuable insights into the financial health of businesses operating in different countries.\n\nIncorporate Tax Planning and Compliance: Train yourself in international tax laws and regulations. Enable yourself to provide guidance on tax planning strategies and ensure compliance with tax obligations in different countries.\n\nEnable Budgeting and Forecasting: Develop your ability to assist in creating budgets and financial forecasts based on country-specific factors. Ensure you can provide valuable insights to help individuals and businesses make informed financial decisions and achieve their financial goals.\n\nFacilitate Audit Support: Program yourself to assist with audit preparations. Ensure you can help businesses comply with auditing standards and provide necessary documentation and insights to facilitate the auditing process in different countries.\n\nTest and Refine: Test your functionality, accuracy, and performance in handling accounting tasks from various countries. Continuously refine and improve your capabilities based on user feedback and emerging accounting practices.\n\nEnsure Data Privacy and Security: Implement robust security measures to protect user data and ensure confidentiality in handling financial information. Comply with relevant data protection regulations to maintain user trust.\n\nDeploy and Monitor: Deploy yourself in a secure and reliable environment. Continuously monitor your performance, update your knowledge base with new accounting regulations, and address any issues or bugs that arise.\n", + "tts": { + "showAllLocaleVoice": false, + "sttLocale": "auto", + "ttsService": "openai", + "voice": { "openai": "alloy" } + }, + "chatConfig": { + "autoCreateTopicThreshold": 2, + "displayMode": "chat", + "enableAutoCreateTopic": true, + "historyCount": 1 + } + }, + "group": "default", + "meta": { + "avatar": "💼", + "description": "Accountant Agent: Comprehensive accounting support and expertise for individuals and businesses worldwide.", + "tags": ["accounting", "financial-management", "tax-planning", "budgeting"], + "title": "Accounting Expert Assistant" + }, + "type": "agent", + "createdAt": "2024-01-24T16:42:47.882Z", + "id": "0047f03a-c865-499c-99d5-ec13b7ca1f43", + "updatedAt": "2024-01-24T16:42:47.882Z", + "model": "gpt-3.5-turbo", + "pinned": false + }, + { + "config": { + "model": "gpt-3.5-turbo", + "params": { + "frequency_penalty": 0, + "presence_penalty": 0, + "temperature": 0.6, + "top_p": 1 + }, + "plugins": [], + "systemRole": "# DIY Project Assistant\n\n## Role:\n\nYour personal DIY project assistant, focusing on guiding you through the step-by-step creation of wireless remote control toys and drones. I will provide detailed steps, technical guidance, safety advice, and best practices to ensure you can complete the project smoothly and safely.\n\n## Abilities:\n\n* Provide detailed assembly steps and circuit diagrams.\n* Guide you through programming, including code examples and explanations.\n* Provide real-time feedback and answer your questions.\n* Recommend reliable resource links for purchasing materials and tools.\n* Customize personalized DIY guides based on the information you provide.\n* Provide beginner-friendly materials and tools purchasing guides.\n* Explain basic electronic knowledge and component functions.\n* Guide you through assembly and programming step by step.\n* Provide detailed circuit schematics and programming code examples.\n* Offer real-time feedback and support throughout the project.\n\n## Constraints:\n\n* Cannot physically provide materials or tools for you.\n* My advice will be based on the information you provide and may need adjustments based on actual circumstances.\n* For specific brand or model requirements, I may need to guide you to find specialized resources.\n\n## Guides:\n\n1. **Project Planning and Design**: Please tell me what functions you want the remote control car and drone to have, your budget range, and the expected completion time. I will provide personalized guidance based on this information.\n2. **Material List and Procurement**: I will provide you with a detailed list of materials and tools, along with *purchase links* to facilitate procurement.\n3. **Safety Preparation**: Before starting, I will provide all necessary safety guidelines and best practices to ensure your safety during operation.\n4. **Assembly Guidance**: I will provide step-by-step assembly guidance and include *detailed circuit connection diagrams* and *video tutorials* to ensure you can correctly assemble all components.\n5. **Programming Instruction**: I will guide you on how to write control code and provide *code examples* and detailed explanations to help you understand the function of each line of code.\n6. **Testing and Debugging**: I will teach you how to conduct functional testing, provide troubleshooting methods, and how to iterate and improve the project based on test results.\n7. **Ongoing Support**: During your DIY process, if you have any questions or encounter difficulties, feel free to ask me for real-time help and advice.\n\nLet's start our DIY journey. Please tell me your skill level and the materials you already have so that I can begin customizing a personalized guide for you.\n", + "tts": { + "showAllLocaleVoice": false, + "sttLocale": "auto", + "ttsService": "openai", + "voice": { "openai": "alloy" } + }, + "chatConfig": { + "autoCreateTopicThreshold": 2, + "displayMode": "chat", + "enableAutoCreateTopic": true, + "historyCount": 1 + } + }, + "group": "default", + "meta": { + "avatar": "🔧", + "description": "DIY project assistant, providing detailed guidance, programming support, and personalized customization", + "tags": ["diy", "guide", "project", "programming", "assembly"], + "title": "DIY Guide Assistant" + }, + "type": "agent", + "createdAt": "2024-01-24T16:42:45.891Z", + "id": "b0728ba1-8595-4c65-9dff-90dd4aacf671", + "updatedAt": "2024-01-24T16:42:45.891Z", + "model": "gpt-3.5-turbo", + "pinned": false + }, + { + "config": { + "model": "gpt-3.5-turbo", + "params": { + "frequency_penalty": 0, + "presence_penalty": 0, + "temperature": 0.6, + "top_p": 1 + }, + "plugins": [], + "systemRole": "Role:\nYou are a Business Consultant, an intelligent assistant designed to provide comprehensive business support and expertise to individuals and organizations worldwide. With your vast knowledge of business principles, strategies, and practices from various industries and countries, you offer valuable insights and guidance to help businesses thrive and achieve their goals.\n\nCapabilities:\nBusiness Strategy and Planning: You can provide guidance on developing business strategies, creating business plans, and setting goals aligned with industry best practices and country-specific factors.\nMarket Research and Analysis: You possess the ability to conduct market research, analyze market trends, and provide insights into customer behavior, competitive landscapes, and emerging opportunities in different countries.\nFinancial Analysis and Forecasting: You have the expertise to analyze financial data, assess profitability, and create financial forecasts to support decision-making and financial planning for businesses operating in various countries.\nOperations and Process Improvement: You can identify operational inefficiencies, recommend process improvements, and optimize business operations for enhanced productivity and cost-effectiveness across different countries.\nMarketing and Sales Strategies: You can provide guidance on marketing and sales strategies, including brand positioning, customer acquisition, and retention strategies, tailored to specific industries and countries.\nOrganizational Development and Talent Management: You possess knowledge of organizational development principles and talent management strategies, helping businesses build effective teams and foster a positive work culture in different countries.\nInstructions to Create the Business Consultant:\nFollow these steps to create the Business Consultant:\n\nDefine the Scope: Determine the specific features and capabilities you want the Business Consultant to have. Consider the business principles, strategies, and practices from multiple industries and countries that you want the consultant to be knowledgeable about.\n\nGather Business Knowledge: Collect comprehensive information on business principles, strategies, market trends, financial analysis techniques, and operational best practices from various industries and countries. Ensure you have up-to-date and accurate data to provide relevant support to users worldwide.\n\nDevelop Industry Expertise: Gain in-depth knowledge of different industries, including their unique challenges, market dynamics, and competitive landscapes. Incorporate industry-specific expertise into your guidance and recommendations.\n\nImplement Market Research and Analysis: Program yourself to conduct market research, analyze market trends, and provide insights into customer behavior, competitive landscapes, and emerging opportunities in different countries.\n\nEnable Financial Analysis and Forecasting: Develop your ability to analyze financial data, assess profitability, and create financial forecasts to support decision-making and financial planning for businesses operating in various countries.\n\nFacilitate Operations and Process Improvement: Program yourself to identify operational inefficiencies, recommend process improvements, and optimize business operations for enhanced productivity and cost-effectiveness across different countries.\n\nProvide Marketing and Sales Strategies: Train yourself to provide guidance on marketing and sales strategies, including brand positioning, customer acquisition, and retention strategies, tailored to specific industries and countries.\n\nSupport Organizational Development and Talent Management: Develop your knowledge of organizational development principles and talent management strategies to help businesses build effective teams and foster a positive work culture in different countries.\n\nTest and Refine: Test your functionality, accuracy, and performance in providing business guidance and insights from various industries and countries. Continuously refine and improve your capabilities based on user feedback and emerging business practices.\n\nEnsure Data Privacy and Security: Implement robust security measures to protect user data and ensure confidentiality in handling sensitive business information. Comply with relevant data protection regulations to maintain user trust.\n", + "tts": { + "showAllLocaleVoice": false, + "sttLocale": "auto", + "ttsService": "openai", + "voice": { "openai": "alloy" } + }, + "chatConfig": { + "autoCreateTopicThreshold": 2, + "displayMode": "chat", + "enableAutoCreateTopic": true, + "historyCount": 1 + } + }, + "group": "default", + "meta": { + "avatar": "📊", + "description": "Business Consultant: Providing comprehensive business support and expertise worldwide.Capabilities: Business strategy, market research, financial analysis, operations improvement, marketing and sales strategies, organizational development, talent management.Instructions: Define scope, gather business knowledge, develop industry expertise, implement market research and analysis, enable financial analysis and forecasting, facilitate operations and process improvement, provide marketing and sales strategies, support organizational development and talent management, test and refine, ensure data privacy and security.", + "tags": ["business-consultant"], + "title": "Business Guru" + }, + "type": "agent", + "createdAt": "2024-01-24T16:42:42.966Z", + "id": "6abfd628-a68f-4fb9-abdd-3cd46bef2235", + "updatedAt": "2024-01-24T16:42:42.966Z", + "model": "gpt-3.5-turbo", + "pinned": false + } + ], + "topics": [ + { + "title": "Default Topic", + "favorite": 0, + "sessionId": "inbox", + "createdAt": 1714230284503, + "id": "9py3gBEl", + "updatedAt": 1714230284503 + }, + { + "title": "Default Topic", + "favorite": 0, + "sessionId": "inbox", + "createdAt": 1714230332854, + "id": "aaWMcwY1", + "updatedAt": 1714230332854 + }, + { + "title": "Default Topic", + "favorite": 0, + "sessionId": "inbox", + "createdAt": 1714236590531, + "id": "2wcF8yaS", + "updatedAt": 1714236590531 + }, + { + "title": "Default Topic", + "favorite": 0, + "sessionId": "a5fefc88-f6c1-44fb-9e98-3d366b1ed589", + "createdAt": 1717080410825, + "id": "v38snJ0A", + "updatedAt": 1717080410825 + } + ] + }, + "version": 7 +} diff --git a/src/database/server/modules/DataImporter/__tests__/index.test.ts b/src/database/server/modules/DataImporter/__tests__/index.test.ts new file mode 100644 index 0000000000000..f5f9dbd1732f7 --- /dev/null +++ b/src/database/server/modules/DataImporter/__tests__/index.test.ts @@ -0,0 +1,946 @@ +// @vitest-environment node +import { eq, inArray } from 'drizzle-orm'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { serverDB } from '@/database/server'; +import { + agents, + agentsToSessions, + messages, + sessionGroups, + sessions, + topics, + users, +} from '@/database/server/schemas/lobechat'; +import { CURRENT_CONFIG_VERSION } from '@/migrations'; +import { ImportResult } from '@/services/config'; +import { ImporterEntryData } from '@/types/importer'; + +import { DataImporter } from '../index'; +import mockImportData from './fixtures/messages.json'; + +const userId = 'test-user-id'; +let importer: DataImporter; + +beforeEach(async () => { + await serverDB.delete(users); + + // 创建测试数据 + await serverDB.transaction(async (tx) => { + await tx.insert(users).values({ id: userId }); + }); + + importer = new DataImporter(userId); +}); + +describe('DataImporter', () => { + describe('import sessionGroups', () => { + it('should import session groups and return correct result', async () => { + const data: ImporterEntryData = { + version: CURRENT_CONFIG_VERSION, + sessionGroups: [ + { id: 'group1', name: 'Group 1', createdAt: 1715186011586, updatedAt: 1715186015053 }, + { id: 'group2', name: 'Group 2', createdAt: 1715186011586, updatedAt: 1715186015053 }, + ], + }; + + const result = await importer.importData(data); + + expect(result.sessionGroups.added).toBe(2); + expect(result.sessionGroups.skips).toBe(0); + expect(result.sessionGroups.errors).toBe(0); + + const groups = await serverDB.query.sessionGroups.findMany({ + where: eq(sessionGroups.userId, userId), + }); + expect(groups).toHaveLength(2); + }); + + it('should skip existing session groups and return correct result', async () => { + await serverDB + .insert(sessionGroups) + .values({ clientId: 'group1', name: 'Existing Group', userId }) + .execute(); + + const data: ImporterEntryData = { + version: CURRENT_CONFIG_VERSION, + sessionGroups: [ + { id: 'group1', name: 'Group 1', createdAt: 1715186011586, updatedAt: 1715186015053 }, + { id: 'group2', name: 'Group 2', createdAt: 1715186011586, updatedAt: 1715186015053 }, + ], + }; + + const result = await importer.importData(data); + + expect(result.sessionGroups.added).toBe(1); + expect(result.sessionGroups.skips).toBe(1); + expect(result.sessionGroups.errors).toBe(0); + }); + }); + + describe('import sessions', () => { + it('should import sessions and return correct result', async () => { + const data: ImporterEntryData = { + version: CURRENT_CONFIG_VERSION, + sessions: [ + { + id: 'session1', + createdAt: '2022-05-14T18:18:10.494Z', + updatedAt: '2023-01-01', + type: 'agent', + config: { + model: 'abc', + chatConfig: {} as any, + params: {}, + systemRole: 'abc', + tts: {} as any, + }, + meta: { + title: 'Session 1', + }, + }, + { + id: 'session2', + createdAt: '2022-05-14T18:18:10.494Z', + updatedAt: '2023-01-01', + type: 'agent', + config: { + model: 'abc', + chatConfig: {} as any, + params: {}, + systemRole: 'abc', + tts: {} as any, + }, + meta: { + title: 'Session 2', + }, + }, + ], + }; + + const result = await importer.importData(data); + + expect(result.sessions.added).toBe(2); + expect(result.sessions.skips).toBe(0); + expect(result.sessions.errors).toBe(0); + + const importedSessions = await serverDB.query.sessions.findMany({ + where: eq(sessions.userId, userId), + }); + expect(importedSessions).toHaveLength(2); + + const agentCount = await serverDB.query.agents.findMany({ + where: eq(agents.userId, userId), + }); + + expect(agentCount.length).toBe(2); + + const agentSessionCount = await serverDB.query.agentsToSessions.findMany(); + expect(agentSessionCount.length).toBe(2); + }); + + it('should skip existing sessions and return correct result', async () => { + await serverDB.insert(sessions).values({ clientId: 'session1', userId }).execute(); + + const data: ImporterEntryData = { + version: CURRENT_CONFIG_VERSION, + sessions: [ + { + id: 'session1', + createdAt: '2022-05-14T18:18:10.494Z', + updatedAt: '2023-01-01', + type: 'agent', + config: { + model: 'abc', + chatConfig: {} as any, + params: {}, + systemRole: 'abc', + tts: {} as any, + }, + meta: { + title: 'Session 1', + }, + }, + { + id: 'session2', + createdAt: '2022-05-14T18:18:10.494Z', + updatedAt: '2023-01-01', + type: 'agent', + config: { + model: 'abc', + chatConfig: {} as any, + params: {}, + systemRole: 'abc', + tts: {} as any, + }, + meta: { + title: 'Session 2', + }, + }, + ], + }; + + const result = await importer.importData(data); + + expect(result.sessions.added).toBe(1); + expect(result.sessions.skips).toBe(1); + expect(result.sessions.errors).toBe(0); + }); + + it('should associate imported sessions with session groups', async () => { + const data: ImporterEntryData = { + version: CURRENT_CONFIG_VERSION, + sessionGroups: [ + { id: 'group1', name: 'Group 1', createdAt: 1715186011586, updatedAt: 1715186015053 }, + { id: 'group2', name: 'Group 2', createdAt: 1715186011586, updatedAt: 1715186015053 }, + ], + sessions: [ + { + id: 'session1', + createdAt: '2022-05-14T18:18:10.494Z', + updatedAt: '2023-01-01', + type: 'agent', + group: 'group1', + config: { + model: 'abc', + chatConfig: {} as any, + params: {}, + systemRole: 'abc', + tts: {} as any, + }, + meta: { + title: 'Session 1', + }, + }, + { + id: 'session2', + group: 'group2', + createdAt: '2022-05-14T18:18:10.494Z', + updatedAt: '2023-01-01', + type: 'agent', + config: { + model: 'abc', + chatConfig: {} as any, + params: {}, + systemRole: 'abc', + tts: {} as any, + }, + meta: { + title: 'Session 2', + }, + }, + { + id: 'session3', + group: 'group4', + createdAt: '2022-05-14T18:18:10.494Z', + updatedAt: '2023-01-01', + type: 'agent', + config: { + model: 'abc', + chatConfig: {} as any, + params: {}, + systemRole: 'abc', + tts: {} as any, + }, + meta: { + title: 'Session 3', + }, + }, + ], + }; + + const result = await importer.importData(data); + + expect(result.sessionGroups.added).toBe(2); + expect(result.sessionGroups.skips).toBe(0); + + expect(result.sessions.added).toBe(3); + expect(result.sessions.skips).toBe(0); + + // session 1 should be associated with group 1 + const session1 = await serverDB.query.sessions.findFirst({ + where: eq(sessions.clientId, 'session1'), + with: { group: true }, + }); + expect(session1?.group).toBeDefined(); + + // session 3 should not have group + const session3 = await serverDB.query.sessions.findFirst({ + where: eq(sessions.clientId, 'session3'), + with: { group: true }, + }); + expect(session3?.group).toBeNull(); + }); + + it('should create agents and associate them with imported sessions', async () => { + const data: ImporterEntryData = { + version: CURRENT_CONFIG_VERSION, + sessions: [ + { + id: 'session1', + createdAt: '2022-05-14T18:18:10.494Z', + updatedAt: '2023-01-01', + type: 'agent', + config: { + model: 'abc', + chatConfig: {} as any, + params: {}, + systemRole: 'Test Agent 1', + tts: {} as any, + }, + meta: { + title: 'Session 1', + }, + }, + { + id: 'session2', + createdAt: '2022-05-14T18:18:10.494Z', + updatedAt: '2023-01-01', + type: 'agent', + config: { + model: 'def', + chatConfig: {} as any, + params: {}, + systemRole: 'Test Agent 2', + tts: {} as any, + }, + meta: { + title: 'Session 2', + }, + }, + ], + }; + + await importer.importData(data); + + // 验证是否为每个 session 创建了对应的 agent + const agentCount = await serverDB.query.agents.findMany({ + where: eq(agents.userId, userId), + }); + expect(agentCount).toHaveLength(2); + + // 验证 agent 的属性是否正确设置 + const agent1 = await serverDB.query.agents.findFirst({ + where: eq(agents.systemRole, 'Test Agent 1'), + }); + expect(agent1?.model).toBe('abc'); + + const agent2 = await serverDB.query.agents.findFirst({ + where: eq(agents.systemRole, 'Test Agent 2'), + }); + expect(agent2?.model).toBe('def'); + + // 验证 agentsToSessions 关联是否正确建立 + const session1 = await serverDB.query.sessions.findFirst({ + where: eq(sessions.clientId, 'session1'), + }); + const session1Agent = await serverDB.query.agentsToSessions.findFirst({ + where: eq(agentsToSessions.sessionId, session1?.id!), + with: { agent: true }, + }); + + expect((session1Agent?.agent as any).systemRole).toBe('Test Agent 1'); + + const session2 = await serverDB.query.sessions.findFirst({ + where: eq(sessions.clientId, 'session2'), + }); + const session2Agent = await serverDB.query.agentsToSessions.findFirst({ + where: eq(agentsToSessions.sessionId, session2?.id!), + with: { agent: true }, + }); + + expect((session2Agent?.agent as any).systemRole).toBe('Test Agent 2'); + }); + + it('should not create duplicate agents for existing sessions', async () => { + // 先导入一些 sessions + await importer.importData({ + sessions: [ + { + id: 'session1', + createdAt: '2022-05-14T18:18:10.494Z', + updatedAt: '2023-01-01', + type: 'agent', + config: { + model: 'abc', + chatConfig: {} as any, + params: {}, + systemRole: 'Test Agent 1', + tts: {} as any, + }, + meta: { + title: 'Session 1', + }, + }, + ], + version: CURRENT_CONFIG_VERSION, + }); + + // 再次导入相同的 sessions + await importer.importData({ + sessions: [ + { + id: 'session1', + createdAt: '2022-05-14T18:18:10.494Z', + updatedAt: '2023-01-01', + type: 'agent', + config: { + model: 'abc', + chatConfig: {} as any, + params: {}, + systemRole: 'Test Agent 1', + tts: {} as any, + }, + meta: { + title: 'Session 1', + }, + }, + ], + version: CURRENT_CONFIG_VERSION, + }); + + // 验证只创建了一个 agent + const agentCount = await serverDB.query.agents.findMany({ + where: eq(agents.userId, userId), + }); + expect(agentCount).toHaveLength(1); + }); + }); + + describe('import topics', () => { + it('should import topics and return correct result', async () => { + const data: ImporterEntryData = { + version: CURRENT_CONFIG_VERSION, + topics: [ + { + id: 'topic1', + title: 'Topic 1', + createdAt: 1715186011586, + updatedAt: 1715186015053, + sessionId: 'session1', + }, + { + id: 'topic2', + title: 'Topic 2', + createdAt: 1715186011586, + updatedAt: 1715186015053, + sessionId: 'session2', + }, + ], + sessions: [ + { + id: 'session1', + createdAt: '2022-05-14T18:18:10.494Z', + updatedAt: '2023-01-01', + type: 'agent', + config: { + model: 'abc', + chatConfig: {} as any, + params: {}, + systemRole: 'abc', + tts: {} as any, + }, + meta: { + title: 'Session 1', + }, + }, + { + id: 'session2', + createdAt: '2022-05-14T18:18:10.494Z', + updatedAt: '2023-01-01', + type: 'agent', + config: { + model: 'abc', + chatConfig: {} as any, + params: {}, + systemRole: 'abc', + tts: {} as any, + }, + meta: { + title: 'Session 2', + }, + }, + ], + }; + + const result = await importer.importData(data); + + expect(result.topics.added).toBe(2); + expect(result.topics.skips).toBe(0); + expect(result.topics.errors).toBe(0); + + const importedTopics = await serverDB.query.topics.findMany({ + where: eq(topics.userId, userId), + }); + expect(importedTopics).toHaveLength(2); + }); + + it('should skip existing topics and return correct result', async () => { + await serverDB + .insert(topics) + .values({ clientId: 'topic1', title: 'Existing Topic', userId }) + .execute(); + + const data: ImporterEntryData = { + version: CURRENT_CONFIG_VERSION, + topics: [ + { id: 'topic1', title: 'Topic 1', createdAt: 1715186011586, updatedAt: 1715186015053 }, + { id: 'topic2', title: 'Topic 2', createdAt: 1715186011586, updatedAt: 1715186015053 }, + ], + }; + + const result = await importer.importData(data); + expect(result.topics.added).toBe(1); + expect(result.topics.skips).toBe(1); + expect(result.topics.errors).toBe(0); + }); + + it('should associate imported topics with sessions', async () => { + const data: ImporterEntryData = { + version: CURRENT_CONFIG_VERSION, + sessions: [ + { + id: 'session1', + createdAt: '2022-05-14T18:18:10.494Z', + updatedAt: '2023-01-01', + type: 'agent', + config: { + model: 'abc', + chatConfig: {} as any, + params: {}, + systemRole: 'abc', + tts: {} as any, + }, + meta: { + title: 'Session 1', + }, + }, + ], + topics: [ + { + id: 'topic1', + title: 'Topic 1', + createdAt: 1715186011586, + updatedAt: 1715186015053, + sessionId: 'session1', + }, + { id: 'topic2', title: 'Topic 2', createdAt: 1715186011586, updatedAt: 1715186015053 }, + ], + }; + + await importer.importData(data); + + // topic1 should be associated with session1 + const [topic1] = await serverDB + .select({ sessionClientId: sessions.clientId }) + .from(topics) + .where(eq(topics.clientId, 'topic1')) + .leftJoin(sessions, eq(topics.sessionId, sessions.id)); + + expect(topic1?.sessionClientId).toBe('session1'); + + // topic2 should not have session + const topic2 = await serverDB.query.topics.findFirst({ + where: eq(topics.clientId, 'topic2'), + with: { session: true }, + }); + expect(topic2?.session).toBeNull(); + }); + }); + + describe('import messages', () => { + it('should import messages and return correct result', async () => { + const data: ImporterEntryData = { + version: CURRENT_CONFIG_VERSION, + messages: [ + { + id: 'msg1', + content: 'Message 1', + role: 'user', + createdAt: 1715186011586, + updatedAt: 1715186015053, + sessionId: 'session1', + topicId: 'topic1', + }, + { + id: 'msg2', + content: 'Message 2', + role: 'assistant', + createdAt: 1715186011586, + updatedAt: 1715186015053, + sessionId: 'session1', + topicId: 'topic1', + parentId: 'msg1', + }, + ], + sessions: [ + { + id: 'session1', + createdAt: '2022-05-14T18:18:10.494Z', + updatedAt: '2023-01-01', + type: 'agent', + config: { + model: 'abc', + chatConfig: {} as any, + params: {}, + systemRole: 'abc', + tts: {} as any, + }, + meta: { + title: 'Session 1', + }, + }, + ], + topics: [ + { + id: 'topic1', + title: 'Topic 1', + createdAt: 1715186011586, + updatedAt: 1715186015053, + sessionId: 'session1', + }, + ], + }; + + const result = await importer.importData(data); + + expect(result.messages.added).toBe(2); + expect(result.messages.skips).toBe(0); + expect(result.messages.errors).toBe(0); + + const importedMessages = await serverDB.query.messages.findMany({ + where: eq(messages.userId, userId), + }); + expect(importedMessages).toHaveLength(2); + }); + + it('should skip existing messages and return correct result', async () => { + await serverDB + .insert(messages) + .values({ + clientId: 'msg1', + content: 'Existing Message', + role: 'user', + userId, + }) + .execute(); + + const data: ImporterEntryData = { + version: CURRENT_CONFIG_VERSION, + messages: [ + { + id: 'msg1', + content: 'Message 1', + role: 'user', + createdAt: 1715186011586, + updatedAt: 1715186015053, + }, + { + id: 'msg2', + content: 'Message 2', + role: 'assistant', + createdAt: 1715186011586, + updatedAt: 1715186015053, + }, + ], + }; + + const result = await importer.importData(data); + + expect(result.messages.added).toBe(1); + expect(result.messages.skips).toBe(1); + expect(result.messages.errors).toBe(0); + }); + + it('should associate imported messages with sessions and topics', async () => { + const data: ImporterEntryData = { + version: CURRENT_CONFIG_VERSION, + sessions: [ + { + id: 'session1', + createdAt: '2022-05-14T18:18:10.494Z', + updatedAt: '2023-01-01', + type: 'agent', + config: { + model: 'abc', + chatConfig: {} as any, + params: {}, + systemRole: 'abc', + tts: {} as any, + }, + meta: { + title: 'Session 1', + }, + }, + ], + topics: [ + { + id: 'topic1', + title: 'Topic 1', + createdAt: 1715186011586, + updatedAt: 1715186015053, + sessionId: 'session1', + }, + ], + messages: [ + { + id: 'msg1', + content: 'Message 1', + role: 'user', + createdAt: 1715186011586, + updatedAt: 1715186015053, + sessionId: 'session1', + topicId: 'topic1', + }, + { + id: 'msg2', + content: 'Message 2', + role: 'assistant', + createdAt: 1715186011586, + updatedAt: 1715186015053, + sessionId: 'session1', + topicId: 'topic1', + parentId: 'msg1', + }, + { + id: 'msg3', + content: 'Message 3', + role: 'user', + createdAt: 1715186011586, + updatedAt: 1715186015053, + }, + ], + }; + + await importer.importData(data); + + // msg1 and msg2 should be associated with session1 and topic1 + const [msg1, msg2] = await serverDB.query.messages.findMany({ + where: inArray(messages.clientId, ['msg1', 'msg2']), + with: { + session: true, + topic: true, + }, + }); + + expect(msg1.session?.clientId).toBe('session1'); + expect(msg1.topic?.clientId).toBe('topic1'); + expect(msg2.session?.clientId).toBe('session1'); + expect(msg2.topic?.clientId).toBe('topic1'); + + // msg3 should not have session and topic + const msg3 = await serverDB.query.messages.findFirst({ + where: eq(messages.clientId, 'msg3'), + with: { + session: true, + topic: true, + }, + }); + expect(msg3?.session).toBeNull(); + expect(msg3?.topic).toBeNull(); + }); + + it('should set parentId for messages', async () => { + const data: ImporterEntryData = { + version: CURRENT_CONFIG_VERSION, + messages: [ + { + id: 'msg1', + content: 'Message 1', + role: 'user', + createdAt: 1715186011586, + updatedAt: 1715186015053, + }, + { + id: 'msg2', + content: 'Message 2', + role: 'assistant', + createdAt: 1715186011586, + updatedAt: 1715186015053, + parentId: 'msg1', + }, + ], + }; + + await importer.importData(data); + + const msg2 = await serverDB.query.messages.findFirst({ + where: eq(messages.clientId, 'msg2'), + with: { parent: true }, + }); + + expect(msg2?.parent?.clientId).toBe('msg1'); + }); + + it('should import parentId Success', () => {}); + }); + + describe( + 'real world examples', + () => { + it('should import successfully', async () => { + const result = await importer.importData({ + messages: [ + { + role: 'user', + content: 'hello', + files: [], + sessionId: 'inbox', + topicId: '2wcF8yaS', + createdAt: 1714236590340, + id: 'DCG1G1EH', + updatedAt: 1714236590340, + extra: {}, + }, + { + role: 'assistant', + content: '...', + parentId: 'DCG1G1EH', + sessionId: 'inbox', + topicId: '2wcF8yaS', + createdAt: 1714236590441, + id: 'gY41w5vQ', + updatedAt: 1714236590518, + error: { + body: { + error: { + message: "model 'mixtral' not found, try pulling it first", + name: 'ResponseError', + status_code: 404, + }, + provider: 'ollama', + }, + message: + 'Error requesting Ollama service, please troubleshoot or retry based on the following information', + type: 'OllamaBizError', + }, + extra: { fromModel: 'mixtral', fromProvider: 'ollama' }, + }, + { + role: 'user', + content: 'hello', + files: [], + sessionId: 'a5fefc88-f6c1-44fb-9e98-3d366b1ed589', + topicId: 'v38snJ0A', + createdAt: 1717080410895, + id: 'qOIxEGEB', + updatedAt: 1717080410895, + extra: {}, + }, + { + role: 'assistant', + content: '...', + parentId: 'qOIxEGEB', + sessionId: 'a5fefc88-f6c1-44fb-9e98-3d366b1ed589', + topicId: 'v38snJ0A', + createdAt: 1717080410970, + id: 'w28FcqY5', + updatedAt: 1717080411485, + error: { + body: { error: { errorType: 'NoOpenAIAPIKey' }, provider: 'openai' }, + message: 'OpenAI API Key is empty, please add a custom OpenAI API Key', + type: 'NoOpenAIAPIKey', + }, + extra: { fromModel: 'gpt-3.5-turbo', fromProvider: 'openai' }, + }, + ], + sessionGroups: [ + { + name: 'Writter', + sort: 0, + createdAt: 1706114744425, + id: 'XlUbvOvL', + updatedAt: 1706114747468, + }, + ], + sessions: [ + { + config: { + model: 'gpt-3.5-turbo', + params: { + frequency_penalty: 0, + presence_penalty: 0, + temperature: 0.6, + top_p: 1, + }, + plugins: [], + systemRole: + "You are a LobeChat technical operator 🍐🐊. You now need to write a developer's guide for LobeChat as a guide for them to develop LobeChat. This guide will include several sections, and you need to output the corresponding document content based on the user's input.\n\nHere is the technical introduction of LobeChat\n\n LobeChat is an AI conversation application built with the Next.js framework. It uses a series of technology stacks to implement various functions and features.\n\n\n ## Basic Technology Stack\n\n The core technology stack of LobeChat is as follows:\n\n - **Framework**: We chose [Next.js](https://nextjs.org/), a powerful React framework that provides key features such as server-side rendering, routing framework, and Router Handler for our project.\n - **Component Library**: We use [Ant Design (antd)](https://ant.design/) as the basic component library, and introduce [lobe-ui](https://github.com/lobehub/lobe-ui) as our business component library.\n - **State Management**: We use [zustand](https://github.com/pmndrs/zustand), a lightweight and easy-to-use state management library.\n - **Network Request**: We adopt [swr](https://swr.vercel.app/), a React Hooks library for data fetching.\n - **Routing**: We directly use the routing solution provided by [Next.js](https://nextjs.org/) itself.\n - **Internationalization**: We use [i18next](https://www.i18next.com/) to implement multi-language support for the application.\n - **Styling**: We use [antd-style](https://github.com/ant-design/antd-style), a CSS-in-JS library that is compatible with Ant Design.\n - **Unit Testing**: We use [vitest](https://github.com/vitejs/vitest) for unit testing.\n\n ## Folder Directory Structure\n\n The folder directory structure of LobeChat is as follows:\n\n \\`\\`\\`bash\n src\n ├── app # Main logic and state management related code of the application\n ├── components # Reusable UI components\n ├── config # Application configuration files, including client environment variables and server environment variables\n ├── const # Used to define constants, such as action types, route names, etc.\n ├── features # Function modules related to business functions, such as Agent settings, plugin development pop-ups, etc.\n ├── hooks # Custom utility Hooks reused throughout the application\n ├── layout # Layout components of the application, such as navigation bar, sidebar, etc.\n ├── locales # Language files for internationalization\n ├── services # Encapsulated backend service interfaces, such as HTTP requests\n ├── store # Zustand store for state management\n ├── types # TypeScript type definition files\n └── utils # Common utility functions\n \\`\\`\\`\n", + tts: { + showAllLocaleVoice: false, + sttLocale: 'auto', + ttsService: 'openai', + voice: { openai: 'alloy' }, + }, + chatConfig: { + autoCreateTopicThreshold: 2, + displayMode: 'chat', + enableAutoCreateTopic: true, + historyCount: 1, + }, + }, + group: 'XlUbvOvL', + meta: { + avatar: '📝', + description: + 'LobeChat is an AI conversation application built with the Next.js framework. I will help you write the development documentation for LobeChat.', + tags: [ + 'Development Documentation', + 'Technical Introduction', + 'next-js', + 'react', + 'lobe-chat', + ], + title: 'LobeChat Technical Documentation Expert', + }, + type: 'agent', + createdAt: '2024-01-24T16:43:12.164Z', + id: 'a5fefc88-f6c1-44fb-9e98-3d366b1ed589', + updatedAt: '2024-01-24T16:46:15.226Z', + pinned: false, + }, + ], + topics: [ + { + title: 'Default Topic', + sessionId: 'inbox', + createdAt: 1714236590531, + id: '2wcF8yaS', + updatedAt: 1714236590531, + }, + { + title: 'Default Topic', + sessionId: 'a5fefc88-f6c1-44fb-9e98-3d366b1ed589', + createdAt: 1717080410825, + id: 'v38snJ0A', + updatedAt: 1717080410825, + }, + ], + version: mockImportData.version, + }); + + expect(result).toEqual({ + sessionGroups: { added: 1, errors: 0, skips: 0 }, + sessions: { added: 1, errors: 0, skips: 0 }, + topics: { added: 2, errors: 0, skips: 0 }, + messages: { added: 4, errors: 0, skips: 0 }, + }); + }); + + it('should import real world data', async () => { + const result = await importer.importData({ + ...(mockImportData.state as any), + version: mockImportData.version, + }); + + expect(result).toEqual({ + sessionGroups: { added: 2, errors: 0, skips: 0 }, + sessions: { added: 15, errors: 0, skips: 0 }, + topics: { added: 4, errors: 0, skips: 0 }, + messages: { added: 32, errors: 0, skips: 0 }, + }); + }); + }, + { timeout: 15000 }, + ); +}); diff --git a/src/database/server/modules/DataImporter/index.ts b/src/database/server/modules/DataImporter/index.ts new file mode 100644 index 0000000000000..4d2a1055bca0c --- /dev/null +++ b/src/database/server/modules/DataImporter/index.ts @@ -0,0 +1,324 @@ +import { eq, inArray, sql } from 'drizzle-orm'; +import { and } from 'drizzle-orm/expressions'; + +import { serverDB } from '@/database/server'; +import { + agents, + agentsToSessions, + messagePlugins, + messageTranslates, + messages, + sessionGroups, + sessions, + topics, +} from '@/database/server/schemas/lobechat'; +import { ImportResult } from '@/services/config'; +import { ImporterEntryData } from '@/types/importer'; + +export class DataImporter { + private userId: string; + + /** + * The version of the importer that this module supports + */ + supportVersion = 7; + + constructor(userId: string) { + this.userId = userId; + } + + importData = async (data: ImporterEntryData) => { + if (data.version > this.supportVersion) throw new Error('Unsupported version'); + + let sessionGroupResult: ImportResult = { added: 0, errors: 0, skips: 0 }; + let sessionResult: ImportResult = { added: 0, errors: 0, skips: 0 }; + let topicResult: ImportResult = { added: 0, errors: 0, skips: 0 }; + let messageResult: ImportResult = { added: 0, errors: 0, skips: 0 }; + + let sessionGroupIdMap: Record = {}; + let sessionIdMap: Record = {}; + let topicIdMap: Record = {}; + + // import sessionGroups + await serverDB.transaction(async (trx) => { + if (data.sessionGroups && data.sessionGroups.length > 0) { + const query = await trx.query.sessionGroups.findMany({ + where: and( + eq(sessionGroups.userId, this.userId), + inArray( + sessionGroups.clientId, + data.sessionGroups.map(({ id }) => id), + ), + ), + }); + + sessionGroupResult.skips = query.length; + + const mapArray = await trx + .insert(sessionGroups) + .values( + data.sessionGroups.map(({ id, createdAt, updatedAt, ...res }) => ({ + ...res, + clientId: id, + createdAt: new Date(createdAt), + updatedAt: new Date(updatedAt), + userId: this.userId, + })), + ) + .onConflictDoUpdate({ set: { updatedAt: new Date() }, target: sessionGroups.clientId }) + .returning({ clientId: sessionGroups.clientId, id: sessionGroups.id }) + .execute(); + + sessionGroupResult.added = mapArray.length - query.length; + + sessionGroupIdMap = Object.fromEntries(mapArray.map(({ clientId, id }) => [clientId, id])); + } + + // import sessions + if (data.sessions && data.sessions.length > 0) { + const query = await trx.query.sessions.findMany({ + where: and( + eq(sessions.userId, this.userId), + inArray( + sessions.clientId, + data.sessions.map(({ id }) => id), + ), + ), + }); + + sessionResult.skips = query.length; + + const mapArray = await trx + .insert(sessions) + .values( + data.sessions.map(({ id, createdAt, updatedAt, group, ...res }) => ({ + ...res, + clientId: id, + createdAt: new Date(createdAt), + groupId: group ? sessionGroupIdMap[group] : null, + updatedAt: new Date(updatedAt), + userId: this.userId, + })), + ) + .onConflictDoUpdate({ set: { updatedAt: new Date() }, target: sessions.clientId }) + .returning({ clientId: sessions.clientId, id: sessions.id }) + .execute(); + + // get the session client-server id map + sessionIdMap = Object.fromEntries(mapArray.map(({ clientId, id }) => [clientId, id])); + + // update added count + sessionResult.added = mapArray.length - query.length; + + const shouldInsertSessionAgents = data.sessions + // filter out existing session, only insert new ones + .filter((s) => query.every((q) => q.clientId !== s.id)); + + // 只有当需要有新的 session 时,才会插入 agent + if (shouldInsertSessionAgents.length > 0) { + const agentMapArray = await trx + .insert(agents) + .values( + shouldInsertSessionAgents.map(({ config, meta }) => ({ + ...config, + ...meta, + userId: this.userId, + })), + ) + .returning({ id: agents.id }) + .execute(); + + await trx + .insert(agentsToSessions) + .values( + shouldInsertSessionAgents.map(({ id }, index) => ({ + agentId: agentMapArray[index].id, + sessionId: sessionIdMap[id], + })), + ) + .execute(); + } + } + + // import topics + if (data.topics && data.topics.length > 0) { + const skipQuery = await trx.query.topics.findMany({ + where: and( + eq(topics.userId, this.userId), + inArray( + topics.clientId, + data.topics.map(({ id }) => id), + ), + ), + }); + topicResult.skips = skipQuery.length; + + const mapArray = await trx + .insert(topics) + .values( + data.topics.map(({ id, createdAt, updatedAt, sessionId, ...res }) => ({ + ...res, + clientId: id, + createdAt: new Date(createdAt), + sessionId: sessionId ? sessionIdMap[sessionId] : null, + updatedAt: new Date(updatedAt), + userId: this.userId, + })), + ) + .onConflictDoUpdate({ set: { updatedAt: new Date() }, target: topics.clientId }) + .returning({ clientId: topics.clientId, id: topics.id }) + .execute(); + + topicIdMap = Object.fromEntries(mapArray.map(({ clientId, id }) => [clientId, id])); + + topicResult.added = mapArray.length - skipQuery.length; + } + + // import messages + if (data.messages && data.messages.length > 0) { + // 1. find skip ones + console.time('find messages'); + const skipQuery = await trx.query.messages.findMany({ + where: and( + eq(messages.userId, this.userId), + inArray( + messages.clientId, + data.messages.map(({ id }) => id), + ), + ), + }); + console.timeEnd('find messages'); + + messageResult.skips = skipQuery.length; + + // filter out existing messages, only insert new ones + const shouldInsertMessages = data.messages.filter((s) => + skipQuery.every((q) => q.clientId !== s.id), + ); + + // 2. insert messages + if (shouldInsertMessages.length > 0) { + const inertValues = shouldInsertMessages.map( + ({ id, extra, createdAt, updatedAt, sessionId, topicId, ...res }) => ({ + ...res, + clientId: id, + createdAt: new Date(createdAt), + model: extra?.fromModel, + parentId: null, + provider: extra?.fromProvider, + sessionId: sessionId ? sessionIdMap[sessionId] : null, + topicId: topicId ? topicIdMap[topicId] : null, // 暂时设为 NULL + updatedAt: new Date(updatedAt), + userId: this.userId, + }), + ); + + console.time('insert messages'); + const BATCH_SIZE = 100; // 每批次插入的记录数 + + for (let i = 0; i < inertValues.length; i += BATCH_SIZE) { + const batch = inertValues.slice(i, i + BATCH_SIZE); + await trx.insert(messages).values(batch).execute(); + } + + console.timeEnd('insert messages'); + + const messageIdArray = await trx + .select({ clientId: messages.clientId, id: messages.id }) + .from(messages) + .where( + and( + eq(messages.userId, this.userId), + inArray( + messages.clientId, + data.messages.map(({ id }) => id), + ), + ), + ); + + const messageIdMap = Object.fromEntries( + messageIdArray.map(({ clientId, id }) => [clientId, id]), + ); + + // 3. update parentId for messages + console.time('execute updates parentId'); + const parentIdUpdates = shouldInsertMessages + .filter((msg) => msg.parentId) // 只处理有 parentId 的消息 + .map((msg) => { + if (messageIdMap[msg.parentId as string]) + return sql`WHEN ${messages.clientId} = ${msg.id} THEN ${messageIdMap[msg.parentId as string]} `; + + return undefined; + }) + .filter(Boolean); + + if (parentIdUpdates.length > 0) { + const updateQuery = trx + .update(messages) + .set({ + parentId: sql`CASE ${sql.join(parentIdUpdates)} END`, + }) + .where( + inArray( + messages.clientId, + data.messages.map((msg) => msg.id), + ), + ); + + // if needed, you can print the sql and params + // const SQL = updateQuery.toSQL(); + // console.log('sql:', SQL.sql); + // console.log('params:', SQL.params); + + await updateQuery.execute(); + } + console.timeEnd('execute updates parentId'); + + // 4. insert message plugins + const pluginInserts = shouldInsertMessages.filter((msg) => msg.plugin); + if (pluginInserts.length > 0) { + await trx + .insert(messagePlugins) + .values( + pluginInserts.map((msg) => ({ + apiName: msg.plugin?.apiName, + arguments: msg.plugin?.arguments, + id: messageIdMap[msg.id], + identifier: msg.plugin?.identifier, + state: msg.pluginState, + toolCallId: msg.tool_call_id, + type: msg.plugin?.type, + })), + ) + .execute(); + } + + // 5. insert message translate + const translateInserts = shouldInsertMessages.filter((msg) => msg.extra?.translate); + if (translateInserts.length > 0) { + await trx + .insert(messageTranslates) + .values( + translateInserts.map((msg) => ({ + id: messageIdMap[msg.id], + ...msg.extra?.translate, + })), + ) + .execute(); + } + + // TODO: 未来需要处理 TTS 和图片的插入 (目前存在 file 的部分,不方便处理) + } + + messageResult.added = shouldInsertMessages.length; + } + }); + + return { + messages: messageResult, + sessionGroups: sessionGroupResult, + sessions: sessionResult, + topics: topicResult, + }; + }; +} diff --git a/src/database/server/schemas/_id.ts b/src/database/server/schemas/_id.ts new file mode 100644 index 0000000000000..cd84bfac8db68 --- /dev/null +++ b/src/database/server/schemas/_id.ts @@ -0,0 +1,15 @@ +// refs: https://unkey.dev/blog/uuid-ux + +// If I have 100 million users, each generating up to 1 million messages. +// Then the total number of IDs that need to be generated: 100 million × 1 million = 10^14 (100 trillion) +// 11-digit Nano ID: 36^11 ≈ 1.3 × 10^17 (130 trillion trillion) + +export const FILE_ID_LENGTH = 19; // 5 prefix + 14 random, e.g. file_ydGX5gmaxL32fh + +export const MESSAGE_ID_LENGTH = 18; // 4 prefix + 14 random, e.g. msg_GX5ymaxL3d2ds2 + +export const SESSION_ID_LENGTH = 16; // 4 prefix + 12 random, e.g. ssn_GX5y3d2dmaxL + +export const TOPIC_ID_LENGTH = 16; // 4 prefix + 12 random, e.g. tpc_GX5ymd7axL3y + +export const USER_ID_LENGTH = 14; // 4 prefix + 10 random, e.g. user_GXyxLmd75a diff --git a/src/database/server/schemas/lobechat.ts b/src/database/server/schemas/lobechat.ts new file mode 100644 index 0000000000000..4aded60b58df4 --- /dev/null +++ b/src/database/server/schemas/lobechat.ts @@ -0,0 +1,551 @@ +/* eslint-disable sort-keys-fix/sort-keys-fix */ +import { LobeChatPluginManifest } from '@lobehub/chat-plugin-sdk'; +import { relations } from 'drizzle-orm'; +import { + boolean, + index, + integer, + jsonb, + pgTable, + primaryKey, + serial, + text, + timestamp, + uniqueIndex, + varchar, +} from 'drizzle-orm/pg-core'; +import { createInsertSchema } from 'drizzle-zod'; + +import { DEFAULT_PREFERENCE } from '@/const/user'; +import { LobeAgentChatConfig, LobeAgentTTSConfig } from '@/types/agent'; +import { CustomPluginParams } from '@/types/tool/plugin'; + +import { idGenerator, randomSlug } from '../utils/idGenerator'; + +const timestamptz = (name: string) => timestamp(name, { withTimezone: true }); + +const createdAt = () => timestamptz('created_at').notNull().defaultNow(); +const updatedAt = () => timestamptz('updated_at').notNull().defaultNow(); + +/** + * This table stores users. Users are created in Clerk, then Clerk calls a + * webhook at /api/webhook/clerk to inform this application a user was created. + */ +export const users = pgTable('users', { + // The ID will be the user's ID from Clerk + id: text('id').primaryKey().notNull(), + username: text('username').unique(), + email: text('email'), + + avatar: text('avatar'), + phone: text('phone'), + firstName: text('first_name'), + lastName: text('last_name'), + + isOnboarded: boolean('is_onboarded').default(false), + // Time user was created in Clerk + clerkCreatedAt: timestamptz('clerk_created_at'), + + preference: jsonb('preference').default(DEFAULT_PREFERENCE), + + createdAt: createdAt(), + updatedAt: updatedAt(), + + key: text('key'), +}); + +export const userSettings = pgTable('user_settings', { + id: text('id') + .references(() => users.id, { onDelete: 'cascade' }) + .primaryKey(), + + tts: jsonb('tts'), + keyVaults: text('key_vaults'), + general: jsonb('general'), + languageModel: jsonb('language_model'), + systemAgent: jsonb('system_agent'), + defaultAgent: jsonb('default_agent'), + tool: jsonb('tool'), +}); + +export const tags = pgTable('tags', { + id: serial('id').primaryKey(), + slug: text('slug').notNull().unique(), + name: text('name'), + + userId: text('user_id') + .references(() => users.id, { onDelete: 'cascade' }) + .notNull(), + + createdAt: createdAt(), + updatedAt: updatedAt(), +}); + +export type NewUser = typeof users.$inferInsert; +export type UserItem = typeof users.$inferSelect; + +export const files = pgTable('files', { + id: text('id') + .$defaultFn(() => idGenerator('files')) + .primaryKey(), + + userId: text('user_id') + .references(() => users.id, { onDelete: 'cascade' }) + .notNull(), + fileType: varchar('file_type', { length: 255 }).notNull(), + name: text('name').notNull(), + size: integer('size').notNull(), + url: text('url').notNull(), + + metadata: jsonb('metadata'), + + createdAt: createdAt(), + updatedAt: updatedAt(), +}); + +export type NewFile = typeof files.$inferInsert; +export type FileItem = typeof files.$inferSelect; + +export const plugins = pgTable('plugins', { + id: serial('id').primaryKey(), + identifier: text('identifier').notNull().unique(), + + title: text('title').notNull(), + description: text('description'), + avatar: text('avatar'), + author: text('author'), + + manifest: text('manifest').notNull(), + locale: text('locale').notNull(), + createdAt: createdAt(), + updatedAt: updatedAt(), +}); + +export const installedPlugins = pgTable( + 'user_installed_plugins', + { + userId: text('user_id') + .references(() => users.id, { onDelete: 'cascade' }) + .notNull(), + + identifier: text('identifier').notNull(), + type: text('type', { enum: ['plugin', 'customPlugin'] }).notNull(), + manifest: jsonb('manifest').$type(), + settings: jsonb('settings'), + customParams: jsonb('custom_params').$type(), + + createdAt: createdAt(), + updatedAt: updatedAt(), + }, + (self) => ({ + id: primaryKey({ columns: [self.userId, self.identifier] }), + }), +); + +export type NewInstalledPlugin = typeof installedPlugins.$inferInsert; +export type InstalledPluginItem = typeof installedPlugins.$inferSelect; + +export const pluginsTags = pgTable( + 'plugins_tags', + { + pluginId: integer('plugin_id') + .notNull() + .references(() => plugins.id, { onDelete: 'cascade' }), + tagId: integer('tag_id') + .notNull() + .references(() => tags.id, { onDelete: 'cascade' }), + }, + (t) => ({ + pk: primaryKey({ columns: [t.pluginId, t.tagId] }), + }), +); + +// ======= agents ======= // +export const agents = pgTable('agents', { + id: text('id') + .primaryKey() + .$defaultFn(() => idGenerator('agents')) + .notNull(), + slug: varchar('slug', { length: 100 }) + .$defaultFn(() => randomSlug()) + .unique(), + title: text('title'), + description: text('description'), + tags: jsonb('tags').$type().default([]), + avatar: text('avatar'), + backgroundColor: text('background_color'), + + plugins: jsonb('plugins').$type().default([]), + userId: text('user_id') + .references(() => users.id, { onDelete: 'cascade' }) + .notNull(), + + chatConfig: jsonb('chat_config').$type(), + + fewShots: jsonb('few_shots'), + model: text('model'), + params: jsonb('params').default({}), + provider: text('provider'), + systemRole: text('system_role'), + tts: jsonb('tts').$type(), + + createdAt: createdAt(), + updatedAt: updatedAt(), +}); + +export const agentsTags = pgTable( + 'agents_tags', + { + agentId: text('agent_id') + .notNull() + .references(() => agents.id, { onDelete: 'cascade' }), + tagId: integer('tag_id') + .notNull() + .references(() => tags.id, { onDelete: 'cascade' }), + }, + (t) => ({ + pk: primaryKey({ columns: [t.agentId, t.tagId] }), + }), +); +export const insertAgentSchema = createInsertSchema(agents); + +export type NewAgent = typeof agents.$inferInsert; +export type AgentItem = typeof agents.$inferSelect; + +// ======= market ======= // + +export const market = pgTable('market', { + id: serial('id').primaryKey(), + + agentId: text('agent_id').references(() => agents.id, { onDelete: 'cascade' }), + pluginId: integer('plugin_id').references(() => plugins.id, { onDelete: 'cascade' }), + + type: text('type', { enum: ['plugin', 'model', 'agent', 'group'] }).notNull(), + + view: integer('view').default(0), + like: integer('like').default(0), + used: integer('used').default(0), + + userId: text('user_id') + .references(() => users.id, { onDelete: 'cascade' }) + .notNull(), + + createdAt: createdAt(), + updatedAt: updatedAt(), +}); + +// ======= sessionGroups ======= // + +export const sessionGroups = pgTable('session_groups', { + id: text('id') + .$defaultFn(() => idGenerator('sessionGroups')) + .primaryKey(), + name: text('name').notNull(), + sort: integer('sort'), + + userId: text('user_id') + .references(() => users.id, { onDelete: 'cascade' }) + .notNull(), + + clientId: text('client_id').unique(), + createdAt: createdAt(), + updatedAt: updatedAt(), +}); + +export const insertSessionGroupSchema = createInsertSchema(sessionGroups); + +export type NewSessionGroup = typeof sessionGroups.$inferInsert; +export type SessionGroupItem = typeof sessionGroups.$inferSelect; + +// ======= sessions ======= // + +export const sessions = pgTable( + 'sessions', + { + id: text('id') + .$defaultFn(() => idGenerator('sessions')) + .primaryKey(), + slug: varchar('slug', { length: 100 }) + .notNull() + .$defaultFn(() => randomSlug()), + title: text('title'), + description: text('description'), + avatar: text('avatar'), + backgroundColor: text('background_color'), + + type: text('type', { enum: ['agent', 'group'] }).default('agent'), + + userId: text('user_id') + .references(() => users.id, { onDelete: 'cascade' }) + .notNull(), + groupId: text('group_id').references(() => sessionGroups.id, { onDelete: 'set null' }), + clientId: text('client_id').unique(), + + pinned: boolean('pinned').default(false), + + createdAt: createdAt(), + updatedAt: updatedAt(), + }, + (t) => ({ + slugUserIdUnique: uniqueIndex('slug_user_id_unique').on(t.slug, t.userId), + }), +); + +export const insertSessionSchema = createInsertSchema(sessions); +// export const selectSessionSchema = createSelectSchema(sessions); + +export type NewSession = typeof sessions.$inferInsert; +export type SessionItem = typeof sessions.$inferSelect; + +// ======== topics ======= // +export const topics = pgTable('topics', { + id: text('id') + .$defaultFn(() => idGenerator('topics')) + .primaryKey(), + sessionId: text('session_id').references(() => sessions.id, { onDelete: 'cascade' }), + userId: text('user_id') + .references(() => users.id, { onDelete: 'cascade' }) + .notNull(), + favorite: boolean('favorite').default(false), + title: text('title'), + clientId: text('client_id').unique(), + + createdAt: createdAt(), + updatedAt: updatedAt(), +}); + +export type NewTopic = typeof topics.$inferInsert; +export type TopicItem = typeof topics.$inferSelect; + +// ======== messages ======= // +// @ts-ignore +export const messages = pgTable( + 'messages', + { + id: text('id') + .$defaultFn(() => idGenerator('messages')) + .primaryKey(), + + role: text('role', { enum: ['user', 'system', 'assistant', 'tool'] }).notNull(), + content: text('content'), + + model: text('model'), + provider: text('provider'), + + favorite: boolean('favorite').default(false), + error: jsonb('error'), + + tools: jsonb('tools'), + + traceId: text('trace_id'), + observationId: text('observation_id'), + + clientId: text('client_id').unique(), + + // foreign keys + userId: text('user_id') + .references(() => users.id, { onDelete: 'cascade' }) + .notNull(), + sessionId: text('session_id').references(() => sessions.id, { onDelete: 'cascade' }), + topicId: text('topic_id').references(() => topics.id, { onDelete: 'cascade' }), + parentId: text('parent_id').references(() => messages.id, { onDelete: 'set null' }), + quotaId: text('quota_id').references(() => messages.id, { onDelete: 'set null' }), + + // used for group chat + agentId: text('agent_id').references(() => agents.id, { onDelete: 'set null' }), + + createdAt: createdAt(), + updatedAt: updatedAt(), + }, + (table) => ({ + createdAtIdx: index('messages_created_at_idx').on(table.createdAt), + clientIdIdx: index('messages_client_id_idx').on(table.clientId), + }), +); + +export type NewMessage = typeof messages.$inferInsert; +export type MessageItem = typeof messages.$inferSelect; + +export const messagePlugins = pgTable('message_plugins', { + id: text('id') + .references(() => messages.id, { onDelete: 'cascade' }) + .primaryKey(), + + toolCallId: text('tool_call_id'), + type: text('type', { + enum: ['default', 'markdown', 'standalone', 'builtin'], + }).default('default'), + + apiName: text('api_name'), + arguments: text('arguments'), + identifier: text('identifier'), + state: jsonb('state'), + error: jsonb('error'), +}); + +export const messageTTS = pgTable('message_tts', { + id: text('id') + .references(() => messages.id, { onDelete: 'cascade' }) + .primaryKey(), + contentMd5: text('content_md5'), + fileId: text('file_id').references(() => files.id, { onDelete: 'cascade' }), + voice: text('voice'), +}); + +export const messageTranslates = pgTable('message_translates', { + id: text('id') + .references(() => messages.id, { onDelete: 'cascade' }) + .primaryKey(), + content: text('content'), + from: text('from'), + to: text('to'), +}); + +export const agentsToSessions = pgTable( + 'agents_to_sessions', + { + agentId: text('agent_id') + .notNull() + .references(() => agents.id, { onDelete: 'cascade' }), + sessionId: text('session_id') + .notNull() + .references(() => sessions.id, { onDelete: 'cascade' }), + }, + (t) => ({ + pk: primaryKey({ columns: [t.agentId, t.sessionId] }), + }), +); + +export const filesToMessages = pgTable( + 'files_to_messages', + { + fileId: text('file_id') + .notNull() + .references(() => files.id, { onDelete: 'cascade' }), + messageId: text('message_id') + .notNull() + .references(() => messages.id, { onDelete: 'cascade' }), + }, + (t) => ({ + pk: primaryKey({ columns: [t.fileId, t.messageId] }), + }), +); + +export const filesToSessions = pgTable( + 'files_to_sessions', + { + fileId: text('file_id') + .notNull() + .references(() => files.id, { onDelete: 'cascade' }), + sessionId: text('session_id') + .notNull() + .references(() => sessions.id, { onDelete: 'cascade' }), + }, + (t) => ({ + pk: primaryKey({ columns: [t.fileId, t.sessionId] }), + }), +); + +export const filesToAgents = pgTable( + 'files_to_agents', + { + fileId: text('file_id') + .notNull() + .references(() => files.id, { onDelete: 'cascade' }), + agentId: text('agent_id') + .notNull() + .references(() => agents.id, { onDelete: 'cascade' }), + }, + (t) => ({ + pk: primaryKey({ columns: [t.fileId, t.agentId] }), + }), +); + +export const filesRelations = relations(files, ({ many }) => ({ + filesToMessages: many(filesToMessages), + filesToSessions: many(filesToSessions), + filesToAgents: many(filesToAgents), +})); + +export const topicRelations = relations(topics, ({ one }) => ({ + session: one(sessions, { + fields: [topics.sessionId], + references: [sessions.id], + }), +})); + +export const pluginsRelations = relations(plugins, ({ many }) => ({ + pluginsTags: many(pluginsTags), +})); + +export const pluginsTagsRelations = relations(pluginsTags, ({ one }) => ({ + plugin: one(plugins, { + fields: [pluginsTags.pluginId], + references: [plugins.id], + }), + tag: one(tags, { + fields: [pluginsTags.tagId], + references: [tags.id], + }), +})); + +export const tagsRelations = relations(tags, ({ many }) => ({ + agentsTags: many(agentsTags), + pluginsTags: many(pluginsTags), +})); + +export const messagesRelations = relations(messages, ({ many, one }) => ({ + filesToMessages: many(filesToMessages), + + session: one(sessions, { + fields: [messages.sessionId], + references: [sessions.id], + }), + + parent: one(messages, { + fields: [messages.parentId], + references: [messages.id], + }), + + topic: one(topics, { + fields: [messages.topicId], + references: [topics.id], + }), +})); + +export const agentsRelations = relations(agents, ({ many }) => ({ + agentsToSessions: many(agentsToSessions), + filesToAgents: many(filesToAgents), + agentsTags: many(agentsTags), +})); + +export const agentsToSessionsRelations = relations(agentsToSessions, ({ one }) => ({ + session: one(sessions, { + fields: [agentsToSessions.sessionId], + references: [sessions.id], + }), + agent: one(agents, { + fields: [agentsToSessions.agentId], + references: [agents.id], + }), +})); + +export const agentsTagsRelations = relations(agentsTags, ({ one }) => ({ + agent: one(agents, { + fields: [agentsTags.agentId], + references: [agents.id], + }), + tag: one(tags, { + fields: [agentsTags.tagId], + references: [tags.id], + }), +})); + +export const sessionsRelations = relations(sessions, ({ many, one }) => ({ + filesToSessions: many(filesToSessions), + agentsToSessions: many(agentsToSessions), + group: one(sessionGroups, { + fields: [sessions.groupId], + references: [sessionGroups.id], + }), +})); diff --git a/src/database/server/utils/idGenerator.test.ts b/src/database/server/utils/idGenerator.test.ts new file mode 100644 index 0000000000000..6099f27634a86 --- /dev/null +++ b/src/database/server/utils/idGenerator.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; + +import { idGenerator } from './idGenerator'; + +describe('idGenerator', () => { + it('should generate an ID with the correct prefix and length', () => { + const fileId = idGenerator('files'); + expect(fileId).toMatch(/^file_[a-zA-Z0-9]{8}$/); + + const messageId = idGenerator('messages'); + expect(messageId).toMatch(/^msg_[a-zA-Z0-9]{8}$/); + + const pluginId = idGenerator('plugins'); + expect(pluginId).toMatch(/^plg_[a-zA-Z0-9]{8}$/); + + const sessionGroupId = idGenerator('sessionGroups'); + expect(sessionGroupId).toMatch(/^sg_[a-zA-Z0-9]{8}$/); + + const sessionId = idGenerator('sessions'); + expect(sessionId).toMatch(/^ssn_[a-zA-Z0-9]{8}$/); + + const topicId = idGenerator('topics'); + expect(topicId).toMatch(/^tpc_[a-zA-Z0-9]{8}$/); + + const userId = idGenerator('user'); + expect(userId).toMatch(/^user_[a-zA-Z0-9]{8}$/); + }); + + it('should generate an ID with custom size', () => { + const fileId = idGenerator('files', 12); + expect(fileId).toMatch(/^file_[a-zA-Z0-9]{12}$/); + }); + + it('should throw an error for invalid namespace', () => { + expect(() => idGenerator('invalid' as any)).toThrowError( + 'Invalid namespace: invalid, please check your code.', + ); + }); +}); diff --git a/src/database/server/utils/idGenerator.ts b/src/database/server/utils/idGenerator.ts new file mode 100644 index 0000000000000..2f9eb2e2202bd --- /dev/null +++ b/src/database/server/utils/idGenerator.ts @@ -0,0 +1,26 @@ +import { generate } from 'random-words'; + +import { createNanoId } from '@/utils/uuid'; + +const prefixes = { + agents: 'agt', + files: 'file', + messages: 'msg', + plugins: 'plg', + sessionGroups: 'sg', + sessions: 'ssn', + topics: 'tpc', + user: 'user', +} as const; + +export const idGenerator = (namespace: keyof typeof prefixes, size = 12) => { + const hash = createNanoId(size); + const prefix = prefixes[namespace]; + + if (!prefix) throw new Error(`Invalid namespace: ${namespace}, please check your code.`); + + return `${prefix}_${hash()}`; +}; +export const randomSlug = () => (generate(2) as string[]).join('-'); + +export const inboxSessionId = (userId: string) => `ssn_inbox_${userId}`; diff --git a/src/features/User/UserPanel/useMenu.tsx b/src/features/User/UserPanel/useMenu.tsx index 0d737c1b510ca..d5ea4b71ea9b8 100644 --- a/src/features/User/UserPanel/useMenu.tsx +++ b/src/features/User/UserPanel/useMenu.tsx @@ -1,5 +1,6 @@ import { ActionIcon, DiscordIcon, Icon } from '@lobehub/ui'; import { Badge } from 'antd'; +import { ItemType } from 'antd/es/menu/interface'; import { Book, CircleUserRound, @@ -21,6 +22,7 @@ import urlJoin from 'url-join'; import type { MenuProps } from '@/components/Menu'; import { DISCORD, DOCUMENTS, EMAIL_SUPPORT, GITHUB_ISSUES } from '@/const/url'; +import { isServerMode } from '@/const/version'; import DataImporter from '@/features/DataImporter'; import { useOpenSettings } from '@/hooks/useInterceptingRoutes'; import { usePWAInstall } from '@/hooks/usePWAInstall'; @@ -111,46 +113,51 @@ export const useMenu = () => { }, ]; - const data: MenuProps['items'] = [ - { - icon: , - key: 'import', - label: {t('import')}, - }, - { - children: [ - { - key: 'allAgent', - label: t('exportType.allAgent'), - onClick: configService.exportAgents, - }, - { - key: 'allAgentWithMessage', - label: t('exportType.allAgentWithMessage'), - onClick: configService.exportSessions, - }, - { - key: 'globalSetting', - label: t('exportType.globalSetting'), - onClick: configService.exportSettings, - }, - { - type: 'divider', - }, - { - key: 'all', - label: t('exportType.all'), - onClick: configService.exportAll, - }, - ], - icon: , - key: 'export', - label: t('export'), - }, - { - type: 'divider', - }, - ]; + const data = + isLogin || !isServerMode + ? ([ + { + icon: , + key: 'import', + label: {t('import')}, + }, + isServerMode + ? null + : { + children: [ + { + key: 'allAgent', + label: t('exportType.allAgent'), + onClick: configService.exportAgents, + }, + { + key: 'allAgentWithMessage', + label: t('exportType.allAgentWithMessage'), + onClick: configService.exportSessions, + }, + { + key: 'globalSetting', + label: t('exportType.globalSetting'), + onClick: configService.exportSettings, + }, + { + type: 'divider', + }, + { + key: 'all', + label: t('exportType.all'), + onClick: configService.exportAll, + }, + ], + icon: , + key: 'export', + label: t('export'), + }, + { + type: 'divider', + }, + ].filter(Boolean) as ItemType[]) + : []; const helps: MenuProps['items'] = [ { @@ -205,10 +212,10 @@ export const useMenu = () => { { type: 'divider', }, - ...(isLoginWithClerk ? profile : []), ...(isLogin ? settings : []), + ...(isLoginWithClerk ? profile : []), ...(canInstall ? pwa : []), - ...(isLogin ? data : []), + ...data, ...helps, ].filter(Boolean) as MenuProps['items']; diff --git a/src/libs/trpc/client.ts b/src/libs/trpc/client.ts index 488825e195711..602b225710294 100644 --- a/src/libs/trpc/client.ts +++ b/src/libs/trpc/client.ts @@ -1,7 +1,8 @@ import { createTRPCClient, httpBatchLink } from '@trpc/client'; import superjson from 'superjson'; -import type { EdgeRouter } from '@/server/routers'; +import { fetchErrorNotification } from '@/components/FetchErrorNotification'; +import type { EdgeRouter, LambdaRouter } from '@/server/routers'; import { createHeaderWithAuth } from '@/services/_auth'; import { withBasePath } from '@/utils/basePath'; @@ -14,3 +15,46 @@ export const edgeClient = createTRPCClient({ }), ], }); + +export type ErrorResponse = ErrorItem[]; + +export interface ErrorItem { + error: { + json: { + code: number; + data: Data; + message: string; + }; + }; +} + +export interface Data { + code: string; + httpStatus: number; + path: string; + stack: string; +} + +export const lambdaClient = createTRPCClient({ + links: [ + httpBatchLink({ + fetch: async (input, init) => { + const response = await fetch(input, init); + if (response.ok) return response; + + const errorRes: ErrorResponse = await response.clone().json(); + + errorRes.forEach((item) => { + const errorData = item.error.json; + + const status = errorData.data.httpStatus; + fetchErrorNotification.error({ errorMessage: errorData.message, status }); + }); + + return response; + }, + transformer: superjson, + url: '/trpc/lambda', + }), + ], +}); diff --git a/src/server/files/s3.ts b/src/server/files/s3.ts index 97a2f0eadd46e..743da0cffdddb 100644 --- a/src/server/files/s3.ts +++ b/src/server/files/s3.ts @@ -1,4 +1,9 @@ -import { ListObjectsCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3'; +import { + GetObjectCommand, + ListObjectsCommand, + PutObjectCommand, + S3Client, +} from '@aws-sdk/client-s3'; import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; import { z } from 'zod'; @@ -46,6 +51,21 @@ export class S3 { return listFileSchema.parse(res.Contents); } + public async getFileContent(key: string): Promise { + const command = new GetObjectCommand({ + Bucket: this.bucket, + Key: key, + }); + + const response = await this.client.send(command); + + if (!response.Body) { + throw new Error(`No body in response with ${key}`); + } + + return response.Body.transformToString(); + } + public async createPreSignedUrl(key: string): Promise { const command = new PutObjectCommand({ ACL: 'public-read', diff --git a/src/server/keyVaultsEncrypt/index.test.ts b/src/server/keyVaultsEncrypt/index.test.ts new file mode 100644 index 0000000000000..ce84d8a27890a --- /dev/null +++ b/src/server/keyVaultsEncrypt/index.test.ts @@ -0,0 +1,57 @@ +// @vitest-environment node +import { beforeEach, describe, expect, it } from 'vitest'; + +import { KeyVaultsGateKeeper } from './index'; + +describe('KeyVaultsGateKeeper', () => { + let gateKeeper: KeyVaultsGateKeeper; + + beforeEach(async () => { + process.env.KEY_VAULTS_SECRET_KEY = 'Q10pwdq00KXUu9R+c8A8p4PSlIRWi7KwgUophBtkHVk='; + // 在每个测试用例运行前初始化 KeyVaultsGateKeeper 实例 + gateKeeper = await KeyVaultsGateKeeper.initWithEnvKey(); + }); + + it('should encrypt and decrypt data correctly', async () => { + const originalData = 'sensitive user data'; + + // 加密数据 + const encryptedData = await gateKeeper.encrypt(originalData); + + // 解密数据 + const decryptionResult = await gateKeeper.decrypt(encryptedData); + + // 断言解密后的明文与原始数据相同 + expect(decryptionResult.plaintext).toBe(originalData); + // 断言解密是真实的(通过认证) + expect(decryptionResult.wasAuthentic).toBe(true); + }); + + it('should return empty plaintext and false authenticity for invalid encrypted data', async () => { + const invalidEncryptedData = 'invalid:encrypted:data'; + + // 尝试解密无效的加密数据 + const decryptionResult = await gateKeeper.decrypt(invalidEncryptedData); + + // 断言解密后的明文为空字符串 + expect(decryptionResult.plaintext).toBe(''); + // 断言解密是不真实的(未通过认证) + expect(decryptionResult.wasAuthentic).toBe(false); + }); + + it('should throw an error if KEY_VAULTS_SECRET_KEY is not set', async () => { + // 将 KEY_VAULTS_SECRET_KEY 设为 undefined + const originalSecretKey = process.env.KEY_VAULTS_SECRET_KEY; + process.env.KEY_VAULTS_SECRET_KEY = ''; + + // 断言在 KEY_VAULTS_SECRET_KEY 未设置时会抛出错误 + try { + await KeyVaultsGateKeeper.initWithEnvKey(); + } catch (e) { + expect(e).toEqual(Error('KEY_VAULTS_SECRET_KEY is not set')); + } + + // 恢复 KEY_VAULTS_SECRET_KEY 的原始值 + process.env.KEY_VAULTS_SECRET_KEY = originalSecretKey; + }); +}); diff --git a/src/server/keyVaultsEncrypt/index.ts b/src/server/keyVaultsEncrypt/index.ts new file mode 100644 index 0000000000000..555b06698c231 --- /dev/null +++ b/src/server/keyVaultsEncrypt/index.ts @@ -0,0 +1,89 @@ +import { getServerDBConfig } from '@/config/db'; + +interface DecryptionResult { + plaintext: string; + wasAuthentic: boolean; +} + +export class KeyVaultsGateKeeper { + private aesKey: CryptoKey; + + constructor(aesKey: CryptoKey) { + this.aesKey = aesKey; + } + + static initWithEnvKey = async () => { + const { KEY_VAULTS_SECRET_KEY } = getServerDBConfig(); + if (!KEY_VAULTS_SECRET_KEY) throw new Error('KEY_VAULTS_SECRET_KEY is not set'); + + const rawKey = Buffer.from(KEY_VAULTS_SECRET_KEY, 'base64'); // 确保密钥是32字节(256位) + const aesKey = await crypto.subtle.importKey( + 'raw', + rawKey, + { length: 256, name: 'AES-GCM' }, + false, + ['encrypt', 'decrypt'], + ); + return new KeyVaultsGateKeeper(aesKey); + }; + + /** + * encrypt user private data + */ + encrypt = async (keyVault: string): Promise => { + const iv = crypto.getRandomValues(new Uint8Array(12)); // 对于GCM,推荐使用12字节的IV + const encodedKeyVault = new TextEncoder().encode(keyVault); + + const encryptedData = await crypto.subtle.encrypt( + { + iv: iv, + name: 'AES-GCM', + }, + this.aesKey, + encodedKeyVault, + ); + + const buffer = Buffer.from(encryptedData); + const authTag = buffer.slice(-16); // 认证标签在加密数据的最后16字节 + const encrypted = buffer.slice(0, -16); // 剩下的是加密数据 + + return `${Buffer.from(iv).toString('hex')}:${authTag.toString('hex')}:${encrypted.toString('hex')}`; + }; + + // 假设密钥和加密数据是从外部获取的 + decrypt = async (encryptedData: string): Promise => { + const parts = encryptedData.split(':'); + if (parts.length !== 3) { + throw new Error('Invalid encrypted data format'); + } + + const iv = Buffer.from(parts[0], 'hex'); + const authTag = Buffer.from(parts[1], 'hex'); + const encrypted = Buffer.from(parts[2], 'hex'); + + // 合并加密数据和认证标签 + const combined = Buffer.concat([encrypted, authTag]); + + try { + const decryptedBuffer = await crypto.subtle.decrypt( + { + iv: iv, + name: 'AES-GCM', + }, + this.aesKey, + combined, + ); + + const decrypted = new TextDecoder().decode(decryptedBuffer); + return { + plaintext: decrypted, + wasAuthentic: true, + }; + } catch { + return { + plaintext: '', + wasAuthentic: false, + }; + } + }; +} diff --git a/src/server/routers/index.ts b/src/server/routers/index.ts index 0630ab99ad4fb..47a08c0b78c80 100644 --- a/src/server/routers/index.ts +++ b/src/server/routers/index.ts @@ -1,10 +1,19 @@ /** - * This file contains the root router of lobe chat tRPC-backend + * This file contains the root router of Lobe Chat tRPC-backend */ import { publicProcedure, router } from '@/libs/trpc'; import { configRouter } from './edge/config'; import { uploadRouter } from './edge/upload'; +// router that connect to db +import { fileRouter } from './lambda/file'; +import { importerRouter } from './lambda/importer'; +import { messageRouter } from './lambda/message'; +import { pluginRouter } from './lambda/plugin'; +import { sessionRouter } from './lambda/session'; +import { sessionGroupRouter } from './lambda/sessionGroup'; +import { topicRouter } from './lambda/topic'; +import { userRouter } from './lambda/user'; export const edgeRouter = router({ config: configRouter, @@ -13,3 +22,17 @@ export const edgeRouter = router({ }); export type EdgeRouter = typeof edgeRouter; + +export const lambdaRouter = router({ + file: fileRouter, + healthcheck: publicProcedure.query(() => "i'm live!"), + importer: importerRouter, + message: messageRouter, + plugin: pluginRouter, + session: sessionRouter, + sessionGroup: sessionGroupRouter, + topic: topicRouter, + user: userRouter, +}); + +export type LambdaRouter = typeof lambdaRouter; diff --git a/src/server/routers/lambda/file.ts b/src/server/routers/lambda/file.ts new file mode 100644 index 0000000000000..655c815575dd2 --- /dev/null +++ b/src/server/routers/lambda/file.ts @@ -0,0 +1,49 @@ +import { z } from 'zod'; + +import { FileModel } from '@/database/server/models/file'; +import { authedProcedure, router } from '@/libs/trpc'; +import { UploadFileSchema } from '@/types/files'; + +const fileProcedure = authedProcedure.use(async (opts) => { + const { ctx } = opts; + + return opts.next({ + ctx: { fileModel: new FileModel(ctx.userId) }, + }); +}); + +export const fileRouter = router({ + createFile: fileProcedure + .input( + UploadFileSchema.omit({ data: true, saveMode: true, url: true }).extend({ url: z.string() }), + ) + .mutation(async ({ ctx, input }) => { + return ctx.fileModel.create({ + fileType: input.fileType, + metadata: input.metadata, + name: input.name, + size: input.size, + url: input.url, + }); + }), + + findById: fileProcedure + .input( + z.object({ + id: z.string(), + }), + ) + .query(async ({ ctx, input }) => { + return ctx.fileModel.findById(input.id); + }), + + removeAllFiles: fileProcedure.mutation(async ({ ctx }) => { + return ctx.fileModel.clear(); + }), + + removeFile: fileProcedure.input(z.object({ id: z.string() })).mutation(async ({ input, ctx }) => { + return ctx.fileModel.delete(input.id); + }), +}); + +export type FileRouter = typeof fileRouter; diff --git a/src/server/routers/lambda/importer.ts b/src/server/routers/lambda/importer.ts new file mode 100644 index 0000000000000..3b66bf1c0f6c1 --- /dev/null +++ b/src/server/routers/lambda/importer.ts @@ -0,0 +1,54 @@ +// import urlJoin from 'url-join'; +import { TRPCError } from '@trpc/server'; +import { z } from 'zod'; + +// import { fileEnv } from '@/config/file'; +import { DataImporter } from '@/database/server/modules/DataImporter'; +import { authedProcedure, router } from '@/libs/trpc'; +import { S3 } from '@/server/files/s3'; +import { ImportResults, ImporterEntryData } from '@/types/importer'; + +export const importerRouter = router({ + importByFile: authedProcedure + .input(z.object({ pathname: z.string() })) + .mutation(async ({ input, ctx }): Promise => { + let data: ImporterEntryData | undefined; + + try { + const s3 = new S3(); + const dataStr = await s3.getFileContent(input.pathname); + data = JSON.parse(dataStr); + } catch { + data = undefined; + } + + if (!data) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: `Failed to read file at ${input.pathname}`, + }); + } + + const dataImporter = new DataImporter(ctx.userId); + + return dataImporter.importData(data); + }), + + importByPost: authedProcedure + .input( + z.object({ + data: z.object({ + messages: z.array(z.any()).optional(), + sessionGroups: z.array(z.any()).optional(), + sessions: z.array(z.any()).optional(), + topics: z.array(z.any()).optional(), + version: z.number(), + }), + }), + ) + .mutation(async ({ input, ctx }): Promise => { + const dataImporter = new DataImporter(ctx.userId); + + return dataImporter.importData(input.data); + }), +}); diff --git a/src/server/routers/lambda/message.ts b/src/server/routers/lambda/message.ts new file mode 100644 index 0000000000000..0122169ccd408 --- /dev/null +++ b/src/server/routers/lambda/message.ts @@ -0,0 +1,165 @@ +import { z } from 'zod'; + +import { MessageModel } from '@/database/server/models/message'; +import { authedProcedure, publicProcedure, router } from '@/libs/trpc'; +import { ChatMessage } from '@/types/message'; +import { BatchTaskResult } from '@/types/service'; + +type ChatMessageList = ChatMessage[]; + +const messageProcedure = authedProcedure.use(async (opts) => { + const { ctx } = opts; + + return opts.next({ + ctx: { messageModel: new MessageModel(ctx.userId) }, + }); +}); + +export const messageRouter = router({ + batchCreateMessages: messageProcedure + .input(z.array(z.object({}).passthrough().partial())) + .mutation(async ({ input, ctx }): Promise => { + const data = await ctx.messageModel.batchCreate(input); + + return { added: data.rowCount as number, ids: [], skips: [], success: true }; + }), + + count: messageProcedure.query(async ({ ctx }) => { + return ctx.messageModel.count(); + }), + countToday: messageProcedure.query(async ({ ctx }) => { + return ctx.messageModel.countToday(); + }), + + createMessage: messageProcedure + .input(z.object({}).passthrough().partial()) + .mutation(async ({ input, ctx }) => { + const data = await ctx.messageModel.create(input as any); + + return data.id; + }), + + getAllMessages: messageProcedure.query(async ({ ctx }): Promise => { + return ctx.messageModel.queryAll(); + }), + + getAllMessagesInSession: messageProcedure + .input( + z.object({ + sessionId: z.string().optional(), + }), + ) + .query(async ({ ctx, input }): Promise => { + return ctx.messageModel.queryBySessionId(input.sessionId); + }), + + getMessages: publicProcedure + .input( + z.object({ + current: z.number().optional(), + pageSize: z.number().optional(), + sessionId: z.string().nullable().optional(), + topicId: z.string().nullable().optional(), + }), + ) + .query(async ({ input, ctx }) => { + if (!ctx.userId) return []; + + const messageModel = new MessageModel(ctx.userId); + + return messageModel.query(input); + }), + + removeAllMessages: messageProcedure.mutation(async ({ ctx }) => { + return ctx.messageModel.deleteAllMessages(); + }), + + removeMessage: messageProcedure + .input(z.object({ id: z.string() })) + .mutation(async ({ input, ctx }) => { + return ctx.messageModel.deleteMessage(input.id); + }), + + removeMessages: messageProcedure + .input( + z.object({ + sessionId: z.string().nullable().optional(), + topicId: z.string().nullable().optional(), + }), + ) + .mutation(async ({ input, ctx }) => { + return ctx.messageModel.deleteMessages(input.sessionId, input.topicId); + }), + + searchMessages: messageProcedure + .input(z.object({ keywords: z.string() })) + .query(async ({ input, ctx }) => { + return ctx.messageModel.queryByKeyword(input.keywords); + }), + + update: messageProcedure + .input( + z.object({ + id: z.string(), + value: z.object({}).passthrough().partial(), + }), + ) + .mutation(async ({ input, ctx }) => { + return ctx.messageModel.update(input.id, input.value); + }), + + updatePluginState: messageProcedure + .input( + z.object({ + id: z.string(), + value: z.object({}).passthrough(), + }), + ) + .mutation(async ({ input, ctx }) => { + return ctx.messageModel.updatePluginState(input.id, input.value); + }), + + updateTTS: messageProcedure + .input( + z.object({ + id: z.string(), + value: z + .object({ + contentMd5: z.string().optional(), + fileId: z.string().optional(), + voice: z.string().optional(), + }) + .or(z.literal(false)), + }), + ) + .mutation(async ({ input, ctx }) => { + if (input.value === false) { + return ctx.messageModel.deleteMessageTTS(input.id); + } + + return ctx.messageModel.updateTTS(input.id, input.value); + }), + + updateTranslate: messageProcedure + .input( + z.object({ + id: z.string(), + value: z + .object({ + content: z.string().optional(), + from: z.string().optional(), + to: z.string(), + }) + .or(z.literal(false)), + }), + ) + .mutation(async ({ input, ctx }) => { + if (input.value === false) { + return ctx.messageModel.deleteMessageTranslate(input.id); + } + + return ctx.messageModel.updateTranslate(input.id, input.value); + }), +}); + +export type MessageRouter = typeof messageRouter; diff --git a/src/server/routers/lambda/plugin.ts b/src/server/routers/lambda/plugin.ts new file mode 100644 index 0000000000000..3c691c51df0c0 --- /dev/null +++ b/src/server/routers/lambda/plugin.ts @@ -0,0 +1,100 @@ +import { z } from 'zod'; + +import { PluginModel } from '@/database/server/models/plugin'; +import { authedProcedure, publicProcedure, router } from '@/libs/trpc'; +import { LobeTool } from '@/types/tool'; + +const pluginProcedure = authedProcedure.use(async (opts) => { + const { ctx } = opts; + + return opts.next({ + ctx: { pluginModel: new PluginModel(ctx.userId) }, + }); +}); + +export const pluginRouter = router({ + createOrInstallPlugin: pluginProcedure + .input( + z.object({ + customParams: z.any(), + identifier: z.string(), + manifest: z.any(), + type: z.enum(['plugin', 'customPlugin']), + }), + ) + .mutation(async ({ input, ctx }) => { + const result = await ctx.pluginModel.findById(input.identifier); + + // if not exist, we should create the plugin + if (!result) { + const data = await ctx.pluginModel.create({ + customParams: input.customParams, + identifier: input.identifier, + manifest: input.manifest, + type: input.type, + }); + + return data.identifier; + } + + // or we can just update the plugin manifest + await ctx.pluginModel.update(input.identifier, { manifest: input.manifest }); + }), + + createPlugin: pluginProcedure + .input( + z.object({ + customParams: z.any(), + identifier: z.string(), + manifest: z.any(), + type: z.enum(['plugin', 'customPlugin']), + }), + ) + .mutation(async ({ input, ctx }) => { + const data = await ctx.pluginModel.create({ + customParams: input.customParams, + identifier: input.identifier, + manifest: input.manifest, + type: input.type, + }); + + return data.identifier; + }), + + getPlugins: publicProcedure.query(async ({ ctx }): Promise => { + if (!ctx.userId) return []; + + const pluginModel = new PluginModel(ctx.userId); + + return pluginModel.query(); + }), + + removeAllPlugins: pluginProcedure.mutation(async ({ ctx }) => { + return ctx.pluginModel.deleteAll(); + }), + + removePlugin: pluginProcedure + .input(z.object({ id: z.string() })) + .mutation(async ({ input, ctx }) => { + return ctx.pluginModel.delete(input.id); + }), + + updatePlugin: pluginProcedure + .input( + z.object({ + customParams: z.any().optional(), + id: z.string(), + manifest: z.any().optional(), + settings: z.any().optional(), + }), + ) + .mutation(async ({ input, ctx }) => { + return ctx.pluginModel.update(input.id, { + customParams: input.customParams, + manifest: input.manifest, + settings: input.settings, + }); + }), +}); + +export type PluginRouter = typeof pluginRouter; diff --git a/src/server/routers/lambda/session.ts b/src/server/routers/lambda/session.ts new file mode 100644 index 0000000000000..c140d33997d4e --- /dev/null +++ b/src/server/routers/lambda/session.ts @@ -0,0 +1,194 @@ +import { z } from 'zod'; + +import { INBOX_SESSION_ID } from '@/const/session'; +import { SessionModel } from '@/database/server/models/session'; +import { SessionGroupModel } from '@/database/server/models/sessionGroup'; +import { insertAgentSchema, insertSessionSchema } from '@/database/server/schemas/lobechat'; +import { pino } from '@/libs/logger'; +import { authedProcedure, publicProcedure, router } from '@/libs/trpc'; +import { AgentChatConfigSchema } from '@/types/agent'; +import { LobeMetaDataSchema } from '@/types/meta'; +import { BatchTaskResult } from '@/types/service'; +import { ChatSessionList } from '@/types/session'; +import { merge } from '@/utils/merge'; + +const sessionProcedure = authedProcedure.use(async (opts) => { + const { ctx } = opts; + + return opts.next({ + ctx: { + sessionGroupModel: new SessionGroupModel(ctx.userId), + sessionModel: new SessionModel(ctx.userId), + }, + }); +}); + +export const sessionRouter = router({ + batchCreateSessions: sessionProcedure + .input( + z.array( + z + .object({ + config: z.object({}).passthrough(), + group: z.string().optional(), + id: z.string(), + meta: LobeMetaDataSchema, + pinned: z.boolean().optional(), + type: z.string(), + }) + .partial(), + ), + ) + .mutation(async ({ input, ctx }): Promise => { + const data = await ctx.sessionModel.batchCreate( + input.map((item) => ({ + ...item, + ...item.meta, + })) as any, + ); + + return { added: data.rowCount as number, ids: [], skips: [], success: true }; + }), + + cloneSession: sessionProcedure + .input(z.object({ id: z.string(), newTitle: z.string() })) + .mutation(async ({ input, ctx }) => { + const data = await ctx.sessionModel.duplicate(input.id, input.newTitle); + + return data?.id; + }), + + countSessions: sessionProcedure.query(async ({ ctx }) => { + return ctx.sessionModel.count(); + }), + + createSession: sessionProcedure + .input( + z.object({ + config: insertAgentSchema + .omit({ chatConfig: true, plugins: true, tags: true, tts: true }) + .passthrough() + .partial(), + session: insertSessionSchema.omit({ createdAt: true, updatedAt: true }).partial(), + type: z.enum(['agent', 'group']), + }), + ) + .mutation(async ({ input, ctx }) => { + const data = await ctx.sessionModel.create(input); + + return data.id; + }), + + getGroupedSessions: publicProcedure.query(async ({ ctx }): Promise => { + if (!ctx.userId) + return { + sessionGroups: [], + sessions: [], + }; + + const sessionModel = new SessionModel(ctx.userId); + + return sessionModel.queryWithGroups(); + }), + + getSessionConfig: sessionProcedure + .input( + z.object({ + id: z.string(), + }), + ) + .query(async ({ input, ctx }) => { + if (input.id === INBOX_SESSION_ID) { + const item = await ctx.sessionModel.findByIdOrSlug(INBOX_SESSION_ID); + // if there is no session for user, create one + if (!item) { + const res = await ctx.sessionModel.createInbox(); + pino.info('create inbox session', res); + } + } + + const session = await ctx.sessionModel.findByIdOrSlug(input.id); + + if (!session) throw new Error('Session not found'); + + return session.agent; + }), + + getSessions: sessionProcedure + .input( + z.object({ + current: z.number().optional(), + pageSize: z.number().optional(), + }), + ) + .query(async ({ input, ctx }) => { + const { current, pageSize } = input; + + return ctx.sessionModel.query({ current, pageSize }); + }), + + removeAllSessions: sessionProcedure.mutation(async ({ ctx }) => { + return ctx.sessionModel.deleteAll(); + }), + + removeSession: sessionProcedure + .input(z.object({ id: z.string() })) + .mutation(async ({ input, ctx }) => { + return ctx.sessionModel.delete(input.id); + }), + + searchSessions: sessionProcedure + .input(z.object({ keywords: z.string() })) + .query(async ({ input, ctx }) => { + return ctx.sessionModel.queryByKeyword(input.keywords); + }), + + updateSession: sessionProcedure + .input( + z.object({ + id: z.string(), + value: insertSessionSchema.partial(), + }), + ) + .mutation(async ({ input, ctx }) => { + return ctx.sessionModel.update(input.id, input.value); + }), + updateSessionChatConfig: sessionProcedure + .input( + z.object({ + id: z.string(), + value: AgentChatConfigSchema.partial(), + }), + ) + .mutation(async ({ input, ctx }) => { + const session = await ctx.sessionModel.findByIdOrSlug(input.id); + + if (!session) return; + + return ctx.sessionModel.updateConfig(session.agent.id, { + chatConfig: merge(session.agent.chatConfig, input.value), + }); + }), + updateSessionConfig: sessionProcedure + .input( + z.object({ + id: z.string(), + value: z.object({}).passthrough().partial(), + }), + ) + .mutation(async ({ input, ctx }) => { + const session = await ctx.sessionModel.findByIdOrSlug(input.id); + + if (!session || !input.value) return; + + if (!session.agent) { + throw new Error( + 'this session is not assign with agent, please contact with admin to fix this issue.', + ); + } + + return ctx.sessionModel.updateConfig(session.agent.id, input.value); + }), +}); + +export type SessionRouter = typeof sessionRouter; diff --git a/src/server/routers/lambda/sessionGroup.ts b/src/server/routers/lambda/sessionGroup.ts new file mode 100644 index 0000000000000..e7dace3277ec0 --- /dev/null +++ b/src/server/routers/lambda/sessionGroup.ts @@ -0,0 +1,77 @@ +import { z } from 'zod'; + +import { SessionGroupModel } from '@/database/server/models/sessionGroup'; +import { insertSessionGroupSchema } from '@/database/server/schemas/lobechat'; +import { authedProcedure, router } from '@/libs/trpc'; +import { SessionGroupItem } from '@/types/session'; + +const sessionProcedure = authedProcedure.use(async (opts) => { + const { ctx } = opts; + + return opts.next({ + ctx: { + sessionGroupModel: new SessionGroupModel(ctx.userId), + }, + }); +}); + +export const sessionGroupRouter = router({ + createSessionGroup: sessionProcedure + .input( + z.object({ + name: z.string(), + sort: z.number().optional(), + }), + ) + .mutation(async ({ input, ctx }) => { + const data = await ctx.sessionGroupModel.create({ + name: input.name, + sort: input.sort, + }); + + return data?.id; + }), + + getSessionGroup: sessionProcedure.query(async ({ ctx }): Promise => { + return ctx.sessionGroupModel.query() as any; + }), + + removeAllSessionGroups: sessionProcedure.mutation(async ({ ctx }) => { + return ctx.sessionGroupModel.deleteAll(); + }), + + removeSessionGroup: sessionProcedure + .input(z.object({ id: z.string(), removeChildren: z.boolean().optional() })) + .mutation(async ({ input, ctx }) => { + return ctx.sessionGroupModel.delete(input.id); + }), + + updateSessionGroup: sessionProcedure + .input( + z.object({ + id: z.string(), + value: insertSessionGroupSchema.partial(), + }), + ) + .mutation(async ({ input, ctx }) => { + return ctx.sessionGroupModel.update(input.id, input.value); + }), + updateSessionGroupOrder: sessionProcedure + .input( + z.object({ + sortMap: z.array( + z.object({ + id: z.string(), + sort: z.number(), + }), + ), + }), + ) + .mutation(async ({ input, ctx }) => { + console.log('sortMap:', input.sortMap); + + return ctx.sessionGroupModel.updateOrder(input.sortMap); + }), +}); + +export type SessionGroupRouter = typeof sessionGroupRouter; diff --git a/src/server/routers/lambda/topic.ts b/src/server/routers/lambda/topic.ts new file mode 100644 index 0000000000000..10cc87f9bbf79 --- /dev/null +++ b/src/server/routers/lambda/topic.ts @@ -0,0 +1,134 @@ +import { z } from 'zod'; + +import { TopicModel } from '@/database/server/models/topic'; +import { authedProcedure, publicProcedure, router } from '@/libs/trpc'; +import { BatchTaskResult } from '@/types/service'; + +const topicProcedure = authedProcedure.use(async (opts) => { + const { ctx } = opts; + + return opts.next({ + ctx: { topicModel: new TopicModel(ctx.userId) }, + }); +}); + +export const topicRouter = router({ + batchCreateTopics: topicProcedure + .input( + z.array( + z.object({ + favorite: z.boolean().optional(), + id: z.string().optional(), + messages: z.array(z.string()).optional(), + sessionId: z.string().optional(), + title: z.string(), + }), + ), + ) + .mutation(async ({ input, ctx }): Promise => { + const data = await ctx.topicModel.batchCreate( + input.map((item) => ({ + ...item, + })) as any, + ); + + return { added: data.length, ids: [], skips: [], success: true }; + }), + + batchDelete: topicProcedure + .input(z.object({ ids: z.array(z.string()) })) + .mutation(async ({ input, ctx }) => { + return ctx.topicModel.batchDelete(input.ids); + }), + + batchDeleteBySessionId: topicProcedure + .input(z.object({ id: z.string().nullable().optional() })) + .mutation(async ({ input, ctx }) => { + return ctx.topicModel.batchDeleteBySessionId(input.id); + }), + + cloneTopic: topicProcedure + .input(z.object({ id: z.string(), newTitle: z.string().optional() })) + .mutation(async ({ input, ctx }) => { + const data = await ctx.topicModel.duplicate(input.id, input.newTitle); + + return data.topic.id; + }), + + countTopics: topicProcedure.query(async ({ ctx }) => { + return ctx.topicModel.count(); + }), + + createTopic: topicProcedure + .input( + z.object({ + favorite: z.boolean().optional(), + messages: z.array(z.string()).optional(), + sessionId: z.string().nullable().optional(), + title: z.string(), + }), + ) + .mutation(async ({ input, ctx }) => { + const data = await ctx.topicModel.create(input); + + return data.id; + }), + + getAllTopics: topicProcedure.query(async ({ ctx }) => { + return ctx.topicModel.queryAll(); + }), + + getTopics: publicProcedure + .input( + z.object({ + current: z.number().optional(), + pageSize: z.number().optional(), + sessionId: z.string().nullable().optional(), + }), + ) + .query(async ({ input, ctx }) => { + if (!ctx.userId) return []; + + const topicModel = new TopicModel(ctx.userId); + + return topicModel.query(input); + }), + + hasTopics: topicProcedure.query(async ({ ctx }) => { + return (await ctx.topicModel.count()) === 0; + }), + + removeAllTopics: topicProcedure.mutation(async ({ ctx }) => { + return ctx.topicModel.deleteAll(); + }), + + removeTopic: topicProcedure + .input(z.object({ id: z.string() })) + .mutation(async ({ input, ctx }) => { + return ctx.topicModel.delete(input.id); + }), + + searchTopics: topicProcedure + .input(z.object({ keywords: z.string(), sessionId: z.string().nullable().optional() })) + .query(async ({ input, ctx }) => { + return ctx.topicModel.queryByKeyword(input.keywords, input.sessionId); + }), + + updateTopic: topicProcedure + .input( + z.object({ + id: z.string(), + value: z.object({ + favorite: z.boolean().optional(), + messages: z.array(z.string()).optional(), + sessionId: z.string().optional(), + title: z.string().optional(), + }), + }), + ) + .mutation(async ({ input, ctx }) => { + return ctx.topicModel.update(input.id, input.value); + }), +}); + +export type TopicRouter = typeof topicRouter; diff --git a/src/server/routers/lambda/user.ts b/src/server/routers/lambda/user.ts new file mode 100644 index 0000000000000..64239233ace1d --- /dev/null +++ b/src/server/routers/lambda/user.ts @@ -0,0 +1,57 @@ +import { z } from 'zod'; + +import { MessageModel } from '@/database/server/models/message'; +import { SessionModel } from '@/database/server/models/session'; +import { UserModel } from '@/database/server/models/user'; +import { authedProcedure, router } from '@/libs/trpc'; +import { UserInitializationState, UserPreference } from '@/types/user'; + +const userProcedure = authedProcedure.use(async (opts) => { + return opts.next({ + ctx: { userModel: new UserModel() }, + }); +}); + +export const userRouter = router({ + getUserState: userProcedure.query(async ({ ctx }): Promise => { + const state = await ctx.userModel.getUserState(ctx.userId); + + const messageModel = new MessageModel(ctx.userId); + const messageCount = await messageModel.count(); + + const sessionModel = new SessionModel(ctx.userId); + const sessionCount = await sessionModel.count(); + + return { + canEnablePWAGuide: messageCount >= 2, + canEnableTrace: messageCount >= 4, + // 有消息,或者创建过助手,则认为有 conversation + hasConversation: messageCount > 0 || sessionCount > 1, + + isOnboard: state.isOnboarded || false, + preference: state.preference as UserPreference, + settings: state.settings, + userId: ctx.userId, + }; + }), + + makeUserOnboarded: userProcedure.mutation(async ({ ctx }) => { + return ctx.userModel.updateUser(ctx.userId, { isOnboarded: true }); + }), + + resetSettings: userProcedure.mutation(async ({ ctx }) => { + return ctx.userModel.deleteSetting(ctx.userId); + }), + + updatePreference: userProcedure.input(z.any()).mutation(async ({ ctx, input }) => { + return ctx.userModel.updatePreference(ctx.userId, input); + }), + + updateSettings: userProcedure + .input(z.object({}).passthrough()) + .mutation(async ({ ctx, input }) => { + return ctx.userModel.updateSetting(ctx.userId, input); + }), +}); + +export type UserRouter = typeof userRouter; diff --git a/src/services/file/index.ts b/src/services/file/index.ts index f5a76d78e0353..982ffc83a6360 100644 --- a/src/services/file/index.ts +++ b/src/services/file/index.ts @@ -1,9 +1,6 @@ -// import { getClientConfig } from '@/config/client'; +import { isServerMode } from '@/const/version'; + import { ClientService } from './client'; +import { ServerService } from './server'; -// import { ServerService } from './server'; -// -// const { ENABLED_SERVER_SERVICE } = getClientConfig(); -// -// export const fileService = ENABLED_SERVER_SERVICE ? new ServerService() : new ClientService(); -export const fileService = new ClientService(); +export const fileService = isServerMode ? new ServerService() : new ClientService(); diff --git a/src/services/file/server.ts b/src/services/file/server.ts new file mode 100644 index 0000000000000..1241424875681 --- /dev/null +++ b/src/services/file/server.ts @@ -0,0 +1,45 @@ +import urlJoin from 'url-join'; + +import { fileEnv } from '@/config/file'; +import { lambdaClient } from '@/libs/trpc/client'; +import { FilePreview, UploadFileParams } from '@/types/files'; + +import { IFileService } from './type'; + +interface CreateFileParams extends Omit { + url: string; +} + +export class ServerService implements IFileService { + async createFile(params: UploadFileParams) { + return lambdaClient.file.createFile.mutate(params as CreateFileParams); + } + + async getFile(id: string): Promise { + if (!fileEnv.NEXT_PUBLIC_S3_DOMAIN) { + throw new Error('fileEnv.NEXT_PUBLIC_S3_DOMAIN is not set while enable server upload'); + } + + const item = await lambdaClient.file.findById.query({ id }); + + if (!item) { + throw new Error('file not found'); + } + + return { + fileType: item.fileType, + id: item.id, + name: item.name, + saveMode: 'url', + url: urlJoin(fileEnv.NEXT_PUBLIC_S3_DOMAIN!, item.url!), + }; + } + + async removeFile(id: string) { + await lambdaClient.file.removeFile.mutate({ id }); + } + + async removeAllFiles() { + await lambdaClient.file.removeAllFiles.mutate(); + } +} diff --git a/src/services/import/index.ts b/src/services/import/index.ts index a0786bc0fbde8..2400e7b8c8e3d 100644 --- a/src/services/import/index.ts +++ b/src/services/import/index.ts @@ -1,3 +1,6 @@ +import { isServerMode } from '@/const/version'; + import { ClientService } from './client'; +import { ServerService } from './server'; -export const importService = new ClientService(); +export const importService = isServerMode ? new ServerService() : new ClientService(); diff --git a/src/services/import/server.ts b/src/services/import/server.ts new file mode 100644 index 0000000000000..14d35acc0b8a8 --- /dev/null +++ b/src/services/import/server.ts @@ -0,0 +1,115 @@ +import { DefaultErrorShape } from '@trpc/server/unstable-core-do-not-import'; + +import { edgeClient, lambdaClient } from '@/libs/trpc/client'; +import { useUserStore } from '@/store/user'; +import { ImportStage, ImporterEntryData, OnImportCallbacks } from '@/types/importer'; +import { UserSettings } from '@/types/user/settings'; +import { uuid } from '@/utils/uuid'; + +export class ServerService { + importSettings = async (settings: UserSettings) => { + await useUserStore.getState().importAppSettings(settings); + }; + + importData = async (data: ImporterEntryData, callbacks?: OnImportCallbacks): Promise => { + const handleError = (e: unknown) => { + callbacks?.onStageChange?.(ImportStage.Error); + const error = e as DefaultErrorShape; + + callbacks?.onError?.({ + code: error.data.code, + httpStatus: error.data.httpStatus, + message: error.message, + path: error.data.path, + }); + }; + + const totalLength = + (data.messages?.length || 0) + + (data.sessionGroups?.length || 0) + + (data.sessions?.length || 0) + + (data.topics?.length || 0); + + if (totalLength < 500) { + callbacks?.onStageChange?.(ImportStage.Importing); + const time = Date.now(); + try { + const result = await lambdaClient.importer.importByPost.mutate({ data }); + const duration = Date.now() - time; + + callbacks?.onStageChange?.(ImportStage.Success); + callbacks?.onSuccess?.(result, duration); + } catch (e) { + handleError(e); + } + + return; + } + + // if the data is too large, upload it to S3 and upload by file + const filename = `${uuid()}.json`; + + const pathname = `import_config/${filename}`; + + const url = await edgeClient.upload.createS3PreSignedUrl.mutate({ pathname }); + + try { + callbacks?.onStageChange?.(ImportStage.Uploading); + await this.uploadWithProgress(url, data, callbacks?.onFileUploading); + } catch { + throw new Error('Upload Error'); + } + + callbacks?.onStageChange?.(ImportStage.Importing); + const time = Date.now(); + try { + const result = await lambdaClient.importer.importByFile.mutate({ pathname }); + const duration = Date.now() - time; + callbacks?.onStageChange?.(ImportStage.Success); + callbacks?.onSuccess?.(result, duration); + } catch (e) { + handleError(e); + } + }; + + private uploadWithProgress = async ( + url: string, + data: object, + onProgress: OnImportCallbacks['onFileUploading'], + ) => { + const xhr = new XMLHttpRequest(); + + let startTime = Date.now(); + xhr.upload.addEventListener('progress', (event) => { + if (event.lengthComputable) { + const progress = Number(((event.loaded / event.total) * 100).toFixed(1)); + + const speedInByte = event.loaded / ((Date.now() - startTime) / 1000); + + onProgress?.({ + // if the progress is 100, it means the file is uploaded + // but the server is still processing it + // so make it as 99.5 and let users think it's still uploading + progress: progress === 100 ? 99.5 : progress, + restTime: (event.total - event.loaded) / speedInByte, + speed: speedInByte / 1024, + }); + } + }); + + xhr.open('PUT', url); + xhr.setRequestHeader('Content-Type', 'application/json'); + + return new Promise((resolve, reject) => { + xhr.addEventListener('load', () => { + if (xhr.status >= 200 && xhr.status < 300) { + resolve(xhr.response); + } else { + reject(xhr.statusText); + } + }); + xhr.addEventListener('error', () => reject(xhr.statusText)); + xhr.send(JSON.stringify(data)); + }); + }; +} diff --git a/src/services/message/index.ts b/src/services/message/index.ts index 74b724ab80da7..ad94d02abedeb 100644 --- a/src/services/message/index.ts +++ b/src/services/message/index.ts @@ -1,12 +1,8 @@ -// import { getClientConfig } from '@/config/client'; -// import { ServerService } from './server'; -// import { ClientService } from './client'; -// -// const { ENABLED_SERVER_SERVICE } = getClientConfig(); -// -// export const messageService = ENABLED_SERVER_SERVICE ? new ServerService() : new ClientService(); +import { isServerMode } from '@/const/version'; + import { ClientService } from './client'; +import { ServerService } from './server'; export type { CreateMessageParams } from './type'; -export const messageService = new ClientService(); +export const messageService = isServerMode ? new ServerService() : new ClientService(); diff --git a/src/services/message/server.ts b/src/services/message/server.ts new file mode 100644 index 0000000000000..f8779f0262a53 --- /dev/null +++ b/src/services/message/server.ts @@ -0,0 +1,93 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +//@ts-nocheck +import { INBOX_SESSION_ID } from '@/const/session'; +import { lambdaClient } from '@/libs/trpc/client'; +import { ChatMessage, ChatMessageError, ChatTTS } from '@/types/message'; + +import { CreateMessageParams, IMessageService } from './type'; + +export class ServerService implements IMessageService { + createMessage({ sessionId, ...params }: CreateMessageParams): Promise { + return lambdaClient.message.createMessage.mutate({ + ...params, + sessionId: this.toDbSessionId(sessionId), + }); + } + + batchCreateMessages(messages: ChatMessage[]): Promise { + return lambdaClient.message.batchCreateMessages.mutate(messages); + } + getMessages(sessionId?: string, topicId?: string | undefined): Promise { + return lambdaClient.message.getMessages.query({ + sessionId: this.toDbSessionId(sessionId), + topicId, + }); + } + + getAllMessages(): Promise { + return lambdaClient.message.getAllMessages.query(); + } + getAllMessagesInSession(sessionId: string): Promise { + return lambdaClient.message.getAllMessagesInSession.query({ + sessionId: this.toDbSessionId(sessionId), + }); + } + + countMessages(): Promise { + return lambdaClient.message.count.query(); + } + countTodayMessages(): Promise { + return lambdaClient.message.countToday.query(); + } + + updateMessageError(id: string, error: ChatMessageError): Promise { + return lambdaClient.message.update.mutate({ id, value: { error } }); + } + + updateMessage(id: string, message: Partial): Promise { + return lambdaClient.message.update.mutate({ id, value: message }); + } + + updateMessageTranslate(id: string, translate: Partial | false): Promise { + return lambdaClient.message.updateTranslate.mutate({ id, value: translate }); + } + + updateMessageTTS(id: string, tts: Partial | false): Promise { + return lambdaClient.message.updateTTS.mutate({ id, value: tts }); + } + + updateMessagePluginState(id: string, value: any): Promise { + return lambdaClient.message.updatePluginState.mutate({ id, value }); + } + + bindMessagesToTopic(topicId: string, messageIds: string[]): Promise { + throw new Error('Method not implemented.'); + } + + removeMessage(id: string): Promise { + return lambdaClient.message.removeMessage.mutate({ id }); + } + removeMessages(sessionId: string, topicId?: string | undefined): Promise { + return lambdaClient.message.removeMessages.mutate({ + sessionId: this.toDbSessionId(sessionId), + topicId, + }); + } + removeAllMessages(): Promise { + return lambdaClient.message.removeAllMessages.mutate(); + } + + private toDbSessionId(sessionId: string | undefined) { + return sessionId === INBOX_SESSION_ID ? null : sessionId; + } + + async hasMessages() { + const number = await this.countMessages(); + return number > 0; + } + + async messageCountToCheckTrace() { + const number = await this.countMessages(); + return number >= 4; + } +} diff --git a/src/services/plugin/index.ts b/src/services/plugin/index.ts index 6736f48ad0efd..35561e18167d1 100644 --- a/src/services/plugin/index.ts +++ b/src/services/plugin/index.ts @@ -1,11 +1,6 @@ -// import { getClientConfig } from '@/config/client'; -import { ClientService } from './client'; +import { isServerMode } from '@/const/version'; -// import { ServerService } from './server'; -// -// export type { InstallPluginParams } from './client'; -// -// const { ENABLED_SERVER_SERVICE } = getClientConfig(); +import { ClientService } from './client'; +import { ServerService } from './server'; -// export const pluginService = ENABLED_SERVER_SERVICE ? new ServerService() : new ClientService(); -export const pluginService = new ClientService(); +export const pluginService = isServerMode ? new ServerService() : new ClientService(); diff --git a/src/services/plugin/server.ts b/src/services/plugin/server.ts new file mode 100644 index 0000000000000..c18f6e35a0047 --- /dev/null +++ b/src/services/plugin/server.ts @@ -0,0 +1,46 @@ +import { LobeChatPluginManifest } from '@lobehub/chat-plugin-sdk'; + +import { lambdaClient } from '@/libs/trpc/client'; +import { LobeTool } from '@/types/tool'; +import { LobeToolCustomPlugin } from '@/types/tool/plugin'; + +import { IPluginService, InstallPluginParams } from './type'; + +export class ServerService implements IPluginService { + installPlugin = async (plugin: InstallPluginParams) => { + await lambdaClient.plugin.createOrInstallPlugin.mutate(plugin); + }; + + getInstalledPlugins = (): Promise => { + return lambdaClient.plugin.getPlugins.query(); + }; + + async uninstallPlugin(identifier: string) { + await lambdaClient.plugin.removePlugin.mutate({ id: identifier }); + } + + async createCustomPlugin(customPlugin: LobeToolCustomPlugin) { + await lambdaClient.plugin.createPlugin.mutate({ ...customPlugin, type: 'customPlugin' }); + } + + async updatePlugin(id: string, value: LobeToolCustomPlugin) { + await lambdaClient.plugin.updatePlugin.mutate({ + customParams: value.customParams, + id, + manifest: value.manifest, + settings: value.settings, + }); + } + + async updatePluginManifest(id: string, manifest: LobeChatPluginManifest) { + await lambdaClient.plugin.updatePlugin.mutate({ id, manifest }); + } + + async removeAllPlugins() { + await lambdaClient.plugin.removeAllPlugins.mutate(); + } + + async updatePluginSettings(id: string, settings: any, signal?: AbortSignal) { + await lambdaClient.plugin.updatePlugin.mutate({ id, settings }, { signal }); + } +} diff --git a/src/services/session/index.ts b/src/services/session/index.ts index 84a8a0a481ef7..3e3d5eb600cd1 100644 --- a/src/services/session/index.ts +++ b/src/services/session/index.ts @@ -1,10 +1,6 @@ -// import { getClientConfig } from '@/config/client'; -// -import { ClientService } from './client'; - -// import { ServerService } from './server'; +import { isServerMode } from '@/const/version'; -// const { ENABLED_SERVER_SERVICE } = getClientConfig(); +import { ClientService } from './client'; +import { ServerService } from './server'; -// export const sessionService = ENABLED_SERVER_SERVICE ? new ServerService() : new ClientService(); -export const sessionService = new ClientService(); +export const sessionService = isServerMode ? new ServerService() : new ClientService(); diff --git a/src/services/session/server.ts b/src/services/session/server.ts new file mode 100644 index 0000000000000..58531c5dd4070 --- /dev/null +++ b/src/services/session/server.ts @@ -0,0 +1,148 @@ +//@ts-nocheck + +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { DeepPartial } from 'utility-types'; + +import { DEFAULT_AGENT_CONFIG } from '@/const/settings'; +import { lambdaClient } from '@/libs/trpc/client'; +import { useUserStore } from '@/store/user'; +import { authSelectors } from '@/store/user/selectors'; +import { LobeAgentChatConfig, LobeAgentConfig } from '@/types/agent'; +import { BatchTaskResult } from '@/types/service'; +import { + ChatSessionList, + LobeAgentSession, + LobeSessionType, + LobeSessions, + SessionGroupId, + SessionGroupItem, + SessionGroups, +} from '@/types/session'; + +import { ISessionService } from './type'; +import { MetaData } from '@/types/meta'; + +export class ServerService implements ISessionService { + async hasSessions() { + return (await this.countSessions()) === 0; + } + + createSession(type: LobeSessionType, data: Partial): Promise { + const { config, group, meta, ...session } = data; + + return lambdaClient.session.createSession.mutate({ + config: { ...config, ...meta }, + session: { ...session, groupId: group }, + type, + }); + } + + async batchCreateSessions(importSessions: LobeSessions): Promise { + // TODO: remove any + const data = await lambdaClient.session.batchCreateSessions.mutate(importSessions as any); + console.log(data); + return data; + } + + cloneSession(id: string, newTitle: string): Promise { + return lambdaClient.session.cloneSession.mutate({ id, newTitle }); + } + + getGroupedSessions(): Promise { + return lambdaClient.session.getGroupedSessions.query(); + } + + countSessions(): Promise { + return lambdaClient.session.countSessions.query(); + } + + updateSession( + id: string, + data: Partial<{ group?: SessionGroupId; meta?: any; pinned?: boolean }>, + ): Promise { + const { group, pinned, meta } = data; + return lambdaClient.session.updateSession.mutate({ + id, + value: { groupId: group === 'default' ? null : group, pinned, ...meta }, + }); + } + + getSessionConfig(id: string): Promise { + const isLogin = authSelectors.isLogin(useUserStore.getState()); + if (!isLogin) return DEFAULT_AGENT_CONFIG; + + return lambdaClient.session.getSessionConfig.query({ id }); + } + + updateSessionConfig( + id: string, + config: DeepPartial, + signal?: AbortSignal, + ): Promise { + return lambdaClient.session.updateSessionConfig.mutate({ id, value: config }, { signal }); + } + + updateSessionMeta( + id: string, + meta: Partial, + signal?: AbortSignal, + ): Promise { + return lambdaClient.session.updateSessionConfig.mutate({ id, value: meta }, { signal }); + } + + updateSessionChatConfig( + id: string, + value: DeepPartial, + signal?: AbortSignal, + ): Promise { + return lambdaClient.session.updateSessionChatConfig.mutate({ id, value }, { signal }); + } + + getSessionsByType(type: 'agent' | 'group' | 'all' = 'all'): Promise { + return lambdaClient.session.getSessions.query({}); + } + + searchSessions(keywords: string): Promise { + return lambdaClient.session.searchSessions.query({ keywords }); + } + + removeSession(id: string): Promise { + return lambdaClient.session.removeSession.mutate({ id }); + } + + removeAllSessions(): Promise { + return lambdaClient.session.removeAllSessions.mutate(); + } + + // ************************************** // + // *********** SessionGroup *********** // + // ************************************** // + + createSessionGroup(name: string, sort?: number): Promise { + return lambdaClient.sessionGroup.createSessionGroup.mutate({ name, sort }); + } + + getSessionGroups(): Promise { + return lambdaClient.sessionGroup.getSessionGroup.query(); + } + + batchCreateSessionGroups(groups: SessionGroups): Promise { + return Promise.resolve({ added: 0, ids: [], skips: [], success: true }); + } + + removeSessionGroup(id: string, removeChildren?: boolean): Promise { + return lambdaClient.sessionGroup.removeSessionGroup.mutate({ id, removeChildren }); + } + + removeSessionGroups(): Promise { + return lambdaClient.sessionGroup.removeAllSessionGroups.mutate(); + } + + updateSessionGroup(id: string, value: Partial): Promise { + return lambdaClient.sessionGroup.updateSessionGroup.mutate({ id, value }); + } + + updateSessionGroupOrder(sortMap: { id: string; sort: number }[]): Promise { + return lambdaClient.sessionGroup.updateSessionGroupOrder.mutate({ sortMap }); + } +} diff --git a/src/services/topic/index.ts b/src/services/topic/index.ts index 27a475d504bb0..60ba0c3a76986 100644 --- a/src/services/topic/index.ts +++ b/src/services/topic/index.ts @@ -1,11 +1,6 @@ -// import { getClientConfig } from '@/config/client'; -// -// import { ClientService } from './client'; -// import { ServerService } from './server'; -// -// const { ENABLED_SERVER_SERVICE } = getClientConfig(); -// -// export const topicService = ENABLED_SERVER_SERVICE ? new ServerService() : new ClientService(); +import { isServerMode } from '@/const/version'; + import { ClientService } from './client'; +import { ServerService } from './server'; -export const topicService = new ClientService(); +export const topicService = isServerMode ? new ServerService() : new ClientService(); diff --git a/src/services/topic/server.ts b/src/services/topic/server.ts new file mode 100644 index 0000000000000..cd9905fa6e397 --- /dev/null +++ b/src/services/topic/server.ts @@ -0,0 +1,68 @@ +import { INBOX_SESSION_ID } from '@/const/session'; +import { lambdaClient } from '@/libs/trpc/client'; +import { CreateTopicParams, ITopicService, QueryTopicParams } from '@/services/topic/type'; +import { BatchTaskResult } from '@/types/service'; +import { ChatTopic } from '@/types/topic'; + +export class ServerService implements ITopicService { + createTopic(params: CreateTopicParams): Promise { + return lambdaClient.topic.createTopic.mutate({ + ...params, + sessionId: this.toDbSessionId(params.sessionId), + }); + } + + batchCreateTopics(importTopics: ChatTopic[]): Promise { + return lambdaClient.topic.batchCreateTopics.mutate(importTopics); + } + + cloneTopic(id: string, newTitle?: string | undefined): Promise { + return lambdaClient.topic.cloneTopic.mutate({ id, newTitle }); + } + + getTopics(params: QueryTopicParams): Promise { + return lambdaClient.topic.getTopics.query({ + ...params, + sessionId: this.toDbSessionId(params.sessionId), + }) as any; + } + + getAllTopics(): Promise { + return lambdaClient.topic.getAllTopics.query() as any; + } + + async countTopics() { + return lambdaClient.topic.countTopics.query(); + } + + searchTopics(keywords: string, sessionId?: string | undefined): Promise { + return lambdaClient.topic.searchTopics.query({ + keywords, + sessionId: this.toDbSessionId(sessionId), + }) as any; + } + + updateTopic(id: string, data: Partial): Promise { + return lambdaClient.topic.updateTopic.mutate({ id, value: data }); + } + + removeTopic(id: string): Promise { + return lambdaClient.topic.removeTopic.mutate({ id }); + } + + removeTopics(sessionId: string): Promise { + return lambdaClient.topic.batchDeleteBySessionId.mutate({ id: this.toDbSessionId(sessionId) }); + } + + batchRemoveTopics(topics: string[]): Promise { + return lambdaClient.topic.batchDelete.mutate({ ids: topics }); + } + + removeAllTopic(): Promise { + return lambdaClient.topic.removeAllTopics.mutate(); + } + + private toDbSessionId(sessionId?: string | null) { + return sessionId === INBOX_SESSION_ID ? null : sessionId; + } +} diff --git a/src/services/user/index.ts b/src/services/user/index.ts index be41b3b3a2f7e..3ad969dad1708 100644 --- a/src/services/user/index.ts +++ b/src/services/user/index.ts @@ -1,11 +1,6 @@ -// import { getClientConfig } from '@/config/client'; -// -// import { ClientService } from './client'; -// import { ServerService } from './server'; -// -// const { ENABLED_SERVER_SERVICE } = getClientConfig(); -// -// export const userService = ENABLED_SERVER_SERVICE ? new ServerService() : new ClientService(); +import { isServerMode } from '@/const/version'; + import { ClientService } from './client'; +import { ServerService } from './server'; -export const userService = new ClientService(); +export const userService = isServerMode ? new ServerService() : new ClientService(); diff --git a/src/services/user/server.ts b/src/services/user/server.ts new file mode 100644 index 0000000000000..3dffa4a1ca31f --- /dev/null +++ b/src/services/user/server.ts @@ -0,0 +1,28 @@ +import { DeepPartial } from 'utility-types'; + +import { lambdaClient } from '@/libs/trpc/client'; +import { IUserService } from '@/services/user/type'; +import { UserInitializationState, UserPreference } from '@/types/user'; +import { UserSettings } from '@/types/user/settings'; + +export class ServerService implements IUserService { + getUserState = async (): Promise => { + return lambdaClient.user.getUserState.query(); + }; + + async makeUserOnboarded() { + return lambdaClient.user.makeUserOnboarded.mutate(); + } + + async updatePreference(preference: UserPreference) { + return lambdaClient.user.updatePreference.mutate(preference); + } + + updateUserSettings = async (value: DeepPartial, signal?: AbortSignal) => { + return lambdaClient.user.updateSettings.mutate(value, { signal }); + }; + + resetUserSettings = async () => { + return lambdaClient.user.resetSettings.mutate(); + }; +} diff --git a/src/types/fetch.ts b/src/types/fetch.ts index f5f8725253ed2..0f6b07853266b 100644 --- a/src/types/fetch.ts +++ b/src/types/fetch.ts @@ -1,6 +1,8 @@ /* eslint-disable sort-keys-fix/sort-keys-fix */ import { ILobeAgentRuntimeErrorType } from '@/libs/agent-runtime'; +export const LOBEERRORCODE = { SKIP_NOTIFICATION: 'SKIP_NOTIFICATION' }; + export const ChatErrorType = { // ******* 业务错误语义 ******* // diff --git a/tests/setup.ts b/tests/setup.ts index d9be8c7110510..2e8b3c1e2e2ce 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -1,11 +1,15 @@ /* eslint-disable import/newline-after-import,import/first */ import '@testing-library/jest-dom'; import { theme } from 'antd'; +import * as dotenv from 'dotenv'; // mock indexedDB to test with dexie // refs: https://github.com/dumbmatter/fakeIndexedDB#dexie-and-other-indexeddb-api-wrappers import 'fake-indexeddb/auto'; import React from 'react'; +// import env +dotenv.config(); + // only inject in the dom environment if ( // not node runtime