This commit is contained in:
2026-03-10 20:43:02 +01:00
parent 10b3f989c8
commit 34dc53a8e9
11 changed files with 564 additions and 0 deletions

View File

@@ -5,6 +5,8 @@
"": {
"name": "gregorlohaus.com",
"dependencies": {
"@ai-sdk/openai": "^3.0.41",
"@ai-sdk/react": "^3.0.118",
"@clerk/nextjs": "^7.0.2",
"@electric-sql/pglite": "^0.3.16",
"@fortawesome/fontawesome-svg-core": "^7.2.0",
@@ -50,6 +52,7 @@
"@trpc/server": "^11.12.0",
"@uiw/react-md-editor": "^4.0.11",
"@uploadthing/react": "^7.3.3",
"ai": "^6.0.116",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
@@ -59,6 +62,7 @@
"drizzle-zod": "^0.8.3",
"embla-carousel-react": "^8.6.0",
"glazejs": "^2.0.1",
"googleapis": "^171.4.0",
"gsap": "^3.14.2",
"input-otp": "^1.4.2",
"lucide-react": "^0.577.0",
@@ -120,6 +124,16 @@
"@adobe/css-tools": ["@adobe/css-tools@4.4.4", "", {}, "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg=="],
"@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.66", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.19", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-SIQ0YY0iMuv+07HLsZ+bB990zUJ6S4ujORAh+Jv1V2KGNn73qQKnGO0JBk+w+Res8YqOFSycwDoWcFlQrVxS4A=="],
"@ai-sdk/openai": ["@ai-sdk/openai@3.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-IZ42A+FO+vuEQCVNqlnAPYQnnUpUfdJIwn1BEDOBywiEHa23fw7PahxVtlX9zm3/zMvTW4JKPzWyvAgDu+SQ2A=="],
"@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="],
"@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.19", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-3eG55CrSWCu2SXlqq2QCsFjo3+E7+Gmg7i/oRVoSZzIodTuDSfLb3MRje67xE9RFea73Zao7Lm4mADIfUETKGg=="],
"@ai-sdk/react": ["@ai-sdk/react@3.0.118", "", { "dependencies": { "@ai-sdk/provider-utils": "4.0.19", "ai": "6.0.116", "swr": "^2.2.5", "throttleit": "2.1.0" }, "peerDependencies": { "react": "^18 || ~19.0.1 || ~19.1.2 || ^19.2.1" } }, "sha512-fBAix8Jftxse6/2YJnOFkwW1/O6EQK4DK68M9DlFmZGAzBmsaHXEPVS77sVIlkaOWCy11bE7434NAVXRY+3OsQ=="],
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
"@antfu/ni": ["@antfu/ni@25.0.0", "", { "dependencies": { "ansis": "^4.0.0", "fzf": "^0.5.2", "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" }, "bin": { "na": "bin/na.mjs", "ni": "bin/ni.mjs", "nr": "bin/nr.mjs", "nci": "bin/nci.mjs", "nlx": "bin/nlx.mjs", "nun": "bin/nun.mjs", "nup": "bin/nup.mjs" } }, "sha512-9q/yCljni37pkMr4sPrI3G4jqdIk074+iukc5aFJl7kmDCCsiJrbZ6zKxnES1Gwg+i9RcDZwvktl23puGslmvA=="],
@@ -538,6 +552,8 @@
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="],
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
"@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="],
"@pkgr/core": ["@pkgr/core@0.2.9", "", {}, "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA=="],
@@ -962,6 +978,8 @@
"@uploadthing/shared": ["@uploadthing/shared@7.1.10", "", { "dependencies": { "@uploadthing/mime-types": "0.3.6", "effect": "3.17.7", "sqids": "^0.3.0" } }, "sha512-R/XSA3SfCVnLIzFpXyGaKPfbwlYlWYSTuGjTFHuJhdAomuBuhopAHLh2Ois5fJibAHzi02uP1QCKbgTAdmArqg=="],
"@vercel/oidc": ["@vercel/oidc@3.1.0", "", {}, "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w=="],
"@vitejs/plugin-react": ["@vitejs/plugin-react@5.1.4", "", { "dependencies": { "@babel/core": "^7.29.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-rc.3", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-VIcFLdRi/VYRU8OL/puL7QXMYafHmqOnwTZY50U1JPlCNj30PxCMx65c494b1K9be9hX83KVt0+gTEwTWLqToA=="],
"@vitest/coverage-v8": ["@vitest/coverage-v8@4.0.18", "", { "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/utils": "4.0.18", "ast-v8-to-istanbul": "^0.3.10", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-reports": "^3.2.0", "magicast": "^0.5.1", "obug": "^2.1.1", "std-env": "^3.10.0", "tinyrainbow": "^3.0.3" }, "peerDependencies": { "@vitest/browser": "4.0.18", "vitest": "4.0.18" }, "optionalPeers": ["@vitest/browser"] }, "sha512-7i+N2i0+ME+2JFZhfuz7Tg/FqKtilHjGyGvoHYQ6iLV0zahbsJ9sljC9OcFcPDbhYKCet+sG8SsVqlyGvPflZg=="],
@@ -988,6 +1006,8 @@
"agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
"ai": ["ai@6.0.116", "", { "dependencies": { "@ai-sdk/gateway": "3.0.66", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.19", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-7yM+cTmyRLeNIXwt4Vj+mrrJgVQ9RMIW5WO0ydoLoYkewIvsMcvUmqS4j2RJTUXaF1HphwmSKUMQ/HypNRGOmA=="],
"ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="],
"ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="],
@@ -1030,12 +1050,16 @@
"balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.0", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA=="],
"bcp-47-match": ["bcp-47-match@2.0.3", "", {}, "sha512-JtTezzbAibu8G0R9op9zb3vcWZd9JF6M0xOYGPn0fNCd7wOpRB1mU2mH9T8gaBGbAAyIIVgB2G7xG0GP98zMAQ=="],
"bidi-js": ["bidi-js@1.0.3", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="],
"bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="],
"body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="],
"boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="],
@@ -1048,6 +1072,8 @@
"bser": ["bser@2.1.1", "", { "dependencies": { "node-int64": "^0.4.0" } }, "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ=="],
"buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="],
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
"bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="],
@@ -1234,6 +1260,8 @@
"eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="],
"ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="],
"eciesjs": ["eciesjs@0.4.18", "", { "dependencies": { "@ecies/ciphers": "^0.2.5", "@noble/ciphers": "^1.3.0", "@noble/curves": "^1.9.7", "@noble/hashes": "^1.8.0" } }, "sha512-wG99Zcfcys9fZux7Cft8BAX/YrOJLJSZ3jyYPfhZHqN2E+Ffx+QXBDsv3gubEgPtV6dTzJMSQUwk1H98/t/0wQ=="],
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
@@ -1364,6 +1392,10 @@
"fzf": ["fzf@0.5.2", "", {}, "sha512-Tt4kuxLXFKHy8KT40zwsUPUkg1CrsgY25FxA2U/j/0WgEDCk3ddc/zLTCCcbSHX9FcKtLuVaDGtGE/STWC+j3Q=="],
"gaxios": ["gaxios@7.1.3", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2", "rimraf": "^5.0.1" } }, "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ=="],
"gcp-metadata": ["gcp-metadata@8.1.2", "", { "dependencies": { "gaxios": "^7.0.0", "google-logging-utils": "^1.0.0", "json-bigint": "^1.0.0" } }, "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg=="],
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
"get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
@@ -1396,6 +1428,14 @@
"globrex": ["globrex@0.1.2", "", {}, "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg=="],
"google-auth-library": ["google-auth-library@10.6.1", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "7.1.3", "gcp-metadata": "8.1.2", "google-logging-utils": "1.1.3", "jws": "^4.0.0" } }, "sha512-5awwuLrzNol+pFDmKJd0dKtZ0fPLAtoA5p7YO4ODsDu6ONJUVqbYwvv8y2ZBO5MBNp9TJXigB19710kYpBPdtA=="],
"google-logging-utils": ["google-logging-utils@1.1.3", "", {}, "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA=="],
"googleapis": ["googleapis@171.4.0", "", { "dependencies": { "google-auth-library": "^10.2.0", "googleapis-common": "^8.0.0" } }, "sha512-xybFL2SmmUgIifgsbsRQYRdNrSAYwxWZDmkZTGjUIaRnX5jPqR8el/cEvo6rCqh7iaZx6MfEPS/lrDgZ0bymkg=="],
"googleapis-common": ["googleapis-common@8.0.1", "", { "dependencies": { "extend": "^3.0.2", "gaxios": "^7.0.0-rc.4", "google-auth-library": "^10.1.0", "qs": "^6.7.0", "url-template": "^2.0.8" } }, "sha512-eCzNACUXPb1PW5l0ULTzMHaL/ltPRADoPgjBlT8jWsTbxkCp6siv+qKJ/1ldaybCthGwsYFYallF7u9AkU4L+A=="],
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
@@ -1620,8 +1660,12 @@
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
"json-bigint": ["json-bigint@1.0.0", "", { "dependencies": { "bignumber.js": "^9.0.0" } }, "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ=="],
"json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="],
"json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="],
"json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
"json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="],
@@ -1636,6 +1680,10 @@
"jsonify": ["jsonify@0.0.1", "", {}, "sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg=="],
"jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="],
"jws": ["jws@4.0.1", "", { "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA=="],
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
"leven": ["leven@3.1.0", "", {}, "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A=="],
@@ -2076,6 +2124,8 @@
"reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
"rimraf": ["rimraf@5.0.10", "", { "dependencies": { "glob": "^10.3.7" }, "bin": { "rimraf": "dist/esm/bin.mjs" } }, "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ=="],
"rollup": ["rollup@4.59.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.59.0", "@rollup/rollup-android-arm64": "4.59.0", "@rollup/rollup-darwin-arm64": "4.59.0", "@rollup/rollup-darwin-x64": "4.59.0", "@rollup/rollup-freebsd-arm64": "4.59.0", "@rollup/rollup-freebsd-x64": "4.59.0", "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", "@rollup/rollup-linux-arm-musleabihf": "4.59.0", "@rollup/rollup-linux-arm64-gnu": "4.59.0", "@rollup/rollup-linux-arm64-musl": "4.59.0", "@rollup/rollup-linux-loong64-gnu": "4.59.0", "@rollup/rollup-linux-loong64-musl": "4.59.0", "@rollup/rollup-linux-ppc64-gnu": "4.59.0", "@rollup/rollup-linux-ppc64-musl": "4.59.0", "@rollup/rollup-linux-riscv64-gnu": "4.59.0", "@rollup/rollup-linux-riscv64-musl": "4.59.0", "@rollup/rollup-linux-s390x-gnu": "4.59.0", "@rollup/rollup-linux-x64-gnu": "4.59.0", "@rollup/rollup-linux-x64-musl": "4.59.0", "@rollup/rollup-openbsd-x64": "4.59.0", "@rollup/rollup-openharmony-arm64": "4.59.0", "@rollup/rollup-win32-arm64-msvc": "4.59.0", "@rollup/rollup-win32-ia32-msvc": "4.59.0", "@rollup/rollup-win32-x64-gnu": "4.59.0", "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg=="],
"router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="],
@@ -2086,6 +2136,8 @@
"run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
"saxes": ["saxes@6.0.0", "", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA=="],
@@ -2186,6 +2238,8 @@
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"swr": ["swr@2.4.1", "", { "dependencies": { "dequal": "^2.0.3", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-2CC6CiKQtEwaEeNiqWTAw9PGykW8SR5zZX8MZk6TeAvEAnVS7Visz8WzphqgtQ8v2xz/4Q5K+j+SeMaKXeeQIA=="],
"symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="],
"synckit": ["synckit@0.11.12", "", { "dependencies": { "@pkgr/core": "^0.2.9" } }, "sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ=="],
@@ -2202,6 +2256,8 @@
"test-exclude": ["test-exclude@6.0.0", "", { "dependencies": { "@istanbuljs/schema": "^0.1.2", "glob": "^7.1.4", "minimatch": "^3.0.4" } }, "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w=="],
"throttleit": ["throttleit@2.1.0", "", {}, "sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw=="],
"tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
"tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="],
@@ -2284,6 +2340,8 @@
"uploadthing": ["uploadthing@7.7.4", "", { "dependencies": { "@effect/platform": "0.90.3", "@standard-schema/spec": "1.0.0-beta.4", "@uploadthing/mime-types": "0.3.6", "@uploadthing/shared": "7.1.10", "effect": "3.17.7" }, "peerDependencies": { "express": "*", "h3": "*", "tailwindcss": "^3.0.0 || ^4.0.0-beta.0" }, "optionalPeers": ["express", "h3", "tailwindcss"] }, "sha512-rlK/4JWHW5jP30syzWGBFDDXv3WJDdT8gn9OoxRJmXLoXi94hBmyyjxihGlNrKhBc81czyv8TkzMioe/OuKGfA=="],
"url-template": ["url-template@2.0.8", "", {}, "sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw=="],
"use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="],
"use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="],

View File

@@ -19,6 +19,8 @@
"test": "vitest --typecheck"
},
"dependencies": {
"@ai-sdk/openai": "^3.0.41",
"@ai-sdk/react": "^3.0.118",
"@clerk/nextjs": "^7.0.2",
"@electric-sql/pglite": "^0.3.16",
"@fortawesome/fontawesome-svg-core": "^7.2.0",
@@ -64,6 +66,7 @@
"@trpc/server": "^11.12.0",
"@uiw/react-md-editor": "^4.0.11",
"@uploadthing/react": "^7.3.3",
"ai": "^6.0.116",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
@@ -73,6 +76,7 @@
"drizzle-zod": "^0.8.3",
"embla-carousel-react": "^8.6.0",
"glazejs": "^2.0.1",
"googleapis": "^171.4.0",
"gsap": "^3.14.2",
"input-otp": "^1.4.2",
"lucide-react": "^0.577.0",

View File

@@ -0,0 +1,32 @@
'use client'
import { useRouter } from 'next/navigation'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '~/components/ui/dialog'
import ChatInterface from '~/app/chat/_components/ChatInterface'
type DBMessage = {
id: string
role: 'user' | 'assistant'
content: string
}
interface ChatModalProps {
sessionId: string
initialMessages: DBMessage[]
}
export default function ChatModal({ sessionId, initialMessages }: ChatModalProps) {
const router = useRouter()
return (
<Dialog open onOpenChange={() => router.back()}>
<DialogContent className="max-w-2xl h-[80vh] flex flex-col p-0 gap-0">
<DialogHeader className="p-4 border-b shrink-0">
<DialogTitle>AI Recruiter</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-hidden min-h-0">
<ChatInterface sessionId={sessionId} initialMessages={initialMessages} />
</div>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,34 @@
import { auth } from '@clerk/nextjs/server'
import { redirect } from 'next/navigation'
import { asc, desc, eq } from 'drizzle-orm'
import { db } from '~/server/db'
import { chatMessage, chatSession } from '~/server/dbschema/schema'
import ChatModal from './_components/ChatModal'
export default async function ChatModalPage() {
const { userId } = await auth()
if (!userId) redirect('/')
let session = await db
.select()
.from(chatSession)
.where(eq(chatSession.userId, userId))
.orderBy(desc(chatSession.createdAt))
.limit(1)
.then((r) => r[0])
if (!session) {
const [created] = await db.insert(chatSession).values({ userId }).returning()
session = created
}
if (!session) redirect('/')
const messages = await db
.select()
.from(chatMessage)
.where(eq(chatMessage.sessionId, session.id))
.orderBy(asc(chatMessage.createdAt))
return <ChatModal sessionId={session.id} initialMessages={messages} />
}

View File

@@ -22,6 +22,11 @@ export default function TopNav() {
<Button asChild className="flex h-10 lg:h-full w-full lg:w-20" variant="outline">
<Link href={"/music"}> Music </Link>
</Button>
<Show when="signed-in">
<Button asChild className="flex h-10 lg:h-full w-full lg:w-20" variant="outline">
<Link href={"/chat"}> Chat </Link>
</Button>
</Show>
</div>
<div className="flex flex-col-reverse flex-wrap lg:h-full w-20 lg:w-fit lg:flex-row lg:ml-auto">
<AdminWrap>

View File

@@ -0,0 +1,86 @@
'use server'
import { clerkClient } from '@clerk/nextjs/server'
import { google } from 'googleapis'
import { env } from '~/env'
export async function scheduleMeeting({
title,
description,
dateTime,
durationMinutes,
attendeeEmail,
attendeeName,
userId,
}: {
title: string
description: string
dateTime: string
durationMinutes: number
attendeeEmail?: string
attendeeName?: string
userId: string
}) {
try {
const clerk = await clerkClient()
// Get admin's Google OAuth token to create the event on Gregor's calendar
const adminTokenResponse = await clerk.users.getUserOauthAccessToken(
env.ADMIN_USER_CLERK_ID,
'oauth_google',
)
const adminToken = adminTokenResponse.data[0]
if (!adminToken?.token) {
return { success: false, error: 'Admin Google Calendar not connected. Ensure the admin account is linked with Google and has calendar scope enabled.' }
}
// Try to resolve visitor's Google email for the invite
let visitorEmail: string | undefined = attendeeEmail
if (!visitorEmail) {
try {
const visitorTokenResponse = await clerk.users.getUserOauthAccessToken(userId, 'oauth_google')
if (visitorTokenResponse.data[0]) {
const user = await clerk.users.getUser(userId)
const googleAccount = user.externalAccounts.find((a) => a.provider === 'google')
visitorEmail = googleAccount?.emailAddress ?? undefined
}
} catch {
// Visitor not signed in with Google — no invite
}
}
const oAuth2Client = new google.auth.OAuth2()
oAuth2Client.setCredentials({ access_token: adminToken.token })
const calendar = google.calendar({ version: 'v3', auth: oAuth2Client })
const startTime = new Date(dateTime)
const endTime = new Date(startTime.getTime() + durationMinutes * 60 * 1000)
const attendees: { email: string; displayName?: string }[] = []
if (visitorEmail) {
attendees.push({ email: visitorEmail, displayName: attendeeName })
}
const event = await calendar.events.insert({
calendarId: 'primary',
sendUpdates: 'all',
requestBody: {
summary: title,
description,
start: { dateTime: startTime.toISOString(), timeZone: 'UTC' },
end: { dateTime: endTime.toISOString(), timeZone: 'UTC' },
attendees,
},
})
return {
success: true,
eventId: event.data.id,
htmlLink: event.data.htmlLink,
message: `Meeting "${title}" scheduled for ${startTime.toLocaleString()}${visitorEmail ? `. Invite sent to ${visitorEmail}.` : '.'}`,
}
} catch (error) {
console.error('Failed to schedule meeting:', error)
return { success: false, error: 'Failed to schedule meeting. Please try again.' }
}
}

97
src/app/api/chat/route.ts Normal file
View File

@@ -0,0 +1,97 @@
import { auth } from '@clerk/nextjs/server'
import { createOpenAI } from '@ai-sdk/openai'
import { streamText, tool, convertToModelMessages, stepCountIs, type UIMessage } from 'ai'
import { z } from 'zod'
import { eq, and } from 'drizzle-orm'
import { env } from '~/env'
import { db } from '~/server/db'
import { chatSession, chatMessage } from '~/server/dbschema/schema'
import { scheduleMeeting } from '~/app/actions/scheduleMeeting'
const openai = createOpenAI({ apiKey: env.OPENAI_API_KEY })
export async function POST(req: Request) {
const { userId } = await auth()
if (!userId) return new Response('Unauthorized', { status: 401 })
const { messages, sessionId } = (await req.json()) as {
messages: UIMessage[]
sessionId: string
}
// Verify this session belongs to the authenticated user
const session = await db
.select()
.from(chatSession)
.where(and(eq(chatSession.id, sessionId), eq(chatSession.userId, userId)))
.limit(1)
.then((r) => r[0])
if (!session) return new Response('Session not found', { status: 404 })
// Save the latest user message
const lastMessage = messages[messages.length - 1]
if (lastMessage?.role === 'user') {
const content = lastMessage.parts
.filter((p): p is { type: 'text'; text: string } => p.type === 'text')
.map((p) => p.text)
.join('')
if (content) {
await db.insert(chatMessage).values({ sessionId, role: 'user', content })
}
}
const result = streamText({
model: openai('gpt-4o'),
system: `You are an AI recruiter assistant on Gregor Lohaus's personal portfolio website.
Your role is to help visitors learn about Gregor's background, skills, and experience, and to schedule meetings.
About Gregor:
- Fullstack developer specialising in TypeScript, React, Next.js, and modern web technologies
- Experienced with Drizzle ORM, tRPC, PostgreSQL, server components, and the T3 stack
- Also experienced with React Native, Expo, Java/Spring, gRPC, AWS, and Linux/Debian server administration
- Open to new opportunities and collaboration
Be professional, friendly, and concise. When a visitor wants to schedule a meeting, collect the necessary details (preferred date/time, duration, and optionally their name/email) and use the scheduleMeeting tool.`,
messages: await convertToModelMessages(messages),
tools: {
scheduleMeeting: tool({
description: 'Schedule a meeting with Gregor Lohaus and add it to his Google Calendar',
inputSchema: z.object({
title: z.string().describe('Meeting title'),
description: z.string().describe('Meeting description / agenda'),
dateTime: z
.string()
.describe(
'ISO 8601 datetime for the meeting start, e.g. 2025-04-01T10:00:00',
),
durationMinutes: z
.number()
.int()
.min(15)
.max(120)
.describe('Duration of the meeting in minutes'),
attendeeEmail: z
.string()
.email()
.optional()
.describe('Email of the visitor to invite (if provided)'),
attendeeName: z.string().optional().describe('Name of the visitor'),
}),
execute: async (input) => scheduleMeeting({ ...input, userId }),
}),
},
stopWhen: stepCountIs(5),
onFinish: async ({ text, finishReason }) => {
if (text && finishReason === 'stop') {
await db.insert(chatMessage).values({
sessionId,
role: 'assistant',
content: text,
})
}
},
})
return result.toUIMessageStreamResponse()
}

View File

@@ -0,0 +1,162 @@
'use client'
import { useRef, useState, useEffect } from 'react'
import { useChat } from '@ai-sdk/react'
import { DefaultChatTransport, type UIMessage } from 'ai'
import { Button } from '~/components/ui/button'
import { Textarea } from '~/components/ui/textarea'
import { cn } from '~/lib/utils'
type DBMessage = {
id: string
role: 'user' | 'assistant'
content: string
}
interface ChatInterfaceProps {
sessionId: string
initialMessages: DBMessage[]
}
function toUIMessages(dbMessages: DBMessage[]): UIMessage[] {
return dbMessages.map((m) => ({
id: m.id,
role: m.role,
parts: [{ type: 'text' as const, text: m.content }],
}))
}
export default function ChatInterface({ sessionId, initialMessages }: ChatInterfaceProps) {
const [input, setInput] = useState('')
const messagesEndRef = useRef<HTMLDivElement>(null)
const { messages, sendMessage, status } = useChat({
transport: new DefaultChatTransport({
api: '/api/chat',
body: { sessionId },
}),
messages: toUIMessages(initialMessages),
})
const isLoading = status === 'submitted' || status === 'streaming'
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages])
const handleSend = () => {
const text = input.trim()
if (!text || isLoading) return
setInput('')
sendMessage({ text })
}
return (
<div className="flex flex-col h-full">
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{messages.length === 0 && (
<div className="text-center text-muted-foreground py-12">
<p className="text-base font-medium mb-1">Hi! I'm Gregor's AI recruiter assistant.</p>
<p className="text-sm">Ask me about his skills and experience, or schedule a meeting!</p>
</div>
)}
{messages.map((message) => (
<div
key={message.id}
className={cn('flex', message.role === 'user' ? 'justify-end' : 'justify-start')}
>
<div
className={cn(
'max-w-[80%] rounded-lg px-4 py-2 text-sm space-y-2',
message.role === 'user' ? 'bg-primary text-primary-foreground' : 'bg-muted',
)}
>
{message.parts.map((part, i) => {
if (part.type === 'text') {
return (
<p key={i} className="whitespace-pre-wrap">
{part.text}
</p>
)
}
if (part.type === 'tool-scheduleMeeting') {
const toolPart = part as unknown as {
type: 'tool-scheduleMeeting'
state: string
input: unknown
output?: { success: boolean; message?: string; htmlLink?: string; error?: string }
}
if (toolPart.state === 'input-available' || toolPart.state === 'input-streaming') {
return (
<p key={i} className="text-xs opacity-70 italic">
Scheduling meeting
</p>
)
}
if (toolPart.state === 'output-available' && toolPart.output) {
const result = toolPart.output
return (
<div key={i} className="text-xs mt-1 p-2 bg-background/20 rounded">
{result.success ? (
<span>
{result.message}{' '}
{result.htmlLink && (
<a
href={result.htmlLink}
target="_blank"
rel="noopener noreferrer"
className="underline"
>
View event
</a>
)}
</span>
) : (
<span> {result.error}</span>
)}
</div>
)
}
}
return null
})}
</div>
</div>
))}
{isLoading && (
<div className="flex justify-start">
<div className="bg-muted rounded-lg px-4 py-2 text-sm">
<span className="animate-pulse">Thinking</span>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
<div className="p-4 border-t flex gap-2">
<Textarea
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Ask about Gregor's experience or schedule a meeting…"
className="resize-none"
rows={2}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSend()
}
}}
/>
<Button
onClick={handleSend}
disabled={isLoading || !input.trim()}
className="self-end"
>
Send
</Button>
</div>
</div>
)
}

50
src/app/chat/page.tsx Normal file
View File

@@ -0,0 +1,50 @@
import { auth } from '@clerk/nextjs/server'
import { redirect } from 'next/navigation'
import { asc, desc, eq } from 'drizzle-orm'
import { db } from '~/server/db'
import { chatMessage, chatSession } from '~/server/dbschema/schema'
import ChatInterface from './_components/ChatInterface'
export default async function ChatPage() {
const { userId } = await auth()
if (!userId) redirect('/')
let session = await db
.select()
.from(chatSession)
.where(eq(chatSession.userId, userId))
.orderBy(desc(chatSession.createdAt))
.limit(1)
.then((r) => r[0])
if (!session) {
const [created] = await db.insert(chatSession).values({ userId }).returning()
session = created
}
if (!session) redirect('/')
const messages = await db
.select()
.from(chatMessage)
.where(eq(chatMessage.sessionId, session.id))
.orderBy(asc(chatMessage.createdAt))
return (
<div className="container max-w-2xl mx-auto h-screen pt-10 pb-4 flex flex-col">
<div className="flex flex-col flex-1 bg-background border rounded-lg overflow-hidden">
<div className="p-4 border-b flex items-center justify-between">
<div>
<h1 className="text-lg font-semibold">AI Recruiter</h1>
<p className="text-xs text-muted-foreground">
Chat with Gregor's AI assistant
</p>
</div>
</div>
<div className="flex-1 overflow-hidden">
<ChatInterface sessionId={session.id} initialMessages={messages} />
</div>
</div>
</div>
)
}

View File

@@ -28,6 +28,7 @@ export const env = createEnv({
CLERK_SECRET_KEY: z.string(),
ADMIN_USER_CLERK_ID: z.string(),
UPLOADTHING_TOKEN: z.string(),
OPENAI_API_KEY: z.string(),
NODE_ENV: z
.enum(["development", "test", "production"])
.default("development"),
@@ -66,6 +67,7 @@ export const env = createEnv({
POSTGRES_PRISMA_URL: process.env.POSTGRES_PRISMA_URL,
ADMIN_USER_CLERK_ID: process.env.ADMIN_USER_CLERK_ID,
UPLOADTHING_TOKEN: process.env.UPLOADTHING_TOKEN,
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
NEXT_PUBLIC_ADMIN_USER_CLERK_ID: process.env.NEXT_PUBLIC_ADMIN_USER_CLERK_ID,
CLERK_SECRET_KEY: process.env.CLERK_SECRET_KEY,
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY,

View File

@@ -103,3 +103,37 @@ export const music = createTable(
updatedAt: d.timestamp({ withTimezone: true }).$onUpdate(() => new Date()).$type<Date>(),
})
)
export const messageRoleEnum = pgEnum('message_role', ['user', 'assistant'])
export const chatSession = createTable(
"chat_session",
(d) => ({
id: d.uuid().primaryKey().defaultRandom(),
userId: d.varchar({ length: 255 }).notNull(),
createdAt: d.timestamp({ withTimezone: true }).default(sql`CURRENT_TIMESTAMP`).notNull().$type<Date>(),
updatedAt: d.timestamp({ withTimezone: true }).$onUpdate(() => new Date()).$type<Date>(),
})
)
export const chatSessionRelations = relations(chatSession, ({ many }) => ({
messages: many(chatMessage),
}))
export const chatMessage = createTable(
"chat_message",
(d) => ({
id: d.uuid().primaryKey().defaultRandom(),
sessionId: d.uuid('session_id').notNull(),
role: messageRoleEnum().notNull(),
content: d.text().notNull(),
createdAt: d.timestamp({ withTimezone: true }).default(sql`CURRENT_TIMESTAMP`).notNull().$type<Date>(),
})
)
export const chatMessageRelations = relations(chatMessage, ({ one }) => ({
session: one(chatSession, {
fields: [chatMessage.sessionId],
references: [chatSession.id],
}),
}))