From 6c3ad8cf1e44674eee11769edd3896fa97d374f2 Mon Sep 17 00:00:00 2001
From: Kindra <228341058+chadlnorman95@users.noreply.github.com>
Date: Sun, 5 Oct 2025 05:25:06 +0000
Subject: [PATCH 1/8] fix: env, Docker, and OpenRouter config for working local
and Docker dev
---
.env.example | 7 +-
README.md | 8 +-
better-chatbot | 1 +
next.config.ts | 6 +
package.json | 6 +-
pnpm-lock.yaml | 20 +
src/app/api/mcp/list/route.ts | 2 +-
src/app/globals.css | 2302 +++++++++++++--------------------
src/components/Layout.tsx | 58 +
src/hooks/use-copilotui.ts | 92 ++
src/lib/ai/mcp/mcp-manager.ts | 20 +-
11 files changed, 1079 insertions(+), 1443 deletions(-)
create mode 160000 better-chatbot
create mode 100644 src/components/Layout.tsx
create mode 100644 src/hooks/use-copilotui.ts
diff --git a/.env.example b/.env.example
index fb340b7bf..271049c2d 100644
--- a/.env.example
+++ b/.env.example
@@ -5,6 +5,7 @@ OPENAI_API_KEY=****
XAI_API_KEY=****
ANTHROPIC_API_KEY=****
OPENROUTER_API_KEY=****
+OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
OLLAMA_BASE_URL=http://localhost:11434/api
GROQ_API_KEY=****
GROQ_BASE_URL=https://api.groq.com/openai/v1
@@ -12,7 +13,7 @@ GROQ_BASE_URL=https://api.groq.com/openai/v1
# (Optional) Default model to use when none is specified
# Format: provider/model (e.g., openRouter/qwen3-8b:free)
-E2E_DEFAULT_MODEL=
+E2E_DEFAULT_MODEL=***
# === Database ===
@@ -45,12 +46,12 @@ EXA_API_KEY=
# - With Redis: Real-time MCP synchronization + reduced polling
# - Without Redis: Polling-only synchronization (single instance or dev mode)
# redis://localhost:6379
-REDIS_URL=
+REDIS_URL=***
# (Optional)
# Whether to use file-based MCP config (default: false)
-FILE_BASED_MCP_CONFIG=false
+FILE_BASED_MCP_CONFIG=true
# (Optional)
# === OAuth Settings ===
diff --git a/README.md b/README.md
index e96498b56..4fe0e8f6a 100644
--- a/README.md
+++ b/README.md
@@ -32,15 +32,15 @@ cd better-chatbot
# 2. (Optional) Install pnpm if you don't have it
-npm install -g pnpm
+cd better-chatbot
# 3. Install dependencies
-pnpm i
+cd better-chatbot
# 4. (Optional) Start a local PostgreSQL instance
-pnpm docker:pg
+cd better-chatbot
# If you already have your own PostgreSQL running, you can skip this step.
# In that case, make sure to update the PostgreSQL URL in your .env file.
@@ -290,7 +290,7 @@ pnpm db:migrate
# Run app locally
-pnpm dev # or: pnpm build && pnpm start
+pnpm docker-compose:up # or: pnpm build && pnpm start
```
Open [http://localhost:3000](http://localhost:3000) in your browser to get started.
diff --git a/better-chatbot b/better-chatbot
new file mode 160000
index 000000000..9ca4d403e
--- /dev/null
+++ b/better-chatbot
@@ -0,0 +1 @@
+Subproject commit 9ca4d403e72b43e1f851b74758acbd009ffaed3b
diff --git a/next.config.ts b/next.config.ts
index 59f265072..3b564f94c 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -18,6 +18,12 @@ export default () => {
experimental: {
taint: true,
authInterrupts: true,
+ serverActions: {
+ // domains only, no protocol
+ allowedOrigins: ["localhost:3000", "*.app.github.dev"],
+ // legacy option for older Next builds
+ allowedForwardedHosts: ["*.app.github.dev"],
+ },
},
};
const withNextIntl = createNextIntlPlugin();
diff --git a/package.json b/package.json
index 69163ad41..5b36403cc 100644
--- a/package.json
+++ b/package.json
@@ -108,6 +108,7 @@
"next-themes": "^0.4.6",
"ogl": "^1.0.11",
"ollama-ai-provider-v2": "^1.4.1",
+ "openai": "^6.1.0",
"pg": "^8.16.3",
"react": "^19.2.0",
"react-dom": "^19.2.0",
@@ -152,10 +153,7 @@
"vitest": "^3.2.4"
},
"lint-staged": {
- "*.{js,json,mjs,ts,yaml,tsx,css}": [
- "pnpm format",
- "pnpm lint:fix"
- ]
+ "*.{js,json,mjs,ts,yaml,tsx,css}": ["pnpm format", "pnpm lint:fix"]
},
"packageManager": "pnpm@10.2.1",
"engines": {
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 46f7e05b3..ac24b50bf 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -188,6 +188,9 @@ importers:
ollama-ai-provider-v2:
specifier: ^1.4.1
version: 1.4.1(zod@4.1.11)
+ openai:
+ specifier: ^6.1.0
+ version: 6.1.0(ws@8.18.3)(zod@4.1.11)
pg:
specifier: ^8.16.3
version: 8.16.3
@@ -5077,6 +5080,18 @@ packages:
oniguruma-to-es@4.3.3:
resolution: {integrity: sha512-rPiZhzC3wXwE59YQMRDodUwwT9FZ9nNBwQQfsd1wfdtlKEyCdRV0avrTcSZ5xlIvGRVPd/cx6ZN45ECmS39xvg==}
+ openai@6.1.0:
+ resolution: {integrity: sha512-5sqb1wK67HoVgGlsPwcH2bUbkg66nnoIYKoyV9zi5pZPqh7EWlmSrSDjAh4O5jaIg/0rIlcDKBtWvZBuacmGZg==}
+ hasBin: true
+ peerDependencies:
+ ws: ^8.18.0
+ zod: ^3.25 || ^4.0
+ peerDependenciesMeta:
+ ws:
+ optional: true
+ zod:
+ optional: true
+
optionator@0.9.4:
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
engines: {node: '>= 0.8.0'}
@@ -11793,6 +11808,11 @@ snapshots:
regex: 6.0.1
regex-recursion: 6.0.2
+ openai@6.1.0(ws@8.18.3)(zod@4.1.11):
+ optionalDependencies:
+ ws: 8.18.3
+ zod: 4.1.11
+
optionator@0.9.4:
dependencies:
deep-is: 0.1.4
diff --git a/src/app/api/mcp/list/route.ts b/src/app/api/mcp/list/route.ts
index fb40a7797..7e4331957 100644
--- a/src/app/api/mcp/list/route.ts
+++ b/src/app/api/mcp/list/route.ts
@@ -41,7 +41,7 @@ export async function GET() {
const result = servers.map((server) => {
const mem = memoryMap.get(server.id);
- const info = mem?.getInfo();
+ const info = (mem as any)?.getInfo?.();
const mcpInfo: MCPServerInfo = {
...server,
enabled: info?.enabled ?? true,
diff --git a/src/app/globals.css b/src/app/globals.css
index faf43e911..83898875f 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -1,1456 +1,434 @@
+/* global.css — Full consolidated file
+ Tailwind imports, enhanced theme map, 6-color tokens, base, components,
+ Copilot box, scroll behavior, utilities, and a11y fallbacks.
+*/
+
+/* -----------------------------------
+ Imports
+----------------------------------- */
@import "tailwindcss";
@import "tw-animate-css";
-/* Individual theme variants - themes from https://github.com/rawestmoreland/theme-generator/blob/main/src/app/globals.css */
+/* -----------------------------------
+ Tailwind <-> Token Theme Map
+ (semantic surfaces, states, motion)
+----------------------------------- */
@theme inline {
+ /* Colors: roles */
--color-background: var(--background);
--color-foreground: var(--foreground);
- --font-sans: var(--font-geist-sans);
- --font-mono: var(--font-geist-mono);
- --color-sidebar-ring: var(--sidebar-ring);
- --color-sidebar-border: var(--sidebar-border);
- --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
- --color-sidebar-accent: var(--sidebar-accent);
- --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
- --color-sidebar-primary: var(--sidebar-primary);
- --color-sidebar-foreground: var(--sidebar-foreground);
- --color-sidebar: var(--sidebar);
- --color-chart-5: var(--chart-5, --input);
- --color-chart-4: var(--chart-4, --input);
- --color-chart-3: var(--chart-3, --input);
- --color-chart-2: var(--chart-2, --input);
- --color-chart-1: var(--chart-1, --input);
- --color-ring: var(--ring);
- --color-input: var(--input);
+
+ --color-surface-0: var(--background);
+ --color-surface-1: var(--card);
+ --color-surface-2: color-mix(in oklab, var(--card) 94%, var(--foreground) 6%);
+ --color-surface-3: color-mix(in oklab, var(--card) 90%, var(--foreground) 10%);
+
--color-border: var(--border);
+ --color-input: var(--input);
+ --color-ring: var(--ring);
+
+ --color-primary: var(--primary);
+ --color-primary-foreground: var(--primary-foreground);
+
+ --color-secondary: var(--secondary);
+ --color-secondary-foreground: var(--secondary-foreground);
+
+ --color-muted: var(--muted);
+ --color-muted-foreground: var(--muted-foreground);
+
+ --color-accent: var(--accent);
+ --color-accent-foreground: var(--accent-foreground);
+
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
- --color-accent-foreground: var(--accent-foreground);
- --color-accent: var(--accent);
- --color-muted-foreground: var(--muted-foreground);
- --color-muted: var(--muted);
- --color-secondary-foreground: var(--secondary-foreground);
- --color-secondary: var(--secondary);
- --color-primary-foreground: var(--primary-foreground);
- --color-primary: var(--primary);
- --color-popover-foreground: var(--popover-foreground);
- --color-popover: var(--popover);
- --color-card-foreground: var(--card-foreground);
- --color-card: var(--card);
+
+ /* Optional state colors from 6-color core */
+ --color-success: var(--c4);
+ --color-success-foreground: var(--c1);
+ --color-warning: var(--c5);
+ --color-warning-foreground: var(--c0);
+
+ /* Sidebar */
+ --color-sidebar: var(--sidebar);
+ --color-sidebar-foreground: var(--sidebar-foreground);
+ --color-sidebar-primary: var(--sidebar-primary);
+ --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
+ --color-sidebar-accent: var(--sidebar-accent);
+ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
+ --color-sidebar-border: var(--sidebar-border);
+ --color-sidebar-ring: var(--sidebar-ring);
+
+ /* Charts */
+ --color-chart-1: var(--chart-1);
+ --color-chart-2: var(--chart-2);
+ --color-chart-3: var(--chart-3);
+ --color-chart-4: var(--chart-4);
+ --color-chart-5: var(--chart-5);
+
+ /* Radii */
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
- --radius-xl: calc(var(--radius) + 4px);
+ --radius-xl: calc(var(--radius) + 6px);
+ --radius-full: 999px;
+
+ /* Shadows (semantic) */
+ --shadow-hairline: 0 0 0 1px var(--border);
+ --shadow-card: var(--shadow-1);
+ --shadow-flyout: var(--shadow-2);
+ --shadow-popover: var(--shadow-3);
+ --shadow-modal: var(--shadow-4);
+ --shadow-elevated: var(--shadow-5);
+
+ /* Motion */
+ --duration-fast: var(--dur-1);
+ --duration-normal: var(--dur-2);
+ --easing-in: var(--ease-in);
+ --easing-out: var(--ease-out);
+ --motion-pop: var(--motion-pop);
+ --motion-lift: var(--motion-lift);
+ --motion-fade: var(--motion-fade);
+
+ /* Typography */
+ --font-sans: var(--font-geist-sans);
+ --font-mono: var(--font-geist-mono);
+
+ /* Breakpoints */
+ --breakpoint-sm: 40rem; /* 640px */
+ --breakpoint-md: 48rem; /* 768px */
+ --breakpoint-lg: 64rem; /* 1024px */
+ --breakpoint-xl: 80rem; /* 1280px */
+ --breakpoint-2xl: 96rem; /* 1536px */
+
+ /* Z layers */
+ --z-drawer: 40;
+ --z-popover: 50;
+ --z-modal: 60;
+ --z-toast: 70;
+
+ /* Component slots */
+ --btn-bg: var(--primary);
+ --btn-fg: var(--primary-foreground);
+ --btn-ring: var(--ring);
+ --input-bg: var(--input);
+ --input-fg: var(--foreground);
+ --input-border: var(--border);
+ --panel-bg: var(--color-surface-1);
+ --panel-border: var(--border);
+ --panel-shadow: var(--shadow-card);
}
+/* -----------------------------------
+ 6-Color Core + Derived Tokens
+----------------------------------- */
:root {
+ /* Core 6 */
+ --c0: #0b0c0f; /* ink */
+ --c1: #ffffff; /* paper */
+ --c2: #1f6feb; /* brand */
+ --c3: #ef4444; /* danger */
+ --c4: #10b981; /* success */
+ --c5: #f59e0b; /* warning */
+
+ /* Radius */
--radius: 0.75rem;
- --background: oklch(1 0 0);
- --foreground: oklch(0.141 0.005 285.823);
- --card: oklch(1 0 0);
- --card-foreground: oklch(0.141 0.005 285.823);
- --popover: oklch(1 0 0);
- --popover-foreground: oklch(0.141 0.005 285.823);
- --primary: oklch(0.21 0.006 285.885);
- --primary-foreground: oklch(0.985 0 0);
- --secondary: oklch(0.967 0.001 286.375);
- --secondary-foreground: oklch(0.21 0.006 285.885);
- --muted: oklch(0.967 0.001 286.375);
- --muted-foreground: oklch(0.552 0.016 285.938);
- --accent: oklch(0.967 0.001 286.375);
- --accent-foreground: oklch(0.21 0.006 285.885);
- --destructive: oklch(0.577 0.245 27.325);
- --border: oklch(0.92 0.004 286.32);
- --input: oklch(0.92 0.004 286.32);
- --ring: oklch(0.705 0.015 286.067);
- --chart-1: hsl(221.2 83.2% 53.3%);
- --chart-2: hsl(212 95% 68%);
- --chart-3: hsl(216 92% 60%);
- --chart-4: hsl(210 98% 78%);
- --chart-5: hsl(212 97% 87%);
- --sidebar: oklch(0.985 0 0);
- --sidebar-foreground: oklch(0.141 0.005 285.823);
- --sidebar-primary: oklch(0.21 0.006 285.885);
- --sidebar-primary-foreground: oklch(0.985 0 0);
- --sidebar-accent: oklch(0.967 0.001 286.375);
- --sidebar-accent-foreground: oklch(0.21 0.006 285.885);
- --sidebar-border: oklch(0.92 0.004 286.32);
- --sidebar-ring: oklch(0.705 0.015 286.067);
-}
-.light {
- [data-theme="zinc"] {
- --background: hsl(0 0% 100%);
- --foreground: hsl(240 10% 3.9%);
- --card: hsl(0 0% 100%);
- --card-foreground: hsl(240 10% 3.9%);
- --popover: hsl(0 0% 100%);
- --popover-foreground: hsl(240 10% 3.9%);
- --primary: hsl(240 5.9% 10%);
- --primary-foreground: hsl(0 0% 98%);
- --secondary: hsl(240 4.8% 95.9%);
- --secondary-foreground: hsl(240 5.9% 10%);
- --muted: hsl(240 4.8% 95.9%);
- --muted-foreground: hsl(240 3.8% 46.1%);
- --accent: hsl(240 4.8% 95.9%);
- --accent-foreground: hsl(240 5.9% 10%);
- --destructive: hsl(0 84.2% 60.2%);
- --destructive-foreground: hsl(0 0% 98%);
- --border: hsl(240 5.9% 90%);
- --input: hsl(240 5.9% 90%);
- --ring: hsl(240 5.9% 10%);
- --radius: 0.6rem;
- --chart-1: hsl(12 76% 61%);
- --chart-2: hsl(173 58% 39%);
- --chart-3: hsl(197 37% 24%);
- --chart-4: hsl(43 74% 66%);
- --chart-5: hsl(27 87% 67%);
- --sidebar: hsl(240 4.8% 98%);
- --sidebar-foreground: hsl(240 10% 3.9%);
- --sidebar-primary: hsl(240 5.9% 10%);
- --sidebar-primary-foreground: hsl(0 0% 98%);
- --sidebar-accent: hsl(240 4.8% 95.9%);
- --sidebar-accent-foreground: hsl(240 5.9% 10%);
- --sidebar-border: hsl(240 5.9% 90%);
- --sidebar-ring: hsl(240 5.9% 10%);
- }
-
- [data-theme="slate"] {
- --background: hsl(0 0% 100%);
- --foreground: hsl(222.2 84% 4.9%);
- --card: hsl(0 0% 100%);
- --card-foreground: hsl(222.2 84% 4.9%);
- --popover: hsl(0 0% 100%);
- --popover-foreground: hsl(222.2 84% 4.9%);
- --primary: hsl(222.2 47.4% 11.2%);
- --primary-foreground: hsl(210 40% 98%);
- --secondary: hsl(210 40% 96.1%);
- --secondary-foreground: hsl(222.2 47.4% 11.2%);
- --muted: hsl(210 40% 96.1%);
- --muted-foreground: hsl(215.4 16.3% 46.9%);
- --accent: hsl(210 40% 96.1%);
- --accent-foreground: hsl(222.2 47.4% 11.2%);
- --destructive: hsl(0 84.2% 60.2%);
- --destructive-foreground: hsl(210 40% 98%);
- --border: hsl(214.3 31.8% 91.4%);
- --input: hsl(214.3 31.8% 91.4%);
- --ring: hsl(222.2 84% 4.9%);
- --radius: 0.6rem;
- --chart-1: hsl(12 76% 61%);
- --chart-2: hsl(173 58% 39%);
- --chart-3: hsl(197 37% 24%);
- --chart-4: hsl(43 74% 66%);
- --chart-5: hsl(27 87% 67%);
- --sidebar: hsl(210 40% 98%);
- --sidebar-foreground: hsl(222.2 84% 4.9%);
- --sidebar-primary: hsl(222.2 47.4% 11.2%);
- --sidebar-primary-foreground: hsl(210 40% 98%);
- --sidebar-accent: hsl(210 40% 96.1%);
- --sidebar-accent-foreground: hsl(222.2 47.4% 11.2%);
- --sidebar-border: hsl(214.3 31.8% 91.4%);
- --sidebar-ring: hsl(222.2 84% 4.9%);
- }
-
- [data-theme="stone"] {
- --background: hsl(0 0% 100%);
- --foreground: hsl(28 25% 8%);
- --card: hsl(0 0% 100%);
- --card-foreground: hsl(28 25% 8%);
- --popover: hsl(0 0% 100%);
- --popover-foreground: hsl(28 25% 8%);
- --primary: hsl(28 25% 15%);
- --primary-foreground: hsl(60 9.1% 97.8%);
- --secondary: hsl(60 4.8% 95.9%);
- --secondary-foreground: hsl(28 25% 15%);
- --muted: hsl(60 4.8% 95.9%);
- --muted-foreground: hsl(25 5.3% 44.7%);
- --accent: hsl(60 4.8% 95.9%);
- --accent-foreground: hsl(28 25% 15%);
- --destructive: hsl(0 84.2% 60.2%);
- --destructive-foreground: hsl(60 9.1% 97.8%);
- --border: hsl(20 5.9% 90%);
- --input: hsl(20 5.9% 90%);
- --ring: hsl(28 25% 8%);
- --radius: 0.6rem;
- --chart-1: hsl(12 76% 61%);
- --chart-2: hsl(173 58% 39%);
- --chart-3: hsl(197 37% 24%);
- --chart-4: hsl(43 74% 66%);
- --chart-5: hsl(27 87% 67%);
- --sidebar: hsl(60 4.8% 98%);
- --sidebar-foreground: hsl(28 25% 8%);
- --sidebar-primary: hsl(28 25% 15%);
- --sidebar-primary-foreground: hsl(60 9.1% 97.8%);
- --sidebar-accent: hsl(60 4.8% 95.9%);
- --sidebar-accent-foreground: hsl(28 25% 15%);
- --sidebar-border: hsl(20 5.9% 90%);
- --sidebar-ring: hsl(28 25% 8%);
- }
-
- [data-theme="gray"] {
- --background: hsl(0 0% 100%);
- --foreground: hsl(224 71.4% 4.1%);
- --card: hsl(0 0% 100%);
- --card-foreground: hsl(224 71.4% 4.1%);
- --popover: hsl(0 0% 100%);
- --popover-foreground: hsl(224 71.4% 4.1%);
- --primary: hsl(220.9 39.3% 11%);
- --primary-foreground: hsl(210 20% 98%);
- --secondary: hsl(220 14.3% 95.9%);
- --secondary-foreground: hsl(220.9 39.3% 11%);
- --muted: hsl(220 14.3% 95.9%);
- --muted-foreground: hsl(220 8.9% 46.1%);
- --accent: hsl(220 14.3% 95.9%);
- --accent-foreground: hsl(220.9 39.3% 11%);
- --destructive: hsl(0 84.2% 60.2%);
- --destructive-foreground: hsl(210 20% 98%);
- --border: hsl(220 13% 91%);
- --input: hsl(220 13% 91%);
- --ring: hsl(224 71.4% 4.1%);
- --radius: 0.6rem;
- --chart-1: hsl(12 76% 61%);
- --chart-2: hsl(173 58% 39%);
- --chart-3: hsl(197 37% 24%);
- --chart-4: hsl(43 74% 66%);
- --chart-5: hsl(27 87% 67%);
- --sidebar: hsl(220 14.3% 98%);
- --sidebar-foreground: hsl(224 71.4% 4.1%);
- --sidebar-primary: hsl(220.9 39.3% 11%);
- --sidebar-primary-foreground: hsl(210 20% 98%);
- --sidebar-accent: hsl(220 14.3% 95.9%);
- --sidebar-accent-foreground: hsl(220.9 39.3% 11%);
- --sidebar-border: hsl(220 13% 91%);
- --sidebar-ring: hsl(224 71.4% 4.1%);
- }
-
- [data-theme="blue"] {
- --background: hsl(0 0% 100%);
- --foreground: hsl(222.2 84% 4.9%);
- --card: hsl(0 0% 100%);
- --card-foreground: hsl(222.2 84% 4.9%);
- --popover: hsl(0 0% 100%);
- --popover-foreground: hsl(222.2 84% 4.9%);
- --primary: hsl(221.2 83.2% 53.3%);
- --primary-foreground: hsl(210 40% 98%);
- --secondary: hsl(210 40% 96.1%);
- --secondary-foreground: hsl(222.2 47.4% 11.2%);
- --muted: hsl(210 40% 96.1%);
- --muted-foreground: hsl(215.4 16.3% 46.9%);
- --accent: hsl(210 40% 96.1%);
- --accent-foreground: hsl(222.2 47.4% 11.2%);
- --destructive: hsl(0 84.2% 60.2%);
- --destructive-foreground: hsl(210 40% 98%);
- --border: hsl(214.3 31.8% 91.4%);
- --input: hsl(214.3 31.8% 91.4%);
- --ring: hsl(221.2 83.2% 53.3%);
- --radius: 0.6rem;
- --chart-1: hsl(12 76% 61%);
- --chart-2: hsl(173 58% 39%);
- --chart-3: hsl(197 37% 24%);
- --chart-4: hsl(43 74% 66%);
- --chart-5: hsl(27 87% 67%);
- --sidebar: hsl(210 40% 98%);
- --sidebar-foreground: hsl(222.2 84% 4.9%);
- --sidebar-primary: hsl(221.2 83.2% 53.3%);
- --sidebar-primary-foreground: hsl(210 40% 98%);
- --sidebar-accent: hsl(210 40% 96.1%);
- --sidebar-accent-foreground: hsl(222.2 47.4% 11.2%);
- --sidebar-border: hsl(214.3 31.8% 91.4%);
- --sidebar-ring: hsl(221.2 83.2% 53.3%);
- }
-
- [data-theme="orange"] {
- --background: hsl(0 0% 100%);
- --foreground: hsl(20 14.3% 4.1%);
- --card: hsl(0 0% 100%);
- --card-foreground: hsl(20 14.3% 4.1%);
- --popover: hsl(0 0% 100%);
- --popover-foreground: hsl(20 14.3% 4.1%);
- --primary: hsl(24.6 95% 53.1%);
- --primary-foreground: hsl(60 9.1% 97.8%);
- --secondary: hsl(60 4.8% 95.9%);
- --secondary-foreground: hsl(24 9.8% 10%);
- --muted: hsl(60 4.8% 95.9%);
- --muted-foreground: hsl(25 5.3% 44.7%);
- --accent: hsl(60 4.8% 95.9%);
- --accent-foreground: hsl(24 9.8% 10%);
- --destructive: hsl(0 84.2% 60.2%);
- --destructive-foreground: hsl(60 9.1% 97.8%);
- --border: hsl(20 5.9% 90%);
- --input: hsl(20 5.9% 90%);
- --ring: hsl(24.6 95% 53.1%);
- --radius: 0.6rem;
- --chart-1: hsl(12 76% 61%);
- --chart-2: hsl(173 58% 39%);
- --chart-3: hsl(197 37% 24%);
- --chart-4: hsl(43 74% 66%);
- --chart-5: hsl(27 87% 67%);
- --sidebar: hsl(60 4.8% 98%);
- --sidebar-foreground: hsl(20 14.3% 4.1%);
- --sidebar-primary: hsl(24.6 95% 53.1%);
- --sidebar-primary-foreground: hsl(60 9.1% 97.8%);
- --sidebar-accent: hsl(60 4.8% 95.9%);
- --sidebar-accent-foreground: hsl(24 9.8% 10%);
- --sidebar-border: hsl(20 5.9% 90%);
- --sidebar-ring: hsl(24.6 95% 53.1%);
- }
-
- [data-theme="pink"] {
- --background: hsl(340 100% 99%);
- --foreground: hsl(340 10% 10%);
- --card: hsl(340 100% 98%);
- --card-foreground: hsl(340 10% 10%);
- --popover: hsl(340 100% 98%);
- --popover-foreground: hsl(340 10% 10%);
- --primary: hsl(340 80% 65%);
- --primary-foreground: hsl(0 0% 98%);
- --secondary: hsl(310 70% 85%);
- --secondary-foreground: hsl(310 10% 10%);
- --muted: hsl(340 20% 90%);
- --muted-foreground: hsl(340 10% 40%);
- --accent: hsl(210 40% 96.1%);
- --accent-foreground: hsl(30 10% 10%);
- --destructive: hsl(0 84.2% 60.2%);
- --destructive-foreground: hsl(0 0% 98%);
- --border: hsl(340 60% 90%);
- --input: hsl(340 60% 90%);
- --ring: hsl(340 80% 65%);
- --radius: 0.75rem;
- --chart-1: hsl(340 80% 65%);
- --chart-2: hsl(310 70% 85%);
- --chart-3: hsl(30 90% 80%);
- --chart-4: hsl(260 80% 70%);
- --chart-5: hsl(180 60% 75%);
- --sidebar: hsl(340 80% 95%);
- --sidebar-foreground: hsl(340 10% 10%);
- --sidebar-primary: hsl(340 80% 65%);
- --sidebar-primary-foreground: hsl(0 0% 98%);
- --sidebar-accent: hsl(310 70% 85%);
- --sidebar-accent-foreground: hsl(310 10% 10%);
- --sidebar-border: hsl(340 60% 85%);
- --sidebar-ring: hsl(340 80% 65%);
- }
-
- [data-theme="bubblegum-pop"] {
- --background: hsl(330 100% 99%);
- --foreground: hsl(330 10% 10%);
- --card: hsl(330 100% 98%);
- --card-foreground: hsl(330 10% 10%);
- --popover: hsl(330 100% 98%);
- --popover-foreground: hsl(330 10% 10%);
- --primary: hsl(330 90% 60%);
- --primary-foreground: hsl(330 10% 98%);
- --secondary: hsl(275 90% 80%);
- --secondary-foreground: hsl(275 10% 10%);
- --muted: hsl(330 20% 90%);
- --muted-foreground: hsl(330 10% 40%);
- --accent: hsl(30 90% 80%);
- --accent-foreground: hsl(30 10% 10%);
- --destructive: hsl(0 90% 60%);
- --destructive-foreground: hsl(0 10% 98%);
- --border: hsl(330 60% 90%);
- --input: hsl(330 60% 90%);
- --ring: hsl(330 90% 60%);
- --radius: 1rem;
- --chart-1: hsl(330 90% 60%);
- --chart-2: hsl(275 90% 60%);
- --chart-3: hsl(30 90% 60%);
- --chart-4: hsl(180 90% 60%);
- --chart-5: hsl(60 90% 60%);
- --sidebar: hsl(330 80% 95%);
- --sidebar-foreground: hsl(330 10% 10%);
- --sidebar-primary: hsl(330 90% 60%);
- --sidebar-primary-foreground: hsl(330 10% 98%);
- --sidebar-accent: hsl(275 90% 80%);
- --sidebar-accent-foreground: hsl(275 10% 10%);
- --sidebar-border: hsl(330 60% 85%);
- --sidebar-ring: hsl(330 90% 60%);
- }
-
- [data-theme="cyberpunk-neon"] {
- --background: hsl(220 20% 97%);
- --foreground: hsl(220 80% 5%);
- --card: hsl(220 20% 98%);
- --card-foreground: hsl(220 80% 5%);
- --popover: hsl(220 20% 98%);
- --popover-foreground: hsl(220 80% 5%);
- --primary: hsl(320 100% 50%);
- --primary-foreground: hsl(320 100% 98%);
- --secondary: hsl(180 100% 50%);
- --secondary-foreground: hsl(180 100% 10%);
- --muted: hsl(220 20% 90%);
- --muted-foreground: hsl(220 20% 40%);
- --accent: hsl(65 100% 50%);
- --accent-foreground: hsl(65 100% 10%);
- --destructive: hsl(0 100% 50%);
- --destructive-foreground: hsl(0 100% 98%);
- --border: hsl(220 20% 80%);
- --input: hsl(220 20% 80%);
- --ring: hsl(320 100% 50%);
- --radius: 0.125rem;
- --chart-1: hsl(320 100% 50%);
- --chart-2: hsl(180 100% 50%);
- --chart-3: hsl(65 100% 50%);
- --chart-4: hsl(260 100% 50%);
- --chart-5: hsl(30 100% 50%);
- --sidebar: hsl(220 20% 95%);
- --sidebar-foreground: hsl(220 80% 5%);
- --sidebar-primary: hsl(320 100% 50%);
- --sidebar-primary-foreground: hsl(320 100% 98%);
- --sidebar-accent: hsl(180 100% 50%);
- --sidebar-accent-foreground: hsl(180 100% 10%);
- --sidebar-border: hsl(220 20% 85%);
- --sidebar-ring: hsl(320 100% 50%);
- }
-
- [data-theme="retro-arcade"] {
- --background: hsl(60 10% 95%);
- --foreground: hsl(60 10% 5%);
- --card: hsl(60 10% 97%);
- --card-foreground: hsl(60 10% 5%);
- --popover: hsl(60 10% 97%);
- --popover-foreground: hsl(60 10% 5%);
- --primary: hsl(220 90% 50%);
- --primary-foreground: hsl(220 90% 98%);
- --secondary: hsl(120 90% 40%);
- --secondary-foreground: hsl(120 90% 98%);
- --muted: hsl(60 10% 85%);
- --muted-foreground: hsl(60 10% 45%);
- --accent: hsl(30 90% 50%);
- --accent-foreground: hsl(30 90% 98%);
- --destructive: hsl(0 90% 50%);
- --destructive-foreground: hsl(0 90% 98%);
- --border: hsl(60 10% 75%);
- --input: hsl(60 10% 75%);
- --ring: hsl(220 90% 50%);
- --radius: 0;
- --chart-1: hsl(220 90% 50%);
- --chart-2: hsl(120 90% 40%);
- --chart-3: hsl(30 90% 50%);
- --chart-4: hsl(280 90% 50%);
- --chart-5: hsl(180 90% 40%);
- --sidebar: hsl(60 10% 90%);
- --sidebar-foreground: hsl(60 10% 5%);
- --sidebar-primary: hsl(220 90% 50%);
- --sidebar-primary-foreground: hsl(220 90% 98%);
- --sidebar-accent: hsl(120 90% 40%);
- --sidebar-accent-foreground: hsl(120 90% 98%);
- --sidebar-border: hsl(60 10% 80%);
- --sidebar-ring: hsl(220 90% 50%);
- }
-
- [data-theme="tropical-paradise"] {
- --background: hsl(180 50% 97%);
- --foreground: hsl(180 50% 10%);
- --card: hsl(180 50% 98%);
- --card-foreground: hsl(180 50% 10%);
- --popover: hsl(180 50% 98%);
- --popover-foreground: hsl(180 50% 10%);
- --primary: hsl(150 80% 40%);
- --primary-foreground: hsl(150 80% 98%);
- --secondary: hsl(35 90% 50%);
- --secondary-foreground: hsl(35 90% 10%);
- --muted: hsl(180 30% 90%);
- --muted-foreground: hsl(180 30% 40%);
- --accent: hsl(330 70% 60%);
- --accent-foreground: hsl(330 70% 10%);
- --destructive: hsl(0 90% 60%);
- --destructive-foreground: hsl(0 90% 98%);
- --border: hsl(180 50% 85%);
- --input: hsl(180 50% 85%);
- --ring: hsl(150 80% 40%);
- --radius: 0.75rem;
- --chart-1: hsl(150 80% 40%);
- --chart-2: hsl(35 90% 50%);
- --chart-3: hsl(330 70% 60%);
- --chart-4: hsl(200 90% 50%);
- --chart-5: hsl(50 90% 50%);
- --sidebar: hsl(180 50% 95%);
- --sidebar-foreground: hsl(180 50% 10%);
- --sidebar-primary: hsl(150 80% 40%);
- --sidebar-primary-foreground: hsl(150 80% 98%);
- --sidebar-accent: hsl(35 90% 50%);
- --sidebar-accent-foreground: hsl(35 90% 10%);
- --sidebar-border: hsl(180 50% 90%);
- --sidebar-ring: hsl(150 80% 40%);
- }
-
- [data-theme="steampunk-cogs"] {
- --background: hsl(30 20% 95%);
- --foreground: hsl(30 20% 10%);
- --card: hsl(30 20% 97%);
- --card-foreground: hsl(30 20% 10%);
- --popover: hsl(30 20% 97%);
- --popover-foreground: hsl(30 20% 10%);
- --primary: hsl(25 80% 40%);
- --primary-foreground: hsl(25 80% 98%);
- --secondary: hsl(45 70% 50%);
- --secondary-foreground: hsl(45 70% 10%);
- --muted: hsl(30 15% 85%);
- --muted-foreground: hsl(30 15% 40%);
- --accent: hsl(15 80% 50%);
- --accent-foreground: hsl(15 80% 10%);
- --destructive: hsl(0 80% 50%);
- --destructive-foreground: hsl(0 80% 98%);
- --border: hsl(30 20% 80%);
- --input: hsl(30 20% 80%);
- --ring: hsl(25 80% 40%);
- --radius: 0.25rem;
- --chart-1: hsl(25 80% 40%);
- --chart-2: hsl(45 70% 50%);
- --chart-3: hsl(15 80% 50%);
- --chart-4: hsl(35 80% 40%);
- --chart-5: hsl(55 70% 50%);
- --sidebar: hsl(30 20% 92%);
- --sidebar-foreground: hsl(30 20% 10%);
- --sidebar-primary: hsl(25 80% 40%);
- --sidebar-primary-foreground: hsl(25 80% 98%);
- --sidebar-accent: hsl(45 70% 50%);
- --sidebar-accent-foreground: hsl(45 70% 10%);
- --sidebar-border: hsl(30 20% 85%);
- --sidebar-ring: hsl(25 80% 40%);
- }
-
- [data-theme="neon-synthwave"] {
- --background: hsl(280 30% 95%);
- --foreground: hsl(280 30% 10%);
- --card: hsl(280 30% 97%);
- --card-foreground: hsl(280 30% 10%);
- --popover: hsl(280 30% 97%);
- --popover-foreground: hsl(280 30% 10%);
- --primary: hsl(320 100% 60%);
- --primary-foreground: hsl(320 100% 98%);
- --secondary: hsl(220 100% 60%);
- --secondary-foreground: hsl(220 100% 98%);
- --muted: hsl(280 20% 90%);
- --muted-foreground: hsl(280 20% 40%);
- --accent: hsl(180 100% 50%);
- --accent-foreground: hsl(180 100% 10%);
- --destructive: hsl(0 100% 60%);
- --destructive-foreground: hsl(0 100% 98%);
- --border: hsl(280 30% 80%);
- --input: hsl(280 30% 80%);
- --ring: hsl(320 100% 60%);
- --radius: 0.6rem;
- --chart-1: hsl(320 100% 60%);
- --chart-2: hsl(220 100% 60%);
- --chart-3: hsl(180 100% 50%);
- --chart-4: hsl(260 100% 60%);
- --chart-5: hsl(300 100% 60%);
- --sidebar: hsl(280 30% 92%);
- --sidebar-foreground: hsl(280 30% 10%);
- --sidebar-primary: hsl(320 100% 60%);
- --sidebar-primary-foreground: hsl(320 100% 98%);
- --sidebar-accent: hsl(220 100% 60%);
- --sidebar-accent-foreground: hsl(220 100% 98%);
- --sidebar-border: hsl(280 30% 85%);
- --sidebar-ring: hsl(320 100% 60%);
- }
-
- [data-theme="pastel-kawaii"] {
- --background: hsl(60 30% 97%);
- --foreground: hsl(60 30% 10%);
- --card: hsl(60 30% 98%);
- --card-foreground: hsl(60 30% 10%);
- --popover: hsl(60 30% 98%);
- --popover-foreground: hsl(60 30% 10%);
- --primary: hsl(350 80% 80%);
- --primary-foreground: hsl(350 80% 10%);
- --secondary: hsl(180 60% 80%);
- --secondary-foreground: hsl(180 60% 10%);
- --muted: hsl(60 20% 90%);
- --muted-foreground: hsl(60 20% 40%);
- --accent: hsl(270 70% 80%);
- --accent-foreground: hsl(270 70% 10%);
- --destructive: hsl(0 80% 80%);
- --destructive-foreground: hsl(0 80% 10%);
- --border: hsl(60 30% 85%);
- --input: hsl(60 30% 85%);
- --ring: hsl(350 80% 80%);
- --radius: 1rem;
- --chart-1: hsl(350 80% 80%);
- --chart-2: hsl(180 60% 80%);
- --chart-3: hsl(270 70% 80%);
- --chart-4: hsl(120 60% 80%);
- --chart-5: hsl(30 80% 80%);
- --sidebar: hsl(60 30% 95%);
- --sidebar-foreground: hsl(60 30% 10%);
- --sidebar-primary: hsl(350 80% 80%);
- --sidebar-primary-foreground: hsl(350 80% 10%);
- --sidebar-accent: hsl(180 60% 80%);
- --sidebar-accent-foreground: hsl(180 60% 10%);
- --sidebar-border: hsl(60 30% 90%);
- --sidebar-ring: hsl(350 80% 80%);
- }
-
- [data-theme="space-odyssey"] {
- --background: hsl(220 20% 97%);
- --foreground: hsl(220 20% 10%);
- --card: hsl(220 20% 98%);
- --card-foreground: hsl(220 20% 10%);
- --popover: hsl(220 20% 98%);
- --popover-foreground: hsl(220 20% 10%);
- --primary: hsl(240 80% 50%);
- --primary-foreground: hsl(240 80% 98%);
- --secondary: hsl(180 70% 50%);
- --secondary-foreground: hsl(180 70% 10%);
- --muted: hsl(220 15% 90%);
- --muted-foreground: hsl(220 15% 40%);
- --accent: hsl(300 70% 50%);
- --accent-foreground: hsl(300 70% 98%);
- --destructive: hsl(0 80% 50%);
- --destructive-foreground: hsl(0 80% 98%);
- --border: hsl(220 20% 85%);
- --input: hsl(220 20% 85%);
- --ring: hsl(240 80% 50%);
- --radius: 0.375rem;
- --chart-1: hsl(240 80% 50%);
- --chart-2: hsl(180 70% 50%);
- --chart-3: hsl(300 70% 50%);
- --chart-4: hsl(60 80% 50%);
- --chart-5: hsl(120 70% 50%);
- --sidebar: hsl(220 20% 95%);
- --sidebar-foreground: hsl(220 20% 10%);
- --sidebar-primary: hsl(240 80% 50%);
- --sidebar-primary-foreground: hsl(240 80% 98%);
- --sidebar-accent: hsl(180 70% 50%);
- --sidebar-accent-foreground: hsl(180 70% 10%);
- --sidebar-border: hsl(220 20% 90%);
- --sidebar-ring: hsl(240 80% 50%);
- }
-
- [data-theme="vintage-vinyl"] {
- --background: hsl(30 10% 98%);
- --foreground: hsl(30 10% 10%);
- --card: hsl(30 10% 99%);
- --card-foreground: hsl(30 10% 10%);
- --popover: hsl(30 10% 99%);
- --popover-foreground: hsl(30 10% 10%);
- --primary: hsl(25 20% 40%);
- --primary-foreground: hsl(25 20% 98%);
- --secondary: hsl(200 15% 70%);
- --secondary-foreground: hsl(200 15% 10%);
- --muted: hsl(30 10% 90%);
- --muted-foreground: hsl(30 10% 40%);
- --accent: hsl(340 15% 55%);
- --accent-foreground: hsl(340 15% 98%);
- --destructive: hsl(0 60% 50%);
- --destructive-foreground: hsl(0 60% 98%);
- --border: hsl(30 10% 85%);
- --input: hsl(30 10% 85%);
- --ring: hsl(25 20% 40%);
- --radius: 0.25rem;
- --chart-1: hsl(25 20% 40%);
- --chart-2: hsl(200 15% 70%);
- --chart-3: hsl(340 15% 55%);
- --chart-4: hsl(150 15% 50%);
- --chart-5: hsl(50 20% 60%);
- --sidebar: hsl(30 10% 96%);
- --sidebar-foreground: hsl(30 10% 10%);
- --sidebar-primary: hsl(25 20% 40%);
- --sidebar-primary-foreground: hsl(25 20% 98%);
- --sidebar-accent: hsl(200 15% 70%);
- --sidebar-accent-foreground: hsl(200 15% 10%);
- --sidebar-border: hsl(30 10% 90%);
- --sidebar-ring: hsl(25 20% 40%);
- }
-
- [data-theme="zen-garden"] {
- --background: hsl(90 10% 98%);
- --foreground: hsl(90 10% 10%);
- --card: hsl(90 10% 99%);
- --card-foreground: hsl(90 10% 10%);
- --popover: hsl(90 10% 99%);
- --popover-foreground: hsl(90 10% 10%);
- --primary: hsl(120 15% 45%);
- --primary-foreground: hsl(120 15% 98%);
- --secondary: hsl(60 10% 80%);
- --secondary-foreground: hsl(60 10% 10%);
- --muted: hsl(90 10% 90%);
- --muted-foreground: hsl(90 10% 40%);
- --accent: hsl(180 15% 45%);
- --accent-foreground: hsl(180 15% 98%);
- --destructive: hsl(0 60% 50%);
- --destructive-foreground: hsl(0 60% 98%);
- --border: hsl(90 10% 85%);
- --input: hsl(90 10% 85%);
- --ring: hsl(120 15% 45%);
- --radius: 0.6rem;
- --chart-1: hsl(120 15% 45%);
- --chart-2: hsl(60 10% 80%);
- --chart-3: hsl(180 15% 45%);
- --chart-4: hsl(30 15% 50%);
- --chart-5: hsl(240 10% 60%);
- --sidebar: hsl(90 10% 96%);
- --sidebar-foreground: hsl(90 10% 10%);
- --sidebar-primary: hsl(120 15% 45%);
- --sidebar-primary-foreground: hsl(120 15% 98%);
- --sidebar-accent: hsl(60 10% 80%);
- --sidebar-accent-foreground: hsl(60 10% 10%);
- --sidebar-border: hsl(90 10% 90%);
- --sidebar-ring: hsl(120 15% 45%);
- }
-
- [data-theme="misty-harbor"] {
- --background: hsl(210 15% 98%);
- --foreground: hsl(210 15% 10%);
- --card: hsl(210 15% 99%);
- --card-foreground: hsl(210 15% 10%);
- --popover: hsl(210 15% 99%);
- --popover-foreground: hsl(210 15% 10%);
- --primary: hsl(200 20% 45%);
- --primary-foreground: hsl(200 20% 98%);
- --secondary: hsl(180 10% 75%);
- --secondary-foreground: hsl(180 10% 10%);
- --muted: hsl(210 15% 90%);
- --muted-foreground: hsl(210 15% 40%);
- --accent: hsl(240 15% 55%);
- --accent-foreground: hsl(240 15% 98%);
- --destructive: hsl(0 60% 50%);
- --destructive-foreground: hsl(0 60% 98%);
- --border: hsl(210 15% 85%);
- --input: hsl(210 15% 85%);
- --ring: hsl(200 20% 45%);
- --radius: 0.375rem;
- --chart-1: 200 20% 45%;
- --chart-2: 180 10% 75%;
- --chart-3: hsl(240 15% 55%);
- --chart-4: hsl(160 20% 50%);
- --chart-5: hsl(30 15% 60%);
- --sidebar: hsl(210 15% 96%);
- --sidebar-foreground: hsl(210 15% 10%);
- --sidebar-primary: hsl(200 20% 45%);
- --sidebar-primary-foreground: hsl(200 20% 98%);
- --sidebar-accent: hsl(180 10% 75%);
- --sidebar-accent-foreground: hsl(180 10% 10%);
- --sidebar-border: hsl(210 15% 90%);
- --sidebar-ring: hsl(200 20% 45%);
- }
-
- [data-theme="pink"] {
- --background: hsl(340 100% 99%);
- --foreground: hsl(340 10% 10%);
- --card: hsl(340 100% 98%);
- --card-foreground: hsl(340 10% 10%);
- --popover: hsl(340 100% 98%);
- --popover-foreground: hsl(340 10% 10%);
- --primary: hsl(340 80% 65%);
- --primary-foreground: hsl(0 0% 98%);
- --secondary: hsl(310 70% 85%);
- --secondary-foreground: hsl(310 10% 10%);
- --muted: hsl(340 20% 90%);
- --muted-foreground: hsl(340 10% 40%);
- --accent: hsl(210 40% 96.1%);
- --accent-foreground: hsl(30 10% 10%);
- --destructive: hsl(0 84.2% 60.2%);
- --destructive-foreground: hsl(0 0% 98%);
- --border: hsl(340 60% 90%);
- --input: hsl(340 60% 90%);
- --ring: hsl(340 80% 65%);
- --radius: 0.75rem;
- --chart-1: hsl(340 80% 65%);
- --chart-2: hsl(310 70% 85%);
- --chart-3: hsl(30 90% 80%);
- --chart-4: hsl(260 80% 70%);
- --chart-5: hsl(180 60% 75%);
- --sidebar: hsl(340 80% 95%);
- --sidebar-foreground: hsl(340 10% 10%);
- --sidebar-primary: hsl(340 80% 65%);
- --sidebar-primary-foreground: hsl(0 0% 98%);
- --sidebar-accent: hsl(310 70% 85%);
- --sidebar-accent-foreground: hsl(310 10% 10%);
- --sidebar-border: hsl(340 60% 85%);
- --sidebar-ring: hsl(340 80% 65%);
- }
+
+ /* Base */
+ --background: color-mix(in oklab, var(--c1) 96%, var(--c0) 4%);
+ --foreground: var(--c0);
+
+ --card: var(--c1);
+ --card-foreground: var(--c0);
+
+ --popover: var(--c1);
+ --popover-foreground: var(--c0);
+
+ --primary: var(--c2);
+ --primary-foreground: var(--c1);
+
+ --secondary: color-mix(in oklab, var(--c1) 94%, var(--c0) 6%);
+ --secondary-foreground: var(--c0);
+
+ --muted: color-mix(in oklab, var(--c1) 95%, var(--c0) 5%);
+ --muted-foreground: color-mix(in oklab, var(--c0) 65%, var(--c1) 35%);
+
+ --accent: var(--c2);
+ --accent-foreground: var(--c1);
+
+ --destructive: var(--c3);
+ --destructive-foreground: var(--c1);
+
+ --border: color-mix(in oklab, var(--c1) 85%, var(--c0) 15%);
+ --input: color-mix(in oklab, var(--c1) 98%, var(--c0) 2%);
+ --ring: color-mix(in oklab, var(--c2) 55%, var(--c1) 45%);
+
+ /* Charts: brand tints only */
+ --chart-1: oklch(from var(--primary) 0.7 c h / 1);
+ --chart-2: oklch(from var(--primary) 0.75 c h / 1);
+ --chart-3: oklch(from var(--primary) 0.8 c h / 1);
+ --chart-4: oklch(from var(--primary) 0.85 c h / 1);
+ --chart-5: oklch(from var(--primary) 0.9 c h / 1);
+
+ /* Sidebar */
+ --sidebar: color-mix(in oklab, var(--c1) 98%, var(--c0) 2%);
+ --sidebar-foreground: var(--c0);
+ --sidebar-primary: var(--primary);
+ --sidebar-primary-foreground: var(--primary-foreground);
+ --sidebar-accent: color-mix(in oklab, var(--c1) 96%, var(--c0) 4%);
+ --sidebar-accent-foreground: var(--c0);
+ --sidebar-border: var(--border);
+ --sidebar-ring: var(--ring);
+
+ /* Motion (tighter) */
+ --dur-1: 120ms;
+ --dur-2: 200ms;
+ --ease-in: cubic-bezier(0.4, 0, 1, 1);
+ --ease-out: cubic-bezier(0, 0, 0.2, 1);
+ --motion-pop: 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
+ --motion-lift: 180ms cubic-bezier(0.2, 0.8, 0.2, 1);
+ --motion-fade: 140ms linear;
+
+ /* Density */
+ --density-scale: 1;
+
+ /* Focus + borders */
+ --hairline: color-mix(in oklab, var(--border) 85%, transparent 15%);
+ --border-high-contrast: color-mix(
+ in oklab,
+ var(--border) 100%,
+ var(--foreground) 0%
+ );
+ --focus-outline: 2px solid
+ color-mix(in oklab, var(--ring) 70%, transparent 30%);
+ --focus-offset: 2px;
+
+ /* Elevation (ink-based) */
+ --shadow-1: 0 1px 2px color-mix(in oklab, var(--c0) 10%, transparent 90%);
+ --shadow-2: 0 4px 10px color-mix(in oklab, var(--c0) 14%, transparent 86%);
+ --shadow-3: 0 8px 18px color-mix(in oklab, var(--c0) 18%, transparent 82%);
+ --shadow-4: 0 12px 28px color-mix(in oklab, var(--c0) 22%, transparent 78%);
+ --shadow-5: 0 18px 38px color-mix(in oklab, var(--c0) 26%, transparent 74%);
+
+ /* Glass */
+ --glass-bg: color-mix(in oklab, var(--card) 82%, transparent 18%);
+ --glass-border: color-mix(in oklab, var(--border) 70%, transparent 30%);
}
+/* Dark */
.dark {
- --background: hsl(220 6% 9%);
- --foreground: oklch(0.985 0 0);
- --card: oklch(0.21 0.006 285.885);
- --card-foreground: oklch(0.985 0 0);
- --popover: oklch(0.21 0.006 285.885);
- --popover-foreground: oklch(0.985 0 0);
- --primary: oklch(0.92 0.004 286.32);
- --primary-foreground: oklch(0.21 0.006 285.885);
- --secondary: oklch(0.274 0.006 286.033);
- --secondary-foreground: oklch(0.985 0 0);
- --muted: oklch(0.274 0.006 286.033);
- --muted-foreground: oklch(0.705 0.015 286.067);
- --accent: oklch(0.274 0.006 286.033);
- --accent-foreground: oklch(0.985 0 0);
- --destructive: oklch(0.704 0.191 22.216);
- --border: oklch(1 0 0 / 10%);
- --input: oklch(1 0 0 / 15%);
- --ring: oklch(0.552 0.016 285.938);
- --sidebar: hsl(220 6% 9%);
- --sidebar-foreground: oklch(0.985 0 0);
- --sidebar-primary: oklch(0.488 0.243 264.376);
- --sidebar-primary-foreground: oklch(0.985 0 0);
- --sidebar-accent: oklch(1 0 0 / 15%);
- --sidebar-accent-foreground: oklch(0.985 0 0);
- --sidebar-border: oklch(1 0 0 / 10%);
- --sidebar-ring: oklch(0.552 0.016 285.938);
-
- [data-theme="zinc"] {
- --background: hsl(240 10% 3.9%);
- --foreground: hsl(0 0% 98%);
- --card: hsl(240 10% 3.9%);
- --card-foreground: hsl(0 0% 98%);
- --popover: hsl(240 10% 3.9%);
- --popover-foreground: hsl(0 0% 98%);
- --primary: hsl(0 0% 98%);
- --primary-foreground: hsl(240 5.9% 10%);
- --secondary: hsl(240 3.7% 15.9%);
- --secondary-foreground: hsl(0 0% 98%);
- --muted: hsl(240 3.7% 15.9%);
- --muted-foreground: hsl(240 5% 64.9%);
- --accent: hsl(240 3.7% 15.9%);
- --accent-foreground: hsl(0 0% 98%);
- --destructive: hsl(0 62.8% 30.6%);
- --destructive-foreground: hsl(0 0% 98%);
- --border: hsl(240 3.7% 15.9%);
- --input: hsl(240 3.7% 15.9%);
- --ring: hsl(240 4.9% 83.9%);
- --chart-1: hsl(220 70% 50%);
- --chart-2: hsl(160 60% 45%);
- --chart-3: hsl(30 80% 55%);
- --chart-4: hsl(280 65% 60%);
- --chart-5: hsl(340 75% 55%);
- --sidebar: hsl(240 10% 6%);
- --sidebar-foreground: hsl(0 0% 98%);
- --sidebar-primary: hsl(0 0% 98%);
- --sidebar-primary-foreground: hsl(240 5.9% 10%);
- --sidebar-accent: hsl(240 3.7% 15.9%);
- --sidebar-accent-foreground: hsl(0 0% 98%);
- --sidebar-border: hsl(240 3.7% 15.9%);
- --sidebar-ring: hsl(240 4.9% 83.9%);
- }
-
- [data-theme="slate"] {
- --background: hsl(222.2 84% 4.9%);
- --foreground: hsl(210 40% 98%);
- --card: hsl(222.2 84% 4.9%);
- --card-foreground: hsl(210 40% 98%);
- --popover: hsl(222.2 84% 4.9%);
- --popover-foreground: hsl(210 40% 98%);
- --primary: hsl(210 40% 98%);
- --primary-foreground: hsl(222.2 47.4% 11.2%);
- --secondary: hsl(217.2 32.6% 17.5%);
- --secondary-foreground: hsl(210 40% 98%);
- --muted: hsl(217.2 32.6% 17.5%);
- --muted-foreground: hsl(215 20.2% 65.1%);
- --accent: hsl(217.2 32.6% 17.5%);
- --accent-foreground: hsl(210 40% 98%);
- --destructive: hsl(0 62.8% 30.6%);
- --destructive-foreground: hsl(210 40% 98%);
- --border: hsl(217.2 32.6% 17.5%);
- --input: hsl(217.2 32.6% 17.5%);
- --ring: hsl(212.7 26.8% 83.9%);
- --chart-1: hsl(220 70% 50%);
- --chart-2: hsl(160 60% 45%);
- --chart-3: hsl(30 80% 55%);
- --chart-4: hsl(280 65% 60%);
- --chart-5: hsl(340 75% 55%);
- --sidebar: hsl(222.2 84% 7%);
- --sidebar-foreground: hsl(210 40% 98%);
- --sidebar-primary: hsl(210 40% 98%);
- --sidebar-primary-foreground: hsl(222.2 47.4% 11.2%);
- --sidebar-accent: hsl(217.2 32.6% 17.5%);
- --sidebar-accent-foreground: hsl(210 40% 98%);
- --sidebar-border: hsl(217.2 32.6% 17.5%);
- --sidebar-ring: hsl(212.7 26.8% 83.9%);
- }
-
- [data-theme="stone"] {
- --background: hsl(20 14.3% 4.1%);
- --foreground: hsl(60 9.1% 97.8%);
- --card: hsl(20 14.3% 4.1%);
- --card-foreground: hsl(60 9.1% 97.8%);
- --popover: hsl(20 14.3% 4.1%);
- --popover-foreground: hsl(60 9.1% 97.8%);
- --primary: hsl(20.5 90.2% 48.2%);
- --primary-foreground: hsl(60 9.1% 97.8%);
- --secondary: hsl(12 6.5% 15.1%);
- --secondary-foreground: hsl(60 9.1% 97.8%);
- --muted: hsl(12 6.5% 15.1%);
- --muted-foreground: hsl(24 5.4% 63.9%);
- --accent: hsl(12 6.5% 15.1%);
- --accent-foreground: hsl(60 9.1% 97.8%);
- --destructive: hsl(0 72.2% 50.6%);
- --destructive-foreground: hsl(60 9.1% 97.8%);
- --border: hsl(12 6.5% 15.1%);
- --input: hsl(12 6.5% 15.1%);
- --ring: hsl(24 5.7% 82.9%);
- --chart-1: hsl(220 70% 50%);
- --chart-2: hsl(160 60% 45%);
- --chart-3: hsl(30 80% 55%);
- --chart-4: hsl(280 65% 60%);
- --chart-5: hsl(340 75% 55%);
- --sidebar: hsl(20 14.3% 6%);
- --sidebar-foreground: hsl(60 9.1% 97.8%);
- --sidebar-primary: hsl(60 9.1% 97.8%);
- --sidebar-primary-foreground: hsl(60 9.1% 97.8%);
- --sidebar-accent: hsl(12 6.5% 15.1%);
- --sidebar-accent-foreground: hsl(60 9.1% 97.8%);
- --sidebar-border: hsl(12 6.5% 15.1%);
- --sidebar-ring: hsl(24 5.7% 82.9%);
- }
-
- [data-theme="gray"] {
- --background: hsl(224 71.4% 4.1%);
- --foreground: hsl(210 20% 98%);
- --card: hsl(224 71.4% 4.1%);
- --card-foreground: hsl(210 20% 98%);
- --popover: hsl(224 71.4% 4.1%);
- --popover-foreground: hsl(210 20% 98%);
- --primary: hsl(210 20% 98%);
- --primary-foreground: hsl(220.9 39.3% 11%);
- --secondary: hsl(215 27.9% 16.9%);
- --secondary-foreground: hsl(210 20% 98%);
- --muted: hsl(215 27.9% 16.9%);
- --muted-foreground: hsl(217.9 10.6% 64.9%);
- --accent: hsl(215 27.9% 16.9%);
- --accent-foreground: hsl(210 20% 98%);
- --destructive: hsl(0 62.8% 30.6%);
- --destructive-foreground: hsl(210 20% 98%);
- --border: hsl(215 27.9% 16.9%);
- --input: hsl(215 27.9% 16.9%);
- --ring: hsl(216 12.2% 83.9%);
- --chart-1: hsl(220 70% 50%);
- --chart-2: hsl(160 60% 45%);
- --chart-3: hsl(30 80% 55%);
- --chart-4: hsl(280 65% 60%);
- --chart-5: hsl(340 75% 55%);
- --sidebar: hsl(224 71.4% 6%);
- --sidebar-foreground: hsl(210 20% 98%);
- --sidebar-primary: hsl(210 20% 98%);
- --sidebar-primary-foreground: hsl(220.9 39.3% 11%);
- --sidebar-accent: hsl(215 27.9% 16.9%);
- --sidebar-accent-foreground: hsl(210 20% 98%);
- --sidebar-border: hsl(215 27.9% 16.9%);
- --sidebar-ring: hsl(216 12.2% 83.9%);
- }
-
- [data-theme="blue"] {
- --background: hsl(222.2 84% 4.9%);
- --foreground: hsl(210 40% 98%);
- --card: hsl(222.2 84% 4.9%);
- --card-foreground: hsl(210 40% 98%);
- --popover: hsl(222.2 84% 4.9%);
- --popover-foreground: hsl(210 40% 98%);
- --primary: hsl(217.2 91.2% 59.8%);
- --primary-foreground: hsl(222.2 47.4% 11.2%);
- --secondary: hsl(217.2 32.6% 17.5%);
- --secondary-foreground: hsl(210 40% 98%);
- --muted: hsl(217.2 32.6% 17.5%);
- --muted-foreground: hsl(215 20.2% 65.1%);
- --accent: hsl(217.2 32.6% 17.5%);
- --accent-foreground: hsl(210 40% 98%);
- --destructive: hsl(0 62.8% 30.6%);
- --destructive-foreground: hsl(210 40% 98%);
- --border: hsl(217.2 32.6% 17.5%);
- --input: hsl(217.2 32.6% 17.5%);
- --ring: hsl(224.3 76.3% 48%);
- --chart-1: hsl(220 70% 50%);
- --chart-2: hsl(160 60% 45%);
- --chart-3: hsl(30 80% 55%);
- --chart-4: hsl(280 65% 60%);
- --chart-5: hsl(340 75% 55%);
- --sidebar: hsl(222.2 84% 7%);
- --sidebar-foreground: hsl(210 40% 98%);
- --sidebar-primary: hsl(217.2 91.2% 59.8%);
- --sidebar-primary-foreground: hsl(222.2 47.4% 11.2%);
- --sidebar-accent: hsl(217.2 32.6% 17.5%);
- --sidebar-accent-foreground: hsl(210 40% 98%);
- --sidebar-border: hsl(217.2 32.6% 17.5%);
- --sidebar-ring: hsl(224.3 76.3% 48%);
- }
-
- [data-theme="orange"] {
- --background: hsl(20 14.3% 4.1%);
- --foreground: hsl(60 9.1% 97.8%);
- --card: hsl(20 14.3% 4.1%);
- --card-foreground: hsl(60 9.1% 97.8%);
- --popover: hsl(20 14.3% 4.1%);
- --popover-foreground: hsl(60 9.1% 97.8%);
- --primary: hsl(20.5 90.2% 48.2%);
- --primary-foreground: hsl(60 9.1% 97.8%);
- --secondary: hsl(12 6.5% 15.1%);
- --secondary-foreground: hsl(60 9.1% 97.8%);
- --muted: hsl(12 6.5% 15.1%);
- --muted-foreground: hsl(24 5.4% 63.9%);
- --accent: hsl(12 6.5% 15.1%);
- --accent-foreground: hsl(60 9.1% 97.8%);
- --destructive: hsl(0 72.2% 50.6%);
- --destructive-foreground: hsl(60 9.1% 97.8%);
- --border: hsl(12 6.5% 15.1%);
- --input: hsl(12 6.5% 15.1%);
- --ring: hsl(20.5 90.2% 48.2%);
- --chart-1: hsl(220 70% 50%);
- --chart-2: hsl(160 60% 45%);
- --chart-3: hsl(30 80% 55%);
- --chart-4: hsl(280 65% 60%);
- --chart-5: hsl(340 75% 55%);
- --sidebar: hsl(20 14.3% 6%);
- --sidebar-foreground: hsl(60 9.1% 97.8%);
- --sidebar-primary: hsl(20.5 90.2% 48.2%);
- --sidebar-primary-foreground: hsl(60 9.1% 97.8%);
- --sidebar-accent: hsl(12 6.5% 15.1%);
- --sidebar-accent-foreground: hsl(60 9.1% 97.8%);
- --sidebar-border: hsl(12 6.5% 15.1%);
- --sidebar-ring: hsl(20.5 90.2% 48.2%);
- }
-
- [data-theme="bubblegum-pop"] {
- --background: hsl(330 50% 10%);
- --foreground: hsl(330 10% 95%);
- --card: hsl(330 50% 12%);
- --card-foreground: hsl(330 10% 95%);
- --popover: hsl(330 50% 12%);
- --popover-foreground: hsl(330 10% 95%);
- --primary: hsl(330 90% 60%);
- --primary-foreground: hsl(330 10% 98%);
- --secondary: hsl(275 90% 40%);
- --secondary-foreground: hsl(275 10% 98%);
- --muted: hsl(330 30% 20%);
- --muted-foreground: hsl(330 10% 70%);
- --accent: hsl(30 90% 40%);
- --accent-foreground: hsl(30 10% 98%);
- --destructive: hsl(0 90% 40%);
- --destructive-foreground: hsl(0 10% 98%);
- --border: hsl(330 60% 20%);
- --input: hsl(330 60% 20%);
- --ring: hsl(330 90% 60%);
- --radius: 1rem;
- --chart-1: hsl(330 90% 60%);
- --chart-2: hsl(275 90% 60%);
- --chart-3: hsl(30 90% 60%);
- --chart-4: hsl(180 90% 60%);
- --chart-5: hsl(60 90% 60%);
- --sidebar: hsl(330 50% 15%);
- --sidebar-foreground: hsl(330 10% 95%);
- --sidebar-primary: hsl(330 90% 60%);
- --sidebar-primary-foreground: hsl(330 10% 98%);
- --sidebar-accent: hsl(275 90% 40%);
- --sidebar-accent-foreground: hsl(275 10% 98%);
- --sidebar-border: hsl(330 60% 25%);
- --sidebar-ring: hsl(330 90% 60%);
- }
-
- [data-theme="cyberpunk-neon"] {
- --background: hsl(220 80% 5%);
- --foreground: hsl(220 20% 98%);
- --card: hsl(220 80% 7%);
- --card-foreground: hsl(220 20% 98%);
- --popover: hsl(220 80% 7%);
- --popover-foreground: hsl(220 20% 98%);
- --primary: hsl(320 100% 60%);
- --primary-foreground: hsl(320 100% 10%);
- --secondary: hsl(180 100% 60%);
- --secondary-foreground: hsl(180 100% 10%);
- --muted: hsl(220 80% 20%);
- --muted-foreground: hsl(220 20% 70%);
- --accent: hsl(65 100% 60%);
- --accent-foreground: hsl(65 100% 10%);
- --destructive: hsl(0 100% 60%);
- --destructive-foreground: hsl(0 100% 10%);
- --border: hsl(220 80% 30%);
- --input: hsl(220 80% 30%);
- --radius: 0.125rem;
- --ring: hsl(320 100% 60%);
- --chart-1: hsl(320 100% 60%);
- --chart-2: hsl(180 100% 60%);
- --chart-3: hsl(65 100% 60%);
- --chart-4: hsl(260 100% 60%);
- --chart-5: hsl(30 100% 60%);
- --sidebar: hsl(220 80% 8%);
- --sidebar-foreground: hsl(220 20% 98%);
- --sidebar-primary: hsl(320 100% 60%);
- --sidebar-primary-foreground: hsl(320 100% 10%);
- --sidebar-accent: hsl(180 100% 60%);
- --sidebar-accent-foreground: hsl(180 100% 10%);
- --sidebar-border: hsl(220 80% 35%);
- --sidebar-ring: hsl(320 100% 60%);
- }
-
- [data-theme="retro-arcade"] {
- --background: hsl(240 10% 5%);
- --foreground: hsl(60 10% 95%);
- --card: hsl(240 10% 7%);
- --card-foreground: hsl(60 10% 95%);
- --popover: hsl(240 10% 7%);
- --popover-foreground: hsl(60 10% 95%);
- --primary: hsl(220 90% 60%);
- --primary-foreground: hsl(220 90% 10%);
- --secondary: hsl(120 90% 50%);
- --secondary-foreground: hsl(120 90% 10%);
- --muted: hsl(240 10% 20%);
- --muted-foreground: hsl(60 10% 75%);
- --accent: hsl(30 90% 60%);
- --accent-foreground: hsl(30 90% 10%);
- --destructive: hsl(0 90% 60%);
- --destructive-foreground: hsl(0 90% 10%);
- --border: hsl(240 10% 30%);
- --input: hsl(240 10% 30%);
- --radius: 0;
- --ring: hsl(220 90% 60%);
- --chart-1: hsl(220 90% 60%);
- --chart-2: hsl(120 90% 50%);
- --chart-3: hsl(30 90% 60%);
- --chart-4: hsl(280 90% 60%);
- --chart-5: hsl(180 90% 50%);
- --sidebar: hsl(240 10% 8%);
- --sidebar-foreground: hsl(60 10% 95%);
- --sidebar-primary: hsl(220 90% 60%);
- --sidebar-primary-foreground: hsl(220 90% 10%);
- --sidebar-accent: hsl(120 90% 50%);
- --sidebar-accent-foreground: hsl(120 90% 10%);
- --sidebar-border: hsl(240 10% 35%);
- --sidebar-ring: hsl(220 90% 60%);
- }
-
- [data-theme="tropical-paradise"] {
- --background: hsl(200 70% 10%);
- --foreground: hsl(180 50% 97%);
- --card: hsl(200 70% 12%);
- --card-foreground: hsl(180 50% 97%);
- --popover: hsl(200 70% 12%);
- --popover-foreground: hsl(180 50% 97%);
- --primary: hsl(150 80% 50%);
- --primary-foreground: hsl(150 80% 10%);
- --secondary: hsl(35 90% 60%);
- --secondary-foreground: hsl(35 90% 10%);
- --muted: hsl(200 50% 20%);
- --muted-foreground: hsl(180 30% 80%);
- --accent: hsl(330 70% 70%);
- --accent-foreground: hsl(330 70% 10%);
- --destructive: hsl(0 90% 70%);
- --destructive-foreground: hsl(0 90% 10%);
- --border: hsl(200 70% 30%);
- --input: hsl(200 70% 30%);
- --ring: hsl(150 80% 50%);
- --radius: 0.75rem;
- --chart-1: hsl(150 80% 50%);
- --chart-2: hsl(35 90% 60%);
- --chart-3: hsl(330 70% 70%);
- --chart-4: hsl(200 90% 60%);
- --chart-5: hsl(50 90% 60%);
- --sidebar: hsl(200 70% 15%);
- --sidebar-foreground: hsl(180 50% 97%);
- --sidebar-primary: hsl(150 80% 50%);
- --sidebar-primary-foreground: hsl(150 80% 10%);
- --sidebar-accent: hsl(35 90% 60%);
- --sidebar-accent-foreground: hsl(35 90% 10%);
- --sidebar-border: hsl(200 70% 35%);
- --sidebar-ring: hsl(150 80% 50%);
- }
-
- [data-theme="steampunk-cogs"] {
- --background: hsl(30 30% 10%);
- --foreground: hsl(30 20% 95%);
- --card: hsl(30 30% 12%);
- --card-foreground: hsl(30 20% 95%);
- --popover: hsl(30 30% 12%);
- --popover-foreground: hsl(30 20% 95%);
- --primary: hsl(25 80% 50%);
- --primary-foreground: hsl(25 80% 10%);
- --secondary: hsl(45 70% 60%);
- --secondary-foreground: hsl(45 70% 10%);
- --muted: hsl(30 25% 25%);
- --muted-foreground: hsl(30 15% 70%);
- --accent: hsl(15 80% 60%);
- --accent-foreground: hsl(15 80% 10%);
- --destructive: hsl(0 80% 60%);
- --destructive-foreground: hsl(0 80% 10%);
- --border: hsl(30 30% 30%);
- --input: hsl(30 30% 30%);
- --ring: hsl(25 80% 50%);
- --radius: 0.25rem;
- --chart-1: hsl(25 80% 50%);
- --chart-2: hsl(45 70% 60%);
- --chart-3: hsl(15 80% 60%);
- --chart-4: hsl(35 80% 50%);
- --chart-5: hsl(55 70% 60%);
- --sidebar: hsl(30 30% 15%);
- --sidebar-foreground: hsl(30 20% 95%);
- --sidebar-primary: hsl(25 80% 50%);
- --sidebar-primary-foreground: hsl(25 80% 10%);
- --sidebar-accent: hsl(45 70% 60%);
- --sidebar-accent-foreground: hsl(45 70% 10%);
- --sidebar-border: hsl(30 30% 35%);
- --sidebar-ring: hsl(25 80% 50%);
- }
-
- [data-theme="neon-synthwave"] {
- --background: hsl(280 50% 5%);
- --foreground: hsl(280 30% 95%);
- --card: hsl(280 50% 7%);
- --card-foreground: hsl(280 30% 95%);
- --popover: hsl(280 50% 7%);
- --popover-foreground: hsl(280 30% 95%);
- --primary: hsl(320 100% 70%);
- --primary-foreground: hsl(320 100% 10%);
- --secondary: hsl(220 100% 70%);
- --secondary-foreground: hsl(220 100% 10%);
- --muted: hsl(280 30% 20%);
- --muted-foreground: hsl(280 20% 70%);
- --accent: hsl(180 100% 60%);
- --accent-foreground: hsl(180 100% 10%);
- --destructive: hsl(0 100% 70%);
- --destructive-foreground: hsl(0 100% 10%);
- --border: hsl(280 50% 30%);
- --input: hsl(280 50% 30%);
- --ring: hsl(320 100% 70%);
- --radius: 0.6rem;
- --chart-1: hsl(320 100% 70%);
- --chart-2: hsl(220 100% 70%);
- --chart-3: hsl(180 100% 60%);
- --chart-4: hsl(260 100% 70%);
- --chart-5: hsl(300 100% 70%);
- --sidebar: hsl(280 50% 8%);
- --sidebar-foreground: hsl(280 30% 95%);
- --sidebar-primary: hsl(320 100% 70%);
- --sidebar-primary-foreground: hsl(320 100% 10%);
- --sidebar-accent: hsl(220 100% 70%);
- --sidebar-accent-foreground: hsl(220 100% 10%);
- --sidebar-border: hsl(280 50% 35%);
- --sidebar-ring: hsl(320 100% 70%);
- }
-
- [data-theme="pastel-kawaii"] {
- --background: hsl(270 30% 10%);
- --foreground: hsl(60 30% 97%);
- --card: hsl(270 30% 12%);
- --card-foreground: hsl(60 30% 97%);
- --popover: hsl(270 30% 12%);
- --popover-foreground: hsl(60 30% 97%);
- --primary: hsl(350 80% 70%);
- --primary-foreground: hsl(350 80% 10%);
- --secondary: hsl(180 60% 70%);
- --secondary-foreground: hsl(180 60% 10%);
- --muted: hsl(270 20% 25%);
- --muted-foreground: hsl(60 20% 70%);
- --accent: hsl(270 70% 70%);
- --accent-foreground: hsl(270 70% 10%);
- --destructive: hsl(0 80% 70%);
- --destructive-foreground: hsl(0 80% 10%);
- --border: hsl(270 30% 30%);
- --input: hsl(270 30% 30%);
- --ring: hsl(350 80% 70%);
- --radius: 1rem;
- --chart-1: hsl(350 80% 70%);
- --chart-2: hsl(180 60% 70%);
- --chart-3: hsl(270 70% 70%);
- --chart-4: hsl(120 60% 70%);
- --chart-5: hsl(30 80% 70%);
- --sidebar: hsl(270 30% 15%);
- --sidebar-foreground: hsl(60 30% 97%);
- --sidebar-primary: hsl(350 80% 70%);
- --sidebar-primary-foreground: hsl(350 80% 10%);
- --sidebar-accent: hsl(180 60% 70%);
- --sidebar-accent-foreground: hsl(180 60% 10%);
- --sidebar-border: hsl(270 30% 35%);
- --sidebar-ring: hsl(350 80% 70%);
- }
-
- [data-theme="space-odyssey"] {
- --background: hsl(230 50% 3%);
- --foreground: hsl(220 20% 97%);
- --card: hsl(230 50% 5%);
- --card-foreground: hsl(220 20% 97%);
- --popover: hsl(230 50% 5%);
- --popover-foreground: hsl(220 20% 97%);
- --primary: hsl(240 80% 60%);
- --primary-foreground: hsl(240 80% 10%);
- --secondary: hsl(180 70% 40%);
- --secondary-foreground: hsl(180 70% 98%);
- --muted: hsl(230 30% 15%);
- --muted-foreground: hsl(220 15% 70%);
- --accent: hsl(300 70% 60%);
- --accent-foreground: hsl(300 70% 10%);
- --destructive: hsl(0 80% 60%);
- --destructive-foreground: hsl(0 80% 10%);
- --border: hsl(230 50% 20%);
- --input: hsl(230 50% 20%);
- --ring: hsl(240 80% 60%);
- --radius: 0.375rem;
- --chart-1: hsl(240 80% 60%);
- --chart-2: hsl(180 70% 40%);
- --chart-3: hsl(300 70% 60%);
- --chart-4: hsl(60 80% 60%);
- --chart-5: hsl(120 70% 40%);
- --sidebar: hsl(230 50% 6%);
- --sidebar-foreground: hsl(220 20% 97%);
- --sidebar-primary: hsl(240 80% 60%);
- --sidebar-primary-foreground: hsl(240 80% 10%);
- --sidebar-accent: hsl(180 70% 40%);
- --sidebar-accent-foreground: hsl(180 70% 98%);
- --sidebar-border: hsl(230 50% 25%);
- --sidebar-ring: hsl(240 80% 60%);
- }
-
- [data-theme="vintage-vinyl"] {
- --background: hsl(30 15% 10%);
- --foreground: hsl(30 10% 98%);
- --card: hsl(30 15% 12%);
- --card-foreground: hsl(30 10% 98%);
- --popover: hsl(30 15% 12%);
- --popover-foreground: hsl(30 10% 98%);
- --primary: hsl(25 20% 50%);
- --primary-foreground: hsl(25 20% 10%);
- --secondary: hsl(200 15% 40%);
- --secondary-foreground: hsl(200 15% 98%);
- --muted: hsl(30 15% 20%);
- --muted-foreground: hsl(30 10% 70%);
- --accent: hsl(340 15% 45%);
- --accent-foreground: hsl(340 15% 98%);
- --destructive: hsl(0 60% 40%);
- --destructive-foreground: hsl(0 60% 98%);
- --border: hsl(30 15% 25%);
- --input: hsl(30 15% 25%);
- --ring: hsl(25 20% 50%);
- --radius: 0.25rem;
- --chart-1: hsl(25 20% 50%);
- --chart-2: hsl(200 15% 40%);
- --chart-3: hsl(340 15% 45%);
- --chart-4: hsl(150 15% 40%);
- --chart-5: hsl(50 20% 50%);
- --sidebar: hsl(30 15% 15%);
- --sidebar-foreground: hsl(30 10% 98%);
- --sidebar-primary: hsl(25 20% 50%);
- --sidebar-primary-foreground: hsl(25 20% 10%);
- --sidebar-accent: hsl(200 15% 40%);
- --sidebar-accent-foreground: hsl(200 15% 98%);
- --sidebar-border: hsl(30 15% 30%);
- --sidebar-ring: hsl(25 20% 50%);
- }
-
- [data-theme="zen-garden"] {
- --background: hsl(90 15% 10%);
- --foreground: hsl(90 10% 98%);
- --card: hsl(90 15% 12%);
- --card-foreground: hsl(90 10% 98%);
- --popover: hsl(90 15% 12%);
- --popover-foreground: hsl(90 10% 98%);
- --primary: hsl(120 15% 55%);
- --primary-foreground: hsl(120 15% 10%);
- --secondary: hsl(60 10% 30%);
- --secondary-foreground: hsl(60 10% 98%);
- --muted: hsl(90 15% 20%);
- --muted-foreground: hsl(90 10% 70%);
- --accent: hsl(180 15% 55%);
- --accent-foreground: hsl(180 15% 10%);
- --destructive: hsl(0 60% 40%);
- --destructive-foreground: hsl(0 60% 98%);
- --border: hsl(90 15% 25%);
- --input: hsl(90 15% 25%);
- --ring: hsl(120 15% 55%);
- --radius: 0.6rem;
- --chart-1: hsl(120 15% 55%);
- --chart-2: hsl(60 10% 30%);
- --chart-3: hsl(180 15% 55%);
- --chart-4: hsl(30 15% 60%);
- --chart-5: hsl(240 10% 40%);
- --sidebar: hsl(90 15% 15%);
- --sidebar-foreground: hsl(90 10% 98%);
- --sidebar-primary: hsl(120 15% 55%);
- --sidebar-primary-foreground: hsl(120 15% 10%);
- --sidebar-accent: hsl(60 10% 30%);
- --sidebar-accent-foreground: hsl(60 10% 98%);
- --sidebar-border: hsl(90 15% 30%);
- --sidebar-ring: hsl(120 15% 55%);
- }
-
- [data-theme="misty-harbor"] {
- --background: hsl(210 20% 10%);
- --foreground: hsl(210 15% 98%);
- --card: hsl(210 20% 12%);
- --card-foreground: hsl(210 15% 98%);
- --popover: hsl(210 20% 12%);
- --popover-foreground: hsl(210 15% 98%);
- --primary: hsl(200 20% 55%);
- --primary-foreground: hsl(200 20% 10%);
- --secondary: hsl(180 10% 35%);
- --secondary-foreground: hsl(180 10% 98%);
- --muted: hsl(210 20% 20%);
- --muted-foreground: hsl(210 15% 70%);
- --accent: hsl(240 15% 65%);
- --accent-foreground: hsl(240 15% 10%);
- --destructive: hsl(0 60% 40%);
- --destructive-foreground: hsl(0 60% 98%);
- --border: hsl(210 20% 25%);
- --input: hsl(210 20% 25%);
- --ring: hsl(200 20% 55%);
- --radius: 0.375rem;
- --chart-1: hsl(200 20% 55%);
- --chart-2: hsl(180 10% 35%);
- --chart-3: hsl(240 15% 65%);
- --chart-4: hsl(160 20% 60%);
- --chart-5: hsl(30 15% 50%);
- --sidebar: hsl(210 20% 15%);
- --sidebar-foreground: hsl(210 15% 98%);
- --sidebar-primary: hsl(200 20% 55%);
- --sidebar-primary-foreground: hsl(200 20% 10%);
- --sidebar-accent: hsl(180 10% 35%);
- --sidebar-accent-foreground: hsl(180 10% 98%);
- --sidebar-border: hsl(210 20% 30%);
- --sidebar-ring: hsl(200 20% 55%);
- }
-
- [data-theme="pink"] {
- --background: hsl(340 50% 8%);
- --foreground: hsl(340 10% 95%);
- --card: hsl(340 50% 10%);
- --card-foreground: hsl(340 10% 95%);
- --popover: hsl(340 50% 10%);
- --popover-foreground: hsl(340 10% 95%);
- --primary: hsl(340 80% 75%);
- --primary-foreground: hsl(340 10% 15%);
- --secondary: hsl(310 70% 45%);
- --secondary-foreground: hsl(310 10% 95%);
- --muted: hsl(340 30% 18%);
- --muted-foreground: hsl(340 10% 70%);
- --accent: hsl(217.2 32.6% 17.5%);
- --accent-foreground: hsl(30 10% 95%);
- --destructive: hsl(0 72.2% 50.6%);
- --destructive-foreground: hsl(0 0% 98%);
- --border: hsl(340 60% 20%);
- --input: hsl(340 60% 20%);
- --ring: hsl(340 80% 75%);
- --radius: 0.75rem;
- --chart-1: hsl(340 80% 75%);
- --chart-2: hsl(310 70% 45%);
- --chart-3: hsl(30 90% 45%);
- --chart-4: hsl(260 80% 50%);
- --chart-5: hsl(180 60% 40%);
- --sidebar: hsl(340 50% 12%);
- --sidebar-foreground: hsl(340 10% 95%);
- --sidebar-primary: hsl(340 80% 75%);
- --sidebar-primary-foreground: hsl(340 10% 15%);
- --sidebar-accent: hsl(310 70% 45%);
- --sidebar-accent-foreground: hsl(310 10% 95%);
- --sidebar-border: hsl(340 60% 25%);
- --sidebar-ring: hsl(340 80% 75%);
- }
+ --background: color-mix(in oklab, var(--c0) 92%, var(--c1) 8%);
+ --foreground: color-mix(in oklab, var(--c1) 92%, var(--c0) 8%);
+
+ --card: color-mix(in oklab, var(--c0) 94%, var(--c1) 6%);
+ --card-foreground: var(--foreground);
+
+ --popover: var(--card);
+ --popover-foreground: var(--foreground);
+
+ --primary: color-mix(in oklab, var(--c2) 75%, var(--c1) 25%);
+ --primary-foreground: var(--c0);
+
+ --secondary: color-mix(in oklab, var(--c0) 86%, var(--c1) 14%);
+ --secondary-foreground: var(--foreground);
+
+ --muted: color-mix(in oklab, var(--c0) 88%, var(--c1) 12%);
+ --muted-foreground: color-mix(in oklab, var(--c1) 65%, var(--c0) 35%);
+
+ --accent: color-mix(in oklab, var(--c2) 70%, var(--c0) 30%);
+ --accent-foreground: var(--foreground);
+
+ --destructive: color-mix(in oklab, var(--c3) 80%, var(--c1) 20%);
+ --destructive-foreground: var(--c0);
+
+ --border: color-mix(in oklab, var(--c0) 82%, var(--c1) 18%);
+ --input: color-mix(in oklab, var(--c0) 88%, var(--c1) 12%);
+ --ring: color-mix(in oklab, var(--c2) 60%, var(--c0) 40%);
+
+ --sidebar: color-mix(in oklab, var(--c0) 92%, var(--c1) 8%);
+ --sidebar-foreground: var(--foreground);
+ --sidebar-primary: var(--primary);
+ --sidebar-primary-foreground: var(--primary-foreground);
+ --sidebar-accent: color-mix(in oklab, var(--c0) 86%, var(--c1) 14%);
+ --sidebar-accent-foreground: var(--foreground);
+ --sidebar-border: var(--border);
+ --sidebar-ring: var(--ring);
+
+ /* Stronger ring on dark */
+ --focus-outline: 2px solid
+ color-mix(in oklab, var(--primary) 80%, var(--c0) 20%);
+
+ /* Darker shadows */
+ --shadow-1: 0 1px 2px color-mix(in oklab, var(--c0) 35%, transparent 65%);
+ --shadow-2: 0 4px 10px color-mix(in oklab, var(--c0) 45%, transparent 55%);
+ --shadow-3: 0 8px 18px color-mix(in oklab, var(--c0) 55%, transparent 45%);
+ --shadow-4: 0 12px 28px color-mix(in oklab, var(--c0) 60%, transparent 40%);
+ --shadow-5: 0 18px 38px color-mix(in oklab, var(--c0) 65%, transparent 35%);
}
+/* -----------------------------------
+ Base
+----------------------------------- */
@layer base {
* {
@apply border-border outline-ring/50;
+ box-sizing: border-box;
+ }
+ html,
+ body,
+ #root {
+ height: 100%;
}
+
body {
@apply bg-background text-foreground;
+ margin: 0;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ text-rendering: optimizeLegibility;
}
button {
@apply cursor-pointer;
}
+ /* Headings + text */
+ h1,
+ h2,
+ h3,
+ h4,
+ h5,
+ h6 {
+ line-height: 1.1;
+ margin: 0 0 0.5rem 0;
+ font-weight: 700;
+ }
+ h1 {
+ font-size: clamp(1.875rem, 1.5rem + 1.5vw, 2.5rem);
+ }
+ h2 {
+ font-size: clamp(1.5rem, 1.25rem + 1vw, 2rem);
+ }
+ h3 {
+ font-size: 1.25rem;
+ }
+ p {
+ margin: 0 0 0.75rem 0;
+ color: color-mix(in oklab, var(--foreground) 92%, transparent 8%);
+ }
+
+ /* Links */
+ a {
+ color: var(--primary);
+ text-decoration: none;
+ transition: color var(--dur-1) var(--ease-out);
+ }
+ a:hover {
+ text-decoration: underline;
+ }
+ a:focus-visible {
+ outline: var(--focus-outline);
+ outline-offset: var(--focus-offset);
+ border-radius: 6px;
+ }
+
+ /* Inputs */
+ input,
+ textarea,
+ select {
+ background: var(--input);
+ color: var(--foreground);
+ border: 1px solid var(--border);
+ border-radius: calc(var(--radius) - 4px);
+ padding: 0.5rem 0.75rem;
+ }
+ input:focus,
+ textarea:focus,
+ select:focus {
+ outline: none;
+ box-shadow: 0 0 0 3px color-mix(in oklab, var(--ring) 30%, transparent 70%);
+ border-color: var(--ring);
+ }
+
+ /* Buttons baseline */
+ button {
+ background: var(--secondary);
+ color: var(--secondary-foreground);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ padding: 0.5rem 0.75rem;
+ transition: transform var(--dur-1) var(--ease-out), box-shadow var(--dur-1)
+ var(--ease-out);
+ }
+ button:hover {
+ transform: translateY(-1px);
+ box-shadow: var(--shadow-1);
+ }
+ button:active {
+ transform: translateY(0);
+ box-shadow: none;
+ }
+
+ /* Page and sidebar scrollbars: hidden, still scrollable */
+ html,
+ body {
+ scrollbar-width: none;
+ }
+ html::-webkit-scrollbar,
+ body::-webkit-scrollbar {
+ width: 0;
+ height: 0;
+ }
+
+ .sidebar,
+ [data-region="sidebar"] {
+ overflow: auto;
+ scrollbar-width: none; /* Firefox */
+ -webkit-mask-image: linear-gradient(
+ to bottom,
+ transparent,
+ black 16%,
+ black 84%,
+ transparent
+ );
+ mask-image: linear-gradient(
+ to bottom,
+ transparent,
+ black 16%,
+ black 84%,
+ transparent
+ );
+ }
+ .sidebar::-webkit-scrollbar,
+ [data-region="sidebar"]::-webkit-scrollbar {
+ width: 0;
+ height: 0;
+ }
+
+ /* Scrollbar defaults elsewhere (thin) */
+ * {
+ scrollbar-width: thin;
+ scrollbar-color: color-mix(in oklab, var(--foreground) 25%, transparent 75%)
+ transparent;
+ }
+ *::-webkit-scrollbar {
+ height: 10px;
+ width: 10px;
+ }
+ *::-webkit-scrollbar-track {
+ background: transparent;
+ }
+ *::-webkit-scrollbar-thumb {
+ background: color-mix(in oklab, var(--foreground) 25%, transparent 75%);
+ border-radius: 999px;
+ border: 2px solid transparent;
+ background-clip: content-box;
+ }
+
+ /* Misc utilities kept */
.no-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
-
.mention {
color: #0070f3;
font-weight: 700;
@@ -1472,11 +450,8 @@
animation: delayedFadeIn 5s ease-in-out forwards;
opacity: 0;
}
-
@keyframes delayedFadeIn {
- 0% {
- opacity: 0;
- }
+ 0%,
80% {
opacity: 0;
}
@@ -1488,7 +463,7 @@
@keyframes wiggle {
0%,
100% {
- transform: rotate(0deg);
+ transform: rotate(0);
}
25% {
transform: rotate(-8deg);
@@ -1497,7 +472,6 @@
transform: rotate(8deg);
}
}
-
.wiggle {
animation: wiggle 2.5s ease-in-out infinite;
}
@@ -1512,8 +486,490 @@
.fade-300 {
@apply fade-in animate-in duration-300;
}
-
.transition-colors-300 {
@apply transition-colors duration-300;
}
+
+ /* Global focus fallback */
+ :where(button, [role="button"], a, input, textarea, select):focus-visible {
+ outline: var(--focus-outline);
+ outline-offset: var(--focus-offset);
+ }
+}
+
+/* -----------------------------------
+ Components
+----------------------------------- */
+@layer components {
+ /* Floating Chatbar */
+ .floating-chatbar {
+ position: fixed;
+ inset: auto 0 0 0;
+ z-index: 50;
+ background: var(--card);
+ color: var(--card-foreground);
+ box-shadow: var(--shadow-3);
+ border-top-left-radius: calc(var(--radius) + 6px);
+ border-top-right-radius: calc(var(--radius) + 6px);
+ padding: calc(0.75rem * var(--density-scale)) 1.25rem;
+ transition: box-shadow var(--motion-lift), transform var(--motion-lift),
+ background-color var(--motion-fade);
+ backdrop-filter: blur(6px);
+ border-top: 1px solid
+ color-mix(in oklab, var(--card) 88%, var(--hairline) 12%);
+ }
+ .scroll-elevated {
+ box-shadow: var(--shadow-5);
+ transition: box-shadow var(--dur-2) var(--ease-out);
+ }
+ .floating-chatbar:hover,
+ .floating-chatbar:focus-within {
+ box-shadow: var(--shadow-4);
+ transform: translateY(-4px);
+ }
+ .floating-chatbar--padded {
+ padding-bottom: calc(env(safe-area-inset-bottom, 0px) + 1rem);
+ }
+ .floating-chatbar .chat-input {
+ width: 100%;
+ padding: calc(0.5rem * var(--density-scale)) 0.75rem;
+ border-radius: calc(var(--radius) - 4px);
+ background: var(--input);
+ color: var(--foreground);
+ border: 1px solid color-mix(in oklab, var(--border) 80%, transparent 20%);
+ box-shadow: none;
+ transition: box-shadow var(--dur-1) var(--ease-in), transform var(--dur-1)
+ var(--ease-in);
+ }
+ .floating-chatbar .chat-input:focus {
+ outline: none;
+ transform: scale(1.02);
+ box-shadow: 0 0 0 3px color-mix(in oklab, var(--ring) 30%, transparent 70%);
+ border-color: var(--ring);
+ }
+
+ /* Thin Cards */
+ .card-thin {
+ background: var(--card);
+ color: var(--card-foreground);
+ border-radius: calc(var(--radius) - 2px);
+ padding: calc(0.75rem * var(--density-scale))
+ calc(1rem * var(--density-scale));
+ border: 1px solid color-mix(in oklab, var(--border) 85%, transparent 15%);
+ box-shadow: var(--shadow-1);
+ transition: box-shadow var(--motion-lift), transform var(--motion-lift),
+ border-color var(--motion-fade);
+ overflow: hidden;
+ }
+ .card-thin:hover {
+ box-shadow: var(--shadow-2);
+ transform: translateY(-3px);
+ border-color: color-mix(in oklab, var(--border) 100%, var(--card) 0%);
+ }
+ .card-thin--ultra {
+ padding: calc(0.5rem * var(--density-scale))
+ calc(0.75rem * var(--density-scale));
+ border-radius: calc(var(--radius) - 4px);
+ box-shadow: 0 1px 3px color-mix(in oklab, var(--c0) 6%, transparent 94%);
+ font-size: 0.95rem;
+ }
+
+ /* Glass */
+ .surface-glass {
+ background: var(--glass-bg);
+ border: 1px solid var(--glass-border);
+ backdrop-filter: blur(8px) saturate(1.05);
+ box-shadow: 0 6px 18px color-mix(in oklab, var(--c0) 12%, transparent 88%);
+ border-radius: calc(var(--radius) - 2px);
+ transition: background-color var(--dur-2) var(--ease-out), box-shadow
+ var(--dur-2) var(--ease-out);
+ }
+
+ /* Text helpers */
+ .card-thin .meta {
+ font-size: 0.85rem;
+ color: var(--muted-foreground);
+ line-height: 1.25;
+ }
+ .card-thin .title {
+ font-size: 1rem;
+ color: var(--card-foreground);
+ font-weight: 600;
+ margin-bottom: 0.25rem;
+ }
+
+ /* Elevation utilities */
+ .u-elev-1 {
+ box-shadow: var(--shadow-1) !important;
+ }
+ .u-elev-2 {
+ box-shadow: var(--shadow-2) !important;
+ }
+ .u-elev-3 {
+ box-shadow: var(--shadow-3) !important;
+ }
+ .u-radius-sys {
+ border-radius: var(--radius) !important;
+ }
+
+ /* Ripple primitive */
+ .ripple {
+ position: relative;
+ overflow: hidden;
+ }
+ .ripple .ripple-blob {
+ position: absolute;
+ border-radius: 50%;
+ transform: scale(0);
+ background: color-mix(in oklab, var(--ring) 60%, transparent 40%);
+ opacity: 0.28;
+ pointer-events: none;
+ animation: ripple-pop var(--motion-pop);
+ }
+ @keyframes ripple-pop {
+ 0% {
+ transform: scale(0);
+ opacity: 0.32;
+ }
+ 40% {
+ transform: scale(1.6);
+ opacity: 0.18;
+ }
+ 100% {
+ transform: scale(3.8);
+ opacity: 0;
+ }
+ }
+
+ /* Copilot Conversational Text Box + Disappearing Ink */
+ .copilot-box {
+ position: relative;
+ z-index: 3;
+ background: var(--card);
+ color: var(--card-foreground);
+ border: 1px solid var(--border);
+ border-radius: 12px;
+ box-shadow: 0 4px 12px color-mix(in oklab, var(--c0) 10%, transparent 90%);
+ padding: 0.75rem 1rem;
+ transition: box-shadow var(--motion-lift), transform var(--motion-lift),
+ background-color var(--motion-fade);
+ }
+ .copilot-box:focus-within {
+ box-shadow: var(--shadow-3);
+ transform: translateY(-2px);
+ }
+
+ .copilot-input {
+ width: 100%;
+ min-height: 2.5rem;
+ background: transparent;
+ color: var(--foreground);
+ border: 0;
+ outline: 0;
+ resize: none;
+ caret-color: var(--primary);
+ font: 400 1rem ui-sans-serif, system-ui, "Segoe UI", Roboto, Arial;
+ }
+ .copilot-actions {
+ display: flex;
+ gap: .5rem;
+ align-items: center;
+ margin-top: .5rem;
+ }
+
+ .copilot-pressable {
+ position: relative;
+ overflow: hidden;
+ }
+ .copilot-pressable::after {
+ content: "";
+ position: absolute;
+ inset: 0;
+ background: radial-gradient(
+ circle at var(--x, 50%) var(--y, 50%),
+ rgba(0, 120, 212, 0.2),
+ transparent 40%
+ );
+ opacity: 0;
+ transform: scale(0.8);
+ transition: opacity var(--dur-1) var(--ease-out), transform var(--dur-1)
+ var(--ease-out);
+ pointer-events: none;
+ }
+ .copilot-pressable:active::after {
+ opacity: 1;
+ transform: scale(1);
+ }
+
+ .ink-message {
+ position: relative;
+ padding: .5rem .75rem;
+ border-radius: 12px;
+ background: var(--card);
+ color: var(--card-foreground);
+ box-shadow: var(--shadow-1);
+ animation: ink-appear var(--motion-pop);
+ -webkit-mask-image: linear-gradient(
+ to bottom,
+ rgba(0, 0, 0, 0.9),
+ rgba(0, 0, 0, 0)
+ );
+ mask-image: linear-gradient(to bottom, rgba(0, 0, 0, 0.9), rgba(0, 0, 0, 0));
+ }
+ .ink-complete {
+ animation: ink-complete-sequence 0.6s ease-in-out;
+ }
+ .ink-previous {
+ filter: blur(.2px);
+ opacity: 0.72;
+ box-shadow: none;
+ }
+
+ .copilot-box.scroll-fade-y {
+ --fade: linear-gradient(
+ to bottom,
+ transparent,
+ black 20%,
+ black 80%,
+ transparent
+ );
+ -webkit-mask-image: var(--fade);
+ mask-image: var(--fade);
+ }
+
+ @keyframes ink-appear {
+ from {
+ opacity: 0;
+ filter: blur(2px);
+ }
+ to {
+ opacity: 1;
+ filter: blur(0);
+ }
+ }
+ @keyframes ink-evaporate {
+ 0% {
+ opacity: 1;
+ filter: none;
+ }
+ 100% {
+ opacity: 0;
+ filter: blur(1px);
+ }
+ }
+ @keyframes ink-complete-sequence {
+ 0% {
+ box-shadow: var(--shadow-2);
+ transform: translateY(0);
+ }
+ 60% {
+ box-shadow: var(--shadow-4);
+ transform: translateY(-2px);
+ }
+ 100% {
+ box-shadow: var(--shadow-2);
+ transform: translateY(0);
+ }
+ }
+}
+
+/* Backdrop safety (Safari/low-power) */
+@supports not (
+ (backdrop-filter: blur(1px)) or
+ (-webkit-backdrop-filter: blur(1px))
+ ) {
+ .floating-chatbar,
+ .surface-glass {
+ background: color-mix(in oklab, var(--card) 94%, var(--foreground) 6%);
+ }
+}
+
+/* -----------------------------------
+ Utilities
+----------------------------------- */
+@layer utilities {
+ /* Density */
+ [data-density="compact"] .card-thin {
+ padding: calc(0.5rem * var(--density-scale))
+ calc(0.75rem * var(--density-scale));
+ }
+ [data-density="accessible"] .card-thin {
+ font-size: 1.1rem;
+ padding: 1rem 1.5rem;
+ border-color: var(--border-high-contrast);
+ }
+
+ /* Layout to clear floating chatbar */
+ .layout-with-floating {
+ padding-bottom: calc(4.5rem * var(--density-scale));
+ }
+
+ /* Invisible scrollbars (scoped) */
+ .scroll-invisible {
+ scrollbar-width: none;
+ }
+ .scroll-invisible::-webkit-scrollbar {
+ width: 0;
+ height: 0;
+ }
+
+ /* Stealth card scrollbars that “light up” on hover */
+ .card-scroll {
+ overflow: auto;
+ scrollbar-width: thin;
+ transition: scrollbar-color var(--dur-1) var(--ease-out);
+ --thumb: color-mix(in oklab, var(--foreground) 12%, transparent 88%);
+ --thumb-hover: color-mix(in oklab, var(--foreground) 30%, transparent 70%);
+ scrollbar-color: var(--thumb) transparent;
+ }
+ .card-scroll::-webkit-scrollbar {
+ width: 6px;
+ height: 6px;
+ }
+ .card-scroll::-webkit-scrollbar-thumb {
+ background: var(--thumb);
+ border-radius: 999px;
+ border: 2px solid transparent;
+ background-clip: content-box;
+ }
+ .card-scroll:hover {
+ scrollbar-width: auto;
+ scrollbar-color: var(--thumb-hover) transparent;
+ }
+ .card-scroll:hover::-webkit-scrollbar {
+ width: 10px;
+ height: 10px;
+ }
+ .card-scroll:hover::-webkit-scrollbar-thumb {
+ background: var(--thumb-hover);
+ }
+
+ /* Edge fades */
+ .scroll-fade-y {
+ --fade: linear-gradient(
+ to bottom,
+ transparent,
+ black 18%,
+ black 82%,
+ transparent
+ );
+ -webkit-mask-image: var(--fade);
+ mask-image: var(--fade);
+ }
+ .scroll-fade-x {
+ --fade: linear-gradient(
+ to right,
+ transparent,
+ black 15%,
+ black 85%,
+ transparent
+ );
+ -webkit-mask-image: var(--fade);
+ mask-image: var(--fade);
+ }
+
+ /* Container-aware density */
+ .container-card {
+ container-type: inline-size;
+ }
+ @container (max-width: 520px) {
+ .card-thin {
+ padding: 0.5rem 0.75rem;
+ border-radius: calc(var(--radius) - 4px);
+ }
+ .card-thin .title {
+ font-size: 0.95rem;
+ }
+ }
+
+ /* Snap rows */
+ .snap-row {
+ display: grid;
+ grid-auto-flow: column;
+ grid-auto-columns: 80%;
+ gap: 0.75rem;
+ overflow-x: auto;
+ scroll-snap-type: x mandatory;
+ }
+ .snap-row > * {
+ scroll-snap-align: start;
+ }
+ @media (min-width: 900px) {
+ .snap-row {
+ grid-auto-columns: 33%;
+ }
+ }
+
+ /* Pressable without layout shift */
+ .pressable {
+ transition: transform var(--dur-1) var(--ease-out), box-shadow var(--dur-1)
+ var(--ease-out);
+ }
+ .pressable:active {
+ transform: scale(0.98);
+ box-shadow: var(--shadow-1);
+ }
+
+ /* Text polish */
+ .prose {
+ max-width: 70ch;
+ }
+ small,
+ .text-xs {
+ color: color-mix(in oklab, var(--foreground) 80%, var(--card) 20%);
+ }
+}
+
+/* -----------------------------------
+ A11y Modes
+----------------------------------- */
+:where(button, [role="button"], a, input, textarea, select):focus-visible {
+ outline: var(--focus-outline);
+ outline-offset: var(--focus-offset);
+}
+
+@media (prefers-contrast: more) {
+ :root {
+ --border: color-mix(in oklab, var(--foreground) 18%, var(--card) 82%);
+ }
+ :where(button, [role="button"], a, input, textarea, select):focus-visible {
+ outline: 3px solid var(--ring);
+ outline-offset: 3px;
+ }
+ .card-thin {
+ border-color: var(--border-high-contrast);
+ box-shadow: none;
+ }
+ /* Show thin scrollbars everywhere in high contrast */
+ html,
+ body,
+ .sidebar,
+ [data-region="sidebar"] {
+ scrollbar-width: thin !important;
+ }
+ html::-webkit-scrollbar,
+ body::-webkit-scrollbar,
+ .sidebar::-webkit-scrollbar,
+ [data-region="sidebar"]::-webkit-scrollbar {
+ width: 8px;
+ height: 8px;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ * {
+ transition-duration: 0ms !important;
+ animation-duration: 0ms !important;
+ }
+ .floating-chatbar,
+ .card-thin,
+ .card-thin:hover,
+ .surface-glass,
+ .ripple .ripple-blob,
+ .ink-message,
+ .ink-complete {
+ transition: none !important;
+ animation: none !important;
+ transform: none !important;
+ }
}
diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx
new file mode 100644
index 000000000..d966b52fd
--- /dev/null
+++ b/src/components/Layout.tsx
@@ -0,0 +1,58 @@
+// src/components/Layout.tsx
+import "@/global.css";
+
+export default function Layout({ children }: { children: React.ReactNode }) {
+ return (
+
+ {/* Sidebar */}
+
+
+ {/* Main area */}
+
+
+
+ {/* Page content */}
+
+
+ {/* Floating chatbar */}
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/hooks/use-copilotui.ts b/src/hooks/use-copilotui.ts
new file mode 100644
index 000000000..061b074bc
--- /dev/null
+++ b/src/hooks/use-copilotui.ts
@@ -0,0 +1,92 @@
+"use client";
+
+import { useEffect } from "react";
+
+/**
+ * Auto-resize the main Copilot textarea and set ripple origins.
+ * Call inside your Layout component.
+ */
+export function useCopilotUI() {
+ useEffect(() => {
+ // --- Textarea autosize ---
+ const ta = document.getElementById(
+ "copilot-textarea",
+ ) as HTMLTextAreaElement | null;
+ const autosize = (el: HTMLTextAreaElement) => {
+ el.style.height = "auto";
+ el.style.height = el.scrollHeight + "px";
+ };
+ if (ta) {
+ const onInput = () => autosize(ta);
+ ta.addEventListener("input", onInput);
+ requestAnimationFrame(() => autosize(ta));
+ // Cleanup
+ return () => ta.removeEventListener("input", onInput);
+ }
+
+ return;
+ }, []);
+
+ useEffect(() => {
+ // --- Ripple origin (for .copilot-pressable) ---
+ const handler = (e: PointerEvent) => {
+ const t = (e.target as HTMLElement)?.closest(
+ ".copilot-pressable",
+ ) as HTMLElement | null;
+ if (!t) return;
+ const r = t.getBoundingClientRect();
+ t.style.setProperty("--x", `${e.clientX - r.left}px`);
+ t.style.setProperty("--y", `${e.clientY - r.top}px`);
+ };
+ document.addEventListener("pointerdown", handler, { passive: true });
+ return () => document.removeEventListener("pointerdown", handler);
+ }, []);
+}
+
+/**
+ * Append a new ink message to the main conversation .
+ * Evaporates the last active turn and marks it as previous.
+ */
+export function appendInkMessage(text: string) {
+ const section = document.querySelector("main section");
+ if (!section) return;
+
+ const last = section.querySelector(
+ ".ink-message:not(.ink-previous):last-of-type",
+ ) as HTMLElement | null;
+ if (last) {
+ last.style.animation = "ink-evaporate 0.6s ease-in-out forwards";
+ last.classList.add("ink-previous");
+ }
+
+ const div = document.createElement("div");
+ div.className = "ink-message ink-complete";
+ div.textContent = text;
+ section.appendChild(div);
+}
+
+/**
+ * Wire a submit button to post the textarea contents as an ink message.
+ * Pass the button’s id and optional textarea id (defaults to "copilot-textarea").
+ */
+export function bindSendButton(
+ buttonId: string,
+ textareaId = "copilot-textarea",
+) {
+ const btn = document.getElementById(buttonId);
+ const ta = document.getElementById(textareaId) as HTMLTextAreaElement | null;
+ if (!btn || !ta) return;
+
+ const onClick = () => {
+ const val = ta.value.trim();
+ if (!val) return;
+ appendInkMessage(val);
+ ta.value = "";
+ // reflow height
+ ta.style.height = "auto";
+ ta.style.height = ta.scrollHeight + "px";
+ };
+
+ btn.addEventListener("click", onClick);
+ return () => btn.removeEventListener("click", onClick);
+}
diff --git a/src/lib/ai/mcp/mcp-manager.ts b/src/lib/ai/mcp/mcp-manager.ts
index af0f346e4..58e3a555e 100644
--- a/src/lib/ai/mcp/mcp-manager.ts
+++ b/src/lib/ai/mcp/mcp-manager.ts
@@ -1,13 +1,11 @@
import { createDbBasedMCPConfigsStorage } from "./db-mcp-config-storage";
import { createFileBasedMCPConfigsStorage } from "./fb-mcp-config-storage";
-import {
- createMCPClientsManager,
- type MCPClientsManager,
-} from "./create-mcp-clients-manager";
+import { createMCPClientsManager } from "./create-mcp-clients-manager";
import { FILE_BASED_MCP_CONFIG } from "lib/const";
declare global {
// eslint-disable-next-line no-var
- var __mcpClientsManager__: MCPClientsManager;
+ // use any to avoid cross-package type mismatch for global singleton
+ var __mcpClientsManager__: any;
}
if (!globalThis.__mcpClientsManager__) {
@@ -15,11 +13,17 @@ if (!globalThis.__mcpClientsManager__) {
const storage = FILE_BASED_MCP_CONFIG
? createFileBasedMCPConfigsStorage()
: createDbBasedMCPConfigsStorage();
- globalThis.__mcpClientsManager__ = createMCPClientsManager(storage);
+ // assign as any to avoid type mismatch across module boundaries
+ (globalThis as any).__mcpClientsManager__ = createMCPClientsManager(
+ storage,
+ ) as any;
}
export const initMCPManager = async () => {
- return globalThis.__mcpClientsManager__.init();
+ if (!(globalThis as any).__mcpClientsManager__) {
+ throw new Error("MCP clients manager is not initialized");
+ }
+ return (globalThis as any).__mcpClientsManager__.init();
};
-export const mcpClientsManager = globalThis.__mcpClientsManager__;
+export const mcpClientsManager = (globalThis as any).__mcpClientsManager__;
From 96dc501d389df29d73c55dae8265e5160044d5c8 Mon Sep 17 00:00:00 2001
From: Kindra <228341058+chadlnorman95@users.noreply.github.com>
Date: Sun, 5 Oct 2025 16:46:03 +1100
Subject: [PATCH 2/8] Add CI workflow for Docker image build
---
.github/workflows/docker-image.yml | 18 ++++++++++++++++++
1 file changed, 18 insertions(+)
create mode 100644 .github/workflows/docker-image.yml
diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml
new file mode 100644
index 000000000..3f53646d1
--- /dev/null
+++ b/.github/workflows/docker-image.yml
@@ -0,0 +1,18 @@
+name: Docker Image CI
+
+on:
+ push:
+ branches: [ "main" ]
+ pull_request:
+ branches: [ "main" ]
+
+jobs:
+
+ build:
+
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v4
+ - name: Build the Docker image
+ run: docker build . --file Dockerfile --tag my-image-name:$(date +%s)
From 66c444bb706d1bedaa64c1df11bbd172cc699438 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Sun, 5 Oct 2025 05:48:00 +0000
Subject: [PATCH 3/8] chore(main): release 1.5.2
---
CHANGELOG.md | 156 +++++++++++++++++++++++++++++++++++++++++++++++++++
package.json | 7 ++-
2 files changed, 161 insertions(+), 2 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 659100fba..339397a69 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,161 @@
# Changelog
+## [1.5.2](https://github.com/chadlnorman95/good-chat/compare/v1.23.0...v1.5.2) (2025-10-05)
+
+
+### Features
+
+* Add Azure OpenAI provider support with comprehensive testing ([#189](https://github.com/chadlnorman95/good-chat/issues/189)) ([edad917](https://github.com/chadlnorman95/good-chat/commit/edad91707d49fcb5d3bd244a77fbaae86527742a))
+* add bot name preference to user settings ([f4aa588](https://github.com/chadlnorman95/good-chat/commit/f4aa5885d0be06cc21149d09e604c781e551ec4a))
+* add chat-related translations and enhance UI components with animations and improved accessibility ([c254c84](https://github.com/chadlnorman95/good-chat/commit/c254c8472f6b59a9d79a84cae892a77bcc8aefcb))
+* add common UI translations and enhance project and thread management components with improved localization ([bf05c55](https://github.com/chadlnorman95/good-chat/commit/bf05c55cd9c5878d86fac079a8856bfe257e479e))
+* add Discord badge and section to README for community engagement ([4d2f165](https://github.com/chadlnorman95/good-chat/commit/4d2f16501a0144fd8431dc246aad89dc9c51f204))
+* Add env variables for deafult model and ollama url ([a15791f](https://github.com/chadlnorman95/good-chat/commit/a15791fc0086e26a0e7827ada27c3245f540b502))
+* Add file based MCP servers and fix docker tag ([aa229aa](https://github.com/chadlnorman95/good-chat/commit/aa229aadee505357f4b33bb755a2b8e8b68c5d1b))
+* add husky ([067d58d](https://github.com/chadlnorman95/good-chat/commit/067d58dbbb58428bace3a71769c5a7dcea86bcd5))
+* add husky for formatting and checking commits ([#71](https://github.com/chadlnorman95/good-chat/issues/71)) ([a379cd3](https://github.com/chadlnorman95/good-chat/commit/a379cd3e869b5caab5bcaf3b03f5607021f988ef))
+* Add js-execution tool and bug fixes(tool call) ([#148](https://github.com/chadlnorman95/good-chat/issues/148)) ([12b18a1](https://github.com/chadlnorman95/good-chat/commit/12b18a1cf31a17e565eddc05764b5bd2d0b0edee))
+* add language translation guidelines and instructions for contributing new languages to the chatbot ([3bb8fd3](https://github.com/chadlnorman95/good-chat/commit/3bb8fd3d332e4bf65d87b8ca8b92c190500f308b))
+* add lint-staged and remove commitlint ([67988b8](https://github.com/chadlnorman95/good-chat/commit/67988b8ad29fba4fc4bfee93bf9fe2e9d83b7dc0))
+* add logging for base URL configuration in auth server and update baseURL assignment for better clarity ([6ca13fd](https://github.com/chadlnorman95/good-chat/commit/6ca13fdc8626c054b67c93f1a0c8994d413be411))
+* add new translations for project and chat management, enhance sidebar components with improved UI interactions, and implement dynamic project and chat visibility toggles ([5319d9c](https://github.com/chadlnorman95/good-chat/commit/5319d9c9764467cc74b08a4cc8d7a60fb384bad2))
+* add new translations for reporting issues and joining the community, remove unused language selection component, and enhance theme selection functionality in the sidebar ([314973c](https://github.com/chadlnorman95/good-chat/commit/314973ca033ce5c5bf9fc978c5fe74094faaa6e0))
+* add open router ([a391ce4](https://github.com/chadlnorman95/good-chat/commit/a391ce418dcffe5c7bbdd803ef3f4b80bcb0cd73))
+* add openAI compatible provider support ([#92](https://github.com/chadlnorman95/good-chat/issues/92)) ([6682c9a](https://github.com/chadlnorman95/good-chat/commit/6682c9a320aff9d91912489661d27ae9bb0f4440))
+* add pink themes ([2e43cc6](https://github.com/chadlnorman95/good-chat/commit/2e43cc628ad0ea159865a4cf633821fa35792d38))
+* add Python execution tool and integrate Pyodide support ([#176](https://github.com/chadlnorman95/good-chat/issues/176)) ([de2cf7b](https://github.com/chadlnorman95/good-chat/commit/de2cf7b66444fe64791ed142216277a5f2cdc551))
+* add qwen3 coder to models file for openrouter ([#206](https://github.com/chadlnorman95/good-chat/issues/206)) ([3731d00](https://github.com/chadlnorman95/good-chat/commit/3731d007100ac36a814704f8bde8398ce1378a4e))
+* add sequential thinking tool and enhance UI components ([230f4a7](https://github.com/chadlnorman95/good-chat/commit/230f4a7cd94fa88052069231e7bb6a5c9a18ff6e))
+* add sequential thinking tool and enhance UI components ([#183](https://github.com/chadlnorman95/good-chat/issues/183)) ([5bcbde2](https://github.com/chadlnorman95/good-chat/commit/5bcbde2de776b17c3cc1f47f4968b13e22fc65b2))
+* add Spanish, French, Japanese, and Chinese language support with UI improvements ([#74](https://github.com/chadlnorman95/good-chat/issues/74)) ([e34d43d](https://github.com/chadlnorman95/good-chat/commit/e34d43df78767518f0379a434f8ffb1808b17e17))
+* Add support for Streamable HTTP Transport [#56](https://github.com/chadlnorman95/good-chat/issues/56) ([#64](https://github.com/chadlnorman95/good-chat/issues/64)) ([8783943](https://github.com/chadlnorman95/good-chat/commit/878394337e3b490ec2d17bcc302f38c695108d73))
+* Add web search and content extraction tools using Tavily API ([#126](https://github.com/chadlnorman95/good-chat/issues/126)) ([f7b4ea5](https://github.com/chadlnorman95/good-chat/commit/f7b4ea5828b33756a83dd881b9afa825796bf69f))
+* admin and roles ([#270](https://github.com/chadlnorman95/good-chat/issues/270)) ([63bddca](https://github.com/chadlnorman95/good-chat/commit/63bddcaa4bc62bc85204a0982a06f2bed09fc5f5))
+* agent sharing ([#226](https://github.com/chadlnorman95/good-chat/issues/226)) ([090dd8f](https://github.com/chadlnorman95/good-chat/commit/090dd8f4bf4fb82beb2cd9bfa0b427425bbbf352))
+* **agent:** agent and archive ([#192](https://github.com/chadlnorman95/good-chat/issues/192)) ([c63ae17](https://github.com/chadlnorman95/good-chat/commit/c63ae179363b66bfa4f4b5524bdf27b71166c299))
+* ai v5 ([#230](https://github.com/chadlnorman95/good-chat/issues/230)) ([0461879](https://github.com/chadlnorman95/good-chat/commit/0461879740860055a278c96656328367980fa533))
+* **chat:** enable [@mention](https://github.com/mention) and tool click to trigger workflow execution in chat ([#122](https://github.com/chadlnorman95/good-chat/issues/122)) ([b4e7f02](https://github.com/chadlnorman95/good-chat/commit/b4e7f022fa155ef70be2aee9228a4d1d2643bf10))
+* credit contributors in releases and changlogs ([#104](https://github.com/chadlnorman95/good-chat/issues/104)) ([e0e4443](https://github.com/chadlnorman95/good-chat/commit/e0e444382209a36f03b6e898f26ebd805032c306))
+* enhance authentication UI and add Korean translations ([1389e0a](https://github.com/chadlnorman95/good-chat/commit/1389e0ab1e8e639cfa6f248001ffa2d9c13f6c47))
+* enhance localization by adding new translations for chat, project, and keyboard shortcuts, and improve UI components with dynamic text rendering ([d71997d](https://github.com/chadlnorman95/good-chat/commit/d71997dad6d685fa10099dd2e80d3e83aa403ecf))
+* enhance MCP server selection with link to add a server when none are detected ([e8a4e1c](https://github.com/chadlnorman95/good-chat/commit/e8a4e1c0d0aa24e3d3aaa6bc5f4eb796bff334d3))
+* enhance MCPClient transport handling with StreamableHTTPClientTransport and fallback to SSE ([c3413c3](https://github.com/chadlnorman95/good-chat/commit/c3413c3b6aa23e933bf27184c01aaf9a6c9bc333))
+* enhance PromptInput with new Lightbulb icon and tooltip for Think Mode ([1bf3ad7](https://github.com/chadlnorman95/good-chat/commit/1bf3ad71e40cf395c531a6526d4dc308881462c5))
+* enhance TemporaryChat component with improved shortcut key display and update AppHeader to remove GitHub link, streamline UI interactions in AppSidebarUser for reporting issues and joining community ([5cb31b5](https://github.com/chadlnorman95/good-chat/commit/5cb31b541ad55d19d216028c920202aa4900ec4a))
+* enhance TemporaryChat component with new instructions feature, add corresponding translations, and improve UI interactions in AppHeader for better user experience ([0c7be80](https://github.com/chadlnorman95/good-chat/commit/0c7be8052e815a43dad4ea81da27beb4f84668c3))
+* export chat thread ([#278](https://github.com/chadlnorman95/good-chat/issues/278)) ([23e79cd](https://github.com/chadlnorman95/good-chat/commit/23e79cd570c24bab0abc496eca639bfffcb6060b))
+* **file-storage:** image uploads, generate profile with ai ([#257](https://github.com/chadlnorman95/good-chat/issues/257)) ([46eb43f](https://github.com/chadlnorman95/good-chat/commit/46eb43f84792d48c450f3853b48b24419f67c7a1))
+* groq provider ([#268](https://github.com/chadlnorman95/good-chat/issues/268)) ([aef213d](https://github.com/chadlnorman95/good-chat/commit/aef213d2f9dd0255996cc4184b03425db243cd7b))
+* hide LLM providers without API keys in model selection ([#269](https://github.com/chadlnorman95/good-chat/issues/269)) ([63c15dd](https://github.com/chadlnorman95/good-chat/commit/63c15dd386ea99b8fa56f7b6cb1e58e5779b525d))
+* implement cold start-like auto connection for MCP server and simplify status ([#73](https://github.com/chadlnorman95/good-chat/issues/73)) ([987c442](https://github.com/chadlnorman95/good-chat/commit/987c4425504d6772e0aefe08b4e1911e4cb285c1))
+* implement language selection component and enhance authentication UI with improved translations and user prompts ([d97d891](https://github.com/chadlnorman95/good-chat/commit/d97d891bc3d63b4a9ca0df829177e0368bc4a462))
+* implement real-time chat session creation with OpenAI API ([24c3947](https://github.com/chadlnorman95/good-chat/commit/24c3947498ee1e630bd5ce62f1ca2c173cda16a9))
+* implement sequential thinking mode in chat API and UI components ([8f4d945](https://github.com/chadlnorman95/good-chat/commit/8f4d9452a73902455d362a3d2ff943e4b5757063))
+* implement speech system prompt and update voice chat options for enhanced user interaction ([5a33626](https://github.com/chadlnorman95/good-chat/commit/5a336260899ab542407c3c26925a147c1a9bba11))
+* implement user caching and improve error handling in authentication flow ([6d31966](https://github.com/chadlnorman95/good-chat/commit/6d319668bd445ff1d67f1a5c4e7a1b6a51e6498b))
+* Implementation of PWA for much better UI on mobile ([#252](https://github.com/chadlnorman95/good-chat/issues/252)) ([51e6eab](https://github.com/chadlnorman95/good-chat/commit/51e6eabcc34e1238a7536b5fffa433ba4ae4827a))
+* improve authentication configuration and social login handling ([#211](https://github.com/chadlnorman95/good-chat/issues/211)) ([cd25937](https://github.com/chadlnorman95/good-chat/commit/cd25937020710138ab82458e70ea7f6cabfd03ca))
+* improve markdown table styling ([#244](https://github.com/chadlnorman95/good-chat/issues/244)) ([7338e04](https://github.com/chadlnorman95/good-chat/commit/7338e046196f72a7cc8ec7903593d94ecabcc05e))
+* introduce changesets for version management and fix OpenAI voice chat options bug ([#63](https://github.com/chadlnorman95/good-chat/issues/63)) ([9ae823b](https://github.com/chadlnorman95/good-chat/commit/9ae823b602f1ee20a9b9aeb9e3a88537084033b1))
+* introduce interactive table creation and enhance visualization tools ([#205](https://github.com/chadlnorman95/good-chat/issues/205)) ([623a736](https://github.com/chadlnorman95/good-chat/commit/623a736f6895b8737acaa06811088be2dc1d0b3c))
+* Lazy Chat Title Generation: Save Empty Title First, Then Generate and Upsert in Parallel ([#162](https://github.com/chadlnorman95/good-chat/issues/162)) ([31dfd78](https://github.com/chadlnorman95/good-chat/commit/31dfd7802e33d8d4e91aae321c3d16a07fe42552))
+* **mcp:** oauth ([#208](https://github.com/chadlnorman95/good-chat/issues/208)) ([136aded](https://github.com/chadlnorman95/good-chat/commit/136aded6de716367380ff64c2452d1b4afe4aa7f))
+* Per User Custom instructions ([#86](https://github.com/chadlnorman95/good-chat/issues/86)) ([d45c968](https://github.com/chadlnorman95/good-chat/commit/d45c9684adfb0d9b163c83f3bb63310eef572279))
+* publish container to GitHub registry ([#149](https://github.com/chadlnorman95/good-chat/issues/149)) ([9f03cbc](https://github.com/chadlnorman95/good-chat/commit/9f03cbc1d2890746f14919ebaad60f773b0a333d))
+* realtime voice chatbot with MCP tools ([#50](https://github.com/chadlnorman95/good-chat/issues/50)) ([cf13e9d](https://github.com/chadlnorman95/good-chat/commit/cf13e9df24eded1fc0e6fc8e44f728a44f6bc9d3))
+* refactor chat preferences and shortcuts handling by replacing ShortcutsProvider with AppPopupProvider, update state management for temporary chat, and enhance keyboard shortcut functionality in related components ([84070d5](https://github.com/chadlnorman95/good-chat/commit/84070d5d31922a85ab2f322216d5c79da0dc2f74))
+* **releases:** add debug logging to the add authors and update release step ([#105](https://github.com/chadlnorman95/good-chat/issues/105)) ([c855a6a](https://github.com/chadlnorman95/good-chat/commit/c855a6a94c49dfd93c9a8d1d0932aeda36bd6c7e))
+* remove experimental caching option from Next.js config, add deepmerge dependency, and enhance message handling in i18n request for improved localization ([c1d3e3b](https://github.com/chadlnorman95/good-chat/commit/c1d3e3b1501ebdac2b2c7f169ddc33cf95f3d6f4))
+* set maxTokens to 30 in generateTitleFromUserMessageAction for improved title generation ([7dde3f1](https://github.com/chadlnorman95/good-chat/commit/7dde3f156d60488488862967b40d81aa02a29955))
+* Shortcuts Info ([6a5d71f](https://github.com/chadlnorman95/good-chat/commit/6a5d71f62042663f11bcc43671af73643167da78))
+* start i18n ([a9457d5](https://github.com/chadlnorman95/good-chat/commit/a9457d5d8933a43518a1e4c83114124aaa2dda2e))
+* tsconfig.tsbuildinfo 디렉토리를 기본 정리 목록에 추가 ([62ab4d8](https://github.com/chadlnorman95/good-chat/commit/62ab4d8d5df2e8047756d746c9d8e2b1ff8c09c4))
+* update MCP server UI and translations for improved user experience ([1e2fd31](https://github.com/chadlnorman95/good-chat/commit/1e2fd31f8804669fbcf55a4c54ccf0194a7e797c))
+* update mention ux ([#161](https://github.com/chadlnorman95/good-chat/issues/161)) ([7ceb9c6](https://github.com/chadlnorman95/good-chat/commit/7ceb9c69c32de25d523a4d14623b25a34ffb3c9d))
+* **voice-chat:** binding agent tools ([#275](https://github.com/chadlnorman95/good-chat/issues/275)) ([ed45e82](https://github.com/chadlnorman95/good-chat/commit/ed45e822eb36447f2a02ef3aa69eeec88009e357))
+* web-search with images ([bea76b3](https://github.com/chadlnorman95/good-chat/commit/bea76b3a544d4cf5584fa29e5c509b0aee1d4fee))
+* **web-search:** replace Tavily API with Exa AI integration ([#204](https://github.com/chadlnorman95/good-chat/issues/204)) ([7140487](https://github.com/chadlnorman95/good-chat/commit/7140487dcdadb6c5cb6af08f92b06d42411f7168))
+* workflow beta ([#100](https://github.com/chadlnorman95/good-chat/issues/100)) ([2f5ada2](https://github.com/chadlnorman95/good-chat/commit/2f5ada2a66e8e3cd249094be9d28983e4331d3a1))
+* **workflow:** add auto layout feature for workflow nodes and update UI messages ([0cfbffd](https://github.com/chadlnorman95/good-chat/commit/0cfbffd631c9ae5c6ed57d47ca5f34b9acbb257d))
+* **workflow:** Add HTTP and Template nodes with LLM structured output supportWorkflow node ([#117](https://github.com/chadlnorman95/good-chat/issues/117)) ([10ec438](https://github.com/chadlnorman95/good-chat/commit/10ec438f13849f0745e7fab652cdd7cef8e97ab6))
+* **workflow:** add HTTP node configuration and execution support ([7d2f65f](https://github.com/chadlnorman95/good-chat/commit/7d2f65fe4f0fdaae58ca2a69abb04abee3111c60))
+* **workflow:** stable workflow ( add example workflow : baby-research ) ([#137](https://github.com/chadlnorman95/good-chat/issues/137)) ([c38a7ea](https://github.com/chadlnorman95/good-chat/commit/c38a7ea748cdb117a4d0f4b886e3d8257a135956))
+
+
+### Bug Fixes
+
+* .next 디렉토리 정리 명령어 제거 ([3387ed5](https://github.com/chadlnorman95/good-chat/commit/3387ed51cf24b125a9039147bf14d513d0e9c2bc))
+* [#111](https://github.com/chadlnorman95/good-chat/issues/111) prevent MCP server disconnection during long-running tool calls ([#238](https://github.com/chadlnorman95/good-chat/issues/238)) ([b5bb3dc](https://github.com/chadlnorman95/good-chat/commit/b5bb3dc40a025648ecd78f547e0e1a2edd8681ca))
+* Add .next cleanup to postinstall and fix db-migrate exit handling ([#272](https://github.com/chadlnorman95/good-chat/issues/272)) ([15ff34d](https://github.com/chadlnorman95/good-chat/commit/15ff34d6a8a4c2c968ff08f9dcd6d87b7c85f652))
+* add POST endpoint for MCP client saving with session validation ([fa005aa](https://github.com/chadlnorman95/good-chat/commit/fa005aaecbf1f8d9279f5b4ce5ba85343e18202b))
+* **agent:** improve agent loading logic and validation handling in EditAgent component [#198](https://github.com/chadlnorman95/good-chat/issues/198) ([ec034ab](https://github.com/chadlnorman95/good-chat/commit/ec034ab51dfc656d7378eca1e2b4dc94fbb67863))
+* **agent:** update description field to allow nullish values in ChatMentionSchema ([3e4532d](https://github.com/chadlnorman95/good-chat/commit/3e4532d4c7b561ad03836c743eefb7cd35fe9e74))
+* **api:** handle error case in chat route by using orElse for unwrap ([25580a2](https://github.com/chadlnorman95/good-chat/commit/25580a2a9f6c9fbc4abc29fee362dc4b4f27f9b4))
+* Apply DISABLE_SIGN_UP to OAuth providers ([#282](https://github.com/chadlnorman95/good-chat/issues/282)) ([bcc0db8](https://github.com/chadlnorman95/good-chat/commit/bcc0db8eb81997e54e8904e64fc76229fbfc1338))
+* bug(LineChart): series are incorrectly represented [#165](https://github.com/chadlnorman95/good-chat/issues/165) ([4e4905c](https://github.com/chadlnorman95/good-chat/commit/4e4905c0f7f6a3eca73ea2ac06f718fa29b0f821))
+* change CMD to use shell form for sequential commands (pnpm db:migrate && pnpm start) in Dockerfile ([bfc6585](https://github.com/chadlnorman95/good-chat/commit/bfc6585cecfbd62fc867a79f038b3e69a3567bbe))
+* **chat:** prevent infinite MCP tool call loop by precomputing toolChoice ([#49](https://github.com/chadlnorman95/good-chat/issues/49)) ([ba7673b](https://github.com/chadlnorman95/good-chat/commit/ba7673becd9eaa6acdcfb36a05997e14ab597cc5))
+* clean changlelog and stop duplicate attributions in the changelog file ([#119](https://github.com/chadlnorman95/good-chat/issues/119)) ([aa970b6](https://github.com/chadlnorman95/good-chat/commit/aa970b6a2d39ac1f0ca22db761dd452e3c7a5542))
+* css ([6a2f8e9](https://github.com/chadlnorman95/good-chat/commit/6a2f8e9f19c9279fdc1cb5dfd2d8d9cfb63d89e2))
+* docker postgres not finsihed ([d49f275](https://github.com/chadlnorman95/good-chat/commit/d49f27509f57921daf5491a556cf79abca048e88))
+* Enhance component styles and configurations ([a7284f1](https://github.com/chadlnorman95/good-chat/commit/a7284f12ca02ee29f7da4d57e4fe6e8c6ecb2dfc))
+* enhance error handling in chat bot component ([1519799](https://github.com/chadlnorman95/good-chat/commit/15197996ba1f175db002b06e3eac2765cfae1518))
+* enhance event handling for keyboard shortcuts in chat components ([95dad3b](https://github.com/chadlnorman95/good-chat/commit/95dad3bd1dac4b6e56be2df35957a849617ba056))
+* enhance mobile UI experience with responsive design adjustments ([2eee8ba](https://github.com/chadlnorman95/good-chat/commit/2eee8bab078207841f4d30ce7708885c7268302e))
+* enhance ToolModeDropdown with tooltip updates and debounce functionality ([d06db0b](https://github.com/chadlnorman95/good-chat/commit/d06db0b3e1db34dc4785eb31ebd888d7c2ae0d64))
+* ensure PKCE works for MCP Server auth ([#256](https://github.com/chadlnorman95/good-chat/issues/256)) ([09b938f](https://github.com/chadlnorman95/good-chat/commit/09b938f17ca78993a1c7b84c5a702b95159542b2))
+* ensure thread date fallback to current date in AppSidebarThreads component ([800b504](https://github.com/chadlnorman95/good-chat/commit/800b50498576cfe1717da4385e2a496ac33ea0ad))
+* env, Docker, and OpenRouter config for working local and Docker dev ([6c3ad8c](https://github.com/chadlnorman95/good-chat/commit/6c3ad8cf1e44674eee11769edd3896fa97d374f2))
+* generate title by user message ([9ee4be6](https://github.com/chadlnorman95/good-chat/commit/9ee4be69c6b90f44134d110e90f9c3da5219c79f))
+* generate title sync ([5f3afdc](https://github.com/chadlnorman95/good-chat/commit/5f3afdc4cb7304460606b3480f54f513ef24940c))
+* hsuky ([3dcde85](https://github.com/chadlnorman95/good-chat/commit/3dcde858a365ee57fb67c518e61855485c34c6e3))
+* **i18n:** update agent description fields in English, Spanish, and French JSON files to improve clarity and consistency ([f07d1c4](https://github.com/chadlnorman95/good-chat/commit/f07d1c4dc64b96584faa7e558f981199834a5370))
+* ignore tool binding on unsupported models (server-side) ([#160](https://github.com/chadlnorman95/good-chat/issues/160)) ([277b4fe](https://github.com/chadlnorman95/good-chat/commit/277b4fe986d5b6d9780d9ade83f294d8f34806f6))
+* implement responsive horizontal layout for chat mention input with improved UX And generate Agent Prompt ([43ec980](https://github.com/chadlnorman95/good-chat/commit/43ec98059e0d27ab819491518263df55fb1c9ad3))
+* improve error display with better UX and animation handling ([#227](https://github.com/chadlnorman95/good-chat/issues/227)) ([35d62e0](https://github.com/chadlnorman95/good-chat/commit/35d62e05bb21760086c184511d8062444619696c))
+* improve error handling in chat route and adjust message part styling ([a4387d6](https://github.com/chadlnorman95/good-chat/commit/a4387d661ddcf25a3fdf58c18f4f60788c2a0b17))
+* improve session error handling in authentication ([eb15b55](https://github.com/chadlnorman95/good-chat/commit/eb15b550facf5368f990d58b4b521bf15aecbf72))
+* increase maxTokens for title generation in chat actions issue [#102](https://github.com/chadlnorman95/good-chat/issues/102) ([bea2588](https://github.com/chadlnorman95/good-chat/commit/bea2588e24cf649133e8ce5f3b6391265b604f06))
+* Invalid 'tools': array too long. Expected an array with maximum length 128, but got an array with length 217 instead. [#197](https://github.com/chadlnorman95/good-chat/issues/197) ([b967e3a](https://github.com/chadlnorman95/good-chat/commit/b967e3a30be3a8a48f3801b916e26ac4d7dd50f4))
+* js executor tool and gemini model version ([#169](https://github.com/chadlnorman95/good-chat/issues/169)) ([e25e10a](https://github.com/chadlnorman95/good-chat/commit/e25e10ab9fac4247774b0dee7e01d5f6a4b16191))
+* link to the config generator correctly ([#184](https://github.com/chadlnorman95/good-chat/issues/184)) ([1865ecc](https://github.com/chadlnorman95/good-chat/commit/1865ecc269e567838bc391a3236fcce82c213fc0))
+* **mcp:** ensure database and memory manager sync across server instances ([#229](https://github.com/chadlnorman95/good-chat/issues/229)) ([c4b8ebe](https://github.com/chadlnorman95/good-chat/commit/c4b8ebe9566530986951671e36111a2e529bf592))
+* **mcp:** fix MCP infinite loading issue ([#220](https://github.com/chadlnorman95/good-chat/issues/220)) ([c25e351](https://github.com/chadlnorman95/good-chat/commit/c25e3515867c76cc5494a67e79711e9343196078))
+* **mcp:** Safe MCP manager init logic for the Vercel environment ([#202](https://github.com/chadlnorman95/good-chat/issues/202)) ([708fdfc](https://github.com/chadlnorman95/good-chat/commit/708fdfcfed70299044a90773d3c9a76c9a139f2f))
+* ollama disable issue ([#283](https://github.com/chadlnorman95/good-chat/issues/283)) ([5e0a690](https://github.com/chadlnorman95/good-chat/commit/5e0a690bb6c3f074680d13e09165ca9fff139f93))
+* python executor ([ea58742](https://github.com/chadlnorman95/good-chat/commit/ea58742cccd5490844b3139a37171b1b68046f85))
+* refine thinking prompt condition in chat API ([0192151](https://github.com/chadlnorman95/good-chat/commit/0192151fec1e33f3b7bc1f08b0a9582d66650ef0))
+* remove file to get file types for lint-staged ([0c1e7eb](https://github.com/chadlnorman95/good-chat/commit/0c1e7eb0ba74c8b9222f4981b7d38bae41df1a17))
+* **scripts:** parse openai compatible on windows ([#164](https://github.com/chadlnorman95/good-chat/issues/164)) ([41f5ff5](https://github.com/chadlnorman95/good-chat/commit/41f5ff55b8d17c76a23a2abf4a6e4cb0c4d95dc5))
+* speech ux ([baa849f](https://github.com/chadlnorman95/good-chat/commit/baa849ff2b6b147ec685c6847834385652fc3191))
+* split theme system into base themes and style variants ([61ebd07](https://github.com/chadlnorman95/good-chat/commit/61ebd0745bcfd7a84ba3ad65c3f52b7050b5131a))
+* support OpenAI real-time chat project instructions ([2ebbb5e](https://github.com/chadlnorman95/good-chat/commit/2ebbb5e68105ef6706340a6cfbcf10b4d481274a))
+* temporary chat initial model ([0393f7a](https://github.com/chadlnorman95/good-chat/commit/0393f7a190463faf58cbfbca1c21d349a9ff05dc))
+* tool select ui ([#141](https://github.com/chadlnorman95/good-chat/issues/141)) ([0795524](https://github.com/chadlnorman95/good-chat/commit/0795524991a7aa3e17990777ca75381e32eaa547))
+* UI improvements for mobile experience ([#66](https://github.com/chadlnorman95/good-chat/issues/66)) ([b4349ab](https://github.com/chadlnorman95/good-chat/commit/b4349abf75de69f65a44735de2e0988c6d9d42d8))
+* unify SSE and streamable config as RemoteConfig ([#85](https://github.com/chadlnorman95/good-chat/issues/85)) ([66524a0](https://github.com/chadlnorman95/good-chat/commit/66524a0398bd49230fcdec73130f1eb574e97477))
+* update adding-openAI-like-providers.md ([#101](https://github.com/chadlnorman95/good-chat/issues/101)) ([2bb94e7](https://github.com/chadlnorman95/good-chat/commit/2bb94e7df63a105e33c1d51271751c7b89fead23))
+* update config file path in release workflow ([7209cbe](https://github.com/chadlnorman95/good-chat/commit/7209cbeb89bd65b14aee66a40ed1abb5c5f2e018))
+* update lastThreadAt calculation in chat repository to handle null values by using COALESCE for better data integrity ([4a5489c](https://github.com/chadlnorman95/good-chat/commit/4a5489c8cfacc3d84fb439725e0b3cd5f21469ac))
+* update sign-out behavior to redirect to sign-in page instead of … ([#61](https://github.com/chadlnorman95/good-chat/issues/61)) ([04f771a](https://github.com/chadlnorman95/good-chat/commit/04f771aa0ee1c170438ba8c78dd377fb65cea05e))
+* update sign-out behavior to redirect to sign-in page instead of reloading the window for improved user experience ([3001591](https://github.com/chadlnorman95/good-chat/commit/30015915e9c53a047451bc1501148918f0a941b7))
+* update tool selection logic in McpServerSelector to maintain current selections ([4103c1b](https://github.com/chadlnorman95/good-chat/commit/4103c1b828c3e5b513679a3fb9d72bd37301f99d))
+* update ToolMessagePart to use isExecuting state instead of isExpanded ([752f8f0](https://github.com/chadlnorman95/good-chat/commit/752f8f06e319119569e9ee7c04d621ab1c43ca54))
+* update translation key in ErrorMessage component for improved lo… ([#60](https://github.com/chadlnorman95/good-chat/issues/60)) ([463ea4b](https://github.com/chadlnorman95/good-chat/commit/463ea4b1c129f6554737495dd17401f01f1aad0d))
+* update translation key in ErrorMessage component for improved localization consistency ([789172b](https://github.com/chadlnorman95/good-chat/commit/789172b341c5c0d3a68781f6ca89468ecba5742c))
+* workflow condition node issue ([78b7add](https://github.com/chadlnorman95/good-chat/commit/78b7addbba51b4553ec5d0ce8961bf90be5d649c))
+* **workflow-panel:** fix save button width ([#168](https://github.com/chadlnorman95/good-chat/issues/168)) ([3e66226](https://github.com/chadlnorman95/good-chat/commit/3e6622630c9cc40ff3d4357e051c45f8c860fc10))
+* **workflow:** enhance structured output handling and improve user notifications ([dd43de9](https://github.com/chadlnorman95/good-chat/commit/dd43de99881d64ca0c557e29033e953bcd4adc0e))
+* **workflow:** improve mention handling by ensuring empty values are represented correctly ([92ff9c3](https://github.com/chadlnorman95/good-chat/commit/92ff9c3e14b97d9f58a22f9df2559e479f14537c))
+* **workflow:** llm structure Output ([c529292](https://github.com/chadlnorman95/good-chat/commit/c529292ddc1a4b836a5921e25103598afd7e3ab7))
+* **workflow:** MPC Tool Response Structure And Workflow ([#113](https://github.com/chadlnorman95/good-chat/issues/113)) ([836ffd7](https://github.com/chadlnorman95/good-chat/commit/836ffd7ef5858210bdce44d18ca82a1c8f0fc87f))
+* **workflow:** simplify mention formatting by removing bold styling for non-empty values ([ef65fd7](https://github.com/chadlnorman95/good-chat/commit/ef65fd713ab59c7d8464cae480df7626daeff5cd))
+
+
+### Miscellaneous Chores
+
+* release 1.5.2 ([d185514](https://github.com/chadlnorman95/good-chat/commit/d1855148cfa53ea99c9639f8856d0e7c58eca020))
+
## [1.23.0](https://github.com/cgoinglove/better-chatbot/compare/v1.22.0...v1.23.0) (2025-10-04)
diff --git a/package.json b/package.json
index 5b36403cc..45fbb2527 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "better-chatbot",
- "version": "1.23.0",
+ "version": "1.5.2",
"private": true,
"author": "cgoinglove",
"license": "MIT",
@@ -153,7 +153,10 @@
"vitest": "^3.2.4"
},
"lint-staged": {
- "*.{js,json,mjs,ts,yaml,tsx,css}": ["pnpm format", "pnpm lint:fix"]
+ "*.{js,json,mjs,ts,yaml,tsx,css}": [
+ "pnpm format",
+ "pnpm lint:fix"
+ ]
},
"packageManager": "pnpm@10.2.1",
"engines": {
From 043aff07a151a80f774ee4a0b32d839e540ed9ad Mon Sep 17 00:00:00 2001
From: openhands
Date: Tue, 7 Oct 2025 07:15:18 +0000
Subject: [PATCH 4/8] Add comprehensive feature enhancements
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
✨ Features Added:
- S3 file storage with AWS SDK integration and presigned URLs
- Enhanced search system with PostgreSQL full-text search and fuzzy matching
- Usage analytics with tracking, statistics, and dashboard visualization
- Advanced file processing with text extraction and metadata analysis
- Knowledge base management with document storage and search capabilities
🔧 Technical Implementation:
- Complete API endpoints for all new features
- Comprehensive UI components with modern design
- Client-side hooks for analytics tracking
- Extensive test coverage for all services
- Sidebar navigation integration
📦 Dependencies:
- Added AWS SDK, recharts, react-dropzone
- Enhanced database integration
- Improved search capabilities
Co-authored-by: openhands
---
.env.example | 9 +-
docs/tips-guides/s3-storage.md | 294 ++++++++
package.json | 1 +
pnpm-lock.yaml | 30 +
src/app/(chat)/analytics/page.tsx | 25 +
src/app/(chat)/knowledge-base/page.tsx | 17 +
src/app/(chat)/search/page.tsx | 15 +
src/app/api/analytics/activity/route.ts | 47 ++
src/app/api/analytics/stats/route.ts | 54 ++
src/app/api/analytics/track/route.ts | 56 ++
src/app/api/files/process/route.ts | 157 ++++
.../knowledge-base/documents/[id]/route.ts | 133 ++++
src/app/api/knowledge-base/documents/route.ts | 105 +++
src/app/api/knowledge-base/search/route.ts | 96 +++
src/app/api/knowledge-base/stats/route.ts | 26 +
src/app/api/search/route.ts | 162 +++++
src/app/api/search/suggestions/route.ts | 49 ++
src/app/api/storage/actions.ts | 30 +-
.../analytics/analytics-dashboard.tsx | 423 +++++++++++
.../file-processing/file-processor-dialog.tsx | 533 ++++++++++++++
.../knowledge-base-interface.tsx | 685 ++++++++++++++++++
src/components/layouts/app-sidebar-menus.tsx | 55 ++
.../layouts/app-sidebar-threads.tsx | 17 +-
src/components/search/search-interface.tsx | 538 ++++++++++++++
src/hooks/use-analytics.ts | 133 ++++
src/hooks/use-debounced-value.ts | 23 +
src/lib/analytics/analytics-service.test.ts | 385 ++++++++++
src/lib/analytics/analytics-service.ts | 427 +++++++++++
.../file-processing/file-processor.test.ts | 304 ++++++++
src/lib/file-processing/file-processor.ts | 562 ++++++++++++++
src/lib/file-storage/s3-file-storage.test.ts | 342 +++++++++
src/lib/file-storage/s3-file-storage.ts | 335 ++++++++-
.../knowledge-base-service.test.ts | 497 +++++++++++++
.../knowledge-base/knowledge-base-service.ts | 620 ++++++++++++++++
src/lib/search/search-service.test.ts | 339 +++++++++
src/lib/search/search-service.ts | 336 +++++++++
36 files changed, 7833 insertions(+), 27 deletions(-)
create mode 100644 docs/tips-guides/s3-storage.md
create mode 100644 src/app/(chat)/analytics/page.tsx
create mode 100644 src/app/(chat)/knowledge-base/page.tsx
create mode 100644 src/app/(chat)/search/page.tsx
create mode 100644 src/app/api/analytics/activity/route.ts
create mode 100644 src/app/api/analytics/stats/route.ts
create mode 100644 src/app/api/analytics/track/route.ts
create mode 100644 src/app/api/files/process/route.ts
create mode 100644 src/app/api/knowledge-base/documents/[id]/route.ts
create mode 100644 src/app/api/knowledge-base/documents/route.ts
create mode 100644 src/app/api/knowledge-base/search/route.ts
create mode 100644 src/app/api/knowledge-base/stats/route.ts
create mode 100644 src/app/api/search/route.ts
create mode 100644 src/app/api/search/suggestions/route.ts
create mode 100644 src/components/analytics/analytics-dashboard.tsx
create mode 100644 src/components/file-processing/file-processor-dialog.tsx
create mode 100644 src/components/knowledge-base/knowledge-base-interface.tsx
create mode 100644 src/components/search/search-interface.tsx
create mode 100644 src/hooks/use-analytics.ts
create mode 100644 src/hooks/use-debounced-value.ts
create mode 100644 src/lib/analytics/analytics-service.test.ts
create mode 100644 src/lib/analytics/analytics-service.ts
create mode 100644 src/lib/file-processing/file-processor.test.ts
create mode 100644 src/lib/file-processing/file-processor.ts
create mode 100644 src/lib/file-storage/s3-file-storage.test.ts
create mode 100644 src/lib/knowledge-base/knowledge-base-service.test.ts
create mode 100644 src/lib/knowledge-base/knowledge-base-service.ts
create mode 100644 src/lib/search/search-service.test.ts
create mode 100644 src/lib/search/search-service.ts
diff --git a/.env.example b/.env.example
index 271049c2d..a5927ab94 100644
--- a/.env.example
+++ b/.env.example
@@ -103,8 +103,11 @@ MCP_MAX_TOTAL_TIMEOUT=
# BLOB_READ_WRITE_TOKEN=
-# -- S3 (planned driver) --
+# -- S3 (fully supported) --
# FILE_STORAGE_TYPE=s3
# FILE_STORAGE_PREFIX=uploads
-# FILE_STORAGE_S3_BUCKET=
-# FILE_STORAGE_S3_REGION=
+# FILE_STORAGE_S3_BUCKET=your-bucket-name
+# FILE_STORAGE_S3_REGION=us-east-1
+# AWS_ACCESS_KEY_ID=your-access-key
+# AWS_SECRET_ACCESS_KEY=your-secret-key
+# FILE_STORAGE_S3_ENDPOINT=http://localhost:9000 # Optional: for S3-compatible services like MinIO
diff --git a/docs/tips-guides/s3-storage.md b/docs/tips-guides/s3-storage.md
new file mode 100644
index 000000000..62d789072
--- /dev/null
+++ b/docs/tips-guides/s3-storage.md
@@ -0,0 +1,294 @@
+# S3 Storage Configuration Guide
+
+This guide explains how to configure Amazon S3 or S3-compatible storage services for file uploads in Better Chatbot.
+
+## Overview
+
+The S3 storage driver provides enterprise-grade file storage with the following features:
+
+- **Direct server-side uploads** for AI-generated content
+- **Presigned URLs** for secure client-side uploads
+- **File metadata management** with automatic content type detection
+- **S3-compatible services** support (MinIO, DigitalOcean Spaces, etc.)
+- **Comprehensive error handling** and validation
+
+## Quick Setup
+
+### 1. Amazon S3
+
+1. Create an S3 bucket in your AWS account
+2. Create an IAM user with the following permissions:
+
+```json
+{
+ "Version": "2012-10-17",
+ "Statement": [
+ {
+ "Effect": "Allow",
+ "Action": [
+ "s3:GetObject",
+ "s3:PutObject",
+ "s3:DeleteObject",
+ "s3:HeadObject"
+ ],
+ "Resource": "arn:aws:s3:::your-bucket-name/*"
+ },
+ {
+ "Effect": "Allow",
+ "Action": [
+ "s3:ListBucket"
+ ],
+ "Resource": "arn:aws:s3:::your-bucket-name"
+ }
+ ]
+}
+```
+
+3. Configure your environment variables:
+
+```env
+FILE_STORAGE_TYPE=s3
+FILE_STORAGE_S3_BUCKET=your-bucket-name
+FILE_STORAGE_S3_REGION=us-east-1
+AWS_ACCESS_KEY_ID=your-access-key
+AWS_SECRET_ACCESS_KEY=your-secret-key
+FILE_STORAGE_PREFIX=uploads
+```
+
+### 2. S3-Compatible Services
+
+#### MinIO (Self-hosted)
+
+```env
+FILE_STORAGE_TYPE=s3
+FILE_STORAGE_S3_BUCKET=your-bucket-name
+FILE_STORAGE_S3_REGION=us-east-1
+AWS_ACCESS_KEY_ID=minioadmin
+AWS_SECRET_ACCESS_KEY=minioadmin
+FILE_STORAGE_S3_ENDPOINT=http://localhost:9000
+FILE_STORAGE_PREFIX=uploads
+```
+
+#### DigitalOcean Spaces
+
+```env
+FILE_STORAGE_TYPE=s3
+FILE_STORAGE_S3_BUCKET=your-space-name
+FILE_STORAGE_S3_REGION=nyc3
+AWS_ACCESS_KEY_ID=your-spaces-key
+AWS_SECRET_ACCESS_KEY=your-spaces-secret
+FILE_STORAGE_S3_ENDPOINT=https://nyc3.digitaloceanspaces.com
+FILE_STORAGE_PREFIX=uploads
+```
+
+## Environment Variables
+
+| Variable | Required | Default | Description |
+|----------|----------|---------|-------------|
+| `FILE_STORAGE_TYPE` | Yes | `vercel-blob` | Set to `s3` to enable S3 storage |
+| `FILE_STORAGE_S3_BUCKET` | Yes | - | S3 bucket name |
+| `FILE_STORAGE_S3_REGION` | No | `us-east-1` | AWS region |
+| `AWS_ACCESS_KEY_ID` | Yes | - | AWS access key ID |
+| `AWS_SECRET_ACCESS_KEY` | Yes | - | AWS secret access key |
+| `FILE_STORAGE_S3_ENDPOINT` | No | - | Custom endpoint for S3-compatible services |
+| `FILE_STORAGE_PREFIX` | No | `uploads` | Prefix for all uploaded files |
+
+## Features
+
+### Automatic Content Type Detection
+
+The S3 storage driver automatically detects content types based on file extensions:
+
+- **Images**: `.jpg`, `.png`, `.gif`, `.webp`
+- **Documents**: `.pdf`, `.docx`, `.xlsx`
+- **Media**: `.mp4`, `.mp3`, `.wav`
+- **Text**: `.txt`, `.json`, `.csv`
+- **Fallback**: `application/octet-stream`
+
+### File Organization
+
+Files are automatically organized with the following structure:
+
+```
+uploads/
+├── 2024-01-15/
+│ ├── abc123def456.jpg
+│ └── xyz789uvw012.pdf
+├── 2024-01-16/
+│ └── mno345pqr678.docx
+```
+
+- **Date-based folders**: `YYYY-MM-DD` format
+- **Unique identifiers**: 12-character nanoid
+- **Original extensions**: Preserved from uploaded files
+
+### Presigned URLs
+
+For client-side uploads, the driver generates secure presigned URLs:
+
+```typescript
+const uploadUrl = await storage.createUploadUrl({
+ filename: 'document.pdf',
+ contentType: 'application/pdf',
+ expiresInSeconds: 3600, // 1 hour
+});
+
+// Client can upload directly to uploadUrl.url
+```
+
+### Metadata Management
+
+File metadata is automatically stored and retrieved:
+
+```typescript
+const metadata = await storage.getMetadata('uploads/2024-01-15/abc123def456.jpg');
+// Returns:
+// {
+// key: 'uploads/2024-01-15/abc123def456.jpg',
+// filename: 'profile-picture.jpg',
+// contentType: 'image/jpeg',
+// size: 245760,
+// uploadedAt: Date
+// }
+```
+
+## Security Considerations
+
+### Bucket Permissions
+
+- **Private buckets**: Recommended for sensitive data
+- **Public read**: Only if you need direct public access to files
+- **CORS configuration**: Required for client-side uploads
+
+Example CORS configuration:
+
+```json
+[
+ {
+ "AllowedHeaders": ["*"],
+ "AllowedMethods": ["GET", "PUT", "POST"],
+ "AllowedOrigins": ["https://yourdomain.com"],
+ "ExposeHeaders": ["ETag"],
+ "MaxAgeSeconds": 3000
+ }
+]
+```
+
+### Access Keys
+
+- **Principle of least privilege**: Only grant necessary S3 permissions
+- **Rotation**: Regularly rotate access keys
+- **Environment variables**: Never commit keys to version control
+
+## Troubleshooting
+
+### Common Issues
+
+1. **Bucket not found**
+ - Verify `FILE_STORAGE_S3_BUCKET` is correct
+ - Ensure bucket exists in the specified region
+
+2. **Access denied**
+ - Check IAM permissions
+ - Verify access keys are correct
+ - Ensure bucket policy allows the operations
+
+3. **CORS errors** (client-side uploads)
+ - Configure CORS policy on the bucket
+ - Verify allowed origins match your domain
+
+4. **Endpoint connection issues** (S3-compatible services)
+ - Verify `FILE_STORAGE_S3_ENDPOINT` is accessible
+ - Check if service requires `forcePathStyle: true`
+
+### Testing Configuration
+
+Use the built-in storage check:
+
+```bash
+# The app will validate S3 configuration on startup
+pnpm dev
+
+# Or check via API
+curl http://localhost:3000/api/storage/check
+```
+
+### Debug Logging
+
+Enable debug logging to troubleshoot issues:
+
+```env
+DEBUG=s3*
+```
+
+## Migration from Vercel Blob
+
+To migrate existing files from Vercel Blob to S3:
+
+1. **Backup existing data**
+2. **Update environment variables**
+3. **Test with new uploads**
+4. **Migrate existing files** (custom script required)
+
+Example migration script structure:
+
+```typescript
+// scripts/migrate-to-s3.ts
+import { serverFileStorage as oldStorage } from 'lib/file-storage/vercel-blob-storage';
+import { createS3FileStorage } from 'lib/file-storage/s3-file-storage';
+
+const s3Storage = createS3FileStorage();
+
+// Migrate files...
+```
+
+## Performance Optimization
+
+### Upload Performance
+
+- **Direct uploads**: Use presigned URLs for large files
+- **Multipart uploads**: For files > 100MB (implement if needed)
+- **Compression**: Consider client-side compression
+
+### Download Performance
+
+- **CDN**: Use CloudFront or similar CDN
+- **Caching**: Implement appropriate cache headers
+- **Regions**: Choose regions close to your users
+
+## Cost Optimization
+
+- **Storage classes**: Use appropriate S3 storage classes
+- **Lifecycle policies**: Automatically transition or delete old files
+- **Monitoring**: Track storage usage and costs
+
+Example lifecycle policy:
+
+```json
+{
+ "Rules": [
+ {
+ "Status": "Enabled",
+ "Filter": {"Prefix": "uploads/"},
+ "Transitions": [
+ {
+ "Days": 30,
+ "StorageClass": "STANDARD_IA"
+ },
+ {
+ "Days": 90,
+ "StorageClass": "GLACIER"
+ }
+ ]
+ }
+ ]
+}
+```
+
+## Support
+
+For issues specific to S3 storage:
+
+1. Check the [troubleshooting section](#troubleshooting)
+2. Review AWS S3 documentation
+3. Open an issue with detailed error logs
\ No newline at end of file
diff --git a/package.json b/package.json
index 45fbb2527..33346c19a 100644
--- a/package.json
+++ b/package.json
@@ -112,6 +112,7 @@
"pg": "^8.16.3",
"react": "^19.2.0",
"react-dom": "^19.2.0",
+ "react-dropzone": "^14.3.8",
"react-markdown": "^10.1.0",
"react-resizable-panels": "^2.1.9",
"recharts": "^2.15.4",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index ac24b50bf..5e4af20a7 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -200,6 +200,9 @@ importers:
react-dom:
specifier: ^19.2.0
version: 19.2.0(react@19.2.0)
+ react-dropzone:
+ specifier: ^14.3.8
+ version: 14.3.8(react@19.2.0)
react-markdown:
specifier: ^10.1.0
version: 10.1.0(@types/react@19.2.0)(react@19.2.0)
@@ -3159,6 +3162,10 @@ packages:
async-retry@1.3.3:
resolution: {integrity: sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==}
+ attr-accept@2.2.5:
+ resolution: {integrity: sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ==}
+ engines: {node: '>=4'}
+
available-typed-arrays@1.0.7:
resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==}
engines: {node: '>= 0.4'}
@@ -4114,6 +4121,10 @@ packages:
resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==}
engines: {node: '>=16.0.0'}
+ file-selector@2.1.2:
+ resolution: {integrity: sha512-QgXo+mXTe8ljeqUFaX3QVHc5osSItJ/Km+xpocx0aSqWGMSCf6qYs/VnzZgS864Pjn5iceMRFigeAV7AfTlaig==}
+ engines: {node: '>= 12'}
+
fill-range@7.1.1:
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
engines: {node: '>=8'}
@@ -5377,6 +5388,12 @@ packages:
peerDependencies:
react: ^19.2.0
+ react-dropzone@14.3.8:
+ resolution: {integrity: sha512-sBgODnq+lcA4P296DY4wacOZz3JFpD99fp+hb//iBO2HHnyeZU3FwWyXJ6salNpqQdsZrgMrotuko/BdJMV8Ug==}
+ engines: {node: '>= 10.13'}
+ peerDependencies:
+ react: '>= 16.8 || 18.0.0'
+
react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
@@ -9496,6 +9513,8 @@ snapshots:
dependencies:
retry: 0.13.1
+ attr-accept@2.2.5: {}
+
available-typed-arrays@1.0.7:
dependencies:
possible-typed-array-names: 1.1.0
@@ -10587,6 +10606,10 @@ snapshots:
dependencies:
flat-cache: 4.0.1
+ file-selector@2.1.2:
+ dependencies:
+ tslib: 2.8.1
+
fill-range@7.1.1:
dependencies:
to-regex-range: 5.0.1
@@ -12131,6 +12154,13 @@ snapshots:
react: 19.2.0
scheduler: 0.27.0
+ react-dropzone@14.3.8(react@19.2.0):
+ dependencies:
+ attr-accept: 2.2.5
+ file-selector: 2.1.2
+ prop-types: 15.8.1
+ react: 19.2.0
+
react-is@16.13.1: {}
react-is@18.3.1: {}
diff --git a/src/app/(chat)/analytics/page.tsx b/src/app/(chat)/analytics/page.tsx
new file mode 100644
index 000000000..01262cd35
--- /dev/null
+++ b/src/app/(chat)/analytics/page.tsx
@@ -0,0 +1,25 @@
+import { Metadata } from "next";
+import { AnalyticsDashboard } from "@/components/analytics/analytics-dashboard";
+import { auth } from "lib/auth/server";
+import { redirect } from "next/navigation";
+
+export const metadata: Metadata = {
+ title: "Analytics - Better Chatbot",
+ description: "View your usage analytics and insights",
+};
+
+export default async function AnalyticsPage() {
+ const session = await auth();
+
+ if (!session?.user?.id) {
+ redirect("/auth/signin");
+ }
+
+ return (
+
+ );
+}
\ No newline at end of file
diff --git a/src/app/(chat)/knowledge-base/page.tsx b/src/app/(chat)/knowledge-base/page.tsx
new file mode 100644
index 000000000..1d85109c8
--- /dev/null
+++ b/src/app/(chat)/knowledge-base/page.tsx
@@ -0,0 +1,17 @@
+import { Metadata } from "next";
+import { KnowledgeBaseInterface } from "@/components/knowledge-base/knowledge-base-interface";
+
+export const metadata: Metadata = {
+ title: "Knowledge Base",
+ description: "Manage your personal knowledge documents and notes",
+};
+
+export default function KnowledgeBasePage() {
+ return (
+
+ );
+}
\ No newline at end of file
diff --git a/src/app/(chat)/search/page.tsx b/src/app/(chat)/search/page.tsx
new file mode 100644
index 000000000..12aaa4d58
--- /dev/null
+++ b/src/app/(chat)/search/page.tsx
@@ -0,0 +1,15 @@
+import { Metadata } from "next";
+import { SearchInterface } from "@/components/search/search-interface";
+
+export const metadata: Metadata = {
+ title: "Search - Better Chatbot",
+ description: "Search through your chats and messages",
+};
+
+export default function SearchPage() {
+ return (
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/app/api/analytics/activity/route.ts b/src/app/api/analytics/activity/route.ts
new file mode 100644
index 000000000..0ad89c8b1
--- /dev/null
+++ b/src/app/api/analytics/activity/route.ts
@@ -0,0 +1,47 @@
+import { NextRequest, NextResponse } from "next/server";
+import { auth } from "lib/auth/server";
+import { getChatActivity } from "lib/analytics/analytics-service";
+import { z } from "zod";
+
+const activityQuerySchema = z.object({
+ timeframe: z.enum(["day", "week", "month"]).default("week"),
+ global: z.coerce.boolean().default(false),
+});
+
+export async function GET(request: NextRequest) {
+ try {
+ const session = await auth();
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const { searchParams } = new URL(request.url);
+ const queryParams = Object.fromEntries(searchParams.entries());
+
+ const validatedParams = activityQuerySchema.parse(queryParams);
+
+ const userId = validatedParams.global ? undefined : session.user.id;
+ const activity = await getChatActivity(userId, validatedParams.timeframe);
+
+ return NextResponse.json({
+ activity,
+ timeframe: validatedParams.timeframe,
+ global: validatedParams.global,
+ });
+
+ } catch (error) {
+ console.error("Analytics activity error:", error);
+
+ if (error instanceof z.ZodError) {
+ return NextResponse.json(
+ { error: "Invalid query parameters", details: error.errors },
+ { status: 400 }
+ );
+ }
+
+ return NextResponse.json(
+ { error: "Internal server error" },
+ { status: 500 }
+ );
+ }
+}
\ No newline at end of file
diff --git a/src/app/api/analytics/stats/route.ts b/src/app/api/analytics/stats/route.ts
new file mode 100644
index 000000000..c7f37a055
--- /dev/null
+++ b/src/app/api/analytics/stats/route.ts
@@ -0,0 +1,54 @@
+import { NextRequest, NextResponse } from "next/server";
+import { auth } from "lib/auth/server";
+import { getUserStats, getUsageStats } from "lib/analytics/analytics-service";
+import { z } from "zod";
+
+const statsQuerySchema = z.object({
+ timeframe: z.enum(["day", "week", "month", "all"]).default("week"),
+ type: z.enum(["user", "global"]).default("user"),
+});
+
+export async function GET(request: NextRequest) {
+ try {
+ const session = await auth();
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const { searchParams } = new URL(request.url);
+ const queryParams = Object.fromEntries(searchParams.entries());
+
+ const validatedParams = statsQuerySchema.parse(queryParams);
+
+ let stats;
+
+ if (validatedParams.type === "global") {
+ // Only allow admins to view global stats
+ // For now, we'll allow all users - in production you'd check user role
+ stats = await getUsageStats(validatedParams.timeframe);
+ } else {
+ stats = await getUserStats(session.user.id, validatedParams.timeframe);
+ }
+
+ return NextResponse.json({
+ stats,
+ timeframe: validatedParams.timeframe,
+ type: validatedParams.type,
+ });
+
+ } catch (error) {
+ console.error("Analytics stats error:", error);
+
+ if (error instanceof z.ZodError) {
+ return NextResponse.json(
+ { error: "Invalid query parameters", details: error.errors },
+ { status: 400 }
+ );
+ }
+
+ return NextResponse.json(
+ { error: "Internal server error" },
+ { status: 500 }
+ );
+ }
+}
\ No newline at end of file
diff --git a/src/app/api/analytics/track/route.ts b/src/app/api/analytics/track/route.ts
new file mode 100644
index 000000000..c46b78068
--- /dev/null
+++ b/src/app/api/analytics/track/route.ts
@@ -0,0 +1,56 @@
+import { NextRequest, NextResponse } from "next/server";
+import { auth } from "lib/auth/server";
+import { trackEvent } from "lib/analytics/analytics-service";
+import { z } from "zod";
+
+const trackEventSchema = z.object({
+ eventType: z.string().min(1, "Event type is required"),
+ eventData: z.record(z.any()).optional(),
+ sessionId: z.string().optional(),
+});
+
+export async function POST(request: NextRequest) {
+ try {
+ const session = await auth();
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const body = await request.json();
+ const validatedData = trackEventSchema.parse(body);
+
+ // Get client information
+ const userAgent = request.headers.get("user-agent") || undefined;
+ const forwardedFor = request.headers.get("x-forwarded-for");
+ const ipAddress = forwardedFor ? forwardedFor.split(",")[0].trim() :
+ request.headers.get("x-real-ip") ||
+ "unknown";
+
+ await trackEvent({
+ userId: session.user.id,
+ eventType: validatedData.eventType,
+ eventData: validatedData.eventData,
+ sessionId: validatedData.sessionId,
+ userAgent,
+ ipAddress,
+ timestamp: new Date(),
+ });
+
+ return NextResponse.json({ success: true });
+
+ } catch (error) {
+ console.error("Analytics tracking error:", error);
+
+ if (error instanceof z.ZodError) {
+ return NextResponse.json(
+ { error: "Invalid request body", details: error.errors },
+ { status: 400 }
+ );
+ }
+
+ return NextResponse.json(
+ { error: "Internal server error" },
+ { status: 500 }
+ );
+ }
+}
\ No newline at end of file
diff --git a/src/app/api/files/process/route.ts b/src/app/api/files/process/route.ts
new file mode 100644
index 000000000..274c3417f
--- /dev/null
+++ b/src/app/api/files/process/route.ts
@@ -0,0 +1,157 @@
+import { NextRequest, NextResponse } from "next/server";
+import { auth } from "lib/auth/server";
+import { fileProcessor, FileProcessingOptions } from "lib/file-processing/file-processor";
+import { z } from "zod";
+
+const fileProcessingSchema = z.object({
+ extractText: z.boolean().default(true),
+ generateThumbnails: z.boolean().default(false),
+ generateSummary: z.boolean().default(false),
+ extractKeywords: z.boolean().default(false),
+ detectEntities: z.boolean().default(false),
+ maxTextLength: z.number().min(100).max(50000).default(10000),
+ thumbnailSizes: z.array(z.object({
+ width: z.number().min(50).max(1000),
+ height: z.number().min(50).max(1000),
+ })).optional(),
+});
+
+export async function POST(request: NextRequest) {
+ try {
+ const session = await auth();
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const formData = await request.formData();
+ const file = formData.get("file") as File;
+ const optionsStr = formData.get("options") as string;
+
+ if (!file) {
+ return NextResponse.json(
+ { error: "No file provided" },
+ { status: 400 }
+ );
+ }
+
+ // Validate file size (max 50MB)
+ const maxSize = 50 * 1024 * 1024; // 50MB
+ if (file.size > maxSize) {
+ return NextResponse.json(
+ { error: "File too large. Maximum size is 50MB." },
+ { status: 400 }
+ );
+ }
+
+ // Parse processing options
+ let options: FileProcessingOptions = {};
+ if (optionsStr) {
+ try {
+ const parsedOptions = JSON.parse(optionsStr);
+ options = fileProcessingSchema.parse(parsedOptions);
+ } catch (error) {
+ return NextResponse.json(
+ { error: "Invalid processing options" },
+ { status: 400 }
+ );
+ }
+ }
+
+ // Process the file
+ const result = await fileProcessor.processFile(file, file.name, options);
+
+ return NextResponse.json({
+ success: true,
+ result,
+ });
+
+ } catch (error) {
+ console.error("File processing error:", error);
+
+ if (error instanceof z.ZodError) {
+ return NextResponse.json(
+ { error: "Invalid request parameters", details: error.errors },
+ { status: 400 }
+ );
+ }
+
+ return NextResponse.json(
+ { error: "Internal server error" },
+ { status: 500 }
+ );
+ }
+}
+
+export async function GET(request: NextRequest) {
+ try {
+ const session = await auth();
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ // Return supported file types and processing capabilities
+ const supportedTypes = {
+ text: [
+ 'text/plain',
+ 'text/markdown',
+ 'text/html',
+ 'text/csv',
+ 'application/json',
+ 'application/xml',
+ ],
+ documents: [
+ 'application/pdf',
+ 'application/msword',
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
+ 'application/vnd.ms-excel',
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+ 'application/vnd.ms-powerpoint',
+ 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
+ ],
+ images: [
+ 'image/jpeg',
+ 'image/png',
+ 'image/gif',
+ 'image/svg+xml',
+ ],
+ audio: [
+ 'audio/mpeg',
+ 'audio/wav',
+ 'audio/ogg',
+ ],
+ video: [
+ 'video/mp4',
+ 'video/avi',
+ 'video/quicktime',
+ ],
+ };
+
+ const capabilities = {
+ textExtraction: true,
+ thumbnailGeneration: true,
+ summaryGeneration: true,
+ keywordExtraction: true,
+ entityDetection: true,
+ metadataExtraction: true,
+ };
+
+ const limits = {
+ maxFileSize: 50 * 1024 * 1024, // 50MB
+ maxTextLength: 50000,
+ maxThumbnailSize: { width: 1000, height: 1000 },
+ };
+
+ return NextResponse.json({
+ supportedTypes,
+ capabilities,
+ limits,
+ });
+
+ } catch (error) {
+ console.error("File processing info error:", error);
+ return NextResponse.json(
+ { error: "Internal server error" },
+ { status: 500 }
+ );
+ }
+}
\ No newline at end of file
diff --git a/src/app/api/knowledge-base/documents/[id]/route.ts b/src/app/api/knowledge-base/documents/[id]/route.ts
new file mode 100644
index 000000000..d10d46635
--- /dev/null
+++ b/src/app/api/knowledge-base/documents/[id]/route.ts
@@ -0,0 +1,133 @@
+import { NextRequest, NextResponse } from "next/server";
+import { auth } from "lib/auth/server";
+import { knowledgeBaseService } from "lib/knowledge-base/knowledge-base-service";
+import { z } from "zod";
+
+const updateDocumentSchema = z.object({
+ title: z.string().min(1).max(200).optional(),
+ content: z.string().min(1).optional(),
+ summary: z.string().optional(),
+ tags: z.array(z.string()).optional(),
+ category: z.string().optional(),
+ isPublic: z.boolean().optional(),
+});
+
+export async function GET(
+ request: NextRequest,
+ { params }: { params: { id: string } }
+) {
+ try {
+ const session = await auth();
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const document = await knowledgeBaseService.getDocument(
+ params.id,
+ session.user.id
+ );
+
+ if (!document) {
+ return NextResponse.json(
+ { error: "Document not found" },
+ { status: 404 }
+ );
+ }
+
+ return NextResponse.json({
+ success: true,
+ document,
+ });
+
+ } catch (error) {
+ console.error("Get document error:", error);
+ return NextResponse.json(
+ { error: "Internal server error" },
+ { status: 500 }
+ );
+ }
+}
+
+export async function PUT(
+ request: NextRequest,
+ { params }: { params: { id: string } }
+) {
+ try {
+ const session = await auth();
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const body = await request.json();
+ const validatedData = updateDocumentSchema.parse(body);
+
+ const document = await knowledgeBaseService.updateDocument(
+ params.id,
+ session.user.id,
+ validatedData
+ );
+
+ if (!document) {
+ return NextResponse.json(
+ { error: "Document not found" },
+ { status: 404 }
+ );
+ }
+
+ return NextResponse.json({
+ success: true,
+ document,
+ });
+
+ } catch (error) {
+ console.error("Update document error:", error);
+
+ if (error instanceof z.ZodError) {
+ return NextResponse.json(
+ { error: "Invalid request data", details: error.errors },
+ { status: 400 }
+ );
+ }
+
+ return NextResponse.json(
+ { error: "Internal server error" },
+ { status: 500 }
+ );
+ }
+}
+
+export async function DELETE(
+ request: NextRequest,
+ { params }: { params: { id: string } }
+) {
+ try {
+ const session = await auth();
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const success = await knowledgeBaseService.deleteDocument(
+ params.id,
+ session.user.id
+ );
+
+ if (!success) {
+ return NextResponse.json(
+ { error: "Document not found" },
+ { status: 404 }
+ );
+ }
+
+ return NextResponse.json({
+ success: true,
+ message: "Document deleted successfully",
+ });
+
+ } catch (error) {
+ console.error("Delete document error:", error);
+ return NextResponse.json(
+ { error: "Internal server error" },
+ { status: 500 }
+ );
+ }
+}
\ No newline at end of file
diff --git a/src/app/api/knowledge-base/documents/route.ts b/src/app/api/knowledge-base/documents/route.ts
new file mode 100644
index 000000000..34d9e19b7
--- /dev/null
+++ b/src/app/api/knowledge-base/documents/route.ts
@@ -0,0 +1,105 @@
+import { NextRequest, NextResponse } from "next/server";
+import { auth } from "lib/auth/server";
+import { knowledgeBaseService } from "lib/knowledge-base/knowledge-base-service";
+import { z } from "zod";
+
+const createDocumentSchema = z.object({
+ title: z.string().min(1).max(200),
+ content: z.string().min(1),
+ summary: z.string().optional(),
+ tags: z.array(z.string()).default([]),
+ category: z.string().optional(),
+ sourceType: z.enum(['manual', 'file_upload', 'chat_export', 'web_import']).default('manual'),
+ sourceMetadata: z.object({
+ filename: z.string().optional(),
+ url: z.string().optional(),
+ chatId: z.string().optional(),
+ fileType: z.string().optional(),
+ }).optional(),
+ isPublic: z.boolean().default(false),
+});
+
+const updateDocumentSchema = createDocumentSchema.partial();
+
+const getDocumentsSchema = z.object({
+ limit: z.coerce.number().min(1).max(100).default(20),
+ offset: z.coerce.number().min(0).default(0),
+ category: z.string().optional(),
+ sortBy: z.enum(['created', 'updated', 'accessed', 'title']).default('updated'),
+ sortOrder: z.enum(['asc', 'desc']).default('desc'),
+});
+
+export async function POST(request: NextRequest) {
+ try {
+ const session = await auth();
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const body = await request.json();
+ const validatedData = createDocumentSchema.parse(body);
+
+ const document = await knowledgeBaseService.createDocument({
+ ...validatedData,
+ userId: session.user.id,
+ });
+
+ return NextResponse.json({
+ success: true,
+ document,
+ });
+
+ } catch (error) {
+ console.error("Create document error:", error);
+
+ if (error instanceof z.ZodError) {
+ return NextResponse.json(
+ { error: "Invalid request data", details: error.errors },
+ { status: 400 }
+ );
+ }
+
+ return NextResponse.json(
+ { error: "Internal server error" },
+ { status: 500 }
+ );
+ }
+}
+
+export async function GET(request: NextRequest) {
+ try {
+ const session = await auth();
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const { searchParams } = new URL(request.url);
+ const params = Object.fromEntries(searchParams.entries());
+ const validatedParams = getDocumentsSchema.parse(params);
+
+ const result = await knowledgeBaseService.getUserDocuments(
+ session.user.id,
+ validatedParams
+ );
+
+ return NextResponse.json({
+ success: true,
+ ...result,
+ });
+
+ } catch (error) {
+ console.error("Get documents error:", error);
+
+ if (error instanceof z.ZodError) {
+ return NextResponse.json(
+ { error: "Invalid request parameters", details: error.errors },
+ { status: 400 }
+ );
+ }
+
+ return NextResponse.json(
+ { error: "Internal server error" },
+ { status: 500 }
+ );
+ }
+}
\ No newline at end of file
diff --git a/src/app/api/knowledge-base/search/route.ts b/src/app/api/knowledge-base/search/route.ts
new file mode 100644
index 000000000..1b7847585
--- /dev/null
+++ b/src/app/api/knowledge-base/search/route.ts
@@ -0,0 +1,96 @@
+import { NextRequest, NextResponse } from "next/server";
+import { auth } from "lib/auth/server";
+import { knowledgeBaseService } from "lib/knowledge-base/knowledge-base-service";
+import { z } from "zod";
+
+const searchSchema = z.object({
+ query: z.string().min(1),
+ category: z.string().optional(),
+ tags: z.array(z.string()).optional(),
+ sourceType: z.string().optional(),
+ limit: z.coerce.number().min(1).max(50).default(20),
+ offset: z.coerce.number().min(0).default(0),
+ sortBy: z.enum(['relevance', 'created', 'updated', 'accessed']).default('relevance'),
+ sortOrder: z.enum(['asc', 'desc']).default('desc'),
+});
+
+export async function GET(request: NextRequest) {
+ try {
+ const session = await auth();
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const { searchParams } = new URL(request.url);
+ const params = Object.fromEntries(searchParams.entries());
+
+ // Handle tags parameter (can be comma-separated)
+ if (params.tags) {
+ params.tags = params.tags.split(',').map((tag: string) => tag.trim());
+ }
+
+ const validatedParams = searchSchema.parse(params);
+
+ const result = await knowledgeBaseService.searchDocuments({
+ ...validatedParams,
+ userId: session.user.id,
+ });
+
+ return NextResponse.json({
+ success: true,
+ ...result,
+ });
+
+ } catch (error) {
+ console.error("Search documents error:", error);
+
+ if (error instanceof z.ZodError) {
+ return NextResponse.json(
+ { error: "Invalid search parameters", details: error.errors },
+ { status: 400 }
+ );
+ }
+
+ return NextResponse.json(
+ { error: "Internal server error" },
+ { status: 500 }
+ );
+ }
+}
+
+export async function POST(request: NextRequest) {
+ try {
+ const session = await auth();
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const body = await request.json();
+ const validatedData = searchSchema.parse(body);
+
+ const result = await knowledgeBaseService.searchDocuments({
+ ...validatedData,
+ userId: session.user.id,
+ });
+
+ return NextResponse.json({
+ success: true,
+ ...result,
+ });
+
+ } catch (error) {
+ console.error("Search documents error:", error);
+
+ if (error instanceof z.ZodError) {
+ return NextResponse.json(
+ { error: "Invalid search parameters", details: error.errors },
+ { status: 400 }
+ );
+ }
+
+ return NextResponse.json(
+ { error: "Internal server error" },
+ { status: 500 }
+ );
+ }
+}
\ No newline at end of file
diff --git a/src/app/api/knowledge-base/stats/route.ts b/src/app/api/knowledge-base/stats/route.ts
new file mode 100644
index 000000000..4dc4952d9
--- /dev/null
+++ b/src/app/api/knowledge-base/stats/route.ts
@@ -0,0 +1,26 @@
+import { NextRequest, NextResponse } from "next/server";
+import { auth } from "lib/auth/server";
+import { knowledgeBaseService } from "lib/knowledge-base/knowledge-base-service";
+
+export async function GET(request: NextRequest) {
+ try {
+ const session = await auth();
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const stats = await knowledgeBaseService.getUserStats(session.user.id);
+
+ return NextResponse.json({
+ success: true,
+ stats,
+ });
+
+ } catch (error) {
+ console.error("Get knowledge base stats error:", error);
+ return NextResponse.json(
+ { error: "Internal server error" },
+ { status: 500 }
+ );
+ }
+}
\ No newline at end of file
diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts
new file mode 100644
index 000000000..acfa86b9c
--- /dev/null
+++ b/src/app/api/search/route.ts
@@ -0,0 +1,162 @@
+import { NextRequest, NextResponse } from "next/server";
+import { auth } from "lib/auth/server";
+import { searchChats, searchMessages, SearchFilters, SearchResult } from "lib/search/search-service";
+import { z } from "zod";
+
+const searchQuerySchema = z.object({
+ q: z.string().min(1, "Query is required"),
+ type: z.enum(["all", "chats", "messages"]).default("all"),
+ limit: z.coerce.number().min(1).max(100).default(20),
+ offset: z.coerce.number().min(0).default(0),
+ dateFrom: z.string().optional(),
+ dateTo: z.string().optional(),
+ projectId: z.string().optional(),
+});
+
+export async function GET(request: NextRequest) {
+ try {
+ const session = await auth();
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const { searchParams } = new URL(request.url);
+ const queryParams = Object.fromEntries(searchParams.entries());
+
+ const validatedParams = searchQuerySchema.parse(queryParams);
+
+ const filters: SearchFilters = {
+ userId: session.user.id,
+ projectId: validatedParams.projectId,
+ dateFrom: validatedParams.dateFrom ? new Date(validatedParams.dateFrom) : undefined,
+ dateTo: validatedParams.dateTo ? new Date(validatedParams.dateTo) : undefined,
+ };
+
+ let results: SearchResult[] = [];
+
+ switch (validatedParams.type) {
+ case "chats":
+ results = await searchChats(
+ validatedParams.q,
+ filters,
+ validatedParams.limit,
+ validatedParams.offset
+ );
+ break;
+ case "messages":
+ results = await searchMessages(
+ validatedParams.q,
+ filters,
+ validatedParams.limit,
+ validatedParams.offset
+ );
+ break;
+ case "all":
+ default:
+ const [chatResults, messageResults] = await Promise.all([
+ searchChats(validatedParams.q, filters, Math.ceil(validatedParams.limit / 2), 0),
+ searchMessages(validatedParams.q, filters, Math.ceil(validatedParams.limit / 2), 0),
+ ]);
+ results = [...chatResults, ...messageResults]
+ .sort((a, b) => b.score - a.score)
+ .slice(0, validatedParams.limit);
+ break;
+ }
+
+ return NextResponse.json({
+ results,
+ query: validatedParams.q,
+ type: validatedParams.type,
+ total: results.length,
+ hasMore: results.length === validatedParams.limit,
+ });
+
+ } catch (error) {
+ console.error("Search error:", error);
+
+ if (error instanceof z.ZodError) {
+ return NextResponse.json(
+ { error: "Invalid query parameters", details: error.errors },
+ { status: 400 }
+ );
+ }
+
+ return NextResponse.json(
+ { error: "Internal server error" },
+ { status: 500 }
+ );
+ }
+}
+
+export async function POST(request: NextRequest) {
+ try {
+ const session = await auth();
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const body = await request.json();
+ const validatedParams = searchQuerySchema.parse(body);
+
+ const filters: SearchFilters = {
+ userId: session.user.id,
+ projectId: validatedParams.projectId,
+ dateFrom: validatedParams.dateFrom ? new Date(validatedParams.dateFrom) : undefined,
+ dateTo: validatedParams.dateTo ? new Date(validatedParams.dateTo) : undefined,
+ };
+
+ let results: SearchResult[] = [];
+
+ switch (validatedParams.type) {
+ case "chats":
+ results = await searchChats(
+ validatedParams.q,
+ filters,
+ validatedParams.limit,
+ validatedParams.offset
+ );
+ break;
+ case "messages":
+ results = await searchMessages(
+ validatedParams.q,
+ filters,
+ validatedParams.limit,
+ validatedParams.offset
+ );
+ break;
+ case "all":
+ default:
+ const [chatResults, messageResults] = await Promise.all([
+ searchChats(validatedParams.q, filters, Math.ceil(validatedParams.limit / 2), 0),
+ searchMessages(validatedParams.q, filters, Math.ceil(validatedParams.limit / 2), 0),
+ ]);
+ results = [...chatResults, ...messageResults]
+ .sort((a, b) => b.score - a.score)
+ .slice(0, validatedParams.limit);
+ break;
+ }
+
+ return NextResponse.json({
+ results,
+ query: validatedParams.q,
+ type: validatedParams.type,
+ total: results.length,
+ hasMore: results.length === validatedParams.limit,
+ });
+
+ } catch (error) {
+ console.error("Search error:", error);
+
+ if (error instanceof z.ZodError) {
+ return NextResponse.json(
+ { error: "Invalid request body", details: error.errors },
+ { status: 400 }
+ );
+ }
+
+ return NextResponse.json(
+ { error: "Internal server error" },
+ { status: 500 }
+ );
+ }
+}
\ No newline at end of file
diff --git a/src/app/api/search/suggestions/route.ts b/src/app/api/search/suggestions/route.ts
new file mode 100644
index 000000000..98178541f
--- /dev/null
+++ b/src/app/api/search/suggestions/route.ts
@@ -0,0 +1,49 @@
+import { NextRequest, NextResponse } from "next/server";
+import { auth } from "lib/auth/server";
+import { getSearchSuggestions } from "lib/search/search-service";
+import { z } from "zod";
+
+const suggestionsQuerySchema = z.object({
+ q: z.string().min(1, "Query is required"),
+ limit: z.coerce.number().min(1).max(20).default(5),
+});
+
+export async function GET(request: NextRequest) {
+ try {
+ const session = await auth();
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const { searchParams } = new URL(request.url);
+ const queryParams = Object.fromEntries(searchParams.entries());
+
+ const validatedParams = suggestionsQuerySchema.parse(queryParams);
+
+ const suggestions = await getSearchSuggestions(
+ session.user.id,
+ validatedParams.q,
+ validatedParams.limit
+ );
+
+ return NextResponse.json({
+ suggestions,
+ query: validatedParams.q,
+ });
+
+ } catch (error) {
+ console.error("Search suggestions error:", error);
+
+ if (error instanceof z.ZodError) {
+ return NextResponse.json(
+ { error: "Invalid query parameters", details: error.errors },
+ { status: 400 }
+ );
+ }
+
+ return NextResponse.json(
+ { error: "Internal server error" },
+ { status: 500 }
+ );
+ }
+}
\ No newline at end of file
diff --git a/src/app/api/storage/actions.ts b/src/app/api/storage/actions.ts
index 81f24fbbb..caed45ce2 100644
--- a/src/app/api/storage/actions.ts
+++ b/src/app/api/storage/actions.ts
@@ -47,13 +47,27 @@ export async function checkStorageAction(): Promise {
// 2. Check S3 configuration
if (storageDriver === "s3") {
- return {
- isValid: false,
- error: "S3 storage is not yet implemented",
- solution:
- "S3 storage support is coming soon.\n" +
- "For now, please use Vercel Blob (default)",
- };
+ const requiredEnvVars = [
+ "FILE_STORAGE_S3_BUCKET",
+ "AWS_ACCESS_KEY_ID",
+ "AWS_SECRET_ACCESS_KEY"
+ ];
+
+ const missingVars = requiredEnvVars.filter(varName => !process.env[varName]);
+
+ if (missingVars.length > 0) {
+ return {
+ isValid: false,
+ error: `Missing required S3 environment variables: ${missingVars.join(", ")}`,
+ solution:
+ "Please set the following environment variables:\n" +
+ "- FILE_STORAGE_S3_BUCKET: Your S3 bucket name\n" +
+ "- FILE_STORAGE_S3_REGION: AWS region (optional, defaults to us-east-1)\n" +
+ "- AWS_ACCESS_KEY_ID: Your AWS access key\n" +
+ "- AWS_SECRET_ACCESS_KEY: Your AWS secret key\n" +
+ "- FILE_STORAGE_S3_ENDPOINT: Custom endpoint for S3-compatible services (optional)",
+ };
+ }
}
// 3. Validate storage driver
@@ -64,7 +78,7 @@ export async function checkStorageAction(): Promise {
solution:
"FILE_STORAGE_TYPE must be one of:\n" +
"- 'vercel-blob' (default)\n" +
- "- 's3' (coming soon)",
+ "- 's3' (fully supported)",
};
}
diff --git a/src/components/analytics/analytics-dashboard.tsx b/src/components/analytics/analytics-dashboard.tsx
new file mode 100644
index 000000000..b37f6556f
--- /dev/null
+++ b/src/components/analytics/analytics-dashboard.tsx
@@ -0,0 +1,423 @@
+"use client";
+
+import { useState, useEffect } from "react";
+import { Card, CardContent, CardHeader, CardTitle } from "ui/card";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "ui/tabs";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "ui/select";
+import { Badge } from "ui/badge";
+import { Skeleton } from "ui/skeleton";
+import {
+ BarChart,
+ Bar,
+ XAxis,
+ YAxis,
+ CartesianGrid,
+ Tooltip,
+ ResponsiveContainer,
+ LineChart,
+ Line,
+ PieChart,
+ Pie,
+ Cell
+} from "recharts";
+import {
+ MessageSquare,
+ Hash,
+ Users,
+ TrendingUp,
+ Clock,
+ Zap,
+ Brain,
+ Search
+} from "lucide-react";
+import { UserStats, UsageStats } from "lib/analytics/analytics-service";
+
+interface AnalyticsDashboardProps {
+ userId?: string;
+ showGlobalStats?: boolean;
+}
+
+interface ActivityData {
+ date: string;
+ chats: number;
+ messages: number;
+}
+
+const COLORS = ['#0088FE', '#00C49F', '#FFBB28', '#FF8042', '#8884D8'];
+
+export function AnalyticsDashboard({ userId, showGlobalStats = false }: AnalyticsDashboardProps) {
+ const [timeframe, setTimeframe] = useState<"day" | "week" | "month" | "all">("week");
+ const [userStats, setUserStats] = useState(null);
+ const [globalStats, setGlobalStats] = useState(null);
+ const [activity, setActivity] = useState([]);
+ const [isLoading, setIsLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ fetchAnalyticsData();
+ }, [timeframe, userId, showGlobalStats]);
+
+ const fetchAnalyticsData = async () => {
+ setIsLoading(true);
+ setError(null);
+
+ try {
+ const [statsResponse, activityResponse] = await Promise.all([
+ fetch(`/api/analytics/stats?timeframe=${timeframe}&type=${showGlobalStats ? 'global' : 'user'}`),
+ fetch(`/api/analytics/activity?timeframe=${timeframe}&global=${showGlobalStats}`)
+ ]);
+
+ if (!statsResponse.ok || !activityResponse.ok) {
+ throw new Error('Failed to fetch analytics data');
+ }
+
+ const statsData = await statsResponse.json();
+ const activityData = await activityResponse.json();
+
+ if (showGlobalStats) {
+ setGlobalStats(statsData.stats);
+ } else {
+ setUserStats(statsData.stats);
+ }
+
+ setActivity(activityData.activity);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'An error occurred');
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ const formatDate = (dateString: string) => {
+ const date = new Date(dateString);
+ return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
+ };
+
+ const stats = showGlobalStats ? globalStats : userStats;
+
+ if (isLoading) {
+ return ;
+ }
+
+ if (error) {
+ return (
+
+
+ Error loading analytics: {error}
+
+
+ );
+ }
+
+ return (
+
+ {/* Header */}
+
+
+
+ {showGlobalStats ? 'Global Analytics' : 'Your Analytics'}
+
+
+ {showGlobalStats ? 'System-wide usage statistics' : 'Your personal usage statistics'}
+
+
+
+
+
+
+ {/* Key Metrics */}
+
+ }
+ trend="+12%"
+ />
+ }
+ trend="+8%"
+ />
+ }
+ trend="+5%"
+ />
+ {showGlobalStats && (
+ }
+ trend="+15%"
+ />
+ )}
+
+
+
+
+ Overview
+ Models
+ Tools
+ Activity
+
+
+
+
+ {/* Activity Chart */}
+
+
+ Chat Activity
+
+
+
+
+
+
+
+ formatDate(value as string)}
+ />
+
+
+
+
+
+
+
+ {/* Quick Stats */}
+
+
+ Quick Stats
+
+
+ {!showGlobalStats && userStats && (
+ <>
+
+ Member since
+
+ {userStats.joinedAt.toLocaleDateString()}
+
+
+
+ Last active
+
+ {userStats.lastActiveAt.toLocaleDateString()}
+
+
+ >
+ )}
+
+ {showGlobalStats && globalStats && (
+ <>
+
+ Daily Active Users
+ {globalStats.dailyActiveUsers}
+
+
+ Weekly Active Users
+ {globalStats.weeklyActiveUsers}
+
+
+ Monthly Active Users
+ {globalStats.monthlyActiveUsers}
+
+ >
+ )}
+
+
+
+
+
+
+
+ {/* Model Usage Chart */}
+
+
+ Model Usage
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Model Distribution */}
+
+
+ Model Distribution
+
+
+
+
+ `${model} ${(percent * 100).toFixed(0)}%`}
+ outerRadius={80}
+ fill="#8884d8"
+ dataKey="count"
+ >
+ {(stats?.mostUsedModels || []).map((entry, index) => (
+ |
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+
+ Tool Usage
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Daily Activity
+
+
+
+
+
+
+
+ formatDate(value as string)}
+ />
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function MetricCard({
+ title,
+ value,
+ icon,
+ trend
+}: {
+ title: string;
+ value: number;
+ icon: React.ReactNode;
+ trend?: string;
+}) {
+ return (
+
+
+
+
+
{title}
+
{value.toLocaleString()}
+ {trend && (
+
+
+ {trend}
+
+ )}
+
+
+ {icon}
+
+
+
+
+ );
+}
+
+function AnalyticsSkeletonLoader() {
+ return (
+
+
+
+
+ {Array.from({ length: 4 }).map((_, i) => (
+
+
+
+
+
+
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/file-processing/file-processor-dialog.tsx b/src/components/file-processing/file-processor-dialog.tsx
new file mode 100644
index 000000000..7e05306db
--- /dev/null
+++ b/src/components/file-processing/file-processor-dialog.tsx
@@ -0,0 +1,533 @@
+"use client";
+
+import { useState, useCallback } from "react";
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "ui/dialog";
+import { Button } from "ui/button";
+import { Card, CardContent, CardHeader, CardTitle } from "ui/card";
+import { Badge } from "ui/badge";
+import { Checkbox } from "ui/checkbox";
+import { Label } from "ui/label";
+import { Input } from "ui/input";
+import { Textarea } from "ui/textarea";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "ui/tabs";
+import { ScrollArea } from "ui/scroll-area";
+import { Separator } from "ui/separator";
+import { Progress } from "ui/progress";
+import {
+ Upload,
+ FileText,
+ Image,
+ Hash,
+ Brain,
+ Eye,
+ Download,
+ Copy,
+ Check,
+ AlertCircle,
+ Loader2
+} from "lucide-react";
+import { useDropzone } from "react-dropzone";
+import { ProcessedFileContent, FileProcessingOptions } from "lib/file-processing/file-processor";
+import { useAnalytics } from "@/hooks/use-analytics";
+import { cn } from "lib/utils";
+
+interface FileProcessorDialogProps {
+ trigger?: React.ReactNode;
+ onFileProcessed?: (result: ProcessedFileContent) => void;
+}
+
+export function FileProcessorDialog({ trigger, onFileProcessed }: FileProcessorDialogProps) {
+ const [isOpen, setIsOpen] = useState(false);
+ const [isProcessing, setIsProcessing] = useState(false);
+ const [processingProgress, setProcessingProgress] = useState(0);
+ const [selectedFile, setSelectedFile] = useState(null);
+ const [processedResult, setProcessedResult] = useState(null);
+ const [error, setError] = useState(null);
+ const [copiedText, setCopiedText] = useState(null);
+
+ const [options, setOptions] = useState({
+ extractText: true,
+ generateThumbnails: false,
+ generateSummary: false,
+ extractKeywords: false,
+ detectEntities: false,
+ maxTextLength: 10000,
+ });
+
+ const { trackFeatureUsed } = useAnalytics();
+
+ const onDrop = useCallback((acceptedFiles: File[]) => {
+ if (acceptedFiles.length > 0) {
+ setSelectedFile(acceptedFiles[0]);
+ setProcessedResult(null);
+ setError(null);
+ }
+ }, []);
+
+ const { getRootProps, getInputProps, isDragActive } = useDropzone({
+ onDrop,
+ maxFiles: 1,
+ maxSize: 50 * 1024 * 1024, // 50MB
+ accept: {
+ 'text/*': ['.txt', '.md', '.html', '.css', '.js', '.json', '.xml', '.csv'],
+ 'application/pdf': ['.pdf'],
+ 'application/msword': ['.doc'],
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'],
+ 'application/vnd.ms-excel': ['.xls'],
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'],
+ 'application/vnd.ms-powerpoint': ['.ppt'],
+ 'application/vnd.openxmlformats-officedocument.presentationml.presentation': ['.pptx'],
+ 'image/*': ['.jpg', '.jpeg', '.png', '.gif', '.svg'],
+ 'audio/*': ['.mp3', '.wav', '.ogg'],
+ 'video/*': ['.mp4', '.avi', '.mov'],
+ },
+ });
+
+ const processFile = async () => {
+ if (!selectedFile) return;
+
+ setIsProcessing(true);
+ setProcessingProgress(0);
+ setError(null);
+
+ try {
+ // Simulate progress updates
+ const progressInterval = setInterval(() => {
+ setProcessingProgress(prev => Math.min(prev + 10, 90));
+ }, 200);
+
+ const formData = new FormData();
+ formData.append('file', selectedFile);
+ formData.append('options', JSON.stringify(options));
+
+ const response = await fetch('/api/files/process', {
+ method: 'POST',
+ body: formData,
+ });
+
+ clearInterval(progressInterval);
+ setProcessingProgress(100);
+
+ if (!response.ok) {
+ const errorData = await response.json();
+ throw new Error(errorData.error || 'Processing failed');
+ }
+
+ const data = await response.json();
+ setProcessedResult(data.result);
+ onFileProcessed?.(data.result);
+
+ // Track usage
+ trackFeatureUsed('file_processing', {
+ fileType: selectedFile.type,
+ fileSize: selectedFile.size,
+ options: options,
+ });
+
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'An error occurred');
+ } finally {
+ setIsProcessing(false);
+ setTimeout(() => setProcessingProgress(0), 1000);
+ }
+ };
+
+ const copyToClipboard = async (text: string, type: string) => {
+ try {
+ await navigator.clipboard.writeText(text);
+ setCopiedText(type);
+ setTimeout(() => setCopiedText(null), 2000);
+ } catch (err) {
+ console.error('Failed to copy text:', err);
+ }
+ };
+
+ const formatFileSize = (bytes: number) => {
+ if (bytes === 0) return '0 Bytes';
+ const k = 1024;
+ const sizes = ['Bytes', 'KB', 'MB', 'GB'];
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
+ };
+
+ const resetDialog = () => {
+ setSelectedFile(null);
+ setProcessedResult(null);
+ setError(null);
+ setProcessingProgress(0);
+ setCopiedText(null);
+ };
+
+ return (
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/knowledge-base/knowledge-base-interface.tsx b/src/components/knowledge-base/knowledge-base-interface.tsx
new file mode 100644
index 000000000..dc479e106
--- /dev/null
+++ b/src/components/knowledge-base/knowledge-base-interface.tsx
@@ -0,0 +1,685 @@
+"use client";
+
+import { useState, useEffect, useCallback } from "react";
+import { Card, CardContent, CardHeader, CardTitle } from "ui/card";
+import { Button } from "ui/button";
+import { Input } from "ui/input";
+import { Textarea } from "ui/textarea";
+import { Badge } from "ui/badge";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "ui/tabs";
+import { ScrollArea } from "ui/scroll-area";
+import { Separator } from "ui/separator";
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "ui/dialog";
+import { Label } from "ui/label";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "ui/select";
+import {
+ Search,
+ Plus,
+ BookOpen,
+ FileText,
+ Tag,
+ Calendar,
+ Eye,
+ Edit,
+ Trash2,
+ Filter,
+ BarChart3,
+ Upload,
+ MessageSquare,
+ Globe,
+ Loader2,
+ X
+} from "lucide-react";
+import { KnowledgeDocument } from "lib/knowledge-base/knowledge-base-service";
+import { useAnalytics } from "@/hooks/use-analytics";
+import { useDebouncedValue } from "@/hooks/use-debounced-value";
+import { cn } from "lib/utils";
+
+interface KnowledgeBaseStats {
+ totalDocuments: number;
+ totalWords: number;
+ categoryCounts: Record;
+ tagCounts: Record;
+ sourceTypeCounts: Record;
+ recentActivity: Array<{
+ date: string;
+ documentsCreated: number;
+ documentsUpdated: number;
+ documentsAccessed: number;
+ }>;
+}
+
+export function KnowledgeBaseInterface() {
+ const [documents, setDocuments] = useState([]);
+ const [stats, setStats] = useState(null);
+ const [searchQuery, setSearchQuery] = useState("");
+ const [selectedCategory, setSelectedCategory] = useState("");
+ const [selectedTags, setSelectedTags] = useState([]);
+ const [isLoading, setIsLoading] = useState(false);
+ const [isCreating, setIsCreating] = useState(false);
+ const [selectedDocument, setSelectedDocument] = useState(null);
+ const [showCreateDialog, setShowCreateDialog] = useState(false);
+ const [showStatsDialog, setShowStatsDialog] = useState(false);
+
+ const debouncedSearchQuery = useDebouncedValue(searchQuery, 300);
+ const { trackFeatureUsed } = useAnalytics();
+
+ // New document form state
+ const [newDocument, setNewDocument] = useState({
+ title: "",
+ content: "",
+ summary: "",
+ tags: [] as string[],
+ category: "",
+ isPublic: false,
+ });
+
+ // Load documents and stats
+ useEffect(() => {
+ loadDocuments();
+ loadStats();
+ }, []);
+
+ // Search when query changes
+ useEffect(() => {
+ if (debouncedSearchQuery.trim()) {
+ searchDocuments();
+ } else {
+ loadDocuments();
+ }
+ }, [debouncedSearchQuery, selectedCategory, selectedTags]);
+
+ const loadDocuments = async () => {
+ setIsLoading(true);
+ try {
+ const params = new URLSearchParams({
+ limit: '20',
+ offset: '0',
+ sortBy: 'updated',
+ sortOrder: 'desc',
+ });
+
+ if (selectedCategory) {
+ params.append('category', selectedCategory);
+ }
+
+ const response = await fetch(`/api/knowledge-base/documents?${params}`);
+ const data = await response.json();
+
+ if (data.success) {
+ setDocuments(data.documents);
+ }
+ } catch (error) {
+ console.error('Failed to load documents:', error);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ const searchDocuments = async () => {
+ setIsLoading(true);
+ try {
+ const params = new URLSearchParams({
+ query: debouncedSearchQuery,
+ limit: '20',
+ offset: '0',
+ });
+
+ if (selectedCategory) {
+ params.append('category', selectedCategory);
+ }
+
+ if (selectedTags.length > 0) {
+ params.append('tags', selectedTags.join(','));
+ }
+
+ const response = await fetch(`/api/knowledge-base/search?${params}`);
+ const data = await response.json();
+
+ if (data.success) {
+ setDocuments(data.results.map((r: any) => r.document));
+ }
+ } catch (error) {
+ console.error('Failed to search documents:', error);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ const loadStats = async () => {
+ try {
+ const response = await fetch('/api/knowledge-base/stats');
+ const data = await response.json();
+
+ if (data.success) {
+ setStats(data.stats);
+ }
+ } catch (error) {
+ console.error('Failed to load stats:', error);
+ }
+ };
+
+ const createDocument = async () => {
+ if (!newDocument.title.trim() || !newDocument.content.trim()) {
+ return;
+ }
+
+ setIsCreating(true);
+ try {
+ const response = await fetch('/api/knowledge-base/documents', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(newDocument),
+ });
+
+ const data = await response.json();
+
+ if (data.success) {
+ setDocuments(prev => [data.document, ...prev]);
+ setNewDocument({
+ title: "",
+ content: "",
+ summary: "",
+ tags: [],
+ category: "",
+ isPublic: false,
+ });
+ setShowCreateDialog(false);
+
+ trackFeatureUsed('knowledge_base_create', {
+ category: newDocument.category,
+ tagCount: newDocument.tags.length,
+ contentLength: newDocument.content.length,
+ });
+ }
+ } catch (error) {
+ console.error('Failed to create document:', error);
+ } finally {
+ setIsCreating(false);
+ }
+ };
+
+ const deleteDocument = async (id: string) => {
+ try {
+ const response = await fetch(`/api/knowledge-base/documents/${id}`, {
+ method: 'DELETE',
+ });
+
+ if (response.ok) {
+ setDocuments(prev => prev.filter(doc => doc.id !== id));
+ if (selectedDocument?.id === id) {
+ setSelectedDocument(null);
+ }
+ }
+ } catch (error) {
+ console.error('Failed to delete document:', error);
+ }
+ };
+
+ const addTag = (tag: string) => {
+ if (tag.trim() && !newDocument.tags.includes(tag.trim())) {
+ setNewDocument(prev => ({
+ ...prev,
+ tags: [...prev.tags, tag.trim()],
+ }));
+ }
+ };
+
+ const removeTag = (tagToRemove: string) => {
+ setNewDocument(prev => ({
+ ...prev,
+ tags: prev.tags.filter(tag => tag !== tagToRemove),
+ }));
+ };
+
+ const formatDate = (date: Date) => {
+ return new Intl.DateTimeFormat('en-US', {
+ year: 'numeric',
+ month: 'short',
+ day: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit',
+ }).format(new Date(date));
+ };
+
+ const getSourceIcon = (sourceType: string) => {
+ switch (sourceType) {
+ case 'file_upload':
+ return ;
+ case 'chat_export':
+ return ;
+ case 'web_import':
+ return ;
+ default:
+ return ;
+ }
+ };
+
+ const categories = stats ? Object.keys(stats.categoryCounts) : [];
+ const allTags = stats ? Object.keys(stats.tagCounts) : [];
+
+ return (
+
+ {/* Header */}
+
+
+
Knowledge Base
+
+ Manage your personal knowledge documents and notes
+
+
+
+
+
+
+
+
+
+ {/* Search and Filters */}
+
+
+
+
+
+
+ setSearchQuery(e.target.value)}
+ className="pl-10"
+ />
+
+
+
+
+ {selectedTags.length > 0 && (
+
+ {selectedTags.map(tag => (
+
+ {tag}
+
+
+ ))}
+
+ )}
+
+
+
+
+ {/* Content */}
+
+ {/* Documents List */}
+
+
+
+
+ Documents ({documents.length})
+
+
+
+
+ {isLoading ? (
+
+
+
+ ) : documents.length === 0 ? (
+
+
+
No documents found
+
Create your first document to get started
+
+ ) : (
+
+ {documents.map((document) => (
+
setSelectedDocument(document)}
+ >
+
+
+
+
+ {getSourceIcon(document.sourceType)}
+
{document.title}
+
+
+ {document.summary && (
+
+ {document.summary}
+
+ )}
+
+
+
+
+ {formatDate(document.updatedAt)}
+
+
+
+ {document.accessCount}
+
+ {document.category && (
+
+ {document.category}
+
+ )}
+
+
+ {document.tags.length > 0 && (
+
+ {document.tags.slice(0, 3).map((tag, index) => (
+
+ {tag}
+
+ ))}
+ {document.tags.length > 3 && (
+
+ +{document.tags.length - 3}
+
+ )}
+
+ )}
+
+
+
+
+
+
+
+
+
+ ))}
+
+ )}
+
+
+
+
+
+ {/* Document Viewer */}
+
+
+
+
+ {selectedDocument ? 'Document Preview' : 'Select a Document'}
+
+
+
+ {selectedDocument ? (
+
+
+
+
{selectedDocument.title}
+
+
+
+ {getSourceIcon(selectedDocument.sourceType)}
+
+ {selectedDocument.sourceType.replace('_', ' ')}
+
+
+ Updated {formatDate(selectedDocument.updatedAt)}
+ {selectedDocument.accessCount} views
+
+
+ {selectedDocument.category && (
+
+ {selectedDocument.category}
+
+ )}
+
+ {selectedDocument.tags.length > 0 && (
+
+ {selectedDocument.tags.map((tag, index) => (
+
+
+ {tag}
+
+ ))}
+
+ )}
+
+
+
+
+ {selectedDocument.summary && (
+
+
Summary
+
+ {selectedDocument.summary}
+
+
+ )}
+
+
+
Content
+
+
+ {selectedDocument.content}
+
+
+
+
+
+ ) : (
+
+
+
Select a document to view its content
+
+ )}
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/layouts/app-sidebar-menus.tsx b/src/components/layouts/app-sidebar-menus.tsx
index 8a4e26cd8..fa2faf002 100644
--- a/src/components/layouts/app-sidebar-menus.tsx
+++ b/src/components/layouts/app-sidebar-menus.tsx
@@ -19,15 +19,20 @@ import { useTranslations } from "next-intl";
import { MCPIcon } from "ui/mcp-icon";
import { WriteIcon } from "ui/write-icon";
import {
+ BarChart3,
+ BookOpen,
+ FileText,
FolderOpenIcon,
FolderSearchIcon,
PlusIcon,
+ Search,
Waypoints,
} from "lucide-react";
import { useCallback, useState } from "react";
import { Skeleton } from "ui/skeleton";
import { useArchives } from "@/hooks/queries/use-archives";
import { ArchiveDialog } from "../archive-dialog";
+import { FileProcessorDialog } from "../file-processing/file-processor-dialog";
import { getIsUserAdmin } from "lib/user/utils";
import { BasicUser } from "app-types/user";
import { AppSidebarAdmin } from "./app-sidebar-menu-admin";
@@ -77,6 +82,56 @@ export function AppSidebarMenus({ user }: { user?: BasicUser }) {
+
+
+
+
+
+
+ Search
+
+
+
+
+
+
+
+
+
+
+
+ Analytics
+
+
+
+
+
+
+
+
+
+
+ Process Files
+
+ }
+ />
+
+
+
+
+
+
+
+
+
+ Knowledge Base
+
+
+
+
+
diff --git a/src/components/layouts/app-sidebar-threads.tsx b/src/components/layouts/app-sidebar-threads.tsx
index 4fe66c5b6..13ff2b5a2 100644
--- a/src/components/layouts/app-sidebar-threads.tsx
+++ b/src/components/layouts/app-sidebar-threads.tsx
@@ -11,7 +11,7 @@ import {
import { SidebarGroupContent, SidebarMenu, SidebarMenuItem } from "ui/sidebar";
import { SidebarGroup } from "ui/sidebar";
import { ThreadDropdown } from "../thread-dropdown";
-import { ChevronDown, ChevronUp, MoreHorizontal, Trash } from "lucide-react";
+import { ChevronDown, ChevronUp, MoreHorizontal, Search, Trash } from "lucide-react";
import { useMounted } from "@/hooks/use-mounted";
import { appStore } from "@/app/store";
import { Button } from "ui/button";
@@ -304,18 +304,27 @@ export function AppSidebarThreads() {
{hasExcessThreads && (
- {/* TODO: Later implement a dedicated search/all chats page instead of this expand functionality */}
-
+
+
+
+
diff --git a/src/components/search/search-interface.tsx b/src/components/search/search-interface.tsx
new file mode 100644
index 000000000..d8f4bf240
--- /dev/null
+++ b/src/components/search/search-interface.tsx
@@ -0,0 +1,538 @@
+"use client";
+
+import { useState, useEffect, useCallback, useMemo } from "react";
+import { useRouter, useSearchParams } from "next/navigation";
+import { Search, Filter, Calendar, MessageSquare, Hash, Clock, ExternalLink } from "lucide-react";
+import { Input } from "ui/input";
+import { Button } from "ui/button";
+import { Badge } from "ui/badge";
+import { Card, CardContent, CardHeader, CardTitle } from "ui/card";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "ui/tabs";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "ui/select";
+import { Popover, PopoverContent, PopoverTrigger } from "ui/popover";
+import { Calendar as CalendarComponent } from "ui/calendar";
+import { format } from "date-fns";
+import { useDebouncedValue } from "@/hooks/use-debounced-value";
+import { useAnalytics } from "@/hooks/use-analytics";
+import { SearchResult } from "lib/search/search-service";
+import { cn } from "lib/utils";
+import Link from "next/link";
+import { Skeleton } from "ui/skeleton";
+
+interface SearchResponse {
+ results: SearchResult[];
+ query: string;
+ type: string;
+ total: number;
+ hasMore: boolean;
+}
+
+interface SearchFilters {
+ type: "all" | "chats" | "messages";
+ dateFrom?: Date;
+ dateTo?: Date;
+ projectId?: string;
+}
+
+export function SearchInterface() {
+ const router = useRouter();
+ const searchParams = useSearchParams();
+
+ const [query, setQuery] = useState(searchParams.get("q") || "");
+ const [filters, setFilters] = useState
({
+ type: (searchParams.get("type") as any) || "all",
+ dateFrom: searchParams.get("dateFrom") ? new Date(searchParams.get("dateFrom")!) : undefined,
+ dateTo: searchParams.get("dateTo") ? new Date(searchParams.get("dateTo")!) : undefined,
+ projectId: searchParams.get("projectId") || undefined,
+ });
+
+ const [results, setResults] = useState([]);
+ const [suggestions, setSuggestions] = useState([]);
+ const [isLoading, setIsLoading] = useState(false);
+ const [isLoadingSuggestions, setIsLoadingSuggestions] = useState(false);
+ const [hasMore, setHasMore] = useState(false);
+ const [offset, setOffset] = useState(0);
+ const [showFilters, setShowFilters] = useState(false);
+
+ const debouncedQuery = useDebouncedValue(query, 300);
+ const { trackSearchQuery } = useAnalytics();
+
+ // Update URL when search parameters change
+ const updateURL = useCallback((newQuery: string, newFilters: SearchFilters) => {
+ const params = new URLSearchParams();
+
+ if (newQuery) params.set("q", newQuery);
+ if (newFilters.type !== "all") params.set("type", newFilters.type);
+ if (newFilters.dateFrom) params.set("dateFrom", newFilters.dateFrom.toISOString());
+ if (newFilters.dateTo) params.set("dateTo", newFilters.dateTo.toISOString());
+ if (newFilters.projectId) params.set("projectId", newFilters.projectId);
+
+ const newURL = params.toString() ? `/search?${params.toString()}` : "/search";
+ router.replace(newURL, { scroll: false });
+ }, [router]);
+
+ // Perform search
+ const performSearch = useCallback(async (
+ searchQuery: string,
+ searchFilters: SearchFilters,
+ searchOffset: number = 0,
+ append: boolean = false
+ ) => {
+ if (!searchQuery.trim()) {
+ setResults([]);
+ setHasMore(false);
+ return;
+ }
+
+ setIsLoading(true);
+
+ try {
+ const params = new URLSearchParams({
+ q: searchQuery,
+ type: searchFilters.type,
+ limit: "20",
+ offset: searchOffset.toString(),
+ });
+
+ if (searchFilters.dateFrom) {
+ params.set("dateFrom", searchFilters.dateFrom.toISOString());
+ }
+ if (searchFilters.dateTo) {
+ params.set("dateTo", searchFilters.dateTo.toISOString());
+ }
+ if (searchFilters.projectId) {
+ params.set("projectId", searchFilters.projectId);
+ }
+
+ const response = await fetch(`/api/search?${params.toString()}`);
+
+ if (!response.ok) {
+ throw new Error("Search failed");
+ }
+
+ const data: SearchResponse = await response.json();
+
+ if (append) {
+ setResults(prev => [...prev, ...data.results]);
+ } else {
+ setResults(data.results);
+
+ // Track search query (only for new searches, not pagination)
+ trackSearchQuery(searchQuery, data.results.length, {
+ filters: searchFilters,
+ searchType: data.results.length > 0 ? 'successful' : 'no_results',
+ });
+ }
+
+ setHasMore(data.hasMore);
+ setOffset(searchOffset + data.results.length);
+ } catch (error) {
+ console.error("Search error:", error);
+ setResults([]);
+ setHasMore(false);
+ } finally {
+ setIsLoading(false);
+ }
+ }, []);
+
+ // Get search suggestions
+ const getSuggestions = useCallback(async (searchQuery: string) => {
+ if (!searchQuery.trim() || searchQuery.length < 2) {
+ setSuggestions([]);
+ return;
+ }
+
+ setIsLoadingSuggestions(true);
+
+ try {
+ const params = new URLSearchParams({
+ q: searchQuery,
+ limit: "5",
+ });
+
+ const response = await fetch(`/api/search/suggestions?${params.toString()}`);
+
+ if (response.ok) {
+ const data = await response.json();
+ setSuggestions(data.suggestions || []);
+ }
+ } catch (error) {
+ console.error("Suggestions error:", error);
+ } finally {
+ setIsLoadingSuggestions(false);
+ }
+ }, []);
+
+ // Effect for performing search when query or filters change
+ useEffect(() => {
+ if (debouncedQuery) {
+ performSearch(debouncedQuery, filters, 0, false);
+ updateURL(debouncedQuery, filters);
+ } else {
+ setResults([]);
+ setHasMore(false);
+ }
+ }, [debouncedQuery, filters, performSearch, updateURL]);
+
+ // Effect for getting suggestions
+ useEffect(() => {
+ getSuggestions(query);
+ }, [query, getSuggestions]);
+
+ // Load more results
+ const loadMore = useCallback(() => {
+ if (hasMore && !isLoading && debouncedQuery) {
+ performSearch(debouncedQuery, filters, offset, true);
+ }
+ }, [hasMore, isLoading, debouncedQuery, filters, offset, performSearch]);
+
+ // Handle filter changes
+ const handleFilterChange = useCallback((newFilters: Partial) => {
+ const updatedFilters = { ...filters, ...newFilters };
+ setFilters(updatedFilters);
+ setOffset(0);
+ }, [filters]);
+
+ // Clear filters
+ const clearFilters = useCallback(() => {
+ const clearedFilters: SearchFilters = { type: "all" };
+ setFilters(clearedFilters);
+ setOffset(0);
+ }, []);
+
+ // Group results by type
+ const groupedResults = useMemo(() => {
+ const groups = {
+ chats: results.filter(r => r.type === "chat"),
+ messages: results.filter(r => r.type === "message"),
+ };
+ return groups;
+ }, [results]);
+
+ const hasActiveFilters = filters.type !== "all" || filters.dateFrom || filters.dateTo || filters.projectId;
+
+ return (
+
+ {/* Search Header */}
+
+
+
+
Search
+
+
+ {/* Search Input */}
+
+
+ setQuery(e.target.value)}
+ className="pl-10 pr-4 h-12 text-lg"
+ autoFocus
+ />
+
+ {/* Search Suggestions */}
+ {suggestions.length > 0 && query && (
+
+
+ {suggestions.map((suggestion, index) => (
+
+ ))}
+
+
+ )}
+
+
+ {/* Filters */}
+
+
+
+ {hasActiveFilters && (
+
+ )}
+
+ {/* Active filter badges */}
+ {filters.type !== "all" && (
+
+ Type: {filters.type}
+
+ )}
+ {filters.dateFrom && (
+
+ From: {format(filters.dateFrom, "MMM d, yyyy")}
+
+ )}
+ {filters.dateTo && (
+
+ To: {format(filters.dateTo, "MMM d, yyyy")}
+
+ )}
+
+
+ {/* Filter Panel */}
+ {showFilters && (
+
+
+
+ {/* Content Type Filter */}
+
+
+
+
+
+ {/* Date From Filter */}
+
+
+
+
+
+
+
+ handleFilterChange({ dateFrom: date })}
+ initialFocus
+ />
+
+
+
+
+ {/* Date To Filter */}
+
+
+
+
+
+
+
+ handleFilterChange({ dateTo: date })}
+ initialFocus
+ />
+
+
+
+
+
+
+ )}
+
+
+ {/* Search Results */}
+
+ {isLoading && results.length === 0 ? (
+
+ {Array.from({ length: 3 }).map((_, i) => (
+
+
+
+
+
+
+
+ ))}
+
+ ) : results.length > 0 ? (
+
handleFilterChange({ type: value })}>
+
+
+ All ({results.length})
+
+
+
+ Chats ({groupedResults.chats.length})
+
+
+
+ Messages ({groupedResults.messages.length})
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ) : query && !isLoading ? (
+
+
+
+ No results found
+
+ Try adjusting your search terms or filters
+
+
+
+ ) : (
+
+
+
+ Start searching
+
+ Enter a search term to find your chats and messages
+
+
+
+ )}
+
+ {/* Load More Button */}
+ {hasMore && (
+
+
+
+ )}
+
+
+ );
+}
+
+function SearchResultsList({ results }: { results: SearchResult[] }) {
+ if (results.length === 0) {
+ return (
+
+
+ No results in this category
+
+
+ );
+ }
+
+ return (
+
+ {results.map((result) => (
+
+ ))}
+
+ );
+}
+
+function SearchResultCard({ result }: { result: SearchResult }) {
+ const formatDate = (date: Date) => {
+ const now = new Date();
+ const diffInHours = (now.getTime() - date.getTime()) / (1000 * 60 * 60);
+
+ if (diffInHours < 24) {
+ return format(date, "h:mm a");
+ } else if (diffInHours < 24 * 7) {
+ return format(date, "EEE h:mm a");
+ } else {
+ return format(date, "MMM d, yyyy");
+ }
+ };
+
+ return (
+
+
+
+
+
+ {result.type === "chat" ? (
+
+ ) : (
+
+ )}
+
+ {result.title}
+
+
+ {result.type}
+
+
+
+
+ {result.content}
+
+
+
+
+
+ {formatDate(result.createdAt)}
+
+
+ {result.metadata?.messageRole && (
+
+ {result.metadata.messageRole}
+
+ )}
+
+ {result.metadata?.chatTitle && result.type === "message" && (
+
+ in "{result.metadata.chatTitle}"
+
+ )}
+
+
+
+
+
+ {Math.round(result.score)}%
+
+
+
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/hooks/use-analytics.ts b/src/hooks/use-analytics.ts
new file mode 100644
index 000000000..ddf581857
--- /dev/null
+++ b/src/hooks/use-analytics.ts
@@ -0,0 +1,133 @@
+import { useCallback } from 'react';
+
+interface AnalyticsEvent {
+ eventType: string;
+ eventData?: Record;
+ sessionId?: string;
+}
+
+/**
+ * Hook for tracking analytics events from the client side
+ */
+export function useAnalytics() {
+ const trackEvent = useCallback(async (event: AnalyticsEvent) => {
+ try {
+ await fetch('/api/analytics/track', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(event),
+ });
+ } catch (error) {
+ console.error('Failed to track analytics event:', error);
+ // Don't throw - analytics failures shouldn't break the app
+ }
+ }, []);
+
+ const trackPageView = useCallback((page: string, metadata?: Record) => {
+ trackEvent({
+ eventType: 'page_view',
+ eventData: {
+ page,
+ ...metadata,
+ },
+ });
+ }, [trackEvent]);
+
+ const trackChatCreated = useCallback((chatId: string, metadata?: Record) => {
+ trackEvent({
+ eventType: 'chat_created',
+ eventData: {
+ chatId,
+ ...metadata,
+ },
+ });
+ }, [trackEvent]);
+
+ const trackMessageSent = useCallback((
+ chatId: string,
+ messageLength: number,
+ model?: string,
+ metadata?: Record
+ ) => {
+ trackEvent({
+ eventType: 'message_sent',
+ eventData: {
+ chatId,
+ messageLength,
+ model,
+ ...metadata,
+ },
+ });
+ }, [trackEvent]);
+
+ const trackToolUsed = useCallback((
+ toolName: string,
+ chatId?: string,
+ metadata?: Record
+ ) => {
+ trackEvent({
+ eventType: 'tool_used',
+ eventData: {
+ toolName,
+ chatId,
+ ...metadata,
+ },
+ });
+ }, [trackEvent]);
+
+ const trackSearchQuery = useCallback((
+ query: string,
+ resultsCount: number,
+ metadata?: Record
+ ) => {
+ trackEvent({
+ eventType: 'search_query',
+ eventData: {
+ query,
+ resultsCount,
+ ...metadata,
+ },
+ });
+ }, [trackEvent]);
+
+ const trackFeatureUsed = useCallback((
+ featureName: string,
+ metadata?: Record
+ ) => {
+ trackEvent({
+ eventType: 'feature_used',
+ eventData: {
+ featureName,
+ ...metadata,
+ },
+ });
+ }, [trackEvent]);
+
+ const trackError = useCallback((
+ errorType: string,
+ errorMessage: string,
+ metadata?: Record
+ ) => {
+ trackEvent({
+ eventType: 'error_occurred',
+ eventData: {
+ errorType,
+ errorMessage,
+ ...metadata,
+ },
+ });
+ }, [trackEvent]);
+
+ return {
+ trackEvent,
+ trackPageView,
+ trackChatCreated,
+ trackMessageSent,
+ trackToolUsed,
+ trackSearchQuery,
+ trackFeatureUsed,
+ trackError,
+ };
+}
\ No newline at end of file
diff --git a/src/hooks/use-debounced-value.ts b/src/hooks/use-debounced-value.ts
new file mode 100644
index 000000000..ab04fa21d
--- /dev/null
+++ b/src/hooks/use-debounced-value.ts
@@ -0,0 +1,23 @@
+import { useState, useEffect } from 'react';
+
+/**
+ * Custom hook that debounces a value
+ * @param value - The value to debounce
+ * @param delay - The delay in milliseconds
+ * @returns The debounced value
+ */
+export function useDebouncedValue(value: T, delay: number): T {
+ const [debouncedValue, setDebouncedValue] = useState(value);
+
+ useEffect(() => {
+ const handler = setTimeout(() => {
+ setDebouncedValue(value);
+ }, delay);
+
+ return () => {
+ clearTimeout(handler);
+ };
+ }, [value, delay]);
+
+ return debouncedValue;
+}
\ No newline at end of file
diff --git a/src/lib/analytics/analytics-service.test.ts b/src/lib/analytics/analytics-service.test.ts
new file mode 100644
index 000000000..e09200483
--- /dev/null
+++ b/src/lib/analytics/analytics-service.test.ts
@@ -0,0 +1,385 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import {
+ trackEvent,
+ getUsageStats,
+ getUserStats,
+ getChatActivity,
+ trackToolUsage,
+ trackModelUsage,
+ trackSearchQuery,
+ generateAnalyticsReport
+} from './analytics-service';
+
+// Mock the database
+vi.mock('lib/db/pg', () => ({
+ db: {
+ execute: vi.fn(),
+ },
+}));
+
+vi.mock('drizzle-orm', () => ({
+ sql: vi.fn((strings, ...values) => ({ strings, values })),
+}));
+
+describe('AnalyticsService', () => {
+ let mockDb: any;
+
+ beforeEach(() => {
+ const { db } = require('lib/db/pg');
+ mockDb = db;
+
+ // Mock console.log and console.error to avoid noise in tests
+ vi.spyOn(console, 'log').mockImplementation(() => {});
+ vi.spyOn(console, 'error').mockImplementation(() => {});
+ });
+
+ afterEach(() => {
+ vi.clearAllMocks();
+ vi.restoreAllMocks();
+ });
+
+ describe('trackEvent', () => {
+ it('should log event without throwing', async () => {
+ const event = {
+ userId: 'user1',
+ eventType: 'test_event',
+ eventData: { key: 'value' },
+ };
+
+ await expect(trackEvent(event)).resolves.not.toThrow();
+ expect(console.log).toHaveBeenCalledWith('Analytics Event:', expect.objectContaining({
+ type: 'test_event',
+ user: 'user1',
+ data: { key: 'value' },
+ }));
+ });
+
+ it('should handle errors gracefully', async () => {
+ const event = {
+ userId: 'user1',
+ eventType: 'test_event',
+ };
+
+ // Mock console.log to throw an error
+ vi.spyOn(console, 'log').mockImplementation(() => {
+ throw new Error('Logging failed');
+ });
+
+ await expect(trackEvent(event)).resolves.not.toThrow();
+ expect(console.error).toHaveBeenCalledWith('Failed to track analytics event:', expect.any(Error));
+ });
+ });
+
+ describe('getUsageStats', () => {
+ it('should return usage statistics', async () => {
+ const mockChatStats = {
+ total_chats: 100,
+ total_messages: 500,
+ avg_messages_per_chat: 5,
+ };
+
+ const mockModelStats = [
+ { model: 'gpt-4', count: 50 },
+ { model: 'claude-3', count: 30 },
+ ];
+
+ const mockToolStats = [
+ { tool_name: 'web_search', count: 25 },
+ { tool_name: 'code_execution', count: 15 },
+ ];
+
+ const mockActiveUsers = {
+ daily_active: 10,
+ weekly_active: 25,
+ monthly_active: 50,
+ };
+
+ mockDb.execute
+ .mockResolvedValueOnce([mockChatStats])
+ .mockResolvedValueOnce(mockModelStats)
+ .mockResolvedValueOnce(mockToolStats)
+ .mockResolvedValueOnce([mockActiveUsers]);
+
+ const result = await getUsageStats('week');
+
+ expect(result).toEqual({
+ totalChats: 100,
+ totalMessages: 500,
+ totalTokensUsed: 0,
+ averageMessagesPerChat: 5,
+ mostUsedModels: [
+ { model: 'gpt-4', count: 50 },
+ { model: 'claude-3', count: 30 },
+ ],
+ mostUsedTools: [
+ { tool: 'web_search', count: 25 },
+ { tool: 'code_execution', count: 15 },
+ ],
+ dailyActiveUsers: 10,
+ weeklyActiveUsers: 25,
+ monthlyActiveUsers: 50,
+ });
+
+ expect(mockDb.execute).toHaveBeenCalledTimes(4);
+ });
+
+ it('should handle database errors gracefully', async () => {
+ mockDb.execute.mockRejectedValue(new Error('Database error'));
+
+ const result = await getUsageStats();
+
+ expect(result).toEqual({
+ totalChats: 0,
+ totalMessages: 0,
+ totalTokensUsed: 0,
+ averageMessagesPerChat: 0,
+ mostUsedModels: [],
+ mostUsedTools: [],
+ dailyActiveUsers: 0,
+ weeklyActiveUsers: 0,
+ monthlyActiveUsers: 0,
+ });
+
+ expect(console.error).toHaveBeenCalledWith('Failed to get usage stats:', expect.any(Error));
+ });
+ });
+
+ describe('getUserStats', () => {
+ it('should return user-specific statistics', async () => {
+ const mockUserBasicStats = {
+ total_chats: 20,
+ total_messages: 100,
+ last_active_at: '2024-01-15T10:00:00Z',
+ joined_at: '2024-01-01T10:00:00Z',
+ };
+
+ const mockUserModelStats = [
+ { model: 'gpt-4', count: 15 },
+ { model: 'claude-3', count: 5 },
+ ];
+
+ const mockUserToolStats = [
+ { tool_name: 'web_search', count: 10 },
+ ];
+
+ mockDb.execute
+ .mockResolvedValueOnce([mockUserBasicStats])
+ .mockResolvedValueOnce(mockUserModelStats)
+ .mockResolvedValueOnce(mockUserToolStats);
+
+ const result = await getUserStats('user1', 'month');
+
+ expect(result).toEqual({
+ userId: 'user1',
+ totalChats: 20,
+ totalMessages: 100,
+ totalTokensUsed: 0,
+ favoriteModels: [
+ { model: 'gpt-4', count: 15 },
+ { model: 'claude-3', count: 5 },
+ ],
+ mostUsedTools: [
+ { tool: 'web_search', count: 10 },
+ ],
+ averageSessionDuration: 0,
+ lastActiveAt: new Date('2024-01-15T10:00:00Z'),
+ joinedAt: new Date('2024-01-01T10:00:00Z'),
+ });
+
+ expect(mockDb.execute).toHaveBeenCalledTimes(3);
+ });
+
+ it('should handle database errors gracefully', async () => {
+ mockDb.execute.mockRejectedValue(new Error('Database error'));
+
+ const result = await getUserStats('user1');
+
+ expect(result.userId).toBe('user1');
+ expect(result.totalChats).toBe(0);
+ expect(result.totalMessages).toBe(0);
+ expect(console.error).toHaveBeenCalledWith('Failed to get user stats:', expect.any(Error));
+ });
+ });
+
+ describe('getChatActivity', () => {
+ it('should return chat activity data', async () => {
+ const mockActivity = [
+ { date: '2024-01-15', chats: 5, messages: 25 },
+ { date: '2024-01-14', chats: 3, messages: 15 },
+ ];
+
+ mockDb.execute.mockResolvedValue(mockActivity);
+
+ const result = await getChatActivity('user1', 'week');
+
+ expect(result).toEqual([
+ { date: '2024-01-15', chats: 5, messages: 25 },
+ { date: '2024-01-14', chats: 3, messages: 15 },
+ ]);
+
+ expect(mockDb.execute).toHaveBeenCalledTimes(1);
+ });
+
+ it('should handle database errors gracefully', async () => {
+ mockDb.execute.mockRejectedValue(new Error('Database error'));
+
+ const result = await getChatActivity('user1');
+
+ expect(result).toEqual([]);
+ expect(console.error).toHaveBeenCalledWith('Failed to get chat activity:', expect.any(Error));
+ });
+ });
+
+ describe('tracking functions', () => {
+ it('should track tool usage', async () => {
+ await trackToolUsage('user1', 'web_search', { query: 'test' });
+
+ expect(console.log).toHaveBeenCalledWith('Analytics Event:', expect.objectContaining({
+ type: 'tool_usage',
+ user: 'user1',
+ data: {
+ toolName: 'web_search',
+ query: 'test',
+ },
+ }));
+ });
+
+ it('should track model usage', async () => {
+ await trackModelUsage('user1', 'gpt-4', 100, { temperature: 0.7 });
+
+ expect(console.log).toHaveBeenCalledWith('Analytics Event:', expect.objectContaining({
+ type: 'model_usage',
+ user: 'user1',
+ data: {
+ model: 'gpt-4',
+ tokensUsed: 100,
+ temperature: 0.7,
+ },
+ }));
+ });
+
+ it('should track search queries', async () => {
+ await trackSearchQuery('user1', 'test query', 5, { filters: 'chat' });
+
+ expect(console.log).toHaveBeenCalledWith('Analytics Event:', expect.objectContaining({
+ type: 'search_query',
+ user: 'user1',
+ data: {
+ query: 'test query',
+ resultsCount: 5,
+ filters: 'chat',
+ },
+ }));
+ });
+ });
+
+ describe('generateAnalyticsReport', () => {
+ it('should generate comprehensive analytics report', async () => {
+ // Mock all the required data
+ const mockUsageStats = {
+ totalChats: 100,
+ totalMessages: 500,
+ totalTokensUsed: 0,
+ averageMessagesPerChat: 5,
+ mostUsedModels: [],
+ mostUsedTools: [],
+ dailyActiveUsers: 10,
+ weeklyActiveUsers: 25,
+ monthlyActiveUsers: 50,
+ };
+
+ const mockUserStats = {
+ userId: 'user1',
+ totalChats: 20,
+ totalMessages: 100,
+ totalTokensUsed: 0,
+ favoriteModels: [],
+ mostUsedTools: [],
+ averageSessionDuration: 0,
+ lastActiveAt: new Date(),
+ joinedAt: new Date(),
+ };
+
+ const mockActivity = [
+ { date: '2024-01-15', chats: 5, messages: 25 },
+ ];
+
+ // Mock the database calls for all functions
+ mockDb.execute
+ // getUsageStats calls
+ .mockResolvedValueOnce([{ total_chats: 100, total_messages: 500, avg_messages_per_chat: 5 }])
+ .mockResolvedValueOnce([])
+ .mockResolvedValueOnce([])
+ .mockResolvedValueOnce([{ daily_active: 10, weekly_active: 25, monthly_active: 50 }])
+ // getUserStats calls
+ .mockResolvedValueOnce([{
+ total_chats: 20,
+ total_messages: 100,
+ last_active_at: new Date().toISOString(),
+ joined_at: new Date().toISOString()
+ }])
+ .mockResolvedValueOnce([])
+ .mockResolvedValueOnce([])
+ // getChatActivity call
+ .mockResolvedValueOnce(mockActivity);
+
+ const result = await generateAnalyticsReport('user1', 'week');
+
+ expect(result).toHaveProperty('usageStats');
+ expect(result).toHaveProperty('userStats');
+ expect(result).toHaveProperty('chatActivity');
+ expect(result).toHaveProperty('popularSearchTerms');
+
+ expect(result.usageStats.totalChats).toBe(100);
+ expect(result.userStats?.userId).toBe('user1');
+ expect(result.chatActivity).toHaveLength(1);
+ expect(result.popularSearchTerms).toEqual([]);
+ });
+ });
+
+ describe('timeframe handling', () => {
+ it('should handle different timeframes correctly', async () => {
+ mockDb.execute.mockResolvedValue([]);
+
+ await getChatActivity('user1', 'day');
+ await getChatActivity('user1', 'week');
+ await getChatActivity('user1', 'month');
+
+ expect(mockDb.execute).toHaveBeenCalledTimes(3);
+ });
+ });
+
+ describe('edge cases', () => {
+ it('should handle null/undefined values in database results', async () => {
+ const mockStatsWithNulls = {
+ total_chats: null,
+ total_messages: undefined,
+ avg_messages_per_chat: 0,
+ };
+
+ mockDb.execute
+ .mockResolvedValueOnce([mockStatsWithNulls])
+ .mockResolvedValueOnce([])
+ .mockResolvedValueOnce([])
+ .mockResolvedValueOnce([{ daily_active: null, weekly_active: 0, monthly_active: undefined }]);
+
+ const result = await getUsageStats();
+
+ expect(result.totalChats).toBe(0);
+ expect(result.totalMessages).toBe(0);
+ expect(result.dailyActiveUsers).toBe(0);
+ expect(result.weeklyActiveUsers).toBe(0);
+ expect(result.monthlyActiveUsers).toBe(0);
+ });
+
+ it('should handle empty database results', async () => {
+ mockDb.execute.mockResolvedValue([]);
+
+ const result = await getUsageStats();
+
+ expect(result.totalChats).toBe(0);
+ expect(result.mostUsedModels).toEqual([]);
+ expect(result.mostUsedTools).toEqual([]);
+ });
+ });
+});
\ No newline at end of file
diff --git a/src/lib/analytics/analytics-service.ts b/src/lib/analytics/analytics-service.ts
new file mode 100644
index 000000000..2ac7b6f7e
--- /dev/null
+++ b/src/lib/analytics/analytics-service.ts
@@ -0,0 +1,427 @@
+import "server-only";
+import { db } from "lib/db/pg";
+import { sql } from "drizzle-orm";
+
+export interface AnalyticsEvent {
+ userId: string;
+ eventType: string;
+ eventData?: Record;
+ timestamp?: Date;
+ sessionId?: string;
+ userAgent?: string;
+ ipAddress?: string;
+}
+
+export interface UsageStats {
+ totalChats: number;
+ totalMessages: number;
+ totalTokensUsed: number;
+ averageMessagesPerChat: number;
+ mostUsedModels: Array<{ model: string; count: number }>;
+ mostUsedTools: Array<{ tool: string; count: number }>;
+ dailyActiveUsers: number;
+ weeklyActiveUsers: number;
+ monthlyActiveUsers: number;
+}
+
+export interface UserStats {
+ userId: string;
+ totalChats: number;
+ totalMessages: number;
+ totalTokensUsed: number;
+ favoriteModels: Array<{ model: string; count: number }>;
+ mostUsedTools: Array<{ tool: string; count: number }>;
+ averageSessionDuration: number;
+ lastActiveAt: Date;
+ joinedAt: Date;
+}
+
+/**
+ * Analytics service for tracking usage patterns and generating insights
+ */
+
+/**
+ * Track an analytics event
+ */
+export async function trackEvent(event: AnalyticsEvent): Promise {
+ try {
+ // For now, we'll use a simple approach with existing tables
+ // In a production system, you'd want a dedicated analytics_events table
+
+ // Store basic event tracking in a simple format
+ // This could be extended to use a proper analytics database like ClickHouse
+
+ console.log('Analytics Event:', {
+ type: event.eventType,
+ user: event.userId,
+ data: event.eventData,
+ timestamp: event.timestamp || new Date(),
+ });
+
+ // TODO: Implement proper event storage
+ // await db.insert(analyticsEvents).values({
+ // userId: event.userId,
+ // eventType: event.eventType,
+ // eventData: event.eventData,
+ // timestamp: event.timestamp || new Date(),
+ // sessionId: event.sessionId,
+ // userAgent: event.userAgent,
+ // ipAddress: event.ipAddress,
+ // });
+
+ } catch (error) {
+ console.error('Failed to track analytics event:', error);
+ // Don't throw - analytics failures shouldn't break the app
+ }
+}
+
+/**
+ * Get overall usage statistics
+ */
+export async function getUsageStats(timeframe: 'day' | 'week' | 'month' | 'all' = 'all'): Promise {
+ try {
+ const timeCondition = getTimeCondition(timeframe);
+
+ // Get basic chat and message counts
+ const [chatStats] = await db.execute(sql`
+ SELECT
+ COUNT(DISTINCT ct.id) as total_chats,
+ COUNT(m.id) as total_messages,
+ COALESCE(AVG(message_counts.msg_count), 0) as avg_messages_per_chat
+ FROM chat_threads ct
+ LEFT JOIN messages m ON ct.id = m.chat_id
+ LEFT JOIN (
+ SELECT chat_id, COUNT(*) as msg_count
+ FROM messages
+ ${timeCondition ? sql`WHERE created_at >= ${timeCondition}` : sql``}
+ GROUP BY chat_id
+ ) message_counts ON ct.id = message_counts.chat_id
+ ${timeCondition ? sql`WHERE ct.created_at >= ${timeCondition}` : sql``}
+ `);
+
+ // Get model usage stats
+ const modelStats = await db.execute(sql`
+ SELECT
+ model,
+ COUNT(*) as count
+ FROM messages
+ WHERE model IS NOT NULL
+ ${timeCondition ? sql`AND created_at >= ${timeCondition}` : sql``}
+ GROUP BY model
+ ORDER BY count DESC
+ LIMIT 10
+ `);
+
+ // Get tool usage stats (from message content analysis)
+ const toolStats = await db.execute(sql`
+ SELECT
+ tool_name,
+ COUNT(*) as count
+ FROM (
+ SELECT
+ CASE
+ WHEN content LIKE '%@web%' THEN 'web_search'
+ WHEN content LIKE '%@code%' THEN 'code_execution'
+ WHEN content LIKE '%@file%' THEN 'file_processing'
+ WHEN content LIKE '%@image%' THEN 'image_generation'
+ ELSE 'unknown'
+ END as tool_name
+ FROM messages
+ WHERE role = 'user'
+ AND (content LIKE '%@%')
+ ${timeCondition ? sql`AND created_at >= ${timeCondition}` : sql``}
+ ) tool_usage
+ WHERE tool_name != 'unknown'
+ GROUP BY tool_name
+ ORDER BY count DESC
+ LIMIT 10
+ `);
+
+ // Get active user counts
+ const [activeUsers] = await db.execute(sql`
+ SELECT
+ COUNT(DISTINCT CASE WHEN created_at >= NOW() - INTERVAL '1 day' THEN user_id END) as daily_active,
+ COUNT(DISTINCT CASE WHEN created_at >= NOW() - INTERVAL '7 days' THEN user_id END) as weekly_active,
+ COUNT(DISTINCT CASE WHEN created_at >= NOW() - INTERVAL '30 days' THEN user_id END) as monthly_active
+ FROM chat_threads
+ `);
+
+ return {
+ totalChats: Number(chatStats.total_chats) || 0,
+ totalMessages: Number(chatStats.total_messages) || 0,
+ totalTokensUsed: 0, // TODO: Implement token tracking
+ averageMessagesPerChat: Number(chatStats.avg_messages_per_chat) || 0,
+ mostUsedModels: modelStats.map(row => ({
+ model: row.model as string,
+ count: Number(row.count),
+ })),
+ mostUsedTools: toolStats.map(row => ({
+ tool: row.tool_name as string,
+ count: Number(row.count),
+ })),
+ dailyActiveUsers: Number(activeUsers.daily_active) || 0,
+ weeklyActiveUsers: Number(activeUsers.weekly_active) || 0,
+ monthlyActiveUsers: Number(activeUsers.monthly_active) || 0,
+ };
+
+ } catch (error) {
+ console.error('Failed to get usage stats:', error);
+ return {
+ totalChats: 0,
+ totalMessages: 0,
+ totalTokensUsed: 0,
+ averageMessagesPerChat: 0,
+ mostUsedModels: [],
+ mostUsedTools: [],
+ dailyActiveUsers: 0,
+ weeklyActiveUsers: 0,
+ monthlyActiveUsers: 0,
+ };
+ }
+}
+
+/**
+ * Get user-specific statistics
+ */
+export async function getUserStats(userId: string, timeframe: 'day' | 'week' | 'month' | 'all' = 'all'): Promise {
+ try {
+ const timeCondition = getTimeCondition(timeframe);
+
+ // Get basic user stats
+ const [userBasicStats] = await db.execute(sql`
+ SELECT
+ COUNT(DISTINCT ct.id) as total_chats,
+ COUNT(m.id) as total_messages,
+ MAX(ct.updated_at) as last_active_at,
+ MIN(ct.created_at) as joined_at
+ FROM chat_threads ct
+ LEFT JOIN messages m ON ct.id = m.chat_id
+ WHERE ct.user_id = ${userId}
+ ${timeCondition ? sql`AND ct.created_at >= ${timeCondition}` : sql``}
+ `);
+
+ // Get user's favorite models
+ const userModelStats = await db.execute(sql`
+ SELECT
+ model,
+ COUNT(*) as count
+ FROM messages m
+ JOIN chat_threads ct ON m.chat_id = ct.id
+ WHERE ct.user_id = ${userId}
+ AND m.model IS NOT NULL
+ ${timeCondition ? sql`AND m.created_at >= ${timeCondition}` : sql``}
+ GROUP BY model
+ ORDER BY count DESC
+ LIMIT 5
+ `);
+
+ // Get user's tool usage
+ const userToolStats = await db.execute(sql`
+ SELECT
+ tool_name,
+ COUNT(*) as count
+ FROM (
+ SELECT
+ CASE
+ WHEN m.content LIKE '%@web%' THEN 'web_search'
+ WHEN m.content LIKE '%@code%' THEN 'code_execution'
+ WHEN m.content LIKE '%@file%' THEN 'file_processing'
+ WHEN m.content LIKE '%@image%' THEN 'image_generation'
+ ELSE 'unknown'
+ END as tool_name
+ FROM messages m
+ JOIN chat_threads ct ON m.chat_id = ct.id
+ WHERE ct.user_id = ${userId}
+ AND m.role = 'user'
+ AND (m.content LIKE '%@%')
+ ${timeCondition ? sql`AND m.created_at >= ${timeCondition}` : sql``}
+ ) tool_usage
+ WHERE tool_name != 'unknown'
+ GROUP BY tool_name
+ ORDER BY count DESC
+ LIMIT 5
+ `);
+
+ return {
+ userId,
+ totalChats: Number(userBasicStats.total_chats) || 0,
+ totalMessages: Number(userBasicStats.total_messages) || 0,
+ totalTokensUsed: 0, // TODO: Implement token tracking
+ favoriteModels: userModelStats.map(row => ({
+ model: row.model as string,
+ count: Number(row.count),
+ })),
+ mostUsedTools: userToolStats.map(row => ({
+ tool: row.tool_name as string,
+ count: Number(row.count),
+ })),
+ averageSessionDuration: 0, // TODO: Implement session tracking
+ lastActiveAt: new Date(userBasicStats.last_active_at) || new Date(),
+ joinedAt: new Date(userBasicStats.joined_at) || new Date(),
+ };
+
+ } catch (error) {
+ console.error('Failed to get user stats:', error);
+ return {
+ userId,
+ totalChats: 0,
+ totalMessages: 0,
+ totalTokensUsed: 0,
+ favoriteModels: [],
+ mostUsedTools: [],
+ averageSessionDuration: 0,
+ lastActiveAt: new Date(),
+ joinedAt: new Date(),
+ };
+ }
+}
+
+/**
+ * Get chat activity over time
+ */
+export async function getChatActivity(
+ userId?: string,
+ timeframe: 'day' | 'week' | 'month' = 'week'
+): Promise> {
+ try {
+ const timeCondition = getTimeCondition(timeframe);
+ const userCondition = userId ? sql`AND ct.user_id = ${userId}` : sql``;
+
+ const activity = await db.execute(sql`
+ SELECT
+ DATE(ct.created_at) as date,
+ COUNT(DISTINCT ct.id) as chats,
+ COUNT(m.id) as messages
+ FROM chat_threads ct
+ LEFT JOIN messages m ON ct.id = m.chat_id
+ WHERE ct.created_at >= ${timeCondition}
+ ${userCondition}
+ GROUP BY DATE(ct.created_at)
+ ORDER BY date DESC
+ LIMIT 30
+ `);
+
+ return activity.map(row => ({
+ date: row.date as string,
+ chats: Number(row.chats),
+ messages: Number(row.messages),
+ }));
+
+ } catch (error) {
+ console.error('Failed to get chat activity:', error);
+ return [];
+ }
+}
+
+/**
+ * Get popular search terms (requires search logging)
+ */
+export async function getPopularSearchTerms(limit: number = 10): Promise> {
+ // This would require implementing search term logging
+ // For now, return empty array
+ return [];
+}
+
+/**
+ * Track tool usage
+ */
+export async function trackToolUsage(
+ userId: string,
+ toolName: string,
+ metadata?: Record
+): Promise {
+ await trackEvent({
+ userId,
+ eventType: 'tool_usage',
+ eventData: {
+ toolName,
+ ...metadata,
+ },
+ });
+}
+
+/**
+ * Track model usage
+ */
+export async function trackModelUsage(
+ userId: string,
+ model: string,
+ tokensUsed?: number,
+ metadata?: Record
+): Promise {
+ await trackEvent({
+ userId,
+ eventType: 'model_usage',
+ eventData: {
+ model,
+ tokensUsed,
+ ...metadata,
+ },
+ });
+}
+
+/**
+ * Track search queries
+ */
+export async function trackSearchQuery(
+ userId: string,
+ query: string,
+ resultsCount: number,
+ metadata?: Record
+): Promise {
+ await trackEvent({
+ userId,
+ eventType: 'search_query',
+ eventData: {
+ query,
+ resultsCount,
+ ...metadata,
+ },
+ });
+}
+
+/**
+ * Helper function to get time condition for queries
+ */
+function getTimeCondition(timeframe: 'day' | 'week' | 'month' | 'all'): Date | null {
+ const now = new Date();
+
+ switch (timeframe) {
+ case 'day':
+ return new Date(now.getTime() - 24 * 60 * 60 * 1000);
+ case 'week':
+ return new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
+ case 'month':
+ return new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
+ case 'all':
+ default:
+ return null;
+ }
+}
+
+/**
+ * Generate analytics report
+ */
+export async function generateAnalyticsReport(
+ userId?: string,
+ timeframe: 'day' | 'week' | 'month' | 'all' = 'week'
+): Promise<{
+ usageStats: UsageStats;
+ userStats?: UserStats;
+ chatActivity: Array<{ date: string; chats: number; messages: number }>;
+ popularSearchTerms: Array<{ term: string; count: number }>;
+}> {
+ const [usageStats, userStats, chatActivity, popularSearchTerms] = await Promise.all([
+ getUsageStats(timeframe),
+ userId ? getUserStats(userId, timeframe) : Promise.resolve(undefined),
+ getChatActivity(userId, timeframe),
+ getPopularSearchTerms(10),
+ ]);
+
+ return {
+ usageStats,
+ userStats,
+ chatActivity,
+ popularSearchTerms,
+ };
+}
\ No newline at end of file
diff --git a/src/lib/file-processing/file-processor.test.ts b/src/lib/file-processing/file-processor.test.ts
new file mode 100644
index 000000000..0388955e1
--- /dev/null
+++ b/src/lib/file-processing/file-processor.test.ts
@@ -0,0 +1,304 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { FileProcessor } from './file-processor';
+
+describe('FileProcessor', () => {
+ let fileProcessor: FileProcessor;
+
+ beforeEach(() => {
+ fileProcessor = FileProcessor.getInstance();
+
+ // Mock console methods to avoid noise in tests
+ vi.spyOn(console, 'log').mockImplementation(() => {});
+ vi.spyOn(console, 'error').mockImplementation(() => {});
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ describe('getInstance', () => {
+ it('should return singleton instance', () => {
+ const instance1 = FileProcessor.getInstance();
+ const instance2 = FileProcessor.getInstance();
+ expect(instance1).toBe(instance2);
+ });
+ });
+
+ describe('processFile', () => {
+ it('should process a plain text file', async () => {
+ const textContent = 'Hello, this is a test file with some content.';
+ const buffer = Buffer.from(textContent, 'utf8');
+
+ const result = await fileProcessor.processFile(buffer, 'test.txt', {
+ extractText: true,
+ generateSummary: false,
+ extractKeywords: false,
+ });
+
+ expect(result.text).toBe(textContent);
+ expect(result.metadata.filename).toBe('test.txt');
+ expect(result.metadata.mimeType).toBe('text/plain');
+ expect(result.metadata.size).toBe(buffer.length);
+ expect(result.metadata.checksum).toBeDefined();
+ });
+
+ it('should process a JSON file', async () => {
+ const jsonContent = JSON.stringify({
+ name: 'Test User',
+ email: 'test@example.com',
+ settings: {
+ theme: 'dark',
+ notifications: true
+ }
+ });
+ const buffer = Buffer.from(jsonContent, 'utf8');
+
+ const result = await fileProcessor.processFile(buffer, 'data.json', {
+ extractText: true,
+ });
+
+ expect(result.text).toContain('Test User');
+ expect(result.text).toContain('test@example.com');
+ expect(result.metadata.mimeType).toBe('application/json');
+ });
+
+ it('should process an HTML file', async () => {
+ const htmlContent = `
+
+ Test Page
+
+ Welcome
+ This is a test paragraph with bold text.
+
+
+
+ `;
+ const buffer = Buffer.from(htmlContent, 'utf8');
+
+ const result = await fileProcessor.processFile(buffer, 'page.html', {
+ extractText: true,
+ });
+
+ expect(result.text).toContain('Welcome');
+ expect(result.text).toContain('This is a test paragraph');
+ expect(result.text).not.toContain('');
+ expect(result.text).not.toContain('console.log');
+ expect(result.metadata.mimeType).toBe('text/html');
+ });
+
+ it('should extract keywords from text', async () => {
+ const textContent = 'Machine learning algorithms are powerful tools for data analysis. ' +
+ 'These algorithms can process large datasets and identify patterns. ' +
+ 'Data scientists use machine learning for predictive modeling.';
+ const buffer = Buffer.from(textContent, 'utf8');
+
+ const result = await fileProcessor.processFile(buffer, 'ml-article.txt', {
+ extractText: true,
+ extractKeywords: true,
+ });
+
+ expect(result.keywords).toBeDefined();
+ expect(result.keywords!.length).toBeGreaterThan(0);
+ expect(result.keywords).toContain('machine');
+ expect(result.keywords).toContain('learning');
+ expect(result.keywords).toContain('algorithms');
+ });
+
+ it('should detect entities in text', async () => {
+ const textContent = 'Contact John Doe at john.doe@example.com or visit https://example.com for more information.';
+ const buffer = Buffer.from(textContent, 'utf8');
+
+ const result = await fileProcessor.processFile(buffer, 'contact.txt', {
+ extractText: true,
+ detectEntities: true,
+ });
+
+ expect(result.entities).toBeDefined();
+ expect(result.entities!.length).toBeGreaterThan(0);
+
+ const emailEntity = result.entities!.find(e => e.type === 'EMAIL');
+ const urlEntity = result.entities!.find(e => e.type === 'URL');
+
+ expect(emailEntity).toBeDefined();
+ expect(emailEntity!.text).toBe('john.doe@example.com');
+ expect(urlEntity).toBeDefined();
+ expect(urlEntity!.text).toBe('https://example.com');
+ });
+
+ it('should generate summary for long text', async () => {
+ const longText = 'This is the first sentence of a long document. ' +
+ 'The second sentence provides more context about the topic. ' +
+ 'The third sentence adds additional details and information. ' +
+ 'This continues for many more sentences to create a substantial amount of text content. ' +
+ 'The document covers various aspects of the subject matter in great detail.';
+ const buffer = Buffer.from(longText, 'utf8');
+
+ const result = await fileProcessor.processFile(buffer, 'long-doc.txt', {
+ extractText: true,
+ generateSummary: true,
+ });
+
+ expect(result.summary).toBeDefined();
+ expect(result.summary!.length).toBeGreaterThan(0);
+ expect(result.summary!.length).toBeLessThan(longText.length);
+ });
+
+ it('should handle binary files gracefully', async () => {
+ // Create a buffer with binary data
+ const binaryBuffer = Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]); // PNG header
+
+ const result = await fileProcessor.processFile(binaryBuffer, 'image.png', {
+ extractText: true,
+ });
+
+ expect(result.metadata.mimeType).toBe('image/png');
+ expect(result.metadata.size).toBe(binaryBuffer.length);
+ // Text extraction should not crash, but may return undefined or empty
+ expect(result.text).toBeDefined();
+ });
+
+ it('should respect maxTextLength option', async () => {
+ const longText = 'A'.repeat(1000);
+ const buffer = Buffer.from(longText, 'utf8');
+
+ const result = await fileProcessor.processFile(buffer, 'long.txt', {
+ extractText: true,
+ maxTextLength: 100,
+ });
+
+ expect(result.text!.length).toBeLessThanOrEqual(103); // 100 + "..."
+ expect(result.text).toEndWith('...');
+ });
+
+ it('should handle processing errors gracefully', async () => {
+ const buffer = Buffer.from('test content', 'utf8');
+
+ // Mock a method to throw an error
+ const originalExtractText = (fileProcessor as any).extractText;
+ (fileProcessor as any).extractText = vi.fn().mockRejectedValue(new Error('Processing failed'));
+
+ const result = await fileProcessor.processFile(buffer, 'test.txt', {
+ extractText: true,
+ });
+
+ // Should still return basic metadata even if processing fails
+ expect(result.metadata).toBeDefined();
+ expect(result.metadata.filename).toBe('test.txt');
+ expect(result.text).toBeUndefined();
+
+ // Restore original method
+ (fileProcessor as any).extractText = originalExtractText;
+ });
+
+ it('should detect MIME types correctly', async () => {
+ const testCases = [
+ { filename: 'test.txt', expected: 'text/plain' },
+ { filename: 'data.json', expected: 'application/json' },
+ { filename: 'page.html', expected: 'text/html' },
+ { filename: 'styles.css', expected: 'text/css' },
+ { filename: 'script.js', expected: 'application/javascript' },
+ { filename: 'data.csv', expected: 'text/csv' },
+ { filename: 'unknown.xyz', expected: 'application/octet-stream' },
+ ];
+
+ for (const testCase of testCases) {
+ const buffer = Buffer.from('test content', 'utf8');
+ const result = await fileProcessor.processFile(buffer, testCase.filename);
+ expect(result.metadata.mimeType).toBe(testCase.expected);
+ }
+ });
+
+ it('should calculate checksums correctly', async () => {
+ const content1 = 'Hello World';
+ const content2 = 'Hello World';
+ const content3 = 'Different Content';
+
+ const buffer1 = Buffer.from(content1, 'utf8');
+ const buffer2 = Buffer.from(content2, 'utf8');
+ const buffer3 = Buffer.from(content3, 'utf8');
+
+ const result1 = await fileProcessor.processFile(buffer1, 'test1.txt');
+ const result2 = await fileProcessor.processFile(buffer2, 'test2.txt');
+ const result3 = await fileProcessor.processFile(buffer3, 'test3.txt');
+
+ // Same content should have same checksum
+ expect(result1.metadata.checksum).toBe(result2.metadata.checksum);
+
+ // Different content should have different checksum
+ expect(result1.metadata.checksum).not.toBe(result3.metadata.checksum);
+
+ // Checksums should be hex strings
+ expect(result1.metadata.checksum).toMatch(/^[a-f0-9]{64}$/);
+ });
+
+ it('should handle different text encodings', async () => {
+ const testText = 'Hello, 世界! Café';
+
+ // Test UTF-8
+ const utf8Buffer = Buffer.from(testText, 'utf8');
+ const utf8Result = await fileProcessor.processFile(utf8Buffer, 'utf8.txt');
+ expect(utf8Result.text).toContain('世界');
+ expect(utf8Result.text).toContain('Café');
+
+ // Test Latin-1
+ const latin1Text = 'Hello, Café';
+ const latin1Buffer = Buffer.from(latin1Text, 'latin1');
+ const latin1Result = await fileProcessor.processFile(latin1Buffer, 'latin1.txt');
+ expect(latin1Result.text).toContain('Café');
+ });
+
+ it('should handle File objects', async () => {
+ const textContent = 'File object test content';
+ const file = new File([textContent], 'test-file.txt', { type: 'text/plain' });
+
+ const result = await fileProcessor.processFile(file, 'test-file.txt', {
+ extractText: true,
+ });
+
+ expect(result.text).toBe(textContent);
+ expect(result.metadata.filename).toBe('test-file.txt');
+ expect(result.metadata.size).toBe(textContent.length);
+ });
+ });
+
+ describe('edge cases', () => {
+ it('should handle empty files', async () => {
+ const emptyBuffer = Buffer.alloc(0);
+
+ const result = await fileProcessor.processFile(emptyBuffer, 'empty.txt');
+
+ expect(result.metadata.size).toBe(0);
+ expect(result.text).toBe('');
+ });
+
+ it('should handle files with no extension', async () => {
+ const buffer = Buffer.from('content without extension', 'utf8');
+
+ const result = await fileProcessor.processFile(buffer, 'README');
+
+ expect(result.metadata.mimeType).toBe('application/octet-stream');
+ expect(result.text).toBe('content without extension');
+ });
+
+ it('should handle very large text content', async () => {
+ const largeText = 'A'.repeat(100000);
+ const buffer = Buffer.from(largeText, 'utf8');
+
+ const result = await fileProcessor.processFile(buffer, 'large.txt', {
+ extractText: true,
+ maxTextLength: 1000,
+ });
+
+ expect(result.text!.length).toBeLessThanOrEqual(1003); // 1000 + "..."
+ });
+
+ it('should handle special characters in filenames', async () => {
+ const buffer = Buffer.from('test content', 'utf8');
+ const specialFilename = 'test file (1) [copy].txt';
+
+ const result = await fileProcessor.processFile(buffer, specialFilename);
+
+ expect(result.metadata.filename).toBe(specialFilename);
+ });
+ });
+});
\ No newline at end of file
diff --git a/src/lib/file-processing/file-processor.ts b/src/lib/file-processing/file-processor.ts
new file mode 100644
index 000000000..bf056b5f8
--- /dev/null
+++ b/src/lib/file-processing/file-processor.ts
@@ -0,0 +1,562 @@
+import "server-only";
+
+export interface FileMetadata {
+ filename: string;
+ size: number;
+ mimeType: string;
+ uploadedAt: Date;
+ checksum?: string;
+ dimensions?: {
+ width: number;
+ height: number;
+ };
+ duration?: number; // for audio/video files
+ pageCount?: number; // for documents
+ language?: string; // detected language
+ encoding?: string; // text encoding
+}
+
+export interface ProcessedFileContent {
+ text?: string;
+ metadata: FileMetadata;
+ thumbnails?: string[]; // base64 encoded thumbnails
+ summary?: string;
+ keywords?: string[];
+ entities?: Array<{
+ text: string;
+ type: string;
+ confidence: number;
+ }>;
+}
+
+export interface FileProcessingOptions {
+ extractText?: boolean;
+ generateThumbnails?: boolean;
+ generateSummary?: boolean;
+ extractKeywords?: boolean;
+ detectEntities?: boolean;
+ maxTextLength?: number;
+ thumbnailSizes?: Array<{ width: number; height: number }>;
+}
+
+/**
+ * Enhanced file processing service with support for various file types
+ */
+export class FileProcessor {
+ private static instance: FileProcessor;
+
+ public static getInstance(): FileProcessor {
+ if (!FileProcessor.instance) {
+ FileProcessor.instance = new FileProcessor();
+ }
+ return FileProcessor.instance;
+ }
+
+ /**
+ * Process a file and extract content, metadata, and insights
+ */
+ async processFile(
+ file: File | Buffer,
+ filename: string,
+ options: FileProcessingOptions = {}
+ ): Promise {
+ const buffer = file instanceof File ? Buffer.from(await file.arrayBuffer()) : file;
+ const mimeType = this.detectMimeType(filename, buffer);
+
+ const metadata: FileMetadata = {
+ filename,
+ size: buffer.length,
+ mimeType,
+ uploadedAt: new Date(),
+ checksum: await this.calculateChecksum(buffer),
+ };
+
+ let text: string | undefined;
+ let thumbnails: string[] | undefined;
+ let summary: string | undefined;
+ let keywords: string[] | undefined;
+ let entities: Array<{ text: string; type: string; confidence: number }> | undefined;
+
+ try {
+ // Extract text content
+ if (options.extractText !== false) {
+ text = await this.extractText(buffer, mimeType, filename);
+ if (options.maxTextLength && text && text.length > options.maxTextLength) {
+ text = text.substring(0, options.maxTextLength) + '...';
+ }
+ }
+
+ // Generate thumbnails for images and documents
+ if (options.generateThumbnails && this.canGenerateThumbnails(mimeType)) {
+ thumbnails = await this.generateThumbnails(buffer, mimeType, options.thumbnailSizes);
+ }
+
+ // Extract additional metadata
+ await this.extractAdditionalMetadata(buffer, mimeType, metadata);
+
+ // Generate summary if text is available
+ if (options.generateSummary && text && text.length > 200) {
+ summary = await this.generateSummary(text);
+ }
+
+ // Extract keywords
+ if (options.extractKeywords && text) {
+ keywords = await this.extractKeywords(text);
+ }
+
+ // Detect entities
+ if (options.detectEntities && text) {
+ entities = await this.detectEntities(text);
+ }
+
+ } catch (error) {
+ console.error('Error processing file:', error);
+ // Continue with basic metadata even if processing fails
+ }
+
+ return {
+ text,
+ metadata,
+ thumbnails,
+ summary,
+ keywords,
+ entities,
+ };
+ }
+
+ /**
+ * Extract text content from various file types
+ */
+ private async extractText(buffer: Buffer, mimeType: string, filename: string): Promise {
+ try {
+ switch (mimeType) {
+ case 'text/plain':
+ case 'text/markdown':
+ case 'text/csv':
+ return this.extractPlainText(buffer);
+
+ case 'application/json':
+ return this.extractJsonText(buffer);
+
+ case 'text/html':
+ return this.extractHtmlText(buffer);
+
+ case 'application/pdf':
+ return await this.extractPdfText(buffer);
+
+ case 'application/vnd.openxmlformats-officedocument.wordprocessingml.document':
+ case 'application/msword':
+ return await this.extractDocxText(buffer);
+
+ case 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet':
+ case 'application/vnd.ms-excel':
+ return await this.extractExcelText(buffer);
+
+ case 'application/vnd.openxmlformats-officedocument.presentationml.presentation':
+ case 'application/vnd.ms-powerpoint':
+ return await this.extractPowerpointText(buffer);
+
+ default:
+ // Try to extract as plain text if it's a text-based file
+ if (mimeType.startsWith('text/') || this.isTextFile(filename)) {
+ return this.extractPlainText(buffer);
+ }
+ return undefined;
+ }
+ } catch (error) {
+ console.error('Error extracting text:', error);
+ return undefined;
+ }
+ }
+
+ /**
+ * Extract plain text content
+ */
+ private extractPlainText(buffer: Buffer): string {
+ // Try different encodings
+ const encodings = ['utf8', 'utf16le', 'latin1'];
+
+ for (const encoding of encodings) {
+ try {
+ const text = buffer.toString(encoding as BufferEncoding);
+ // Check if the text looks valid (no excessive null bytes or control characters)
+ if (this.isValidText(text)) {
+ return text;
+ }
+ } catch (error) {
+ continue;
+ }
+ }
+
+ // Fallback to utf8
+ return buffer.toString('utf8');
+ }
+
+ /**
+ * Extract text from JSON files
+ */
+ private extractJsonText(buffer: Buffer): string {
+ try {
+ const jsonStr = buffer.toString('utf8');
+ const parsed = JSON.parse(jsonStr);
+ return this.extractTextFromObject(parsed);
+ } catch (error) {
+ return buffer.toString('utf8');
+ }
+ }
+
+ /**
+ * Extract text from HTML files
+ */
+ private extractHtmlText(buffer: Buffer): string {
+ const html = buffer.toString('utf8');
+ // Simple HTML tag removal - in production, use a proper HTML parser
+ return html
+ .replace(/