ishaq101 commited on
Commit
c0ddd13
·
1 Parent(s): 2b8efe3

[NOTICKET] Major update, re-stylign and upgrade using maintiva demo setup

Browse files
.env.example CHANGED
@@ -1 +1,3 @@
1
- VITE_API_BASE_URL=
 
 
 
1
+ VITE_API_BASE_URL=
2
+ VITE_API_BASE_VOICE_URL=
3
+ VITE_API_BASE_VOICE_WS_URL=
.gitattributes CHANGED
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ public/sounds/* filter=lfs diff=lfs merge=lfs -text
.gitignore CHANGED
@@ -36,4 +36,11 @@ Thumbs.db
36
  *.local
37
  vite.config.ts.timestamp-*
38
 
39
- API_CONTRACT.md
 
 
 
 
 
 
 
 
36
  *.local
37
  vite.config.ts.timestamp-*
38
 
39
+ API_CONTRACT_CHATBOT.md
40
+ API_CONTRACT_VOICE.md
41
+ STYLE.md
42
+ HIGHLIGHT_VOICE.md
43
+ HIGHLIGHT_STT_TTS.md
44
+
45
+ # Database logos (served via CDN)
46
+ public/databases/
ATTRIBUTIONS.md DELETED
@@ -1,3 +0,0 @@
1
- This Figma Make file includes components from [shadcn/ui](https://ui.shadcn.com/) used under [MIT license](https://github.com/shadcn-ui/ui/blob/main/LICENSE.md).
2
-
3
- This Figma Make file includes photos from [Unsplash](https://unsplash.com) used under [license](https://unsplash.com/license).
 
 
 
 
Dockerfile CHANGED
@@ -8,6 +8,11 @@ WORKDIR /app
8
  COPY pnpm-workspace.yaml package.json pnpm-lock.yaml* pnpm-lock.json* ./
9
  RUN pnpm install --no-frozen-lockfile
10
 
 
 
 
 
 
11
  COPY . .
12
  RUN pnpm build
13
 
 
8
  COPY pnpm-workspace.yaml package.json pnpm-lock.yaml* pnpm-lock.json* ./
9
  RUN pnpm install --no-frozen-lockfile
10
 
11
+ ARG VITE_API_BASE_VOICE_URL
12
+ ARG VITE_API_BASE_VOICE_WS_URL
13
+ ENV VITE_API_BASE_VOICE_URL=$VITE_API_BASE_VOICE_URL
14
+ ENV VITE_API_BASE_VOICE_WS_URL=$VITE_API_BASE_VOICE_WS_URL
15
+
16
  COPY . .
17
  RUN pnpm build
18
 
index.html CHANGED
@@ -4,8 +4,8 @@
4
  <head>
5
  <meta charset="UTF-8" />
6
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
- <link rel="icon" type="image/png" href="/logo.png" />
8
- <title>Chatbot application</title>
9
  </head>
10
 
11
  <body>
 
4
  <head>
5
  <meta charset="UTF-8" />
6
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <link rel="icon" type="image/jpeg" href="/maintiva-logo.jpg" />
8
+ <title>Maintiva Agent</title>
9
  </head>
10
 
11
  <body>
package-lock.json CHANGED
@@ -46,6 +46,7 @@
46
  "date-fns": "3.6.0",
47
  "embla-carousel-react": "8.6.0",
48
  "input-otp": "1.4.2",
 
49
  "lucide-react": "0.487.0",
50
  "motion": "12.23.24",
51
  "next-themes": "0.4.6",
@@ -53,12 +54,16 @@
53
  "react-dnd": "16.0.1",
54
  "react-dnd-html5-backend": "16.0.1",
55
  "react-hook-form": "7.55.0",
 
56
  "react-popper": "2.3.0",
57
  "react-resizable-panels": "2.1.7",
58
  "react-responsive-masonry": "2.7.1",
59
  "react-router": "7.13.0",
60
  "react-slick": "0.31.0",
61
  "recharts": "2.15.2",
 
 
 
62
  "sonner": "2.0.3",
63
  "tailwind-merge": "3.2.0",
64
  "tw-animate-css": "1.3.8",
@@ -3341,11 +3346,58 @@
3341
  "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
3342
  "license": "MIT"
3343
  },
 
 
 
 
 
 
 
 
 
3344
  "node_modules/@types/estree": {
3345
  "version": "1.0.8",
3346
  "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
3347
  "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
3348
- "dev": true,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3349
  "license": "MIT"
3350
  },
3351
  "node_modules/@types/parse-json": {
@@ -3379,6 +3431,18 @@
3379
  "@types/react": "*"
3380
  }
3381
  },
 
 
 
 
 
 
 
 
 
 
 
 
3382
  "node_modules/@vitejs/plugin-react": {
3383
  "version": "4.7.0",
3384
  "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
@@ -3427,6 +3491,16 @@
3427
  "npm": ">=6"
3428
  }
3429
  },
 
 
 
 
 
 
 
 
 
 
3430
  "node_modules/baseline-browser-mapping": {
3431
  "version": "2.10.16",
3432
  "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.16.tgz",
@@ -3514,6 +3588,56 @@
3514
  "url": "https://www.paypal.me/kirilvatev"
3515
  }
3516
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3517
  "node_modules/chownr": {
3518
  "version": "3.0.0",
3519
  "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",
@@ -3567,6 +3691,25 @@
3567
  "react-dom": "^18 || ^19 || ^19.0.0-rc"
3568
  }
3569
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3570
  "node_modules/convert-source-map": {
3571
  "version": "1.9.0",
3572
  "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz",
@@ -3771,6 +3914,28 @@
3771
  "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
3772
  "license": "MIT"
3773
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3774
  "node_modules/detect-libc": {
3775
  "version": "2.1.2",
3776
  "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -3787,6 +3952,19 @@
3787
  "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
3788
  "license": "MIT"
3789
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
3790
  "node_modules/dnd-core": {
3791
  "version": "16.0.1",
3792
  "resolved": "https://registry.npmjs.org/dnd-core/-/dnd-core-16.0.1.tgz",
@@ -3857,6 +4035,18 @@
3857
  "node": ">=10.13.0"
3858
  }
3859
  },
 
 
 
 
 
 
 
 
 
 
 
 
3860
  "node_modules/error-ex": {
3861
  "version": "1.3.4",
3862
  "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz",
@@ -3930,12 +4120,28 @@
3930
  "url": "https://github.com/sponsors/sindresorhus"
3931
  }
3932
  },
 
 
 
 
 
 
 
 
 
 
3933
  "node_modules/eventemitter3": {
3934
  "version": "4.0.7",
3935
  "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
3936
  "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
3937
  "license": "MIT"
3938
  },
 
 
 
 
 
 
3939
  "node_modules/fast-deep-equal": {
3940
  "version": "3.1.3",
3941
  "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -4064,6 +4270,174 @@
4064
  "node": ">= 0.4"
4065
  }
4066
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4067
  "node_modules/hoist-non-react-statics": {
4068
  "version": "3.3.2",
4069
  "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz",
@@ -4079,6 +4453,16 @@
4079
  "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
4080
  "license": "MIT"
4081
  },
 
 
 
 
 
 
 
 
 
 
4082
  "node_modules/import-fresh": {
4083
  "version": "3.3.1",
4084
  "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@@ -4095,6 +4479,12 @@
4095
  "url": "https://github.com/sponsors/sindresorhus"
4096
  }
4097
  },
 
 
 
 
 
 
4098
  "node_modules/input-otp": {
4099
  "version": "1.4.2",
4100
  "resolved": "https://registry.npmjs.org/input-otp/-/input-otp-1.4.2.tgz",
@@ -4114,6 +4504,30 @@
4114
  "node": ">=12"
4115
  }
4116
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4117
  "node_modules/is-arrayish": {
4118
  "version": "0.2.1",
4119
  "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",
@@ -4135,6 +4549,38 @@
4135
  "url": "https://github.com/sponsors/ljharb"
4136
  }
4137
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4138
  "node_modules/jiti": {
4139
  "version": "2.6.1",
4140
  "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
@@ -4191,6 +4637,22 @@
4191
  "node": ">=6"
4192
  }
4193
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4194
  "node_modules/lightningcss": {
4195
  "version": "1.30.1",
4196
  "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.1.tgz",
@@ -4448,6 +4910,16 @@
4448
  "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==",
4449
  "license": "MIT"
4450
  },
 
 
 
 
 
 
 
 
 
 
4451
  "node_modules/loose-envify": {
4452
  "version": "1.4.0",
4453
  "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
@@ -4489,32 +4961,925 @@
4489
  "@jridgewell/sourcemap-codec": "^1.5.5"
4490
  }
4491
  },
4492
- "node_modules/minipass": {
4493
- "version": "7.1.3",
4494
- "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
4495
- "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
4496
- "dev": true,
4497
- "license": "BlueOak-1.0.0",
4498
- "engines": {
4499
- "node": ">=16 || 14 >=14.17"
4500
  }
4501
  },
4502
- "node_modules/minizlib": {
4503
- "version": "3.1.0",
4504
- "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz",
4505
- "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==",
4506
- "dev": true,
4507
  "license": "MIT",
4508
  "dependencies": {
4509
- "minipass": "^7.1.2"
 
 
 
4510
  },
4511
- "engines": {
4512
- "node": ">= 18"
 
4513
  }
4514
  },
4515
- "node_modules/motion": {
4516
- "version": "12.23.24",
4517
- "resolved": "https://registry.npmjs.org/motion/-/motion-12.23.24.tgz",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4518
  "integrity": "sha512-Rc5E7oe2YZ72N//S3QXGzbnXgqNrTESv8KKxABR20q2FLch9gHLo0JLyYo2hZ238bZ9Gx6cWhj9VO0IgwbMjCw==",
4519
  "license": "MIT",
4520
  "dependencies": {
@@ -4616,6 +5981,31 @@
4616
  "node": ">=6"
4617
  }
4618
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4619
  "node_modules/parse-json": {
4620
  "version": "5.2.0",
4621
  "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz",
@@ -4634,6 +6024,18 @@
4634
  "url": "https://github.com/sponsors/sindresorhus"
4635
  }
4636
  },
 
 
 
 
 
 
 
 
 
 
 
 
4637
  "node_modules/path-parse": {
4638
  "version": "1.0.7",
4639
  "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
@@ -4714,6 +6116,16 @@
4714
  "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
4715
  "license": "MIT"
4716
  },
 
 
 
 
 
 
 
 
 
 
4717
  "node_modules/react": {
4718
  "version": "18.3.1",
4719
  "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
@@ -4822,6 +6234,33 @@
4822
  "integrity": "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==",
4823
  "license": "MIT"
4824
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4825
  "node_modules/react-popper": {
4826
  "version": "2.3.0",
4827
  "resolved": "https://registry.npmjs.org/react-popper/-/react-popper-2.3.0.tgz",
@@ -5048,6 +6487,107 @@
5048
  "@babel/runtime": "^7.9.2"
5049
  }
5050
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5051
  "node_modules/resize-observer-polyfill": {
5052
  "version": "1.5.1",
5053
  "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
@@ -5183,12 +6723,54 @@
5183
  "node": ">=0.10.0"
5184
  }
5185
  },
 
 
 
 
 
 
 
 
 
 
5186
  "node_modules/string-convert": {
5187
  "version": "0.2.1",
5188
  "resolved": "https://registry.npmjs.org/string-convert/-/string-convert-0.2.1.tgz",
5189
  "integrity": "sha512-u/1tdPl4yQnPBjnVrmdLo9gtuLvELKsAoRapekWggdiQNvvvum+jYF329d84NAa660KQw7pB2n36KrIKVoXa3A==",
5190
  "license": "MIT"
5191
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5192
  "node_modules/stylis": {
5193
  "version": "4.2.0",
5194
  "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz",
@@ -5288,6 +6870,26 @@
5288
  "url": "https://github.com/sponsors/SuperchupuDev"
5289
  }
5290
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5291
  "node_modules/tslib": {
5292
  "version": "2.8.1",
5293
  "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
@@ -5303,6 +6905,121 @@
5303
  "url": "https://github.com/sponsors/Wombosvideo"
5304
  }
5305
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5306
  "node_modules/update-browserslist-db": {
5307
  "version": "1.2.3",
5308
  "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
@@ -5390,6 +7107,48 @@
5390
  "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc"
5391
  }
5392
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5393
  "node_modules/victory-vendor": {
5394
  "version": "36.9.2",
5395
  "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz",
@@ -5496,6 +7255,16 @@
5496
  "loose-envify": "^1.0.0"
5497
  }
5498
  },
 
 
 
 
 
 
 
 
 
 
5499
  "node_modules/yallist": {
5500
  "version": "3.1.1",
5501
  "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
@@ -5520,6 +7289,16 @@
5520
  "funding": {
5521
  "url": "https://github.com/sponsors/eemeli"
5522
  }
 
 
 
 
 
 
 
 
 
 
5523
  }
5524
  }
5525
  }
 
46
  "date-fns": "3.6.0",
47
  "embla-carousel-react": "8.6.0",
48
  "input-otp": "1.4.2",
49
+ "katex": "^0.16.45",
50
  "lucide-react": "0.487.0",
51
  "motion": "12.23.24",
52
  "next-themes": "0.4.6",
 
54
  "react-dnd": "16.0.1",
55
  "react-dnd-html5-backend": "16.0.1",
56
  "react-hook-form": "7.55.0",
57
+ "react-markdown": "^10.1.0",
58
  "react-popper": "2.3.0",
59
  "react-resizable-panels": "2.1.7",
60
  "react-responsive-masonry": "2.7.1",
61
  "react-router": "7.13.0",
62
  "react-slick": "0.31.0",
63
  "recharts": "2.15.2",
64
+ "rehype-katex": "^7.0.1",
65
+ "remark-gfm": "^4.0.1",
66
+ "remark-math": "^6.0.0",
67
  "sonner": "2.0.3",
68
  "tailwind-merge": "3.2.0",
69
  "tw-animate-css": "1.3.8",
 
3346
  "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
3347
  "license": "MIT"
3348
  },
3349
+ "node_modules/@types/debug": {
3350
+ "version": "4.1.13",
3351
+ "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz",
3352
+ "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==",
3353
+ "license": "MIT",
3354
+ "dependencies": {
3355
+ "@types/ms": "*"
3356
+ }
3357
+ },
3358
  "node_modules/@types/estree": {
3359
  "version": "1.0.8",
3360
  "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
3361
  "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
3362
+ "license": "MIT"
3363
+ },
3364
+ "node_modules/@types/estree-jsx": {
3365
+ "version": "1.0.5",
3366
+ "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz",
3367
+ "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==",
3368
+ "license": "MIT",
3369
+ "dependencies": {
3370
+ "@types/estree": "*"
3371
+ }
3372
+ },
3373
+ "node_modules/@types/hast": {
3374
+ "version": "3.0.4",
3375
+ "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz",
3376
+ "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==",
3377
+ "license": "MIT",
3378
+ "dependencies": {
3379
+ "@types/unist": "*"
3380
+ }
3381
+ },
3382
+ "node_modules/@types/katex": {
3383
+ "version": "0.16.8",
3384
+ "resolved": "https://registry.npmjs.org/@types/katex/-/katex-0.16.8.tgz",
3385
+ "integrity": "sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==",
3386
+ "license": "MIT"
3387
+ },
3388
+ "node_modules/@types/mdast": {
3389
+ "version": "4.0.4",
3390
+ "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz",
3391
+ "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==",
3392
+ "license": "MIT",
3393
+ "dependencies": {
3394
+ "@types/unist": "*"
3395
+ }
3396
+ },
3397
+ "node_modules/@types/ms": {
3398
+ "version": "2.1.0",
3399
+ "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
3400
+ "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
3401
  "license": "MIT"
3402
  },
3403
  "node_modules/@types/parse-json": {
 
3431
  "@types/react": "*"
3432
  }
3433
  },
3434
+ "node_modules/@types/unist": {
3435
+ "version": "3.0.3",
3436
+ "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
3437
+ "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
3438
+ "license": "MIT"
3439
+ },
3440
+ "node_modules/@ungap/structured-clone": {
3441
+ "version": "1.3.0",
3442
+ "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz",
3443
+ "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==",
3444
+ "license": "ISC"
3445
+ },
3446
  "node_modules/@vitejs/plugin-react": {
3447
  "version": "4.7.0",
3448
  "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
 
3491
  "npm": ">=6"
3492
  }
3493
  },
3494
+ "node_modules/bail": {
3495
+ "version": "2.0.2",
3496
+ "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz",
3497
+ "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==",
3498
+ "license": "MIT",
3499
+ "funding": {
3500
+ "type": "github",
3501
+ "url": "https://github.com/sponsors/wooorm"
3502
+ }
3503
+ },
3504
  "node_modules/baseline-browser-mapping": {
3505
  "version": "2.10.16",
3506
  "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.16.tgz",
 
3588
  "url": "https://www.paypal.me/kirilvatev"
3589
  }
3590
  },
3591
+ "node_modules/ccount": {
3592
+ "version": "2.0.1",
3593
+ "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz",
3594
+ "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==",
3595
+ "license": "MIT",
3596
+ "funding": {
3597
+ "type": "github",
3598
+ "url": "https://github.com/sponsors/wooorm"
3599
+ }
3600
+ },
3601
+ "node_modules/character-entities": {
3602
+ "version": "2.0.2",
3603
+ "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz",
3604
+ "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==",
3605
+ "license": "MIT",
3606
+ "funding": {
3607
+ "type": "github",
3608
+ "url": "https://github.com/sponsors/wooorm"
3609
+ }
3610
+ },
3611
+ "node_modules/character-entities-html4": {
3612
+ "version": "2.1.0",
3613
+ "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz",
3614
+ "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==",
3615
+ "license": "MIT",
3616
+ "funding": {
3617
+ "type": "github",
3618
+ "url": "https://github.com/sponsors/wooorm"
3619
+ }
3620
+ },
3621
+ "node_modules/character-entities-legacy": {
3622
+ "version": "3.0.0",
3623
+ "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz",
3624
+ "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==",
3625
+ "license": "MIT",
3626
+ "funding": {
3627
+ "type": "github",
3628
+ "url": "https://github.com/sponsors/wooorm"
3629
+ }
3630
+ },
3631
+ "node_modules/character-reference-invalid": {
3632
+ "version": "2.0.1",
3633
+ "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz",
3634
+ "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==",
3635
+ "license": "MIT",
3636
+ "funding": {
3637
+ "type": "github",
3638
+ "url": "https://github.com/sponsors/wooorm"
3639
+ }
3640
+ },
3641
  "node_modules/chownr": {
3642
  "version": "3.0.0",
3643
  "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",
 
3691
  "react-dom": "^18 || ^19 || ^19.0.0-rc"
3692
  }
3693
  },
3694
+ "node_modules/comma-separated-tokens": {
3695
+ "version": "2.0.3",
3696
+ "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz",
3697
+ "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==",
3698
+ "license": "MIT",
3699
+ "funding": {
3700
+ "type": "github",
3701
+ "url": "https://github.com/sponsors/wooorm"
3702
+ }
3703
+ },
3704
+ "node_modules/commander": {
3705
+ "version": "8.3.0",
3706
+ "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz",
3707
+ "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==",
3708
+ "license": "MIT",
3709
+ "engines": {
3710
+ "node": ">= 12"
3711
+ }
3712
+ },
3713
  "node_modules/convert-source-map": {
3714
  "version": "1.9.0",
3715
  "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz",
 
3914
  "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
3915
  "license": "MIT"
3916
  },
3917
+ "node_modules/decode-named-character-reference": {
3918
+ "version": "1.3.0",
3919
+ "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz",
3920
+ "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==",
3921
+ "license": "MIT",
3922
+ "dependencies": {
3923
+ "character-entities": "^2.0.0"
3924
+ },
3925
+ "funding": {
3926
+ "type": "github",
3927
+ "url": "https://github.com/sponsors/wooorm"
3928
+ }
3929
+ },
3930
+ "node_modules/dequal": {
3931
+ "version": "2.0.3",
3932
+ "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
3933
+ "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
3934
+ "license": "MIT",
3935
+ "engines": {
3936
+ "node": ">=6"
3937
+ }
3938
+ },
3939
  "node_modules/detect-libc": {
3940
  "version": "2.1.2",
3941
  "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
 
3952
  "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
3953
  "license": "MIT"
3954
  },
3955
+ "node_modules/devlop": {
3956
+ "version": "1.1.0",
3957
+ "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz",
3958
+ "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==",
3959
+ "license": "MIT",
3960
+ "dependencies": {
3961
+ "dequal": "^2.0.0"
3962
+ },
3963
+ "funding": {
3964
+ "type": "github",
3965
+ "url": "https://github.com/sponsors/wooorm"
3966
+ }
3967
+ },
3968
  "node_modules/dnd-core": {
3969
  "version": "16.0.1",
3970
  "resolved": "https://registry.npmjs.org/dnd-core/-/dnd-core-16.0.1.tgz",
 
4035
  "node": ">=10.13.0"
4036
  }
4037
  },
4038
+ "node_modules/entities": {
4039
+ "version": "6.0.1",
4040
+ "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
4041
+ "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
4042
+ "license": "BSD-2-Clause",
4043
+ "engines": {
4044
+ "node": ">=0.12"
4045
+ },
4046
+ "funding": {
4047
+ "url": "https://github.com/fb55/entities?sponsor=1"
4048
+ }
4049
+ },
4050
  "node_modules/error-ex": {
4051
  "version": "1.3.4",
4052
  "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz",
 
4120
  "url": "https://github.com/sponsors/sindresorhus"
4121
  }
4122
  },
4123
+ "node_modules/estree-util-is-identifier-name": {
4124
+ "version": "3.0.0",
4125
+ "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz",
4126
+ "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==",
4127
+ "license": "MIT",
4128
+ "funding": {
4129
+ "type": "opencollective",
4130
+ "url": "https://opencollective.com/unified"
4131
+ }
4132
+ },
4133
  "node_modules/eventemitter3": {
4134
  "version": "4.0.7",
4135
  "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
4136
  "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
4137
  "license": "MIT"
4138
  },
4139
+ "node_modules/extend": {
4140
+ "version": "3.0.2",
4141
+ "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
4142
+ "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
4143
+ "license": "MIT"
4144
+ },
4145
  "node_modules/fast-deep-equal": {
4146
  "version": "3.1.3",
4147
  "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
 
4270
  "node": ">= 0.4"
4271
  }
4272
  },
4273
+ "node_modules/hast-util-from-dom": {
4274
+ "version": "5.0.1",
4275
+ "resolved": "https://registry.npmjs.org/hast-util-from-dom/-/hast-util-from-dom-5.0.1.tgz",
4276
+ "integrity": "sha512-N+LqofjR2zuzTjCPzyDUdSshy4Ma6li7p/c3pA78uTwzFgENbgbUrm2ugwsOdcjI1muO+o6Dgzp9p8WHtn/39Q==",
4277
+ "license": "ISC",
4278
+ "dependencies": {
4279
+ "@types/hast": "^3.0.0",
4280
+ "hastscript": "^9.0.0",
4281
+ "web-namespaces": "^2.0.0"
4282
+ },
4283
+ "funding": {
4284
+ "type": "opencollective",
4285
+ "url": "https://opencollective.com/unified"
4286
+ }
4287
+ },
4288
+ "node_modules/hast-util-from-html": {
4289
+ "version": "2.0.3",
4290
+ "resolved": "https://registry.npmjs.org/hast-util-from-html/-/hast-util-from-html-2.0.3.tgz",
4291
+ "integrity": "sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==",
4292
+ "license": "MIT",
4293
+ "dependencies": {
4294
+ "@types/hast": "^3.0.0",
4295
+ "devlop": "^1.1.0",
4296
+ "hast-util-from-parse5": "^8.0.0",
4297
+ "parse5": "^7.0.0",
4298
+ "vfile": "^6.0.0",
4299
+ "vfile-message": "^4.0.0"
4300
+ },
4301
+ "funding": {
4302
+ "type": "opencollective",
4303
+ "url": "https://opencollective.com/unified"
4304
+ }
4305
+ },
4306
+ "node_modules/hast-util-from-html-isomorphic": {
4307
+ "version": "2.0.0",
4308
+ "resolved": "https://registry.npmjs.org/hast-util-from-html-isomorphic/-/hast-util-from-html-isomorphic-2.0.0.tgz",
4309
+ "integrity": "sha512-zJfpXq44yff2hmE0XmwEOzdWin5xwH+QIhMLOScpX91e/NSGPsAzNCvLQDIEPyO2TXi+lBmU6hjLIhV8MwP2kw==",
4310
+ "license": "MIT",
4311
+ "dependencies": {
4312
+ "@types/hast": "^3.0.0",
4313
+ "hast-util-from-dom": "^5.0.0",
4314
+ "hast-util-from-html": "^2.0.0",
4315
+ "unist-util-remove-position": "^5.0.0"
4316
+ },
4317
+ "funding": {
4318
+ "type": "opencollective",
4319
+ "url": "https://opencollective.com/unified"
4320
+ }
4321
+ },
4322
+ "node_modules/hast-util-from-parse5": {
4323
+ "version": "8.0.3",
4324
+ "resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz",
4325
+ "integrity": "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==",
4326
+ "license": "MIT",
4327
+ "dependencies": {
4328
+ "@types/hast": "^3.0.0",
4329
+ "@types/unist": "^3.0.0",
4330
+ "devlop": "^1.0.0",
4331
+ "hastscript": "^9.0.0",
4332
+ "property-information": "^7.0.0",
4333
+ "vfile": "^6.0.0",
4334
+ "vfile-location": "^5.0.0",
4335
+ "web-namespaces": "^2.0.0"
4336
+ },
4337
+ "funding": {
4338
+ "type": "opencollective",
4339
+ "url": "https://opencollective.com/unified"
4340
+ }
4341
+ },
4342
+ "node_modules/hast-util-is-element": {
4343
+ "version": "3.0.0",
4344
+ "resolved": "https://registry.npmjs.org/hast-util-is-element/-/hast-util-is-element-3.0.0.tgz",
4345
+ "integrity": "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==",
4346
+ "license": "MIT",
4347
+ "dependencies": {
4348
+ "@types/hast": "^3.0.0"
4349
+ },
4350
+ "funding": {
4351
+ "type": "opencollective",
4352
+ "url": "https://opencollective.com/unified"
4353
+ }
4354
+ },
4355
+ "node_modules/hast-util-parse-selector": {
4356
+ "version": "4.0.0",
4357
+ "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz",
4358
+ "integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==",
4359
+ "license": "MIT",
4360
+ "dependencies": {
4361
+ "@types/hast": "^3.0.0"
4362
+ },
4363
+ "funding": {
4364
+ "type": "opencollective",
4365
+ "url": "https://opencollective.com/unified"
4366
+ }
4367
+ },
4368
+ "node_modules/hast-util-to-jsx-runtime": {
4369
+ "version": "2.3.6",
4370
+ "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz",
4371
+ "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==",
4372
+ "license": "MIT",
4373
+ "dependencies": {
4374
+ "@types/estree": "^1.0.0",
4375
+ "@types/hast": "^3.0.0",
4376
+ "@types/unist": "^3.0.0",
4377
+ "comma-separated-tokens": "^2.0.0",
4378
+ "devlop": "^1.0.0",
4379
+ "estree-util-is-identifier-name": "^3.0.0",
4380
+ "hast-util-whitespace": "^3.0.0",
4381
+ "mdast-util-mdx-expression": "^2.0.0",
4382
+ "mdast-util-mdx-jsx": "^3.0.0",
4383
+ "mdast-util-mdxjs-esm": "^2.0.0",
4384
+ "property-information": "^7.0.0",
4385
+ "space-separated-tokens": "^2.0.0",
4386
+ "style-to-js": "^1.0.0",
4387
+ "unist-util-position": "^5.0.0",
4388
+ "vfile-message": "^4.0.0"
4389
+ },
4390
+ "funding": {
4391
+ "type": "opencollective",
4392
+ "url": "https://opencollective.com/unified"
4393
+ }
4394
+ },
4395
+ "node_modules/hast-util-to-text": {
4396
+ "version": "4.0.2",
4397
+ "resolved": "https://registry.npmjs.org/hast-util-to-text/-/hast-util-to-text-4.0.2.tgz",
4398
+ "integrity": "sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==",
4399
+ "license": "MIT",
4400
+ "dependencies": {
4401
+ "@types/hast": "^3.0.0",
4402
+ "@types/unist": "^3.0.0",
4403
+ "hast-util-is-element": "^3.0.0",
4404
+ "unist-util-find-after": "^5.0.0"
4405
+ },
4406
+ "funding": {
4407
+ "type": "opencollective",
4408
+ "url": "https://opencollective.com/unified"
4409
+ }
4410
+ },
4411
+ "node_modules/hast-util-whitespace": {
4412
+ "version": "3.0.0",
4413
+ "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz",
4414
+ "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==",
4415
+ "license": "MIT",
4416
+ "dependencies": {
4417
+ "@types/hast": "^3.0.0"
4418
+ },
4419
+ "funding": {
4420
+ "type": "opencollective",
4421
+ "url": "https://opencollective.com/unified"
4422
+ }
4423
+ },
4424
+ "node_modules/hastscript": {
4425
+ "version": "9.0.1",
4426
+ "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz",
4427
+ "integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==",
4428
+ "license": "MIT",
4429
+ "dependencies": {
4430
+ "@types/hast": "^3.0.0",
4431
+ "comma-separated-tokens": "^2.0.0",
4432
+ "hast-util-parse-selector": "^4.0.0",
4433
+ "property-information": "^7.0.0",
4434
+ "space-separated-tokens": "^2.0.0"
4435
+ },
4436
+ "funding": {
4437
+ "type": "opencollective",
4438
+ "url": "https://opencollective.com/unified"
4439
+ }
4440
+ },
4441
  "node_modules/hoist-non-react-statics": {
4442
  "version": "3.3.2",
4443
  "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz",
 
4453
  "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
4454
  "license": "MIT"
4455
  },
4456
+ "node_modules/html-url-attributes": {
4457
+ "version": "3.0.1",
4458
+ "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
4459
+ "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==",
4460
+ "license": "MIT",
4461
+ "funding": {
4462
+ "type": "opencollective",
4463
+ "url": "https://opencollective.com/unified"
4464
+ }
4465
+ },
4466
  "node_modules/import-fresh": {
4467
  "version": "3.3.1",
4468
  "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
 
4479
  "url": "https://github.com/sponsors/sindresorhus"
4480
  }
4481
  },
4482
+ "node_modules/inline-style-parser": {
4483
+ "version": "0.2.7",
4484
+ "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz",
4485
+ "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==",
4486
+ "license": "MIT"
4487
+ },
4488
  "node_modules/input-otp": {
4489
  "version": "1.4.2",
4490
  "resolved": "https://registry.npmjs.org/input-otp/-/input-otp-1.4.2.tgz",
 
4504
  "node": ">=12"
4505
  }
4506
  },
4507
+ "node_modules/is-alphabetical": {
4508
+ "version": "2.0.1",
4509
+ "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz",
4510
+ "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==",
4511
+ "license": "MIT",
4512
+ "funding": {
4513
+ "type": "github",
4514
+ "url": "https://github.com/sponsors/wooorm"
4515
+ }
4516
+ },
4517
+ "node_modules/is-alphanumerical": {
4518
+ "version": "2.0.1",
4519
+ "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz",
4520
+ "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==",
4521
+ "license": "MIT",
4522
+ "dependencies": {
4523
+ "is-alphabetical": "^2.0.0",
4524
+ "is-decimal": "^2.0.0"
4525
+ },
4526
+ "funding": {
4527
+ "type": "github",
4528
+ "url": "https://github.com/sponsors/wooorm"
4529
+ }
4530
+ },
4531
  "node_modules/is-arrayish": {
4532
  "version": "0.2.1",
4533
  "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",
 
4549
  "url": "https://github.com/sponsors/ljharb"
4550
  }
4551
  },
4552
+ "node_modules/is-decimal": {
4553
+ "version": "2.0.1",
4554
+ "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz",
4555
+ "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==",
4556
+ "license": "MIT",
4557
+ "funding": {
4558
+ "type": "github",
4559
+ "url": "https://github.com/sponsors/wooorm"
4560
+ }
4561
+ },
4562
+ "node_modules/is-hexadecimal": {
4563
+ "version": "2.0.1",
4564
+ "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz",
4565
+ "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==",
4566
+ "license": "MIT",
4567
+ "funding": {
4568
+ "type": "github",
4569
+ "url": "https://github.com/sponsors/wooorm"
4570
+ }
4571
+ },
4572
+ "node_modules/is-plain-obj": {
4573
+ "version": "4.1.0",
4574
+ "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz",
4575
+ "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==",
4576
+ "license": "MIT",
4577
+ "engines": {
4578
+ "node": ">=12"
4579
+ },
4580
+ "funding": {
4581
+ "url": "https://github.com/sponsors/sindresorhus"
4582
+ }
4583
+ },
4584
  "node_modules/jiti": {
4585
  "version": "2.6.1",
4586
  "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
 
4637
  "node": ">=6"
4638
  }
4639
  },
4640
+ "node_modules/katex": {
4641
+ "version": "0.16.45",
4642
+ "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.45.tgz",
4643
+ "integrity": "sha512-pQpZbdBu7wCTmQUh7ufPmLr0pFoObnGUoL/yhtwJDgmmQpbkg/0HSVti25Fu4rmd1oCR6NGWe9vqTWuWv3GcNA==",
4644
+ "funding": [
4645
+ "https://opencollective.com/katex",
4646
+ "https://github.com/sponsors/katex"
4647
+ ],
4648
+ "license": "MIT",
4649
+ "dependencies": {
4650
+ "commander": "^8.3.0"
4651
+ },
4652
+ "bin": {
4653
+ "katex": "cli.js"
4654
+ }
4655
+ },
4656
  "node_modules/lightningcss": {
4657
  "version": "1.30.1",
4658
  "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.1.tgz",
 
4910
  "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==",
4911
  "license": "MIT"
4912
  },
4913
+ "node_modules/longest-streak": {
4914
+ "version": "3.1.0",
4915
+ "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz",
4916
+ "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==",
4917
+ "license": "MIT",
4918
+ "funding": {
4919
+ "type": "github",
4920
+ "url": "https://github.com/sponsors/wooorm"
4921
+ }
4922
+ },
4923
  "node_modules/loose-envify": {
4924
  "version": "1.4.0",
4925
  "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
 
4961
  "@jridgewell/sourcemap-codec": "^1.5.5"
4962
  }
4963
  },
4964
+ "node_modules/markdown-table": {
4965
+ "version": "3.0.4",
4966
+ "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz",
4967
+ "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==",
4968
+ "license": "MIT",
4969
+ "funding": {
4970
+ "type": "github",
4971
+ "url": "https://github.com/sponsors/wooorm"
4972
  }
4973
  },
4974
+ "node_modules/mdast-util-find-and-replace": {
4975
+ "version": "3.0.2",
4976
+ "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz",
4977
+ "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==",
 
4978
  "license": "MIT",
4979
  "dependencies": {
4980
+ "@types/mdast": "^4.0.0",
4981
+ "escape-string-regexp": "^5.0.0",
4982
+ "unist-util-is": "^6.0.0",
4983
+ "unist-util-visit-parents": "^6.0.0"
4984
  },
4985
+ "funding": {
4986
+ "type": "opencollective",
4987
+ "url": "https://opencollective.com/unified"
4988
  }
4989
  },
4990
+ "node_modules/mdast-util-find-and-replace/node_modules/escape-string-regexp": {
4991
+ "version": "5.0.0",
4992
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz",
4993
+ "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==",
4994
+ "license": "MIT",
4995
+ "engines": {
4996
+ "node": ">=12"
4997
+ },
4998
+ "funding": {
4999
+ "url": "https://github.com/sponsors/sindresorhus"
5000
+ }
5001
+ },
5002
+ "node_modules/mdast-util-from-markdown": {
5003
+ "version": "2.0.3",
5004
+ "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz",
5005
+ "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==",
5006
+ "license": "MIT",
5007
+ "dependencies": {
5008
+ "@types/mdast": "^4.0.0",
5009
+ "@types/unist": "^3.0.0",
5010
+ "decode-named-character-reference": "^1.0.0",
5011
+ "devlop": "^1.0.0",
5012
+ "mdast-util-to-string": "^4.0.0",
5013
+ "micromark": "^4.0.0",
5014
+ "micromark-util-decode-numeric-character-reference": "^2.0.0",
5015
+ "micromark-util-decode-string": "^2.0.0",
5016
+ "micromark-util-normalize-identifier": "^2.0.0",
5017
+ "micromark-util-symbol": "^2.0.0",
5018
+ "micromark-util-types": "^2.0.0",
5019
+ "unist-util-stringify-position": "^4.0.0"
5020
+ },
5021
+ "funding": {
5022
+ "type": "opencollective",
5023
+ "url": "https://opencollective.com/unified"
5024
+ }
5025
+ },
5026
+ "node_modules/mdast-util-gfm": {
5027
+ "version": "3.1.0",
5028
+ "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz",
5029
+ "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==",
5030
+ "license": "MIT",
5031
+ "dependencies": {
5032
+ "mdast-util-from-markdown": "^2.0.0",
5033
+ "mdast-util-gfm-autolink-literal": "^2.0.0",
5034
+ "mdast-util-gfm-footnote": "^2.0.0",
5035
+ "mdast-util-gfm-strikethrough": "^2.0.0",
5036
+ "mdast-util-gfm-table": "^2.0.0",
5037
+ "mdast-util-gfm-task-list-item": "^2.0.0",
5038
+ "mdast-util-to-markdown": "^2.0.0"
5039
+ },
5040
+ "funding": {
5041
+ "type": "opencollective",
5042
+ "url": "https://opencollective.com/unified"
5043
+ }
5044
+ },
5045
+ "node_modules/mdast-util-gfm-autolink-literal": {
5046
+ "version": "2.0.1",
5047
+ "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz",
5048
+ "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==",
5049
+ "license": "MIT",
5050
+ "dependencies": {
5051
+ "@types/mdast": "^4.0.0",
5052
+ "ccount": "^2.0.0",
5053
+ "devlop": "^1.0.0",
5054
+ "mdast-util-find-and-replace": "^3.0.0",
5055
+ "micromark-util-character": "^2.0.0"
5056
+ },
5057
+ "funding": {
5058
+ "type": "opencollective",
5059
+ "url": "https://opencollective.com/unified"
5060
+ }
5061
+ },
5062
+ "node_modules/mdast-util-gfm-footnote": {
5063
+ "version": "2.1.0",
5064
+ "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz",
5065
+ "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==",
5066
+ "license": "MIT",
5067
+ "dependencies": {
5068
+ "@types/mdast": "^4.0.0",
5069
+ "devlop": "^1.1.0",
5070
+ "mdast-util-from-markdown": "^2.0.0",
5071
+ "mdast-util-to-markdown": "^2.0.0",
5072
+ "micromark-util-normalize-identifier": "^2.0.0"
5073
+ },
5074
+ "funding": {
5075
+ "type": "opencollective",
5076
+ "url": "https://opencollective.com/unified"
5077
+ }
5078
+ },
5079
+ "node_modules/mdast-util-gfm-strikethrough": {
5080
+ "version": "2.0.0",
5081
+ "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz",
5082
+ "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==",
5083
+ "license": "MIT",
5084
+ "dependencies": {
5085
+ "@types/mdast": "^4.0.0",
5086
+ "mdast-util-from-markdown": "^2.0.0",
5087
+ "mdast-util-to-markdown": "^2.0.0"
5088
+ },
5089
+ "funding": {
5090
+ "type": "opencollective",
5091
+ "url": "https://opencollective.com/unified"
5092
+ }
5093
+ },
5094
+ "node_modules/mdast-util-gfm-table": {
5095
+ "version": "2.0.0",
5096
+ "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz",
5097
+ "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==",
5098
+ "license": "MIT",
5099
+ "dependencies": {
5100
+ "@types/mdast": "^4.0.0",
5101
+ "devlop": "^1.0.0",
5102
+ "markdown-table": "^3.0.0",
5103
+ "mdast-util-from-markdown": "^2.0.0",
5104
+ "mdast-util-to-markdown": "^2.0.0"
5105
+ },
5106
+ "funding": {
5107
+ "type": "opencollective",
5108
+ "url": "https://opencollective.com/unified"
5109
+ }
5110
+ },
5111
+ "node_modules/mdast-util-gfm-task-list-item": {
5112
+ "version": "2.0.0",
5113
+ "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz",
5114
+ "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==",
5115
+ "license": "MIT",
5116
+ "dependencies": {
5117
+ "@types/mdast": "^4.0.0",
5118
+ "devlop": "^1.0.0",
5119
+ "mdast-util-from-markdown": "^2.0.0",
5120
+ "mdast-util-to-markdown": "^2.0.0"
5121
+ },
5122
+ "funding": {
5123
+ "type": "opencollective",
5124
+ "url": "https://opencollective.com/unified"
5125
+ }
5126
+ },
5127
+ "node_modules/mdast-util-math": {
5128
+ "version": "3.0.0",
5129
+ "resolved": "https://registry.npmjs.org/mdast-util-math/-/mdast-util-math-3.0.0.tgz",
5130
+ "integrity": "sha512-Tl9GBNeG/AhJnQM221bJR2HPvLOSnLE/T9cJI9tlc6zwQk2nPk/4f0cHkOdEixQPC/j8UtKDdITswvLAy1OZ1w==",
5131
+ "license": "MIT",
5132
+ "dependencies": {
5133
+ "@types/hast": "^3.0.0",
5134
+ "@types/mdast": "^4.0.0",
5135
+ "devlop": "^1.0.0",
5136
+ "longest-streak": "^3.0.0",
5137
+ "mdast-util-from-markdown": "^2.0.0",
5138
+ "mdast-util-to-markdown": "^2.1.0",
5139
+ "unist-util-remove-position": "^5.0.0"
5140
+ },
5141
+ "funding": {
5142
+ "type": "opencollective",
5143
+ "url": "https://opencollective.com/unified"
5144
+ }
5145
+ },
5146
+ "node_modules/mdast-util-mdx-expression": {
5147
+ "version": "2.0.1",
5148
+ "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz",
5149
+ "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==",
5150
+ "license": "MIT",
5151
+ "dependencies": {
5152
+ "@types/estree-jsx": "^1.0.0",
5153
+ "@types/hast": "^3.0.0",
5154
+ "@types/mdast": "^4.0.0",
5155
+ "devlop": "^1.0.0",
5156
+ "mdast-util-from-markdown": "^2.0.0",
5157
+ "mdast-util-to-markdown": "^2.0.0"
5158
+ },
5159
+ "funding": {
5160
+ "type": "opencollective",
5161
+ "url": "https://opencollective.com/unified"
5162
+ }
5163
+ },
5164
+ "node_modules/mdast-util-mdx-jsx": {
5165
+ "version": "3.2.0",
5166
+ "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz",
5167
+ "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==",
5168
+ "license": "MIT",
5169
+ "dependencies": {
5170
+ "@types/estree-jsx": "^1.0.0",
5171
+ "@types/hast": "^3.0.0",
5172
+ "@types/mdast": "^4.0.0",
5173
+ "@types/unist": "^3.0.0",
5174
+ "ccount": "^2.0.0",
5175
+ "devlop": "^1.1.0",
5176
+ "mdast-util-from-markdown": "^2.0.0",
5177
+ "mdast-util-to-markdown": "^2.0.0",
5178
+ "parse-entities": "^4.0.0",
5179
+ "stringify-entities": "^4.0.0",
5180
+ "unist-util-stringify-position": "^4.0.0",
5181
+ "vfile-message": "^4.0.0"
5182
+ },
5183
+ "funding": {
5184
+ "type": "opencollective",
5185
+ "url": "https://opencollective.com/unified"
5186
+ }
5187
+ },
5188
+ "node_modules/mdast-util-mdxjs-esm": {
5189
+ "version": "2.0.1",
5190
+ "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz",
5191
+ "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==",
5192
+ "license": "MIT",
5193
+ "dependencies": {
5194
+ "@types/estree-jsx": "^1.0.0",
5195
+ "@types/hast": "^3.0.0",
5196
+ "@types/mdast": "^4.0.0",
5197
+ "devlop": "^1.0.0",
5198
+ "mdast-util-from-markdown": "^2.0.0",
5199
+ "mdast-util-to-markdown": "^2.0.0"
5200
+ },
5201
+ "funding": {
5202
+ "type": "opencollective",
5203
+ "url": "https://opencollective.com/unified"
5204
+ }
5205
+ },
5206
+ "node_modules/mdast-util-phrasing": {
5207
+ "version": "4.1.0",
5208
+ "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz",
5209
+ "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==",
5210
+ "license": "MIT",
5211
+ "dependencies": {
5212
+ "@types/mdast": "^4.0.0",
5213
+ "unist-util-is": "^6.0.0"
5214
+ },
5215
+ "funding": {
5216
+ "type": "opencollective",
5217
+ "url": "https://opencollective.com/unified"
5218
+ }
5219
+ },
5220
+ "node_modules/mdast-util-to-hast": {
5221
+ "version": "13.2.1",
5222
+ "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz",
5223
+ "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==",
5224
+ "license": "MIT",
5225
+ "dependencies": {
5226
+ "@types/hast": "^3.0.0",
5227
+ "@types/mdast": "^4.0.0",
5228
+ "@ungap/structured-clone": "^1.0.0",
5229
+ "devlop": "^1.0.0",
5230
+ "micromark-util-sanitize-uri": "^2.0.0",
5231
+ "trim-lines": "^3.0.0",
5232
+ "unist-util-position": "^5.0.0",
5233
+ "unist-util-visit": "^5.0.0",
5234
+ "vfile": "^6.0.0"
5235
+ },
5236
+ "funding": {
5237
+ "type": "opencollective",
5238
+ "url": "https://opencollective.com/unified"
5239
+ }
5240
+ },
5241
+ "node_modules/mdast-util-to-markdown": {
5242
+ "version": "2.1.2",
5243
+ "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz",
5244
+ "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==",
5245
+ "license": "MIT",
5246
+ "dependencies": {
5247
+ "@types/mdast": "^4.0.0",
5248
+ "@types/unist": "^3.0.0",
5249
+ "longest-streak": "^3.0.0",
5250
+ "mdast-util-phrasing": "^4.0.0",
5251
+ "mdast-util-to-string": "^4.0.0",
5252
+ "micromark-util-classify-character": "^2.0.0",
5253
+ "micromark-util-decode-string": "^2.0.0",
5254
+ "unist-util-visit": "^5.0.0",
5255
+ "zwitch": "^2.0.0"
5256
+ },
5257
+ "funding": {
5258
+ "type": "opencollective",
5259
+ "url": "https://opencollective.com/unified"
5260
+ }
5261
+ },
5262
+ "node_modules/mdast-util-to-string": {
5263
+ "version": "4.0.0",
5264
+ "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz",
5265
+ "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==",
5266
+ "license": "MIT",
5267
+ "dependencies": {
5268
+ "@types/mdast": "^4.0.0"
5269
+ },
5270
+ "funding": {
5271
+ "type": "opencollective",
5272
+ "url": "https://opencollective.com/unified"
5273
+ }
5274
+ },
5275
+ "node_modules/micromark": {
5276
+ "version": "4.0.2",
5277
+ "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz",
5278
+ "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==",
5279
+ "funding": [
5280
+ {
5281
+ "type": "GitHub Sponsors",
5282
+ "url": "https://github.com/sponsors/unifiedjs"
5283
+ },
5284
+ {
5285
+ "type": "OpenCollective",
5286
+ "url": "https://opencollective.com/unified"
5287
+ }
5288
+ ],
5289
+ "license": "MIT",
5290
+ "dependencies": {
5291
+ "@types/debug": "^4.0.0",
5292
+ "debug": "^4.0.0",
5293
+ "decode-named-character-reference": "^1.0.0",
5294
+ "devlop": "^1.0.0",
5295
+ "micromark-core-commonmark": "^2.0.0",
5296
+ "micromark-factory-space": "^2.0.0",
5297
+ "micromark-util-character": "^2.0.0",
5298
+ "micromark-util-chunked": "^2.0.0",
5299
+ "micromark-util-combine-extensions": "^2.0.0",
5300
+ "micromark-util-decode-numeric-character-reference": "^2.0.0",
5301
+ "micromark-util-encode": "^2.0.0",
5302
+ "micromark-util-normalize-identifier": "^2.0.0",
5303
+ "micromark-util-resolve-all": "^2.0.0",
5304
+ "micromark-util-sanitize-uri": "^2.0.0",
5305
+ "micromark-util-subtokenize": "^2.0.0",
5306
+ "micromark-util-symbol": "^2.0.0",
5307
+ "micromark-util-types": "^2.0.0"
5308
+ }
5309
+ },
5310
+ "node_modules/micromark-core-commonmark": {
5311
+ "version": "2.0.3",
5312
+ "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz",
5313
+ "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==",
5314
+ "funding": [
5315
+ {
5316
+ "type": "GitHub Sponsors",
5317
+ "url": "https://github.com/sponsors/unifiedjs"
5318
+ },
5319
+ {
5320
+ "type": "OpenCollective",
5321
+ "url": "https://opencollective.com/unified"
5322
+ }
5323
+ ],
5324
+ "license": "MIT",
5325
+ "dependencies": {
5326
+ "decode-named-character-reference": "^1.0.0",
5327
+ "devlop": "^1.0.0",
5328
+ "micromark-factory-destination": "^2.0.0",
5329
+ "micromark-factory-label": "^2.0.0",
5330
+ "micromark-factory-space": "^2.0.0",
5331
+ "micromark-factory-title": "^2.0.0",
5332
+ "micromark-factory-whitespace": "^2.0.0",
5333
+ "micromark-util-character": "^2.0.0",
5334
+ "micromark-util-chunked": "^2.0.0",
5335
+ "micromark-util-classify-character": "^2.0.0",
5336
+ "micromark-util-html-tag-name": "^2.0.0",
5337
+ "micromark-util-normalize-identifier": "^2.0.0",
5338
+ "micromark-util-resolve-all": "^2.0.0",
5339
+ "micromark-util-subtokenize": "^2.0.0",
5340
+ "micromark-util-symbol": "^2.0.0",
5341
+ "micromark-util-types": "^2.0.0"
5342
+ }
5343
+ },
5344
+ "node_modules/micromark-extension-gfm": {
5345
+ "version": "3.0.0",
5346
+ "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz",
5347
+ "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==",
5348
+ "license": "MIT",
5349
+ "dependencies": {
5350
+ "micromark-extension-gfm-autolink-literal": "^2.0.0",
5351
+ "micromark-extension-gfm-footnote": "^2.0.0",
5352
+ "micromark-extension-gfm-strikethrough": "^2.0.0",
5353
+ "micromark-extension-gfm-table": "^2.0.0",
5354
+ "micromark-extension-gfm-tagfilter": "^2.0.0",
5355
+ "micromark-extension-gfm-task-list-item": "^2.0.0",
5356
+ "micromark-util-combine-extensions": "^2.0.0",
5357
+ "micromark-util-types": "^2.0.0"
5358
+ },
5359
+ "funding": {
5360
+ "type": "opencollective",
5361
+ "url": "https://opencollective.com/unified"
5362
+ }
5363
+ },
5364
+ "node_modules/micromark-extension-gfm-autolink-literal": {
5365
+ "version": "2.1.0",
5366
+ "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz",
5367
+ "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==",
5368
+ "license": "MIT",
5369
+ "dependencies": {
5370
+ "micromark-util-character": "^2.0.0",
5371
+ "micromark-util-sanitize-uri": "^2.0.0",
5372
+ "micromark-util-symbol": "^2.0.0",
5373
+ "micromark-util-types": "^2.0.0"
5374
+ },
5375
+ "funding": {
5376
+ "type": "opencollective",
5377
+ "url": "https://opencollective.com/unified"
5378
+ }
5379
+ },
5380
+ "node_modules/micromark-extension-gfm-footnote": {
5381
+ "version": "2.1.0",
5382
+ "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz",
5383
+ "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==",
5384
+ "license": "MIT",
5385
+ "dependencies": {
5386
+ "devlop": "^1.0.0",
5387
+ "micromark-core-commonmark": "^2.0.0",
5388
+ "micromark-factory-space": "^2.0.0",
5389
+ "micromark-util-character": "^2.0.0",
5390
+ "micromark-util-normalize-identifier": "^2.0.0",
5391
+ "micromark-util-sanitize-uri": "^2.0.0",
5392
+ "micromark-util-symbol": "^2.0.0",
5393
+ "micromark-util-types": "^2.0.0"
5394
+ },
5395
+ "funding": {
5396
+ "type": "opencollective",
5397
+ "url": "https://opencollective.com/unified"
5398
+ }
5399
+ },
5400
+ "node_modules/micromark-extension-gfm-strikethrough": {
5401
+ "version": "2.1.0",
5402
+ "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz",
5403
+ "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==",
5404
+ "license": "MIT",
5405
+ "dependencies": {
5406
+ "devlop": "^1.0.0",
5407
+ "micromark-util-chunked": "^2.0.0",
5408
+ "micromark-util-classify-character": "^2.0.0",
5409
+ "micromark-util-resolve-all": "^2.0.0",
5410
+ "micromark-util-symbol": "^2.0.0",
5411
+ "micromark-util-types": "^2.0.0"
5412
+ },
5413
+ "funding": {
5414
+ "type": "opencollective",
5415
+ "url": "https://opencollective.com/unified"
5416
+ }
5417
+ },
5418
+ "node_modules/micromark-extension-gfm-table": {
5419
+ "version": "2.1.1",
5420
+ "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz",
5421
+ "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==",
5422
+ "license": "MIT",
5423
+ "dependencies": {
5424
+ "devlop": "^1.0.0",
5425
+ "micromark-factory-space": "^2.0.0",
5426
+ "micromark-util-character": "^2.0.0",
5427
+ "micromark-util-symbol": "^2.0.0",
5428
+ "micromark-util-types": "^2.0.0"
5429
+ },
5430
+ "funding": {
5431
+ "type": "opencollective",
5432
+ "url": "https://opencollective.com/unified"
5433
+ }
5434
+ },
5435
+ "node_modules/micromark-extension-gfm-tagfilter": {
5436
+ "version": "2.0.0",
5437
+ "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz",
5438
+ "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==",
5439
+ "license": "MIT",
5440
+ "dependencies": {
5441
+ "micromark-util-types": "^2.0.0"
5442
+ },
5443
+ "funding": {
5444
+ "type": "opencollective",
5445
+ "url": "https://opencollective.com/unified"
5446
+ }
5447
+ },
5448
+ "node_modules/micromark-extension-gfm-task-list-item": {
5449
+ "version": "2.1.0",
5450
+ "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz",
5451
+ "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==",
5452
+ "license": "MIT",
5453
+ "dependencies": {
5454
+ "devlop": "^1.0.0",
5455
+ "micromark-factory-space": "^2.0.0",
5456
+ "micromark-util-character": "^2.0.0",
5457
+ "micromark-util-symbol": "^2.0.0",
5458
+ "micromark-util-types": "^2.0.0"
5459
+ },
5460
+ "funding": {
5461
+ "type": "opencollective",
5462
+ "url": "https://opencollective.com/unified"
5463
+ }
5464
+ },
5465
+ "node_modules/micromark-extension-math": {
5466
+ "version": "3.1.0",
5467
+ "resolved": "https://registry.npmjs.org/micromark-extension-math/-/micromark-extension-math-3.1.0.tgz",
5468
+ "integrity": "sha512-lvEqd+fHjATVs+2v/8kg9i5Q0AP2k85H0WUOwpIVvUML8BapsMvh1XAogmQjOCsLpoKRCVQqEkQBB3NhVBcsOg==",
5469
+ "license": "MIT",
5470
+ "dependencies": {
5471
+ "@types/katex": "^0.16.0",
5472
+ "devlop": "^1.0.0",
5473
+ "katex": "^0.16.0",
5474
+ "micromark-factory-space": "^2.0.0",
5475
+ "micromark-util-character": "^2.0.0",
5476
+ "micromark-util-symbol": "^2.0.0",
5477
+ "micromark-util-types": "^2.0.0"
5478
+ },
5479
+ "funding": {
5480
+ "type": "opencollective",
5481
+ "url": "https://opencollective.com/unified"
5482
+ }
5483
+ },
5484
+ "node_modules/micromark-factory-destination": {
5485
+ "version": "2.0.1",
5486
+ "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz",
5487
+ "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==",
5488
+ "funding": [
5489
+ {
5490
+ "type": "GitHub Sponsors",
5491
+ "url": "https://github.com/sponsors/unifiedjs"
5492
+ },
5493
+ {
5494
+ "type": "OpenCollective",
5495
+ "url": "https://opencollective.com/unified"
5496
+ }
5497
+ ],
5498
+ "license": "MIT",
5499
+ "dependencies": {
5500
+ "micromark-util-character": "^2.0.0",
5501
+ "micromark-util-symbol": "^2.0.0",
5502
+ "micromark-util-types": "^2.0.0"
5503
+ }
5504
+ },
5505
+ "node_modules/micromark-factory-label": {
5506
+ "version": "2.0.1",
5507
+ "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz",
5508
+ "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==",
5509
+ "funding": [
5510
+ {
5511
+ "type": "GitHub Sponsors",
5512
+ "url": "https://github.com/sponsors/unifiedjs"
5513
+ },
5514
+ {
5515
+ "type": "OpenCollective",
5516
+ "url": "https://opencollective.com/unified"
5517
+ }
5518
+ ],
5519
+ "license": "MIT",
5520
+ "dependencies": {
5521
+ "devlop": "^1.0.0",
5522
+ "micromark-util-character": "^2.0.0",
5523
+ "micromark-util-symbol": "^2.0.0",
5524
+ "micromark-util-types": "^2.0.0"
5525
+ }
5526
+ },
5527
+ "node_modules/micromark-factory-space": {
5528
+ "version": "2.0.1",
5529
+ "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz",
5530
+ "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==",
5531
+ "funding": [
5532
+ {
5533
+ "type": "GitHub Sponsors",
5534
+ "url": "https://github.com/sponsors/unifiedjs"
5535
+ },
5536
+ {
5537
+ "type": "OpenCollective",
5538
+ "url": "https://opencollective.com/unified"
5539
+ }
5540
+ ],
5541
+ "license": "MIT",
5542
+ "dependencies": {
5543
+ "micromark-util-character": "^2.0.0",
5544
+ "micromark-util-types": "^2.0.0"
5545
+ }
5546
+ },
5547
+ "node_modules/micromark-factory-title": {
5548
+ "version": "2.0.1",
5549
+ "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz",
5550
+ "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==",
5551
+ "funding": [
5552
+ {
5553
+ "type": "GitHub Sponsors",
5554
+ "url": "https://github.com/sponsors/unifiedjs"
5555
+ },
5556
+ {
5557
+ "type": "OpenCollective",
5558
+ "url": "https://opencollective.com/unified"
5559
+ }
5560
+ ],
5561
+ "license": "MIT",
5562
+ "dependencies": {
5563
+ "micromark-factory-space": "^2.0.0",
5564
+ "micromark-util-character": "^2.0.0",
5565
+ "micromark-util-symbol": "^2.0.0",
5566
+ "micromark-util-types": "^2.0.0"
5567
+ }
5568
+ },
5569
+ "node_modules/micromark-factory-whitespace": {
5570
+ "version": "2.0.1",
5571
+ "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz",
5572
+ "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==",
5573
+ "funding": [
5574
+ {
5575
+ "type": "GitHub Sponsors",
5576
+ "url": "https://github.com/sponsors/unifiedjs"
5577
+ },
5578
+ {
5579
+ "type": "OpenCollective",
5580
+ "url": "https://opencollective.com/unified"
5581
+ }
5582
+ ],
5583
+ "license": "MIT",
5584
+ "dependencies": {
5585
+ "micromark-factory-space": "^2.0.0",
5586
+ "micromark-util-character": "^2.0.0",
5587
+ "micromark-util-symbol": "^2.0.0",
5588
+ "micromark-util-types": "^2.0.0"
5589
+ }
5590
+ },
5591
+ "node_modules/micromark-util-character": {
5592
+ "version": "2.1.1",
5593
+ "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz",
5594
+ "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==",
5595
+ "funding": [
5596
+ {
5597
+ "type": "GitHub Sponsors",
5598
+ "url": "https://github.com/sponsors/unifiedjs"
5599
+ },
5600
+ {
5601
+ "type": "OpenCollective",
5602
+ "url": "https://opencollective.com/unified"
5603
+ }
5604
+ ],
5605
+ "license": "MIT",
5606
+ "dependencies": {
5607
+ "micromark-util-symbol": "^2.0.0",
5608
+ "micromark-util-types": "^2.0.0"
5609
+ }
5610
+ },
5611
+ "node_modules/micromark-util-chunked": {
5612
+ "version": "2.0.1",
5613
+ "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz",
5614
+ "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==",
5615
+ "funding": [
5616
+ {
5617
+ "type": "GitHub Sponsors",
5618
+ "url": "https://github.com/sponsors/unifiedjs"
5619
+ },
5620
+ {
5621
+ "type": "OpenCollective",
5622
+ "url": "https://opencollective.com/unified"
5623
+ }
5624
+ ],
5625
+ "license": "MIT",
5626
+ "dependencies": {
5627
+ "micromark-util-symbol": "^2.0.0"
5628
+ }
5629
+ },
5630
+ "node_modules/micromark-util-classify-character": {
5631
+ "version": "2.0.1",
5632
+ "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz",
5633
+ "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==",
5634
+ "funding": [
5635
+ {
5636
+ "type": "GitHub Sponsors",
5637
+ "url": "https://github.com/sponsors/unifiedjs"
5638
+ },
5639
+ {
5640
+ "type": "OpenCollective",
5641
+ "url": "https://opencollective.com/unified"
5642
+ }
5643
+ ],
5644
+ "license": "MIT",
5645
+ "dependencies": {
5646
+ "micromark-util-character": "^2.0.0",
5647
+ "micromark-util-symbol": "^2.0.0",
5648
+ "micromark-util-types": "^2.0.0"
5649
+ }
5650
+ },
5651
+ "node_modules/micromark-util-combine-extensions": {
5652
+ "version": "2.0.1",
5653
+ "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz",
5654
+ "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==",
5655
+ "funding": [
5656
+ {
5657
+ "type": "GitHub Sponsors",
5658
+ "url": "https://github.com/sponsors/unifiedjs"
5659
+ },
5660
+ {
5661
+ "type": "OpenCollective",
5662
+ "url": "https://opencollective.com/unified"
5663
+ }
5664
+ ],
5665
+ "license": "MIT",
5666
+ "dependencies": {
5667
+ "micromark-util-chunked": "^2.0.0",
5668
+ "micromark-util-types": "^2.0.0"
5669
+ }
5670
+ },
5671
+ "node_modules/micromark-util-decode-numeric-character-reference": {
5672
+ "version": "2.0.2",
5673
+ "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz",
5674
+ "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==",
5675
+ "funding": [
5676
+ {
5677
+ "type": "GitHub Sponsors",
5678
+ "url": "https://github.com/sponsors/unifiedjs"
5679
+ },
5680
+ {
5681
+ "type": "OpenCollective",
5682
+ "url": "https://opencollective.com/unified"
5683
+ }
5684
+ ],
5685
+ "license": "MIT",
5686
+ "dependencies": {
5687
+ "micromark-util-symbol": "^2.0.0"
5688
+ }
5689
+ },
5690
+ "node_modules/micromark-util-decode-string": {
5691
+ "version": "2.0.1",
5692
+ "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz",
5693
+ "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==",
5694
+ "funding": [
5695
+ {
5696
+ "type": "GitHub Sponsors",
5697
+ "url": "https://github.com/sponsors/unifiedjs"
5698
+ },
5699
+ {
5700
+ "type": "OpenCollective",
5701
+ "url": "https://opencollective.com/unified"
5702
+ }
5703
+ ],
5704
+ "license": "MIT",
5705
+ "dependencies": {
5706
+ "decode-named-character-reference": "^1.0.0",
5707
+ "micromark-util-character": "^2.0.0",
5708
+ "micromark-util-decode-numeric-character-reference": "^2.0.0",
5709
+ "micromark-util-symbol": "^2.0.0"
5710
+ }
5711
+ },
5712
+ "node_modules/micromark-util-encode": {
5713
+ "version": "2.0.1",
5714
+ "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz",
5715
+ "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==",
5716
+ "funding": [
5717
+ {
5718
+ "type": "GitHub Sponsors",
5719
+ "url": "https://github.com/sponsors/unifiedjs"
5720
+ },
5721
+ {
5722
+ "type": "OpenCollective",
5723
+ "url": "https://opencollective.com/unified"
5724
+ }
5725
+ ],
5726
+ "license": "MIT"
5727
+ },
5728
+ "node_modules/micromark-util-html-tag-name": {
5729
+ "version": "2.0.1",
5730
+ "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz",
5731
+ "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==",
5732
+ "funding": [
5733
+ {
5734
+ "type": "GitHub Sponsors",
5735
+ "url": "https://github.com/sponsors/unifiedjs"
5736
+ },
5737
+ {
5738
+ "type": "OpenCollective",
5739
+ "url": "https://opencollective.com/unified"
5740
+ }
5741
+ ],
5742
+ "license": "MIT"
5743
+ },
5744
+ "node_modules/micromark-util-normalize-identifier": {
5745
+ "version": "2.0.1",
5746
+ "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz",
5747
+ "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==",
5748
+ "funding": [
5749
+ {
5750
+ "type": "GitHub Sponsors",
5751
+ "url": "https://github.com/sponsors/unifiedjs"
5752
+ },
5753
+ {
5754
+ "type": "OpenCollective",
5755
+ "url": "https://opencollective.com/unified"
5756
+ }
5757
+ ],
5758
+ "license": "MIT",
5759
+ "dependencies": {
5760
+ "micromark-util-symbol": "^2.0.0"
5761
+ }
5762
+ },
5763
+ "node_modules/micromark-util-resolve-all": {
5764
+ "version": "2.0.1",
5765
+ "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz",
5766
+ "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==",
5767
+ "funding": [
5768
+ {
5769
+ "type": "GitHub Sponsors",
5770
+ "url": "https://github.com/sponsors/unifiedjs"
5771
+ },
5772
+ {
5773
+ "type": "OpenCollective",
5774
+ "url": "https://opencollective.com/unified"
5775
+ }
5776
+ ],
5777
+ "license": "MIT",
5778
+ "dependencies": {
5779
+ "micromark-util-types": "^2.0.0"
5780
+ }
5781
+ },
5782
+ "node_modules/micromark-util-sanitize-uri": {
5783
+ "version": "2.0.1",
5784
+ "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz",
5785
+ "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==",
5786
+ "funding": [
5787
+ {
5788
+ "type": "GitHub Sponsors",
5789
+ "url": "https://github.com/sponsors/unifiedjs"
5790
+ },
5791
+ {
5792
+ "type": "OpenCollective",
5793
+ "url": "https://opencollective.com/unified"
5794
+ }
5795
+ ],
5796
+ "license": "MIT",
5797
+ "dependencies": {
5798
+ "micromark-util-character": "^2.0.0",
5799
+ "micromark-util-encode": "^2.0.0",
5800
+ "micromark-util-symbol": "^2.0.0"
5801
+ }
5802
+ },
5803
+ "node_modules/micromark-util-subtokenize": {
5804
+ "version": "2.1.0",
5805
+ "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz",
5806
+ "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==",
5807
+ "funding": [
5808
+ {
5809
+ "type": "GitHub Sponsors",
5810
+ "url": "https://github.com/sponsors/unifiedjs"
5811
+ },
5812
+ {
5813
+ "type": "OpenCollective",
5814
+ "url": "https://opencollective.com/unified"
5815
+ }
5816
+ ],
5817
+ "license": "MIT",
5818
+ "dependencies": {
5819
+ "devlop": "^1.0.0",
5820
+ "micromark-util-chunked": "^2.0.0",
5821
+ "micromark-util-symbol": "^2.0.0",
5822
+ "micromark-util-types": "^2.0.0"
5823
+ }
5824
+ },
5825
+ "node_modules/micromark-util-symbol": {
5826
+ "version": "2.0.1",
5827
+ "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz",
5828
+ "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==",
5829
+ "funding": [
5830
+ {
5831
+ "type": "GitHub Sponsors",
5832
+ "url": "https://github.com/sponsors/unifiedjs"
5833
+ },
5834
+ {
5835
+ "type": "OpenCollective",
5836
+ "url": "https://opencollective.com/unified"
5837
+ }
5838
+ ],
5839
+ "license": "MIT"
5840
+ },
5841
+ "node_modules/micromark-util-types": {
5842
+ "version": "2.0.2",
5843
+ "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz",
5844
+ "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==",
5845
+ "funding": [
5846
+ {
5847
+ "type": "GitHub Sponsors",
5848
+ "url": "https://github.com/sponsors/unifiedjs"
5849
+ },
5850
+ {
5851
+ "type": "OpenCollective",
5852
+ "url": "https://opencollective.com/unified"
5853
+ }
5854
+ ],
5855
+ "license": "MIT"
5856
+ },
5857
+ "node_modules/minipass": {
5858
+ "version": "7.1.3",
5859
+ "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
5860
+ "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
5861
+ "dev": true,
5862
+ "license": "BlueOak-1.0.0",
5863
+ "engines": {
5864
+ "node": ">=16 || 14 >=14.17"
5865
+ }
5866
+ },
5867
+ "node_modules/minizlib": {
5868
+ "version": "3.1.0",
5869
+ "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz",
5870
+ "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==",
5871
+ "dev": true,
5872
+ "license": "MIT",
5873
+ "dependencies": {
5874
+ "minipass": "^7.1.2"
5875
+ },
5876
+ "engines": {
5877
+ "node": ">= 18"
5878
+ }
5879
+ },
5880
+ "node_modules/motion": {
5881
+ "version": "12.23.24",
5882
+ "resolved": "https://registry.npmjs.org/motion/-/motion-12.23.24.tgz",
5883
  "integrity": "sha512-Rc5E7oe2YZ72N//S3QXGzbnXgqNrTESv8KKxABR20q2FLch9gHLo0JLyYo2hZ238bZ9Gx6cWhj9VO0IgwbMjCw==",
5884
  "license": "MIT",
5885
  "dependencies": {
 
5981
  "node": ">=6"
5982
  }
5983
  },
5984
+ "node_modules/parse-entities": {
5985
+ "version": "4.0.2",
5986
+ "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz",
5987
+ "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==",
5988
+ "license": "MIT",
5989
+ "dependencies": {
5990
+ "@types/unist": "^2.0.0",
5991
+ "character-entities-legacy": "^3.0.0",
5992
+ "character-reference-invalid": "^2.0.0",
5993
+ "decode-named-character-reference": "^1.0.0",
5994
+ "is-alphanumerical": "^2.0.0",
5995
+ "is-decimal": "^2.0.0",
5996
+ "is-hexadecimal": "^2.0.0"
5997
+ },
5998
+ "funding": {
5999
+ "type": "github",
6000
+ "url": "https://github.com/sponsors/wooorm"
6001
+ }
6002
+ },
6003
+ "node_modules/parse-entities/node_modules/@types/unist": {
6004
+ "version": "2.0.11",
6005
+ "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz",
6006
+ "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==",
6007
+ "license": "MIT"
6008
+ },
6009
  "node_modules/parse-json": {
6010
  "version": "5.2.0",
6011
  "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz",
 
6024
  "url": "https://github.com/sponsors/sindresorhus"
6025
  }
6026
  },
6027
+ "node_modules/parse5": {
6028
+ "version": "7.3.0",
6029
+ "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
6030
+ "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
6031
+ "license": "MIT",
6032
+ "dependencies": {
6033
+ "entities": "^6.0.0"
6034
+ },
6035
+ "funding": {
6036
+ "url": "https://github.com/inikulin/parse5?sponsor=1"
6037
+ }
6038
+ },
6039
  "node_modules/path-parse": {
6040
  "version": "1.0.7",
6041
  "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
 
6116
  "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
6117
  "license": "MIT"
6118
  },
6119
+ "node_modules/property-information": {
6120
+ "version": "7.1.0",
6121
+ "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz",
6122
+ "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==",
6123
+ "license": "MIT",
6124
+ "funding": {
6125
+ "type": "github",
6126
+ "url": "https://github.com/sponsors/wooorm"
6127
+ }
6128
+ },
6129
  "node_modules/react": {
6130
  "version": "18.3.1",
6131
  "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
 
6234
  "integrity": "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==",
6235
  "license": "MIT"
6236
  },
6237
+ "node_modules/react-markdown": {
6238
+ "version": "10.1.0",
6239
+ "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz",
6240
+ "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==",
6241
+ "license": "MIT",
6242
+ "dependencies": {
6243
+ "@types/hast": "^3.0.0",
6244
+ "@types/mdast": "^4.0.0",
6245
+ "devlop": "^1.0.0",
6246
+ "hast-util-to-jsx-runtime": "^2.0.0",
6247
+ "html-url-attributes": "^3.0.0",
6248
+ "mdast-util-to-hast": "^13.0.0",
6249
+ "remark-parse": "^11.0.0",
6250
+ "remark-rehype": "^11.0.0",
6251
+ "unified": "^11.0.0",
6252
+ "unist-util-visit": "^5.0.0",
6253
+ "vfile": "^6.0.0"
6254
+ },
6255
+ "funding": {
6256
+ "type": "opencollective",
6257
+ "url": "https://opencollective.com/unified"
6258
+ },
6259
+ "peerDependencies": {
6260
+ "@types/react": ">=18",
6261
+ "react": ">=18"
6262
+ }
6263
+ },
6264
  "node_modules/react-popper": {
6265
  "version": "2.3.0",
6266
  "resolved": "https://registry.npmjs.org/react-popper/-/react-popper-2.3.0.tgz",
 
6487
  "@babel/runtime": "^7.9.2"
6488
  }
6489
  },
6490
+ "node_modules/rehype-katex": {
6491
+ "version": "7.0.1",
6492
+ "resolved": "https://registry.npmjs.org/rehype-katex/-/rehype-katex-7.0.1.tgz",
6493
+ "integrity": "sha512-OiM2wrZ/wuhKkigASodFoo8wimG3H12LWQaH8qSPVJn9apWKFSH3YOCtbKpBorTVw/eI7cuT21XBbvwEswbIOA==",
6494
+ "license": "MIT",
6495
+ "dependencies": {
6496
+ "@types/hast": "^3.0.0",
6497
+ "@types/katex": "^0.16.0",
6498
+ "hast-util-from-html-isomorphic": "^2.0.0",
6499
+ "hast-util-to-text": "^4.0.0",
6500
+ "katex": "^0.16.0",
6501
+ "unist-util-visit-parents": "^6.0.0",
6502
+ "vfile": "^6.0.0"
6503
+ },
6504
+ "funding": {
6505
+ "type": "opencollective",
6506
+ "url": "https://opencollective.com/unified"
6507
+ }
6508
+ },
6509
+ "node_modules/remark-gfm": {
6510
+ "version": "4.0.1",
6511
+ "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz",
6512
+ "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==",
6513
+ "license": "MIT",
6514
+ "dependencies": {
6515
+ "@types/mdast": "^4.0.0",
6516
+ "mdast-util-gfm": "^3.0.0",
6517
+ "micromark-extension-gfm": "^3.0.0",
6518
+ "remark-parse": "^11.0.0",
6519
+ "remark-stringify": "^11.0.0",
6520
+ "unified": "^11.0.0"
6521
+ },
6522
+ "funding": {
6523
+ "type": "opencollective",
6524
+ "url": "https://opencollective.com/unified"
6525
+ }
6526
+ },
6527
+ "node_modules/remark-math": {
6528
+ "version": "6.0.0",
6529
+ "resolved": "https://registry.npmjs.org/remark-math/-/remark-math-6.0.0.tgz",
6530
+ "integrity": "sha512-MMqgnP74Igy+S3WwnhQ7kqGlEerTETXMvJhrUzDikVZ2/uogJCb+WHUg97hK9/jcfc0dkD73s3LN8zU49cTEtA==",
6531
+ "license": "MIT",
6532
+ "dependencies": {
6533
+ "@types/mdast": "^4.0.0",
6534
+ "mdast-util-math": "^3.0.0",
6535
+ "micromark-extension-math": "^3.0.0",
6536
+ "unified": "^11.0.0"
6537
+ },
6538
+ "funding": {
6539
+ "type": "opencollective",
6540
+ "url": "https://opencollective.com/unified"
6541
+ }
6542
+ },
6543
+ "node_modules/remark-parse": {
6544
+ "version": "11.0.0",
6545
+ "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz",
6546
+ "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==",
6547
+ "license": "MIT",
6548
+ "dependencies": {
6549
+ "@types/mdast": "^4.0.0",
6550
+ "mdast-util-from-markdown": "^2.0.0",
6551
+ "micromark-util-types": "^2.0.0",
6552
+ "unified": "^11.0.0"
6553
+ },
6554
+ "funding": {
6555
+ "type": "opencollective",
6556
+ "url": "https://opencollective.com/unified"
6557
+ }
6558
+ },
6559
+ "node_modules/remark-rehype": {
6560
+ "version": "11.1.2",
6561
+ "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz",
6562
+ "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==",
6563
+ "license": "MIT",
6564
+ "dependencies": {
6565
+ "@types/hast": "^3.0.0",
6566
+ "@types/mdast": "^4.0.0",
6567
+ "mdast-util-to-hast": "^13.0.0",
6568
+ "unified": "^11.0.0",
6569
+ "vfile": "^6.0.0"
6570
+ },
6571
+ "funding": {
6572
+ "type": "opencollective",
6573
+ "url": "https://opencollective.com/unified"
6574
+ }
6575
+ },
6576
+ "node_modules/remark-stringify": {
6577
+ "version": "11.0.0",
6578
+ "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz",
6579
+ "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==",
6580
+ "license": "MIT",
6581
+ "dependencies": {
6582
+ "@types/mdast": "^4.0.0",
6583
+ "mdast-util-to-markdown": "^2.0.0",
6584
+ "unified": "^11.0.0"
6585
+ },
6586
+ "funding": {
6587
+ "type": "opencollective",
6588
+ "url": "https://opencollective.com/unified"
6589
+ }
6590
+ },
6591
  "node_modules/resize-observer-polyfill": {
6592
  "version": "1.5.1",
6593
  "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
 
6723
  "node": ">=0.10.0"
6724
  }
6725
  },
6726
+ "node_modules/space-separated-tokens": {
6727
+ "version": "2.0.2",
6728
+ "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz",
6729
+ "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==",
6730
+ "license": "MIT",
6731
+ "funding": {
6732
+ "type": "github",
6733
+ "url": "https://github.com/sponsors/wooorm"
6734
+ }
6735
+ },
6736
  "node_modules/string-convert": {
6737
  "version": "0.2.1",
6738
  "resolved": "https://registry.npmjs.org/string-convert/-/string-convert-0.2.1.tgz",
6739
  "integrity": "sha512-u/1tdPl4yQnPBjnVrmdLo9gtuLvELKsAoRapekWggdiQNvvvum+jYF329d84NAa660KQw7pB2n36KrIKVoXa3A==",
6740
  "license": "MIT"
6741
  },
6742
+ "node_modules/stringify-entities": {
6743
+ "version": "4.0.4",
6744
+ "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz",
6745
+ "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==",
6746
+ "license": "MIT",
6747
+ "dependencies": {
6748
+ "character-entities-html4": "^2.0.0",
6749
+ "character-entities-legacy": "^3.0.0"
6750
+ },
6751
+ "funding": {
6752
+ "type": "github",
6753
+ "url": "https://github.com/sponsors/wooorm"
6754
+ }
6755
+ },
6756
+ "node_modules/style-to-js": {
6757
+ "version": "1.1.21",
6758
+ "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz",
6759
+ "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==",
6760
+ "license": "MIT",
6761
+ "dependencies": {
6762
+ "style-to-object": "1.0.14"
6763
+ }
6764
+ },
6765
+ "node_modules/style-to-object": {
6766
+ "version": "1.0.14",
6767
+ "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz",
6768
+ "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==",
6769
+ "license": "MIT",
6770
+ "dependencies": {
6771
+ "inline-style-parser": "0.2.7"
6772
+ }
6773
+ },
6774
  "node_modules/stylis": {
6775
  "version": "4.2.0",
6776
  "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz",
 
6870
  "url": "https://github.com/sponsors/SuperchupuDev"
6871
  }
6872
  },
6873
+ "node_modules/trim-lines": {
6874
+ "version": "3.0.1",
6875
+ "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz",
6876
+ "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==",
6877
+ "license": "MIT",
6878
+ "funding": {
6879
+ "type": "github",
6880
+ "url": "https://github.com/sponsors/wooorm"
6881
+ }
6882
+ },
6883
+ "node_modules/trough": {
6884
+ "version": "2.2.0",
6885
+ "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz",
6886
+ "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==",
6887
+ "license": "MIT",
6888
+ "funding": {
6889
+ "type": "github",
6890
+ "url": "https://github.com/sponsors/wooorm"
6891
+ }
6892
+ },
6893
  "node_modules/tslib": {
6894
  "version": "2.8.1",
6895
  "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
 
6905
  "url": "https://github.com/sponsors/Wombosvideo"
6906
  }
6907
  },
6908
+ "node_modules/unified": {
6909
+ "version": "11.0.5",
6910
+ "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz",
6911
+ "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==",
6912
+ "license": "MIT",
6913
+ "dependencies": {
6914
+ "@types/unist": "^3.0.0",
6915
+ "bail": "^2.0.0",
6916
+ "devlop": "^1.0.0",
6917
+ "extend": "^3.0.0",
6918
+ "is-plain-obj": "^4.0.0",
6919
+ "trough": "^2.0.0",
6920
+ "vfile": "^6.0.0"
6921
+ },
6922
+ "funding": {
6923
+ "type": "opencollective",
6924
+ "url": "https://opencollective.com/unified"
6925
+ }
6926
+ },
6927
+ "node_modules/unist-util-find-after": {
6928
+ "version": "5.0.0",
6929
+ "resolved": "https://registry.npmjs.org/unist-util-find-after/-/unist-util-find-after-5.0.0.tgz",
6930
+ "integrity": "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==",
6931
+ "license": "MIT",
6932
+ "dependencies": {
6933
+ "@types/unist": "^3.0.0",
6934
+ "unist-util-is": "^6.0.0"
6935
+ },
6936
+ "funding": {
6937
+ "type": "opencollective",
6938
+ "url": "https://opencollective.com/unified"
6939
+ }
6940
+ },
6941
+ "node_modules/unist-util-is": {
6942
+ "version": "6.0.1",
6943
+ "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz",
6944
+ "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==",
6945
+ "license": "MIT",
6946
+ "dependencies": {
6947
+ "@types/unist": "^3.0.0"
6948
+ },
6949
+ "funding": {
6950
+ "type": "opencollective",
6951
+ "url": "https://opencollective.com/unified"
6952
+ }
6953
+ },
6954
+ "node_modules/unist-util-position": {
6955
+ "version": "5.0.0",
6956
+ "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz",
6957
+ "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==",
6958
+ "license": "MIT",
6959
+ "dependencies": {
6960
+ "@types/unist": "^3.0.0"
6961
+ },
6962
+ "funding": {
6963
+ "type": "opencollective",
6964
+ "url": "https://opencollective.com/unified"
6965
+ }
6966
+ },
6967
+ "node_modules/unist-util-remove-position": {
6968
+ "version": "5.0.0",
6969
+ "resolved": "https://registry.npmjs.org/unist-util-remove-position/-/unist-util-remove-position-5.0.0.tgz",
6970
+ "integrity": "sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q==",
6971
+ "license": "MIT",
6972
+ "dependencies": {
6973
+ "@types/unist": "^3.0.0",
6974
+ "unist-util-visit": "^5.0.0"
6975
+ },
6976
+ "funding": {
6977
+ "type": "opencollective",
6978
+ "url": "https://opencollective.com/unified"
6979
+ }
6980
+ },
6981
+ "node_modules/unist-util-stringify-position": {
6982
+ "version": "4.0.0",
6983
+ "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz",
6984
+ "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==",
6985
+ "license": "MIT",
6986
+ "dependencies": {
6987
+ "@types/unist": "^3.0.0"
6988
+ },
6989
+ "funding": {
6990
+ "type": "opencollective",
6991
+ "url": "https://opencollective.com/unified"
6992
+ }
6993
+ },
6994
+ "node_modules/unist-util-visit": {
6995
+ "version": "5.1.0",
6996
+ "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz",
6997
+ "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==",
6998
+ "license": "MIT",
6999
+ "dependencies": {
7000
+ "@types/unist": "^3.0.0",
7001
+ "unist-util-is": "^6.0.0",
7002
+ "unist-util-visit-parents": "^6.0.0"
7003
+ },
7004
+ "funding": {
7005
+ "type": "opencollective",
7006
+ "url": "https://opencollective.com/unified"
7007
+ }
7008
+ },
7009
+ "node_modules/unist-util-visit-parents": {
7010
+ "version": "6.0.2",
7011
+ "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz",
7012
+ "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==",
7013
+ "license": "MIT",
7014
+ "dependencies": {
7015
+ "@types/unist": "^3.0.0",
7016
+ "unist-util-is": "^6.0.0"
7017
+ },
7018
+ "funding": {
7019
+ "type": "opencollective",
7020
+ "url": "https://opencollective.com/unified"
7021
+ }
7022
+ },
7023
  "node_modules/update-browserslist-db": {
7024
  "version": "1.2.3",
7025
  "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
 
7107
  "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc"
7108
  }
7109
  },
7110
+ "node_modules/vfile": {
7111
+ "version": "6.0.3",
7112
+ "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz",
7113
+ "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==",
7114
+ "license": "MIT",
7115
+ "dependencies": {
7116
+ "@types/unist": "^3.0.0",
7117
+ "vfile-message": "^4.0.0"
7118
+ },
7119
+ "funding": {
7120
+ "type": "opencollective",
7121
+ "url": "https://opencollective.com/unified"
7122
+ }
7123
+ },
7124
+ "node_modules/vfile-location": {
7125
+ "version": "5.0.3",
7126
+ "resolved": "https://registry.npmjs.org/vfile-location/-/vfile-location-5.0.3.tgz",
7127
+ "integrity": "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==",
7128
+ "license": "MIT",
7129
+ "dependencies": {
7130
+ "@types/unist": "^3.0.0",
7131
+ "vfile": "^6.0.0"
7132
+ },
7133
+ "funding": {
7134
+ "type": "opencollective",
7135
+ "url": "https://opencollective.com/unified"
7136
+ }
7137
+ },
7138
+ "node_modules/vfile-message": {
7139
+ "version": "4.0.3",
7140
+ "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz",
7141
+ "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==",
7142
+ "license": "MIT",
7143
+ "dependencies": {
7144
+ "@types/unist": "^3.0.0",
7145
+ "unist-util-stringify-position": "^4.0.0"
7146
+ },
7147
+ "funding": {
7148
+ "type": "opencollective",
7149
+ "url": "https://opencollective.com/unified"
7150
+ }
7151
+ },
7152
  "node_modules/victory-vendor": {
7153
  "version": "36.9.2",
7154
  "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz",
 
7255
  "loose-envify": "^1.0.0"
7256
  }
7257
  },
7258
+ "node_modules/web-namespaces": {
7259
+ "version": "2.0.1",
7260
+ "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz",
7261
+ "integrity": "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==",
7262
+ "license": "MIT",
7263
+ "funding": {
7264
+ "type": "github",
7265
+ "url": "https://github.com/sponsors/wooorm"
7266
+ }
7267
+ },
7268
  "node_modules/yallist": {
7269
  "version": "3.1.1",
7270
  "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
 
7289
  "funding": {
7290
  "url": "https://github.com/sponsors/eemeli"
7291
  }
7292
+ },
7293
+ "node_modules/zwitch": {
7294
+ "version": "2.0.4",
7295
+ "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz",
7296
+ "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==",
7297
+ "license": "MIT",
7298
+ "funding": {
7299
+ "type": "github",
7300
+ "url": "https://github.com/sponsors/wooorm"
7301
+ }
7302
  }
7303
  }
7304
  }
public/maintiva-logo.jpg ADDED
public/sounds/01_Baik_Saya_sedang_memproses_pertanyaanmu.wav ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:cb6216abe80579ac0815d0b5afe2a832002fa86fc892f90e18a92054d8a94560
3
+ size 930838
public/sounds/02_Oke_mohon_ditunggu_saya_sedang_siapkan_P.wav ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:128880cb46eb3c232e466ad6da94c08073597dd2b8916f1554d3ed933b90511c
3
+ size 1057558
public/sounds/03_Sip_saya_terima_Sedang_saya_proses_Pesan.wav ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:7035343867739852b9bcc5f2b3310cabf3f0b8341baa715cbe0f15c20ca4be52
3
+ size 811798
server.js CHANGED
@@ -26,7 +26,7 @@ const MIME = {
26
  };
27
 
28
  console.log(`Starting server on port ${PORT}`);
29
- console.log(`Backend URL: ${BACKEND_URL || "(not set)"}`);
30
 
31
  const server = http.createServer((req, res) => {
32
  const parsed = url.parse(req.url);
@@ -77,6 +77,18 @@ const server = http.createServer((req, res) => {
77
  return;
78
  }
79
  const ext = path.extname(filePath).toLowerCase();
 
 
 
 
 
 
 
 
 
 
 
 
80
  res.writeHead(200, { "Content-Type": MIME[ext] || "application/octet-stream" });
81
  res.end(data);
82
  });
 
26
  };
27
 
28
  console.log(`Starting server on port ${PORT}`);
29
+ // console.log(`Backend URL: ${BACKEND_URL || "(not set)"}`);
30
 
31
  const server = http.createServer((req, res) => {
32
  const parsed = url.parse(req.url);
 
77
  return;
78
  }
79
  const ext = path.extname(filePath).toLowerCase();
80
+
81
+ if (ext === ".html") {
82
+ const config = { VOICE_API_URL: process.env.VITE_API_BASE_VOICE_URL || "" };
83
+ const html = data.toString().replace(
84
+ "</head>",
85
+ `<script>window.__APP_CONFIG__=${JSON.stringify(config)};</script></head>`
86
+ );
87
+ res.writeHead(200, { "Content-Type": "text/html" });
88
+ res.end(html);
89
+ return;
90
+ }
91
+
92
  res.writeHead(200, { "Content-Type": MIME[ext] || "application/octet-stream" });
93
  res.end(data);
94
  });
src/app/App.tsx CHANGED
@@ -1,6 +1,12 @@
1
  import { RouterProvider } from "react-router";
2
  import { router } from "./routes";
 
3
 
4
  export default function App() {
5
- return <RouterProvider router={router} />;
 
 
 
 
 
6
  }
 
1
  import { RouterProvider } from "react-router";
2
  import { router } from "./routes";
3
+ import { Toaster } from "./components/ui/sonner";
4
 
5
  export default function App() {
6
+ return (
7
+ <>
8
+ <RouterProvider router={router} />
9
+ <Toaster richColors position="top-right" />
10
+ </>
11
+ );
12
  }
src/app/components/KnowledgeManagement.tsx CHANGED
@@ -7,14 +7,27 @@ import {
7
  Loader2,
8
  Database,
9
  X,
 
 
10
  } from "lucide-react";
 
11
  import {
12
  getDocuments,
13
  uploadDocument,
14
  processDocument,
15
  deleteDocument,
 
 
 
 
 
 
16
  type ApiDocument,
17
  type DocumentStatus,
 
 
 
 
18
  } from "../../services/api";
19
 
20
  interface KnowledgeManagementProps {
@@ -22,6 +35,17 @@ interface KnowledgeManagementProps {
22
  onClose: () => void;
23
  }
24
 
 
 
 
 
 
 
 
 
 
 
 
25
  const getUserId = (): string | null => {
26
  const stored = localStorage.getItem("chatbot_user");
27
  if (!stored) return null;
@@ -32,6 +56,8 @@ export default function KnowledgeManagement({
32
  open,
33
  onClose,
34
  }: KnowledgeManagementProps) {
 
 
35
  const [documents, setDocuments] = useState<ApiDocument[]>([]);
36
  const [loadingDocs, setLoadingDocs] = useState(false);
37
  const [docsError, setDocsError] = useState<string | null>(null);
@@ -40,13 +66,68 @@ export default function KnowledgeManagement({
40
  const [processing, setProcessing] = useState<string | null>(null);
41
  const [deleting, setDeleting] = useState<string | null>(null);
42
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
  useEffect(() => {
44
  if (!open) return;
45
  const userId = getUserId();
46
  if (!userId) return;
47
  loadDocuments(userId);
 
 
 
 
 
 
 
 
 
 
48
  }, [open]);
49
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
50
  const loadDocuments = async (userId: string) => {
51
  setLoadingDocs(true);
52
  setDocsError(null);
@@ -77,13 +158,12 @@ export default function KnowledgeManagement({
77
  const newDoc: ApiDocument = {
78
  id: uploadRes.data.id,
79
  filename: uploadRes.data.filename,
80
- status: "pending",
81
  file_size: file.size,
82
  file_type: file.name.split(".").pop() ?? "",
83
  created_at: new Date().toISOString(),
84
  };
85
  setDocuments((prev) => [newDoc, ...prev]);
86
-
87
  await processDocumentById(userId, uploadRes.data.id);
88
  } catch (err) {
89
  setUploadError(err instanceof Error ? err.message : "Upload failed");
@@ -148,6 +228,59 @@ export default function KnowledgeManagement({
148
  setDocuments([]);
149
  };
150
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
151
  const formatFileSize = (bytes: number) => {
152
  if (bytes < 1024) return bytes + " B";
153
  if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(2) + " KB";
@@ -177,7 +310,6 @@ export default function KnowledgeManagement({
177
  );
178
  }
179
 
180
- // pending or failed
181
  return (
182
  <button
183
  onClick={() => {
@@ -195,142 +327,422 @@ export default function KnowledgeManagement({
195
 
196
  if (!open) return null;
197
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
198
  return (
199
- <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
200
- <div className="bg-white rounded-xl shadow-2xl w-full max-w-4xl max-h-[90vh] flex flex-col m-4">
 
201
  {/* Header */}
202
- <div className="flex items-center justify-between p-4 border-b border-slate-200 bg-gradient-to-r from-[#FF8F00] to-[#FF6F00]">
203
- <div className="flex items-center gap-2">
204
- <Database className="w-5 h-5 text-white" />
205
- <h2 className="text-lg text-white">Knowledge Management</h2>
 
 
 
 
 
 
 
 
 
 
 
 
206
  </div>
207
  <button
208
- onClick={onClose}
209
- className="text-white/80 hover:text-white transition"
210
  >
211
- <X className="w-5 h-5" />
212
  </button>
213
  </div>
214
 
215
  {/* Content */}
216
- <div className="flex-1 overflow-y-auto p-4">
217
- {/* Upload Section */}
218
- <div className="mb-4">
219
- <label
220
- htmlFor="file-upload"
221
- className="flex items-center justify-center gap-2 border-2 border-dashed border-slate-300 rounded-lg p-6 cursor-pointer hover:border-[#4FC3F7] hover:bg-slate-50 transition"
222
- >
223
- <Upload className="w-5 h-5 text-slate-600" />
224
- <div className="text-center">
225
- <p className="text-slate-900 font-medium text-sm">
226
- Upload Documents (PDF, DOCX, TXT)
227
- </p>
228
- <p className="text-xs text-slate-500 mt-0.5">
229
- Click to browse or drag and drop
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
230
  </p>
231
- </div>
232
- <input
233
- id="file-upload"
234
- type="file"
235
- accept=".pdf,.docx,.txt"
236
- multiple
237
- onChange={handleFileUpload}
238
- className="hidden"
239
- disabled={uploading}
240
- />
241
- </label>
242
- {uploadError && (
243
- <p className="mt-2 text-xs text-red-600 bg-red-50 border border-red-200 px-3 py-2 rounded-lg">
244
- {uploadError}
245
- </p>
246
- )}
247
- </div>
248
 
249
- {/* Documents List */}
250
- <div className="space-y-2">
251
- <div className="flex items-center justify-between mb-3">
252
- <h3 className="text-sm text-slate-900 font-medium">
253
- Documents ({documents.length})
254
- </h3>
255
- {documents.length > 0 && (
256
- <button
257
- onClick={deleteAllDocuments}
258
- className="text-xs text-red-600 hover:text-red-700 flex items-center gap-1"
259
- >
260
- <Trash2 className="w-3.5 h-3.5" />
261
- Delete All
262
- </button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
263
  )}
264
- </div>
265
 
266
- {loadingDocs ? (
267
- <div className="flex justify-center py-8">
268
- <Loader2 className="w-6 h-6 animate-spin text-slate-400" />
269
- </div>
270
- ) : docsError ? (
271
- <p className="text-center text-sm text-red-600 py-4">
272
- {docsError}
273
- </p>
274
- ) : documents.length === 0 ? (
275
- <div className="text-center py-8">
276
- <FileText className="w-12 h-12 text-slate-300 mx-auto mb-3" />
277
- <p className="text-slate-500 text-sm">
278
- No documents uploaded yet
279
- </p>
280
- <p className="text-xs text-slate-400 mt-1">
281
- Upload files to build your knowledge base
282
- </p>
283
- </div>
284
- ) : (
285
- documents.map((doc) => (
286
- <div
287
- key={doc.id}
288
- className="bg-slate-50 rounded-lg p-3 border border-slate-200"
289
- >
290
- <div className="flex items-start gap-3">
291
- <FileText className="w-8 h-8 text-red-500 flex-shrink-0 mt-0.5" />
292
- <div className="flex-1 min-w-0">
293
- <div className="flex items-start justify-between gap-2">
 
 
 
 
 
 
 
 
 
 
 
 
 
294
  <div className="flex-1 min-w-0">
295
- <h4 className="text-slate-900 font-medium truncate text-sm">
296
  {doc.filename}
297
- </h4>
298
- <p className="text-xs text-slate-500 mt-0.5">
299
- {formatFileSize(doc.file_size)} •{" "}
300
- {formatDate(doc.created_at)}
301
  </p>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
302
  </div>
303
- <button
304
- onClick={() => handleDeleteDocument(doc.id)}
305
- disabled={deleting === doc.id}
306
- className="text-slate-400 hover:text-red-600 transition flex-shrink-0 disabled:opacity-50"
307
- title="Delete document"
308
- >
309
- {deleting === doc.id ? (
310
- <Loader2 className="w-4 h-4 animate-spin" />
311
- ) : (
312
- <Trash2 className="w-4 h-4" />
313
- )}
314
- </button>
315
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
316
 
317
- <div className="mt-2 flex items-center gap-2">
318
- {renderStatus(doc)}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
319
  </div>
320
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
321
  </div>
322
- </div>
323
- ))
324
- )}
325
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
326
  </div>
327
 
328
  {/* Footer */}
329
- <div className="border-t border-slate-200 p-3 bg-slate-50 rounded-b-xl">
330
- <p className="text-[10px] text-slate-500 text-center">
331
- Supported formats: PDF, DOCX, TXT
 
 
 
 
332
  </p>
333
  </div>
 
334
  </div>
335
  </div>
336
  );
 
7
  Loader2,
8
  Database,
9
  X,
10
+ ChevronLeft,
11
+ Link,
12
  } from "lucide-react";
13
+ import { toast } from "sonner";
14
  import {
15
  getDocuments,
16
  uploadDocument,
17
  processDocument,
18
  deleteDocument,
19
+ getDocumentTypes,
20
+ getDatabaseClientTypes,
21
+ connectDatabase,
22
+ getDatabaseClients,
23
+ deleteDatabaseClient,
24
+ ingestDatabaseClient,
25
  type ApiDocument,
26
  type DocumentStatus,
27
+ type DocTypeInfo,
28
+ type DbType,
29
+ type DbTypeInfo,
30
+ type DatabaseClient,
31
  } from "../../services/api";
32
 
33
  interface KnowledgeManagementProps {
 
35
  onClose: () => void;
36
  }
37
 
38
+ type View = "main" | "db-select" | "db-credentials";
39
+
40
+ const LOGO_MAP: Record<string, string> = {
41
+ postgres: "https://cdn.simpleicons.org/postgresql/336791",
42
+ mysql: "https://cdn.simpleicons.org/mysql/4479A1",
43
+ supabase: "https://cdn.simpleicons.org/supabase/3ECF8E",
44
+ sqlserver: "https://cdn.jsdelivr.net/gh/devicons/devicon@latest/icons/microsoftsqlserver/microsoftsqlserver-plain.svg",
45
+ bigquery: "https://cdn.simpleicons.org/googlebigquery/4285F4",
46
+ snowflake: "https://cdn.simpleicons.org/snowflake/29B5E8",
47
+ };
48
+
49
  const getUserId = (): string | null => {
50
  const stored = localStorage.getItem("chatbot_user");
51
  if (!stored) return null;
 
56
  open,
57
  onClose,
58
  }: KnowledgeManagementProps) {
59
+ // ── Document state ──────────────────────────────────────────────────────────
60
+ const [docTypes, setDocTypes] = useState<DocTypeInfo[]>([]);
61
  const [documents, setDocuments] = useState<ApiDocument[]>([]);
62
  const [loadingDocs, setLoadingDocs] = useState(false);
63
  const [docsError, setDocsError] = useState<string | null>(null);
 
66
  const [processing, setProcessing] = useState<string | null>(null);
67
  const [deleting, setDeleting] = useState<string | null>(null);
68
 
69
+ // ── Navigation state ────────────────────────────────────────────────────────
70
+ const [view, setView] = useState<View>("main");
71
+ const [selectedDbType, setSelectedDbType] = useState<DbType | null>(null);
72
+
73
+ // ── DB type & client state ──────────────────────────────────────────────────
74
+ const [dbTypeInfos, setDbTypeInfos] = useState<DbTypeInfo[]>([]);
75
+ const [dbClients, setDbClients] = useState<DatabaseClient[]>([]);
76
+ const [loadingDbTypes, setLoadingDbTypes] = useState(false);
77
+ const [ingesting, setIngesting] = useState<string | null>(null);
78
+ const [deletingClient, setDeletingClient] = useState<string | null>(null);
79
+
80
+ // ── DB credentials form state ───────────────────────────────────────────────
81
+ const [connectionName, setConnectionName] = useState("");
82
+ const [dbForm, setDbForm] = useState<Record<string, string | number | boolean>>({});
83
+ const [connecting, setConnecting] = useState(false);
84
+
85
  useEffect(() => {
86
  if (!open) return;
87
  const userId = getUserId();
88
  if (!userId) return;
89
  loadDocuments(userId);
90
+ loadDbData(userId);
91
+ getDocumentTypes()
92
+ .then((types) => setDocTypes(types.filter((t) => t.status === "active")))
93
+ .catch(() =>
94
+ setDocTypes([
95
+ { doc_type: "pdf", max_size: 10, status: "active", message: null },
96
+ { doc_type: "csv", max_size: 10, status: "active", message: null },
97
+ { doc_type: "xlsx", max_size: 10, status: "active", message: null },
98
+ ])
99
+ );
100
  }, [open]);
101
 
102
+ const acceptedExtensions = docTypes.map((t) => `.${t.doc_type}`).join(",");
103
+ const supportedFormatsText = docTypes.map((t) => t.doc_type.toUpperCase()).join(", ");
104
+
105
+ const loadDbData = async (userId: string) => {
106
+ setLoadingDbTypes(true);
107
+ try {
108
+ const [types, clients] = await Promise.all([
109
+ getDatabaseClientTypes(),
110
+ getDatabaseClients(userId),
111
+ ]);
112
+ setDbTypeInfos(types);
113
+ setDbClients(clients);
114
+ } catch {
115
+ // non-blocking; silently fail
116
+ } finally {
117
+ setLoadingDbTypes(false);
118
+ }
119
+ };
120
+
121
+ const handleClose = () => {
122
+ setView("main");
123
+ setSelectedDbType(null);
124
+ setConnectionName("");
125
+ setDbForm({});
126
+ onClose();
127
+ };
128
+
129
+ // ── Document handlers ───────────────────────────────────────────────────────
130
+
131
  const loadDocuments = async (userId: string) => {
132
  setLoadingDocs(true);
133
  setDocsError(null);
 
158
  const newDoc: ApiDocument = {
159
  id: uploadRes.data.id,
160
  filename: uploadRes.data.filename,
161
+ status: uploadRes.data.status,
162
  file_size: file.size,
163
  file_type: file.name.split(".").pop() ?? "",
164
  created_at: new Date().toISOString(),
165
  };
166
  setDocuments((prev) => [newDoc, ...prev]);
 
167
  await processDocumentById(userId, uploadRes.data.id);
168
  } catch (err) {
169
  setUploadError(err instanceof Error ? err.message : "Upload failed");
 
228
  setDocuments([]);
229
  };
230
 
231
+ // ── DB handlers ─────────────────────────────────────────────────────────────
232
+
233
+ const handleDbConnect = async () => {
234
+ const userId = getUserId();
235
+ if (!userId || !selectedDbType || !connectionName.trim()) return;
236
+ setConnecting(true);
237
+ try {
238
+ await connectDatabase(userId, selectedDbType, connectionName.trim(), dbForm);
239
+ const clients = await getDatabaseClients(userId);
240
+ setDbClients(clients);
241
+ toast.success("Database connected successfully");
242
+ setView("main");
243
+ setConnectionName("");
244
+ setDbForm({});
245
+ } catch (err) {
246
+ toast.error(
247
+ err instanceof Error ? err.message : "Failed to connect to database"
248
+ );
249
+ } finally {
250
+ setConnecting(false);
251
+ }
252
+ };
253
+
254
+ const handleIngest = async (clientId: string) => {
255
+ const userId = getUserId();
256
+ if (!userId) return;
257
+ setIngesting(clientId);
258
+ try {
259
+ const res = await ingestDatabaseClient(clientId, userId);
260
+ toast.success(`Ingested ${res.chunks_ingested} chunks successfully`);
261
+ } catch (err) {
262
+ toast.error(err instanceof Error ? err.message : "Ingestion failed");
263
+ } finally {
264
+ setIngesting(null);
265
+ }
266
+ };
267
+
268
+ const handleDeleteClient = async (clientId: string) => {
269
+ const userId = getUserId();
270
+ if (!userId) return;
271
+ setDeletingClient(clientId);
272
+ try {
273
+ await deleteDatabaseClient(clientId, userId);
274
+ setDbClients((prev) => prev.filter((c) => c.id !== clientId));
275
+ } catch (err) {
276
+ toast.error(err instanceof Error ? err.message : "Failed to delete connection");
277
+ } finally {
278
+ setDeletingClient(null);
279
+ }
280
+ };
281
+
282
+ // ── Helpers ─────────────────────────────────────────────────────────────────
283
+
284
  const formatFileSize = (bytes: number) => {
285
  if (bytes < 1024) return bytes + " B";
286
  if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(2) + " KB";
 
310
  );
311
  }
312
 
 
313
  return (
314
  <button
315
  onClick={() => {
 
327
 
328
  if (!open) return null;
329
 
330
+ // ── Header title & back button logic ────────────────────────────────────────
331
+
332
+ const selectedDbInfo = dbTypeInfos.find((d) => d.db_type === selectedDbType);
333
+
334
+ const headerTitle =
335
+ view === "db-select"
336
+ ? "Connect Database"
337
+ : view === "db-credentials"
338
+ ? `Connect to ${selectedDbInfo?.display_name ?? selectedDbType}`
339
+ : "Knowledge Base";
340
+
341
+ const headerBack =
342
+ view === "db-select"
343
+ ? () => setView("main")
344
+ : view === "db-credentials"
345
+ ? () => setView("db-select")
346
+ : null;
347
+
348
+ // ── Render ───────────────────────────────────────────────────────────────────
349
+
350
  return (
351
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm p-4">
352
+ <div className="bg-white rounded-2xl shadow-xl w-full max-w-md max-h-[85vh] flex flex-col">
353
+
354
  {/* Header */}
355
+ <div className="flex items-center justify-between px-5 py-4 border-b border-slate-100">
356
+ <div className="flex items-center gap-3">
357
+ {headerBack ? (
358
+ <button
359
+ onClick={headerBack}
360
+ className="p-1.5 -ml-1 rounded-lg text-slate-400 hover:text-slate-700 hover:bg-slate-100 transition"
361
+ aria-label="Back"
362
+ >
363
+ <ChevronLeft className="w-4 h-4" />
364
+ </button>
365
+ ) : (
366
+ <div className="w-7 h-7 rounded-lg bg-orange-100 flex items-center justify-center">
367
+ <Database className="w-4 h-4 text-[#FF8F00]" />
368
+ </div>
369
+ )}
370
+ <h2 className="text-sm font-semibold text-slate-900">{headerTitle}</h2>
371
  </div>
372
  <button
373
+ onClick={handleClose}
374
+ className="p-1.5 rounded-lg text-slate-400 hover:text-slate-700 hover:bg-slate-100 transition"
375
  >
376
+ <X className="w-4 h-4" />
377
  </button>
378
  </div>
379
 
380
  {/* Content */}
381
+ <div className="flex-1 overflow-y-auto px-5 py-4">
382
+
383
+ {/* ── VIEW: main ── */}
384
+ {view === "main" && (
385
+ <div className="space-y-4">
386
+
387
+ {/* Upload zone */}
388
+ <label
389
+ htmlFor="file-upload"
390
+ className="group flex flex-col items-center gap-2.5 border-2 border-dashed border-slate-200 rounded-xl p-7 cursor-pointer hover:border-[#FF8F00] hover:bg-orange-50/30 transition-colors"
391
+ >
392
+ <div className="w-10 h-10 rounded-xl bg-slate-100 group-hover:bg-orange-100 flex items-center justify-center transition-colors">
393
+ {uploading ? (
394
+ <Loader2 className="w-5 h-5 text-[#FF8F00] animate-spin" />
395
+ ) : (
396
+ <Upload className="w-5 h-5 text-slate-400 group-hover:text-[#FF8F00] transition-colors" />
397
+ )}
398
+ </div>
399
+ <div className="text-center">
400
+ <p className="text-sm font-medium text-slate-700">
401
+ {uploading ? "Uploading…" : (
402
+ <>Drop files, or <span className="text-[#FF8F00]">browse</span></>
403
+ )}
404
+ </p>
405
+ <p className="text-xs text-slate-400 mt-0.5">{supportedFormatsText}</p>
406
+ </div>
407
+ <input
408
+ id="file-upload"
409
+ type="file"
410
+ accept={acceptedExtensions}
411
+ multiple
412
+ onChange={handleFileUpload}
413
+ className="hidden"
414
+ disabled={uploading}
415
+ />
416
+ </label>
417
+
418
+ {uploadError && (
419
+ <p className="text-xs text-red-500 bg-red-50 border border-red-100 px-3 py-2 rounded-lg">
420
+ {uploadError}
421
  </p>
422
+ )}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
423
 
424
+ {/* Connect DB row */}
425
+ <button
426
+ onClick={() => setView("db-select")}
427
+ className="w-full flex items-center gap-3 px-4 py-3 rounded-xl border border-slate-200 hover:border-slate-300 hover:bg-slate-50 transition text-left group"
428
+ >
429
+ <div className="w-8 h-8 rounded-lg bg-slate-100 group-hover:bg-slate-200 flex items-center justify-center flex-shrink-0 transition-colors">
430
+ <Link className="w-4 h-4 text-slate-500" />
431
+ </div>
432
+ <div className="flex-1 min-w-0">
433
+ <p className="text-sm font-medium text-slate-700">Connect a Database</p>
434
+ <p className="text-xs text-slate-400">PostgreSQL and more</p>
435
+ </div>
436
+ <ChevronLeft className="w-4 h-4 text-slate-300 rotate-180 flex-shrink-0" />
437
+ </button>
438
+
439
+ {/* Database connections list */}
440
+ {dbClients.length > 0 && (
441
+ <div>
442
+ <div className="flex items-center justify-between mb-2">
443
+ <span className="text-[11px] font-semibold text-slate-400 uppercase tracking-wider">
444
+ Databases · {dbClients.length}
445
+ </span>
446
+ </div>
447
+ <div className="space-y-1">
448
+ {dbClients.map((client) => (
449
+ <div
450
+ key={client.id}
451
+ className="flex items-center gap-3 px-3 py-2.5 rounded-xl hover:bg-slate-50 transition group"
452
+ >
453
+ <div className="w-8 h-8 rounded-lg bg-slate-100 flex items-center justify-center flex-shrink-0">
454
+ <img
455
+ src={LOGO_MAP[client.db_type] ?? ""}
456
+ alt={client.db_type}
457
+ className="w-5 h-5 object-contain"
458
+ />
459
+ </div>
460
+ <div className="flex-1 min-w-0">
461
+ <p className="text-sm font-medium text-slate-800 truncate">{client.name}</p>
462
+ <p className="text-xs text-slate-400 capitalize">{client.db_type} · {client.status}</p>
463
+ </div>
464
+ <div className="flex items-center gap-1.5 flex-shrink-0">
465
+ <button
466
+ onClick={() => handleIngest(client.id)}
467
+ disabled={ingesting === client.id || client.status === "inactive"}
468
+ title="Ingest schema to knowledge base"
469
+ className="flex items-center gap-1 text-xs px-2.5 py-1 rounded-lg bg-slate-100 hover:bg-orange-100 hover:text-[#FF8F00] text-slate-500 transition disabled:opacity-40 disabled:cursor-not-allowed"
470
+ >
471
+ {ingesting === client.id ? (
472
+ <Loader2 className="w-3 h-3 animate-spin" />
473
+ ) : (
474
+ <Database className="w-3 h-3" />
475
+ )}
476
+ {ingesting === client.id ? "Ingesting…" : "Ingest"}
477
+ </button>
478
+ <button
479
+ onClick={() => handleDeleteClient(client.id)}
480
+ disabled={deletingClient === client.id}
481
+ className="opacity-0 group-hover:opacity-100 text-slate-300 hover:text-red-500 transition disabled:opacity-30"
482
+ title="Delete connection"
483
+ >
484
+ {deletingClient === client.id ? (
485
+ <Loader2 className="w-3.5 h-3.5 animate-spin" />
486
+ ) : (
487
+ <Trash2 className="w-3.5 h-3.5" />
488
+ )}
489
+ </button>
490
+ </div>
491
+ </div>
492
+ ))}
493
+ </div>
494
+ </div>
495
  )}
 
496
 
497
+ {/* Documents list */}
498
+ <div>
499
+ <div className="flex items-center justify-between mb-2">
500
+ <span className="text-[11px] font-semibold text-slate-400 uppercase tracking-wider">
501
+ Documents · {documents.length}
502
+ </span>
503
+ {documents.length > 0 && (
504
+ <button
505
+ onClick={deleteAllDocuments}
506
+ className="text-xs text-slate-400 hover:text-red-500 flex items-center gap-1 transition"
507
+ >
508
+ <Trash2 className="w-3 h-3" />
509
+ Clear all
510
+ </button>
511
+ )}
512
+ </div>
513
+
514
+ {loadingDocs ? (
515
+ <div className="flex justify-center py-10">
516
+ <Loader2 className="w-5 h-5 animate-spin text-slate-300" />
517
+ </div>
518
+ ) : docsError ? (
519
+ <p className="text-center text-xs text-red-500 py-6">{docsError}</p>
520
+ ) : documents.length === 0 ? (
521
+ <div className="text-center py-10">
522
+ <div className="w-12 h-12 rounded-2xl bg-slate-100 flex items-center justify-center mx-auto mb-3">
523
+ <FileText className="w-5 h-5 text-slate-300" />
524
+ </div>
525
+ <p className="text-sm text-slate-400">No documents yet</p>
526
+ <p className="text-xs text-slate-300 mt-0.5">Upload files to get started</p>
527
+ </div>
528
+ ) : (
529
+ <div className="space-y-1">
530
+ {documents.map((doc) => (
531
+ <div
532
+ key={doc.id}
533
+ className="flex items-center gap-3 px-3 py-2.5 rounded-xl hover:bg-slate-50 transition group"
534
+ >
535
+ <div className="w-8 h-8 rounded-lg bg-red-50 flex items-center justify-center flex-shrink-0">
536
+ <FileText className="w-4 h-4 text-red-400" />
537
+ </div>
538
  <div className="flex-1 min-w-0">
539
+ <p className="text-sm font-medium text-slate-800 truncate" title={doc.filename}>
540
  {doc.filename}
 
 
 
 
541
  </p>
542
+ <p className="text-xs text-slate-400">
543
+ {formatFileSize(doc.file_size)} · {formatDate(doc.created_at)}
544
+ </p>
545
+ </div>
546
+ <div className="flex items-center gap-2 flex-shrink-0">
547
+ {renderStatus(doc)}
548
+ <button
549
+ onClick={() => handleDeleteDocument(doc.id)}
550
+ disabled={deleting === doc.id}
551
+ className="opacity-0 group-hover:opacity-100 text-slate-300 hover:text-red-500 transition disabled:opacity-30"
552
+ title="Delete"
553
+ >
554
+ {deleting === doc.id ? (
555
+ <Loader2 className="w-3.5 h-3.5 animate-spin" />
556
+ ) : (
557
+ <Trash2 className="w-3.5 h-3.5" />
558
+ )}
559
+ </button>
560
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
561
  </div>
562
+ ))}
563
+ </div>
564
+ )}
565
+ </div>
566
+ </div>
567
+ )}
568
+
569
+ {/* ── VIEW: db-select ── */}
570
+ {view === "db-select" && (
571
+ <div className="space-y-4">
572
+ <p className="text-sm text-slate-400">
573
+ Choose a database to connect to your knowledge base.
574
+ </p>
575
+ {loadingDbTypes ? (
576
+ <div className="flex justify-center py-10">
577
+ <Loader2 className="w-5 h-5 animate-spin text-slate-300" />
578
+ </div>
579
+ ) : (
580
+ <div className="grid grid-cols-3 gap-2">
581
+ {dbTypeInfos.map((info) => {
582
+ const active = info.status === "active";
583
+ return (
584
+ <button
585
+ key={info.db_type}
586
+ disabled={!active}
587
+ onClick={() => {
588
+ if (!active) return;
589
+ setSelectedDbType(info.db_type);
590
+ const defaults = Object.fromEntries(
591
+ info.fields.map((f) => [
592
+ f.name,
593
+ f.default != null
594
+ ? f.default
595
+ : f.type === "integer" ? 0 : f.type === "boolean" ? false : "",
596
+ ])
597
+ );
598
+ setDbForm(defaults);
599
+ setView("db-credentials");
600
+ }}
601
+ className={[
602
+ "relative flex flex-col items-center gap-2 p-4 rounded-xl border transition text-xs font-medium",
603
+ active
604
+ ? "border-slate-200 hover:border-[#FF8F00] hover:bg-orange-50/50 text-slate-700 cursor-pointer"
605
+ : "border-slate-100 bg-slate-50/60 text-slate-400 cursor-not-allowed",
606
+ ].join(" ")}
607
+ >
608
+ <img
609
+ src={LOGO_MAP[info.logo] ?? ""}
610
+ alt={info.display_name}
611
+ className={`w-8 h-8 object-contain ${!active ? "opacity-30 grayscale" : ""}`}
612
+ />
613
+ <span>{info.display_name}</span>
614
+ {!active && (
615
+ <span className="absolute top-1.5 right-1.5 text-[9px] bg-slate-200 text-slate-400 px-1.5 py-0.5 rounded-full">
616
+ Soon
617
+ </span>
618
+ )}
619
+ </button>
620
+ );
621
+ })}
622
+ </div>
623
+ )}
624
+ </div>
625
+ )}
626
+
627
+ {/* ── VIEW: db-credentials ── */}
628
+ {view === "db-credentials" && (
629
+ <div className="space-y-4">
630
 
631
+ {/* Selected DB chip */}
632
+ {selectedDbInfo && (
633
+ <div className="flex items-center gap-2.5 px-3 py-2.5 rounded-xl bg-slate-50 border border-slate-100">
634
+ <img src={LOGO_MAP[selectedDbInfo.logo] ?? ""} alt={selectedDbInfo.display_name} className="w-5 h-5 object-contain" />
635
+ <span className="text-sm font-medium text-slate-700">{selectedDbInfo.display_name}</span>
636
+ </div>
637
+ )}
638
+
639
+ <div className="grid grid-cols-2 gap-3">
640
+ {/* Connection Name */}
641
+ <div className="col-span-2">
642
+ <label className="block text-xs font-medium text-slate-500 mb-1">
643
+ Connection Name <span className="text-red-400">*</span>
644
+ </label>
645
+ <input
646
+ type="text"
647
+ placeholder="Production DB"
648
+ value={connectionName}
649
+ onChange={(e) => setConnectionName(e.target.value)}
650
+ className="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm bg-white focus:outline-none focus:ring-2 focus:ring-[#FF8F00]/25 focus:border-[#FF8F00] placeholder:text-slate-300 transition"
651
+ />
652
+ </div>
653
+
654
+ {/* Dynamic fields from API */}
655
+ {selectedDbInfo?.fields.map((field) => (
656
+ <div
657
+ key={field.name}
658
+ className={field.name === "host" || field.name === "service_account_json" ? "col-span-2" : ""}
659
+ >
660
+ <label className="block text-xs font-medium text-slate-500 mb-1 capitalize">
661
+ {field.name.replace(/_/g, " ")}
662
+ {field.required && <span className="text-red-400 ml-0.5">*</span>}
663
+ </label>
664
+
665
+ {field.type === "select" ? (
666
+ <select
667
+ value={String(dbForm[field.name] ?? field.default ?? "")}
668
+ onChange={(e) => setDbForm((f) => ({ ...f, [field.name]: e.target.value }))}
669
+ className="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm bg-white focus:outline-none focus:ring-2 focus:ring-[#FF8F00]/25 focus:border-[#FF8F00] transition"
670
+ >
671
+ {field.options?.map((opt) => (
672
+ <option key={opt} value={opt}>{opt}</option>
673
+ ))}
674
+ </select>
675
+ ) : field.type === "boolean" ? (
676
+ <div className="flex items-center gap-2 pt-1">
677
+ <input
678
+ type="checkbox"
679
+ id={`field-${field.name}`}
680
+ checked={Boolean(dbForm[field.name] ?? field.default ?? false)}
681
+ onChange={(e) => setDbForm((f) => ({ ...f, [field.name]: e.target.checked }))}
682
+ className="w-4 h-4 accent-[#FF8F00]"
683
+ />
684
+ <label htmlFor={`field-${field.name}`} className="text-xs text-slate-500">
685
+ {field.description}
686
+ </label>
687
  </div>
688
+ ) : field.name === "service_account_json" ? (
689
+ <textarea
690
+ rows={4}
691
+ placeholder={field.description}
692
+ value={String(dbForm[field.name] ?? "")}
693
+ onChange={(e) => setDbForm((f) => ({ ...f, [field.name]: e.target.value }))}
694
+ className="w-full border border-slate-200 rounded-lg px-3 py-2 text-xs bg-white focus:outline-none focus:ring-2 focus:ring-[#FF8F00]/25 focus:border-[#FF8F00] placeholder:text-slate-300 transition font-mono"
695
+ />
696
+ ) : (
697
+ <input
698
+ type={field.sensitive ? "password" : field.type === "integer" ? "number" : "text"}
699
+ placeholder={field.default != null ? String(field.default) : field.description}
700
+ value={String(dbForm[field.name] ?? "")}
701
+ onChange={(e) =>
702
+ setDbForm((f) => ({
703
+ ...f,
704
+ [field.name]: field.type === "integer"
705
+ ? (parseInt(e.target.value, 10) || 0)
706
+ : e.target.value,
707
+ }))
708
+ }
709
+ className="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm bg-white focus:outline-none focus:ring-2 focus:ring-[#FF8F00]/25 focus:border-[#FF8F00] placeholder:text-slate-300 transition"
710
+ />
711
+ )}
712
  </div>
713
+ ))}
714
+ </div>
715
+
716
+ <button
717
+ onClick={handleDbConnect}
718
+ disabled={
719
+ connecting ||
720
+ !connectionName.trim() ||
721
+ (selectedDbInfo?.fields.filter((f) => f.required).some((f) => !dbForm[f.name]) ?? false)
722
+ }
723
+ className="w-full flex items-center justify-center gap-2 bg-[#FF8F00] hover:bg-[#FF6F00] active:bg-[#E65100] text-white py-2.5 rounded-xl text-sm font-medium transition disabled:opacity-40 disabled:cursor-not-allowed"
724
+ >
725
+ {connecting ? (
726
+ <><Loader2 className="w-4 h-4 animate-spin" /> Connecting…</>
727
+ ) : (
728
+ "Connect"
729
+ )}
730
+ </button>
731
+ </div>
732
+ )}
733
  </div>
734
 
735
  {/* Footer */}
736
+ <div className="px-5 py-3 border-t border-slate-100">
737
+ <p className="text-[10px] text-slate-300 text-center">
738
+ {view === "main"
739
+ ? `Supported formats: ${supportedFormatsText}`
740
+ : view === "db-select"
741
+ ? "More integrations coming soon"
742
+ : "Credentials are encrypted at rest"}
743
  </p>
744
  </div>
745
+
746
  </div>
747
  </div>
748
  );
src/app/components/Login.tsx CHANGED
@@ -1,13 +1,15 @@
1
  import { useState } from "react";
2
  import { useNavigate } from "react-router";
3
- import { LogIn, Loader2 } from "lucide-react";
4
  import { login } from "../../services/api";
 
5
 
6
  export default function Login() {
7
  const [email, setEmail] = useState("");
8
  const [password, setPassword] = useState("");
9
  const [error, setError] = useState("");
10
  const [isLoading, setIsLoading] = useState(false);
 
11
  const navigate = useNavigate();
12
 
13
  const handleLogin = async (e: React.FormEvent) => {
@@ -43,28 +45,137 @@ export default function Login() {
43
  };
44
 
45
  return (
46
- <div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-[#BAE6FD] via-[#A7F3D0] to-[#FDE68A]">
47
- <div className="w-full max-w-md px-4">
48
- <div className="bg-white rounded-xl shadow-2xl p-6 border border-slate-200">
49
- <div className="flex items-center justify-center mb-6">
50
- <div className="bg-gradient-to-br from-[#059669] to-[#047857] p-2.5 rounded-lg">
51
- <LogIn className="w-6 h-6 text-white" />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
  </div>
53
  </div>
54
 
55
- <h1 className="text-xl text-center mb-1 text-slate-900">
56
- Welcome Back
57
- </h1>
58
- <p className="text-center text-slate-500 text-sm mb-6">
59
- Sign in to continue to your chatbot
60
- </p>
61
-
62
- <form onSubmit={handleLogin} className="space-y-4">
63
  <div>
64
- <label
65
- htmlFor="email"
66
- className="block text-xs mb-1.5 text-slate-700"
67
- >
68
  Email Address
69
  </label>
70
  <input
@@ -72,32 +183,40 @@ export default function Login() {
72
  type="email"
73
  value={email}
74
  onChange={(e) => setEmail(e.target.value)}
75
- className="w-full px-3 py-2 text-sm rounded-lg border border-slate-300 focus:outline-none focus:ring-2 focus:ring-[#4FC3F7] focus:border-transparent transition"
76
  placeholder="you@example.com"
77
  disabled={isLoading}
78
  />
79
  </div>
80
 
81
  <div>
82
- <label
83
- htmlFor="password"
84
- className="block text-xs mb-1.5 text-slate-700"
85
- >
86
  Password
87
  </label>
88
- <input
89
- id="password"
90
- type="password"
91
- value={password}
92
- onChange={(e) => setPassword(e.target.value)}
93
- className="w-full px-3 py-2 text-sm rounded-lg border border-slate-300 focus:outline-none focus:ring-2 focus:ring-[#4FC3F7] focus:border-transparent transition"
94
- placeholder="Enter your password"
95
- disabled={isLoading}
96
- />
 
 
 
 
 
 
 
 
 
 
 
97
  </div>
98
 
99
  {error && (
100
- <div className="bg-red-50 border border-red-200 text-red-700 px-3 py-2 rounded-lg text-xs">
101
  {error}
102
  </div>
103
  )}
@@ -105,14 +224,14 @@ export default function Login() {
105
  <button
106
  type="submit"
107
  disabled={isLoading}
108
- className="w-full flex items-center justify-center gap-2 bg-gradient-to-r from-[#059669] to-[#047857] text-white py-2.5 text-sm rounded-lg hover:from-[#047857] hover:to-[#065F46] transition font-medium disabled:opacity-60 disabled:cursor-not-allowed"
109
  >
110
  {isLoading ? (
111
- <Loader2 className="w-4 h-4 animate-spin" />
112
  ) : (
113
- <LogIn className="w-4 h-4" />
114
  )}
115
- {isLoading ? "Signing in..." : "Sign In"}
116
  </button>
117
  </form>
118
  </div>
 
1
  import { useState } from "react";
2
  import { useNavigate } from "react-router";
3
+ import { LogIn, Loader2, Eye, EyeOff } from "lucide-react";
4
  import { login } from "../../services/api";
5
+ import logoUrl from "../../assets/maintiva-logo.jpg";
6
 
7
  export default function Login() {
8
  const [email, setEmail] = useState("");
9
  const [password, setPassword] = useState("");
10
  const [error, setError] = useState("");
11
  const [isLoading, setIsLoading] = useState(false);
12
+ const [showPassword, setShowPassword] = useState(false);
13
  const navigate = useNavigate();
14
 
15
  const handleLogin = async (e: React.FormEvent) => {
 
45
  };
46
 
47
  return (
48
+ <div className="relative min-h-screen flex items-center justify-center overflow-hidden bg-[#fcfdfd]">
49
+
50
+ {/* ── Dot grid texture ── */}
51
+ <div
52
+ className="absolute inset-0 z-0 opacity-20"
53
+ style={{
54
+ backgroundImage: "radial-gradient(circle, #334155 1px, transparent 1px)",
55
+ backgroundSize: "28px 28px",
56
+ }}
57
+ />
58
+
59
+ {/* ── Gradient orbs — glowing on dark bg ── */}
60
+ {/* Cyan — top left */}
61
+ <div
62
+ className="absolute -top-32 -left-32 w-[520px] h-[520px] rounded-full blur-3xl opacity-[0.18] animate-pulse"
63
+ style={{ background: "#0ea5e9", animationDuration: "5s" }}
64
+ />
65
+ {/* Orange — bottom right */}
66
+ <div
67
+ className="absolute -bottom-40 -right-40 w-[560px] h-[560px] rounded-full blur-3xl opacity-[0.16] animate-pulse"
68
+ style={{ background: "#f97316", animationDuration: "7s" }}
69
+ />
70
+ {/* Green — top right */}
71
+ <div
72
+ className="absolute -top-24 right-1/4 w-[320px] h-[320px] rounded-full blur-3xl opacity-[0.12] animate-pulse"
73
+ style={{ background: "#10b981", animationDuration: "9s" }}
74
+ />
75
+ {/* Purple accent — center left */}
76
+ <div
77
+ className="absolute top-1/2 -left-40 w-[360px] h-[360px] rounded-full blur-3xl opacity-[0.10] animate-pulse"
78
+ style={{ background: "#8b5cf6", animationDuration: "11s" }}
79
+ />
80
+
81
+ {/* ── Neural network — bottom left ── */}
82
+ <svg
83
+ className="absolute bottom-8 left-8 opacity-[0.35] animate-float-slow"
84
+ width="200" height="200" viewBox="0 0 200 200"
85
+ fill="none" xmlns="http://www.w3.org/2000/svg"
86
+ >
87
+ <line x1="20" y1="150" x2="65" y2="90" stroke="#0ea5e9" strokeWidth="1"/>
88
+ <line x1="20" y1="150" x2="55" y2="170" stroke="#0ea5e9" strokeWidth="1"/>
89
+ <line x1="65" y1="90" x2="110" y2="125" stroke="#0ea5e9" strokeWidth="1"/>
90
+ <line x1="65" y1="90" x2="130" y2="55" stroke="#10b981" strokeWidth="1"/>
91
+ <line x1="110" y1="125" x2="170" y2="105" stroke="#0ea5e9" strokeWidth="1"/>
92
+ <line x1="130" y1="55" x2="170" y2="105" stroke="#10b981" strokeWidth="1"/>
93
+ <line x1="130" y1="55" x2="175" y2="30" stroke="#10b981" strokeWidth="1"/>
94
+ <line x1="55" y1="170" x2="110" y2="125" stroke="#0ea5e9" strokeWidth="1"/>
95
+ <line x1="175" y1="30" x2="170" y2="105" stroke="#10b981" strokeWidth="1"/>
96
+ <circle cx="20" cy="150" r="3.5" fill="#0ea5e9" fillOpacity="0.7"/>
97
+ <circle cx="55" cy="170" r="2.5" fill="#0ea5e9" fillOpacity="0.5"/>
98
+ <circle cx="65" cy="90" r="5" fill="#0ea5e9" fillOpacity="0.9"/>
99
+ <circle cx="110" cy="125" r="3.5" fill="#0ea5e9" fillOpacity="0.7"/>
100
+ <circle cx="130" cy="55" r="5" fill="#10b981" fillOpacity="0.9"/>
101
+ <circle cx="170" cy="105" r="3.5" fill="#10b981" fillOpacity="0.7"/>
102
+ <circle cx="175" cy="30" r="2.5" fill="#10b981" fillOpacity="0.5"/>
103
+ </svg>
104
+
105
+ {/* ── Neural network — top right ── */}
106
+ <svg
107
+ className="absolute top-8 right-8 opacity-[0.30] animate-float"
108
+ style={{ "--float-rotate": "0deg" } as React.CSSProperties}
109
+ width="180" height="180" viewBox="0 0 180 180"
110
+ fill="none" xmlns="http://www.w3.org/2000/svg"
111
+ >
112
+ <line x1="160" y1="30" x2="115" y2="80" stroke="#f97316" strokeWidth="1"/>
113
+ <line x1="115" y1="80" x2="70" y2="55" stroke="#f97316" strokeWidth="1"/>
114
+ <line x1="115" y1="80" x2="130" y2="135" stroke="#0ea5e9" strokeWidth="1"/>
115
+ <line x1="70" y1="55" x2="25" y2="90" stroke="#f97316" strokeWidth="1"/>
116
+ <line x1="25" y1="90" x2="80" y2="155" stroke="#0ea5e9" strokeWidth="1"/>
117
+ <line x1="80" y1="155" x2="130" y2="135" stroke="#0ea5e9" strokeWidth="1"/>
118
+ <line x1="70" y1="55" x2="30" y2="25" stroke="#f97316" strokeWidth="1"/>
119
+ <circle cx="160" cy="30" r="3.5" fill="#f97316" fillOpacity="0.7"/>
120
+ <circle cx="115" cy="80" r="5" fill="#f97316" fillOpacity="0.9"/>
121
+ <circle cx="70" cy="55" r="3.5" fill="#f97316" fillOpacity="0.7"/>
122
+ <circle cx="130" cy="135" r="2.5" fill="#0ea5e9" fillOpacity="0.5"/>
123
+ <circle cx="25" cy="90" r="3.5" fill="#0ea5e9" fillOpacity="0.7"/>
124
+ <circle cx="80" cy="155" r="2.5" fill="#0ea5e9" fillOpacity="0.5"/>
125
+ <circle cx="30" cy="25" r="2.5" fill="#f97316" fillOpacity="0.5"/>
126
+ </svg>
127
+
128
+
129
+ {/* ── Rotated square — bottom left ── */}
130
+ <div
131
+ className="absolute bottom-16 left-16 w-24 h-24 border border-slate-200 opacity-50 animate-float"
132
+ style={{ transform: "rotate(20deg)", "--float-rotate": "20deg" } as React.CSSProperties}
133
+ />
134
+ <div
135
+ className="absolute bottom-28 left-28 w-12 h-12 border border-slate-200 opacity-40"
136
+ style={{ transform: "rotate(45deg)" }}
137
+ />
138
+
139
+ {/* ── Hexagon — bottom right ── */}
140
+ <svg
141
+ className="absolute bottom-12 right-16 opacity-[0.35] animate-float"
142
+ style={{ "--float-rotate": "0deg", animationDelay: "2s" } as React.CSSProperties}
143
+ width="80" height="80" viewBox="0 0 160 160"
144
+ fill="none" xmlns="http://www.w3.org/2000/svg"
145
+ >
146
+ <path
147
+ d="M 140 80 L 110 132 L 50 132 L 20 80 L 50 28 L 110 28 Z"
148
+ stroke="#f97316" strokeWidth="1.5" fill="none"
149
+ />
150
+ <path
151
+ d="M 122 80 L 101 114 L 59 114 L 38 80 L 59 46 L 101 46 Z"
152
+ stroke="#f97316" strokeWidth="1" strokeOpacity="0.5" fill="none"
153
+ />
154
+ </svg>
155
+
156
+
157
+ {/* ── Small floating dots ── */}
158
+ <div className="absolute top-1/4 left-12 w-2 h-2 rounded-full bg-sky-400 opacity-60 animate-float" style={{ animationDelay: "1s" }} />
159
+ <div className="absolute top-1/3 right-20 w-1.5 h-1.5 rounded-full bg-orange-400 opacity-60 animate-float-slow" style={{ animationDelay: "3s" }} />
160
+ <div className="absolute bottom-1/3 left-1/4 w-1.5 h-1.5 rounded-full bg-emerald-400 opacity-60 animate-float" style={{ animationDelay: "0.5s" }} />
161
+ <div className="absolute top-2/3 right-1/3 w-1 h-1 rounded-full bg-violet-400 opacity-50 animate-float-slow" style={{ animationDelay: "4s" }} />
162
+
163
+ {/* ── Login card ── */}
164
+ <div className="relative z-10 w-full max-w-md xl:max-w-lg 2xl:max-w-xl px-4">
165
+ <div className="bg-white rounded-2xl shadow-2xl shadow-black/40 p-7 xl:p-10 border border-white/10">
166
+
167
+ {/* Brand logo */}
168
+ <div className="flex flex-col items-center gap-2 xl:gap-4 mb-6 xl:mb-8">
169
+ <img src={logoUrl} alt="Maintiva" className="w-12 h-12 xl:w-20 xl:h-20 object-contain" />
170
+ <div className="text-center">
171
+ <h1 className="text-xl xl:text-3xl font-semibold text-slate-900">Maintiva Agent</h1>
172
+ <p className="text-slate-400 text-sm xl:text-base mt-0.5 xl:mt-1">Welcome back to your AI Based Virtual Agent for Analysis, Learning & RCA</p>
173
  </div>
174
  </div>
175
 
176
+ <form onSubmit={handleLogin} className="space-y-4 xl:space-y-5">
 
 
 
 
 
 
 
177
  <div>
178
+ <label htmlFor="email" className="block text-xs xl:text-sm font-medium mb-1.5 text-slate-600">
 
 
 
179
  Email Address
180
  </label>
181
  <input
 
183
  type="email"
184
  value={email}
185
  onChange={(e) => setEmail(e.target.value)}
186
+ className="w-full px-3 xl:px-4 py-2 xl:py-3 text-sm xl:text-base rounded-xl border border-slate-200 bg-slate-50 focus:outline-none focus:ring-2 focus:ring-sky-400/30 focus:border-sky-400 placeholder:text-slate-300 transition"
187
  placeholder="you@example.com"
188
  disabled={isLoading}
189
  />
190
  </div>
191
 
192
  <div>
193
+ <label htmlFor="password" className="block text-xs xl:text-sm font-medium mb-1.5 text-slate-600">
 
 
 
194
  Password
195
  </label>
196
+ <div className="relative">
197
+ <input
198
+ id="password"
199
+ type={showPassword ? "text" : "password"}
200
+ value={password}
201
+ onChange={(e) => setPassword(e.target.value)}
202
+ className="w-full px-3 xl:px-4 py-2 xl:py-3 pr-10 xl:pr-12 text-sm xl:text-base rounded-xl border border-slate-200 bg-slate-50 focus:outline-none focus:ring-2 focus:ring-sky-400/30 focus:border-sky-400 placeholder:text-slate-300 transition"
203
+ placeholder="Enter your password"
204
+ disabled={isLoading}
205
+ />
206
+ <button
207
+ type="button"
208
+ onClick={() => setShowPassword((v) => !v)}
209
+ className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 transition"
210
+ tabIndex={-1}
211
+ aria-label={showPassword ? "Hide password" : "Show password"}
212
+ >
213
+ {showPassword ? <EyeOff className="w-4 h-4 xl:w-5 xl:h-5" /> : <Eye className="w-4 h-4 xl:w-5 xl:h-5" />}
214
+ </button>
215
+ </div>
216
  </div>
217
 
218
  {error && (
219
+ <div className="bg-red-50 border border-red-100 text-red-600 px-3 py-2 rounded-xl text-xs xl:text-sm">
220
  {error}
221
  </div>
222
  )}
 
224
  <button
225
  type="submit"
226
  disabled={isLoading}
227
+ className="w-full flex items-center justify-center gap-2 bg-[#059669] hover:bg-[#047857] active:bg-[#065F46] text-white py-2.5 xl:py-3.5 text-sm xl:text-base rounded-xl transition font-medium disabled:opacity-50 disabled:cursor-not-allowed mt-1"
228
  >
229
  {isLoading ? (
230
+ <Loader2 className="w-4 h-4 xl:w-5 xl:h-5 animate-spin" />
231
  ) : (
232
+ <LogIn className="w-4 h-4 xl:w-5 xl:h-5" />
233
  )}
234
+ {isLoading ? "Signing in" : "Sign In"}
235
  </button>
236
  </form>
237
  </div>
src/app/components/Main.tsx CHANGED
@@ -1,23 +1,7 @@
1
- import { useState, useEffect, useRef } from "react";
2
  import { useNavigate } from "react-router";
3
- import {
4
- Send,
5
- Plus,
6
- Trash2,
7
- LogOut,
8
- Menu,
9
- X,
10
- MessageSquare,
11
- User,
12
- Bot,
13
- Loader2,
14
- Database,
15
- } from "lucide-react";
16
- import ReactMarkdown from "react-markdown";
17
- import remarkGfm from "remark-gfm";
18
- import remarkMath from "remark-math";
19
- import rehypeKatex from "rehype-katex";
20
- import type { Components } from "react-markdown";
21
  import KnowledgeManagement from "./KnowledgeManagement";
22
  import {
23
  getRooms,
@@ -27,21 +11,16 @@ import {
27
  streamChat,
28
  type ChatSource,
29
  } from "../../services/api";
30
-
31
- interface StoredUser {
32
- user_id: string;
33
- email: string;
34
- name: string;
35
- loginTime: string;
36
- }
37
-
38
- interface Message {
39
- id: string;
40
- role: "user" | "assistant";
41
- content: string;
42
- timestamp: number;
43
- sources?: ChatSource[];
44
- }
45
 
46
  interface ChatRoom {
47
  id: string;
@@ -52,177 +31,32 @@ interface ChatRoom {
52
  messagesLoaded: boolean;
53
  }
54
 
55
- // Preprocess markdown to ensure tables are properly separated from surrounding text
56
- function preprocessMarkdown(content: string): string {
57
- // Step 1: Split concatenated table rows — "| val ||" means end of row + start of next
58
- let result = content.replace(/\|\|/g, "|\n|");
59
-
60
- // Step 2: Per-line — if a line has non-pipe text before a pipe table row, split them
61
- const lines = result.split("\n");
62
- const processed = lines.map((line) => {
63
- const tableStart = line.indexOf("|");
64
- if (tableStart > 0) {
65
- const tableContent = line.slice(tableStart);
66
- // Confirm it looks like a table row (at least 2 pipes)
67
- if ((tableContent.match(/\|/g) ?? []).length >= 2) {
68
- return line.slice(0, tableStart).trimEnd() + "\n\n" + tableContent;
69
- }
70
- }
71
- return line;
72
- });
73
- result = processed.join("\n");
74
-
75
- // Step 3: Ensure blank line before table rows preceded by non-table text (not another row ending with |)
76
- result = result.replace(/([^|\n])\n(\|)/g, "$1\n\n$2");
77
-
78
- return result;
79
- }
80
-
81
- // Markdown component overrides for clean rendering inside chat bubbles
82
- const markdownComponents: Components = {
83
- p: ({ children }) => (
84
- <p className="text-sm mb-2 last:mb-0 leading-relaxed">{children}</p>
85
- ),
86
- h1: ({ children }) => (
87
- <h1 className="text-lg font-bold mb-3 mt-4 first:mt-0">{children}</h1>
88
- ),
89
- h2: ({ children }) => (
90
- <h2 className="text-base font-bold mb-2 mt-3 first:mt-0">{children}</h2>
91
- ),
92
- h3: ({ children }) => (
93
- <h3 className="text-sm font-semibold mb-2 mt-2 first:mt-0">{children}</h3>
94
- ),
95
- ul: ({ children }) => (
96
- <ul className="list-disc pl-5 mb-2 space-y-1 text-sm">{children}</ul>
97
- ),
98
- ol: ({ children }) => (
99
- <ol className="list-decimal pl-5 mb-2 space-y-1 text-sm">{children}</ol>
100
- ),
101
- li: ({ children }) => <li className="text-sm leading-relaxed">{children}</li>,
102
- code: ({ children, className }) => {
103
- const isBlock = className?.startsWith("language-");
104
- if (isBlock) {
105
- return (
106
- <code className="block text-xs font-mono text-slate-100 leading-relaxed">
107
- {children}
108
- </code>
109
- );
110
- }
111
- return (
112
- <code className="bg-slate-100 text-pink-600 px-1.5 py-0.5 rounded text-xs font-mono">
113
- {children}
114
- </code>
115
- );
116
- },
117
- pre: ({ children }) => (
118
- <pre className="bg-slate-900 rounded-lg p-3 mb-2 mt-1 overflow-x-auto text-xs">
119
- {children}
120
- </pre>
121
- ),
122
- blockquote: ({ children }) => (
123
- <blockquote className="border-l-4 border-slate-300 pl-3 text-slate-500 italic mb-2 text-sm">
124
- {children}
125
- </blockquote>
126
- ),
127
- table: ({ children }) => (
128
- <div className="overflow-x-auto mb-2">
129
- <table className="w-full text-sm border-collapse">{children}</table>
130
- </div>
131
- ),
132
- th: ({ children }) => (
133
- <th className="border border-slate-200 px-3 py-1.5 bg-slate-100 font-medium text-left text-xs">
134
- {children}
135
- </th>
136
- ),
137
- td: ({ children }) => (
138
- <td className="border border-slate-200 px-3 py-1.5 text-xs">{children}</td>
139
- ),
140
- a: ({ children, href }) => (
141
- <a
142
- href={href}
143
- target="_blank"
144
- rel="noopener noreferrer"
145
- className="text-blue-600 underline hover:text-blue-800"
146
- >
147
- {children}
148
- </a>
149
- ),
150
- strong: ({ children }) => (
151
- <strong className="font-semibold">{children}</strong>
152
- ),
153
- hr: () => <hr className="border-slate-200 my-3" />,
154
- };
155
-
156
- // Typing indicator — three bouncing dots
157
- function useLoadingMessages() {
158
- const [messages, setMessages] = useState<string[]>([]);
159
-
160
- useEffect(() => {
161
- fetch("/loading-messages.yaml")
162
- .then((r) => r.text())
163
- .then((text) => {
164
- const parsed = text
165
- .split("\n")
166
- .filter((l) => l.trimStart().startsWith("- "))
167
- .map((l) => l.replace(/^\s*- /, "").trim())
168
- .filter(Boolean);
169
- if (parsed.length > 0) setMessages(parsed);
170
- })
171
- .catch(() => {});
172
- }, []);
173
-
174
- return messages;
175
- }
176
-
177
- function TypingIndicator() {
178
- const messages = useLoadingMessages();
179
- const [index, setIndex] = useState(0);
180
-
181
- useEffect(() => {
182
- if (messages.length === 0) return;
183
- setIndex(Math.floor(Math.random() * messages.length));
184
- const id = setInterval(() => {
185
- setIndex((prev) => {
186
- let next: number;
187
- do { next = Math.floor(Math.random() * messages.length); } while (messages.length > 1 && next === prev);
188
- return next;
189
- });
190
- }, 300);
191
- return () => clearInterval(id);
192
- }, [messages]);
193
-
194
- if (messages.length === 0) {
195
- return (
196
- <div className="flex gap-1.5 items-center py-1 px-0.5">
197
- <span className="w-2 h-2 bg-slate-400 rounded-full animate-bounce" style={{ animationDelay: "0ms", animationDuration: "1s" }} />
198
- <span className="w-2 h-2 bg-slate-400 rounded-full animate-bounce" style={{ animationDelay: "200ms", animationDuration: "1s" }} />
199
- <span className="w-2 h-2 bg-slate-400 rounded-full animate-bounce" style={{ animationDelay: "400ms", animationDuration: "1s" }} />
200
- </div>
201
- );
202
- }
203
-
204
- return (
205
- <div className="flex items-center gap-2 py-1 px-0.5 text-sm text-slate-400 italic">
206
- <span className="inline-block w-2 h-2 rounded-full bg-slate-400 animate-pulse" />
207
- <span>{messages[index]}…</span>
208
- </div>
209
- );
210
- }
211
-
212
  export default function Main() {
213
  const navigate = useNavigate();
214
- const [sidebarOpen, setSidebarOpen] = useState(true);
215
  const [chats, setChats] = useState<ChatRoom[]>([]);
216
  const [currentChatId, setCurrentChatId] = useState<string | null>(null);
217
- const [input, setInput] = useState("");
218
  const [isStreaming, setIsStreaming] = useState(false);
219
  const [streamingMsgId, setStreamingMsgId] = useState<string | null>(null);
220
  const [roomsLoading, setRoomsLoading] = useState(false);
221
- const [roomsError, setRoomsError] = useState<string | null>(null);
222
- const messagesEndRef = useRef<HTMLDivElement>(null);
223
  const [user, setUser] = useState<StoredUser | null>(null);
224
  const [knowledgeOpen, setKnowledgeOpen] = useState(false);
 
225
  const abortControllerRef = useRef<AbortController | null>(null);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
226
 
227
  useEffect(() => {
228
  const storedUser = localStorage.getItem("chatbot_user");
@@ -233,10 +67,6 @@ export default function Main() {
233
  }
234
  }, []);
235
 
236
- useEffect(() => {
237
- messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
238
- }, [currentChatId, chats]);
239
-
240
  useEffect(() => {
241
  if (!currentChatId) return;
242
  const chat = chats.find((c) => c.id === currentChatId);
@@ -248,7 +78,6 @@ export default function Main() {
248
 
249
  const loadRooms = async (userId: string) => {
250
  setRoomsLoading(true);
251
- setRoomsError(null);
252
  try {
253
  const apiRooms = await getRooms(userId);
254
  const mapped: ChatRoom[] = apiRooms.map((r) => ({
@@ -261,105 +90,123 @@ export default function Main() {
261
  }));
262
  setChats(mapped);
263
  if (mapped.length > 0) {
264
- setCurrentChatId(mapped[0].id);
 
 
 
 
265
  }
266
- } catch (err) {
267
- setRoomsError(
268
- err instanceof Error ? err.message : "Failed to load chats"
269
- );
270
  } finally {
271
  setRoomsLoading(false);
272
  }
273
  };
274
 
275
- const loadRoomMessages = async (roomId: string) => {
276
  try {
277
  const detail = await getRoom(roomId);
278
  const messages: Message[] = detail.messages.map((m) => ({
279
  id: m.id,
280
  role: m.role,
281
  content: m.content,
 
282
  timestamp: new Date(m.created_at).getTime(),
283
  sources: m.sources ?? [],
284
  }));
 
 
 
 
 
 
 
 
285
  setChats((prev) =>
286
  prev.map((chat) =>
287
- chat.id === roomId
288
- ? { ...chat, messages, messagesLoaded: true }
289
- : chat
290
  )
291
  );
292
- } catch {
 
 
293
  setChats((prev) =>
294
  prev.map((chat) =>
295
  chat.id === roomId ? { ...chat, messagesLoaded: true } : chat
296
  )
297
  );
 
298
  }
299
  };
300
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
301
  const currentChat = chats.find((chat) => chat.id === currentChatId);
302
 
303
- const createNewChat = () => {
304
- setCurrentChatId(null);
305
- };
306
 
307
- const deleteChat = async (chatId: string) => {
308
  if (!user) return;
309
  try {
310
  await deleteRoom(chatId, user.user_id);
311
  } catch {
312
  return;
313
  }
314
- const updatedChats = chats.filter((chat) => chat.id !== chatId);
315
- setChats(updatedChats);
316
  if (currentChatId === chatId) {
317
- setCurrentChatId(updatedChats.length > 0 ? updatedChats[0].id : null);
318
  }
319
  };
320
 
321
- const deleteAllChats = async () => {
322
- if (!user) return;
323
- await Promise.allSettled(
324
- chats.map((chat) => deleteRoom(chat.id, user.user_id))
325
- );
326
- setChats([]);
327
- setCurrentChatId(null);
328
- };
329
-
330
  const handleLogout = () => {
331
  localStorage.removeItem("chatbot_user");
332
  navigate("/login");
333
  };
334
 
335
- const handleSend = async () => {
336
- if (!input.trim() || isStreaming || !user) return;
 
337
 
338
- let roomId = currentChatId;
 
 
 
 
 
339
 
 
 
340
  if (!roomId) {
341
- try {
342
- const res = await createRoom(user.user_id, input.slice(0, 50));
343
- const newRoom: ChatRoom = {
344
- id: res.data.id,
345
- title: res.data.title,
346
- messages: [],
347
- createdAt: res.data.created_at,
348
- updatedAt: res.data.updated_at,
349
- messagesLoaded: true,
350
- };
351
- setChats((prev) => [newRoom, ...prev]);
352
- roomId = newRoom.id;
353
- setCurrentChatId(roomId);
354
- } catch {
355
- return;
356
- }
357
  }
358
 
359
  const userMessage: Message = {
360
  id: crypto.randomUUID(),
361
  role: "user",
362
- content: input,
363
  timestamp: Date.now(),
364
  };
365
 
@@ -375,10 +222,7 @@ export default function Main() {
375
  )
376
  );
377
 
378
- const sentMessage = input;
379
- setInput("");
380
  setIsStreaming(true);
381
-
382
  const assistantMsgId = crypto.randomUUID();
383
  setStreamingMsgId(assistantMsgId);
384
 
@@ -394,7 +238,7 @@ export default function Main() {
394
  role: "assistant",
395
  content: "",
396
  timestamp: Date.now(),
397
- sources: [],
398
  },
399
  ],
400
  }
@@ -404,15 +248,68 @@ export default function Main() {
404
 
405
  abortControllerRef.current = new AbortController();
406
 
407
- try {
408
- const response = await streamChat(user.user_id, roomId, sentMessage);
409
 
 
 
410
  if (!response.body) throw new Error("No response body");
411
 
412
  const reader = response.body.getReader();
413
  const decoder = new TextDecoder();
414
  let buffer = "";
415
  let currentEvent = "";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
416
 
417
  while (true) {
418
  const { done, value } = await reader.read();
@@ -423,63 +320,60 @@ export default function Main() {
423
  buffer = lines.pop() ?? "";
424
 
425
  for (const line of lines) {
426
- if (line.startsWith("event:")) {
 
 
 
 
 
 
 
427
  currentEvent = line.replace("event:", "").trim();
428
  } else if (line.startsWith("data:")) {
429
- const data = line.replace(/^data: ?/, "");
430
-
431
- if (currentEvent === "sources" && data) {
432
- const sources: ChatSource[] = JSON.parse(data);
433
- setChats((prev) =>
434
- prev.map((chat) =>
435
- chat.id === roomId
436
- ? {
437
- ...chat,
438
- messages: chat.messages.map((m) =>
439
- m.id === assistantMsgId ? { ...m, sources } : m
440
- ),
441
- }
442
- : chat
443
- )
444
- );
445
- } else if (currentEvent === "chunk" && data) {
446
- setChats((prev) =>
447
- prev.map((chat) =>
448
- chat.id === roomId
449
- ? {
450
- ...chat,
451
- messages: chat.messages.map((m) =>
452
- m.id === assistantMsgId
453
- ? { ...m, content: m.content + data }
454
- : m
455
- ),
456
- }
457
- : chat
458
- )
459
- );
460
- } else if (currentEvent === "message" && data) {
461
- setChats((prev) =>
462
- prev.map((chat) =>
463
- chat.id === roomId
464
- ? {
465
- ...chat,
466
- messages: chat.messages.map((m) =>
467
- m.id === assistantMsgId
468
- ? { ...m, content: data }
469
- : m
470
- ),
471
- }
472
- : chat
473
- )
474
- );
475
- } else if (currentEvent === "done") {
476
- break;
477
- }
478
  }
479
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
480
  }
 
481
  } catch (err: unknown) {
482
  if ((err as Error).name !== "AbortError") {
 
483
  setChats((prev) =>
484
  prev.map((chat) =>
485
  chat.id === roomId
@@ -489,8 +383,7 @@ export default function Main() {
489
  m.id === assistantMsgId
490
  ? {
491
  ...m,
492
- content:
493
- "Sorry, I couldn't get a response. Please try again.",
494
  }
495
  : m
496
  ),
@@ -498,275 +391,194 @@ export default function Main() {
498
  : chat
499
  )
500
  );
 
501
  }
502
  } finally {
 
503
  setIsStreaming(false);
504
  setStreamingMsgId(null);
505
  abortControllerRef.current = null;
506
  }
507
- };
508
 
509
- const handleKeyPress = (e: React.KeyboardEvent) => {
510
- if (e.key === "Enter" && !e.shiftKey) {
511
- e.preventDefault();
512
- handleSend();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
513
  }
514
- };
515
 
516
- return (
517
- <div className="flex h-screen bg-slate-50">
518
- {/* Sidebar */}
519
- <div
520
- className={`${
521
- sidebarOpen ? "w-64" : "w-0"
522
- } bg-gradient-to-b from-[#059669] to-[#047857] text-white transition-all duration-300 flex flex-col overflow-hidden`}
523
- >
524
- <div className="p-3 border-b border-white/20">
525
- <button
526
- onClick={createNewChat}
527
- className="w-full flex items-center justify-center gap-2 bg-white/20 hover:bg-white/30 px-3 py-2 rounded-lg transition text-sm"
528
- >
529
- <Plus className="w-4 h-4" />
530
- New Chat
531
- </button>
532
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
533
 
534
- <div className="flex-1 overflow-y-auto p-2 space-y-1">
535
- {roomsLoading ? (
536
- <div className="flex justify-center py-4">
537
- <Loader2 className="w-4 h-4 animate-spin text-white/70" />
538
- </div>
539
- ) : roomsError ? (
540
- <p className="text-xs text-red-200 text-center px-2 py-2">
541
- {roomsError}
542
- </p>
543
- ) : (
544
- chats.map((chat) => (
545
- <div
546
- key={chat.id}
547
- className={`flex items-center gap-2 p-2 rounded-lg cursor-pointer transition group ${
548
- currentChatId === chat.id
549
- ? "bg-white/25"
550
- : "hover:bg-white/15"
551
- }`}
552
- >
553
- <MessageSquare className="w-3.5 h-3.5 flex-shrink-0" />
554
- <div
555
- className="flex-1 truncate text-sm"
556
- onClick={() => setCurrentChatId(chat.id)}
557
- >
558
- {chat.title}
559
- </div>
560
- <button
561
- onClick={(e) => {
562
- e.stopPropagation();
563
- deleteChat(chat.id);
564
- }}
565
- className="opacity-0 group-hover:opacity-100 transition"
566
- >
567
- <Trash2 className="w-3.5 h-3.5 text-red-100 hover:text-white" />
568
- </button>
569
- </div>
570
- ))
571
- )}
572
- </div>
573
 
574
- <div className="border-t border-white/20 p-3 space-y-2">
575
- {chats.length > 0 && (
576
- <button
577
- onClick={deleteAllChats}
578
- className="w-full flex items-center justify-center gap-2 text-red-100 hover:text-white px-3 py-2 rounded-lg hover:bg-white/15 transition text-xs"
579
- >
580
- <Trash2 className="w-3.5 h-3.5" />
581
- Clear All Chats
582
- </button>
583
- )}
584
-
585
- <div className="flex items-center gap-2 p-2 rounded-lg bg-white/20">
586
- <div className="w-7 h-7 bg-white/30 rounded-full flex items-center justify-center">
587
- <User className="w-3.5 h-3.5" />
588
- </div>
589
- <div className="flex-1 min-w-0">
590
- <div className="text-xs truncate">{user?.name}</div>
591
- <div className="text-[10px] text-white/70 truncate">
592
- {user?.email}
593
- </div>
594
- </div>
595
- <button
596
- onClick={handleLogout}
597
- className="text-white/70 hover:text-white transition"
598
- title="Logout"
599
- >
600
- <LogOut className="w-3.5 h-3.5" />
601
- </button>
602
- </div>
603
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
604
  </div>
605
 
606
- {/* Main Content */}
607
- <div className="flex-1 flex flex-col min-w-0">
608
- {/* Header */}
609
- <div className="bg-white border-b border-slate-200 p-3 flex items-center gap-3">
610
- <button
611
- onClick={() => setSidebarOpen(!sidebarOpen)}
612
- className="text-slate-600 hover:text-slate-900 transition"
613
- >
614
- {sidebarOpen ? (
615
- <X className="w-5 h-5" />
616
- ) : (
617
- <Menu className="w-5 h-5" />
618
- )}
619
- </button>
620
- <h1 className="text-base text-slate-900 flex-1 truncate">
621
- {currentChat?.title || "Chatbot"}
622
- </h1>
623
- <button
624
- onClick={() => setKnowledgeOpen(true)}
625
- className="flex items-center gap-2 bg-[#F59E0B] hover:bg-[#D97706] text-white px-3 py-2 rounded-lg transition-all duration-200 hover:scale-105 text-sm flex-shrink-0"
626
- >
627
- <Database className="w-4 h-4" />
628
- Knowledge
629
- </button>
630
- </div>
631
 
632
- {/* Messages */}
633
- <div className="flex-1 overflow-y-auto p-4 space-y-4">
634
- {currentChat?.messages.length === 0 && (
635
- <div className="flex items-center justify-center h-full">
636
- <div className="text-center">
637
- <MessageSquare className="w-12 h-12 text-slate-300 mx-auto mb-3" />
638
- <h2 className="text-base text-slate-600 mb-1">
639
- Start a conversation
640
- </h2>
641
- <p className="text-sm text-slate-400">
642
- Send a message to begin chatting
643
- </p>
644
- </div>
645
- </div>
646
- )}
647
-
648
- {currentChat?.messages.map((message) => (
649
- <div
650
- key={message.id}
651
- className={`flex ${
652
- message.role === "user" ? "justify-end" : "justify-start"
653
- }`}
654
- >
655
- {/* Avatar for assistant */}
656
- {message.role === "assistant" && (
657
- <div className="w-7 h-7 rounded-full bg-gradient-to-br from-[#059669] to-[#047857] flex items-center justify-center flex-shrink-0 mt-0.5 mr-2">
658
- <Bot className="w-3.5 h-3.5 text-white" />
659
- </div>
660
- )}
661
-
662
- <div
663
- className={`max-w-2xl px-4 py-3 rounded-2xl ${
664
- message.role === "user"
665
- ? "bg-[#3B82F6] text-white rounded-tr-sm shadow-sm"
666
- : "bg-[#F3F4F6] border-0 text-slate-900 rounded-tl-sm shadow-sm"
667
- }`}
668
- >
669
- {message.role === "user" ? (
670
- <p className="text-sm whitespace-pre-wrap break-words leading-relaxed">
671
- {message.content}
672
- </p>
673
- ) : message.content === "" && streamingMsgId === message.id ? (
674
- // Waiting for first chunk — show typing indicator
675
- <TypingIndicator />
676
- ) : (
677
- // Render markdown for assistant messages
678
- <div className="text-slate-900">
679
- <ReactMarkdown
680
- remarkPlugins={[remarkGfm, remarkMath]}
681
- rehypePlugins={[rehypeKatex]}
682
- components={markdownComponents}
683
- >
684
- {preprocessMarkdown(message.content)}
685
- </ReactMarkdown>
686
- </div>
687
- )}
688
-
689
- {/* Sources */}
690
- {message.role === "assistant" &&
691
- message.sources &&
692
- message.sources.length > 0 && (
693
- <div className="mt-2 pt-2 border-t border-slate-100">
694
- <p className="text-[10px] text-slate-400 mb-1.5">
695
- Sources:
696
- </p>
697
- <div className="flex flex-wrap gap-1">
698
- {message.sources.map((src, i) => (
699
- <span
700
- key={i}
701
- className="text-[10px] bg-slate-100 text-slate-600 px-2 py-0.5 rounded-full border border-slate-200"
702
- title={
703
- src.page_label
704
- ? `Page ${src.page_label}`
705
- : undefined
706
- }
707
- >
708
- 📄 {src.filename}
709
- {src.page_label ? ` p.${src.page_label}` : ""}
710
- </span>
711
- ))}
712
- </div>
713
- </div>
714
- )}
715
- </div>
716
- </div>
717
- ))}
718
-
719
- {!currentChat && chats.length === 0 && !roomsLoading && (
720
- <div className="flex items-center justify-center h-full">
721
- <div className="text-center">
722
- <MessageSquare className="w-12 h-12 text-slate-300 mx-auto mb-3" />
723
- <h2 className="text-base text-slate-600 mb-1">
724
- Welcome to Chatbot
725
- </h2>
726
- <p className="text-sm text-slate-400">
727
- Create a new chat to get started
728
- </p>
729
- </div>
730
- </div>
731
- )}
732
-
733
- <div ref={messagesEndRef} />
734
- </div>
735
 
736
- {/* Input Area */}
737
- <div className="bg-white border-t border-slate-200 p-3 shadow-[0_-2px_10px_rgba(0,0,0,0.06)]">
738
- <div className="max-w-4xl mx-auto">
739
- <div className="flex gap-2 items-end">
740
- <textarea
741
- value={input}
742
- onChange={(e) => setInput(e.target.value)}
743
- onKeyDown={handleKeyPress}
744
- placeholder="Ask me anything... (Enter to send, Shift+Enter for newline)"
745
- rows={1}
746
- className="flex-1 px-3 py-2 text-sm rounded-lg border border-slate-300 focus:outline-none focus:ring-2 focus:ring-[#3B82F6] focus:border-transparent resize-none max-h-32"
747
- disabled={isStreaming}
748
- />
749
- <button
750
- onClick={handleSend}
751
- disabled={!input.trim() || isStreaming}
752
- className="bg-[#3B82F6] hover:bg-[#2563EB] text-white p-2.5 rounded-lg transition-all duration-200 hover:scale-105 disabled:opacity-50 disabled:cursor-not-allowed flex-shrink-0"
753
- >
754
- {isStreaming ? (
755
- <Loader2 className="w-4 h-4 animate-spin" />
756
- ) : (
757
- <Send className="w-4 h-4" />
758
- )}
759
- </button>
760
- </div>
761
- </div>
762
  </div>
763
  </div>
764
 
765
- {/* Knowledge Management Modal */}
766
  <KnowledgeManagement
767
  open={knowledgeOpen}
768
  onClose={() => setKnowledgeOpen(false)}
769
  />
770
- </div>
771
  );
772
  }
 
1
+ import { useState, useEffect, useRef, useCallback } from "react";
2
  import { useNavigate } from "react-router";
3
+ import { Database, Menu } from "lucide-react";
4
+ import { AnimatePresence } from "motion/react";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
  import KnowledgeManagement from "./KnowledgeManagement";
6
  import {
7
  getRooms,
 
11
  streamChat,
12
  type ChatSource,
13
  } from "../../services/api";
14
+ import { textToSpeech } from "../../services/voiceApi";
15
+ import { replayAudio } from "../../audio/AudioPlayer";
16
+ import ChatLayout from "./chat/ChatLayout";
17
+ import Sidebar from "./chat/Sidebar";
18
+ import ChatWindow from "./chat/ChatWindow";
19
+ import ChatInput from "./chat/ChatInput";
20
+ import VoiceStatusBar from "./chat/VoiceStatusBar";
21
+ import { useVoiceSession } from "../../hooks/useVoiceSession";
22
+ import type { VoiceState } from "../../hooks/useVoiceSession";
23
+ import type { Message, ChatSession, StoredUser } from "./chat/types";
 
 
 
 
 
24
 
25
  interface ChatRoom {
26
  id: string;
 
31
  messagesLoaded: boolean;
32
  }
33
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
  export default function Main() {
35
  const navigate = useNavigate();
 
36
  const [chats, setChats] = useState<ChatRoom[]>([]);
37
  const [currentChatId, setCurrentChatId] = useState<string | null>(null);
 
38
  const [isStreaming, setIsStreaming] = useState(false);
39
  const [streamingMsgId, setStreamingMsgId] = useState<string | null>(null);
40
  const [roomsLoading, setRoomsLoading] = useState(false);
 
 
41
  const [user, setUser] = useState<StoredUser | null>(null);
42
  const [knowledgeOpen, setKnowledgeOpen] = useState(false);
43
+ const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
44
  const abortControllerRef = useRef<AbortController | null>(null);
45
+ const isVoiceActiveRef = useRef(false);
46
+ const setVoiceStateRef = useRef<((s: VoiceState) => void) | null>(null);
47
+
48
+ // Stable refs so voice callbacks always see the latest values
49
+ const currentChatIdRef = useRef<string | null>(null);
50
+ useEffect(() => { currentChatIdRef.current = currentChatId; }, [currentChatId]);
51
+
52
+ const userRef = useRef<StoredUser | null>(null);
53
+ useEffect(() => { userRef.current = user; }, [user]);
54
+
55
+ useEffect(() => {
56
+ if (currentChatId) {
57
+ localStorage.setItem("chatbot_last_room_id", currentChatId);
58
+ }
59
+ }, [currentChatId]);
60
 
61
  useEffect(() => {
62
  const storedUser = localStorage.getItem("chatbot_user");
 
67
  }
68
  }, []);
69
 
 
 
 
 
70
  useEffect(() => {
71
  if (!currentChatId) return;
72
  const chat = chats.find((c) => c.id === currentChatId);
 
78
 
79
  const loadRooms = async (userId: string) => {
80
  setRoomsLoading(true);
 
81
  try {
82
  const apiRooms = await getRooms(userId);
83
  const mapped: ChatRoom[] = apiRooms.map((r) => ({
 
90
  }));
91
  setChats(mapped);
92
  if (mapped.length > 0) {
93
+ const lastRoomId = localStorage.getItem("chatbot_last_room_id");
94
+ const restoredId = lastRoomId && mapped.find((r) => r.id === lastRoomId)
95
+ ? lastRoomId
96
+ : mapped[0].id;
97
+ setCurrentChatId(restoredId);
98
  }
99
+ } catch {
100
+ // silently fail — UI shows empty state
 
 
101
  } finally {
102
  setRoomsLoading(false);
103
  }
104
  };
105
 
106
+ const loadRoomMessages = async (roomId: string): Promise<Message[]> => {
107
  try {
108
  const detail = await getRoom(roomId);
109
  const messages: Message[] = detail.messages.map((m) => ({
110
  id: m.id,
111
  role: m.role,
112
  content: m.content,
113
+ audioText: m.audio_text ?? undefined,
114
  timestamp: new Date(m.created_at).getTime(),
115
  sources: m.sources ?? [],
116
  }));
117
+ const asstMsgs = messages.filter(m => m.role === "assistant");
118
+ const lastAsst = asstMsgs[asstMsgs.length - 1];
119
+ if (lastAsst) {
120
+ const hasCR = lastAsst.content.includes("\r");
121
+ // console.log("[loadRoomMessages] last assistant contentLen=", lastAsst.content.length, "hasCR=", hasCR);
122
+ // console.log("[loadRoomMessages] CONTENT JSON →", JSON.stringify(lastAsst.content.slice(0, 600)));
123
+ }
124
+ // console.log("[loadRoomMessages] room", roomId, "→", messages.length, "messages from server");
125
  setChats((prev) =>
126
  prev.map((chat) =>
127
+ chat.id === roomId ? { ...chat, messages, messagesLoaded: true } : chat
 
 
128
  )
129
  );
130
+ return messages;
131
+ } catch (err) {
132
+ console.error("[loadRoomMessages] failed:", err);
133
  setChats((prev) =>
134
  prev.map((chat) =>
135
  chat.id === roomId ? { ...chat, messagesLoaded: true } : chat
136
  )
137
  );
138
+ return [];
139
  }
140
  };
141
 
142
+ // Ensures a chat room exists; creates one for voice if needed.
143
+ const ensureRoom = useCallback(async (title?: string): Promise<string | null> => {
144
+ if (currentChatIdRef.current) return currentChatIdRef.current;
145
+ const currentUser = userRef.current;
146
+ if (!currentUser) return null;
147
+ try {
148
+ const res = await createRoom(currentUser.user_id, title ?? "Voice Session");
149
+ const newRoom: ChatRoom = {
150
+ id: res.data.id,
151
+ title: res.data.title,
152
+ messages: [],
153
+ createdAt: res.data.created_at,
154
+ updatedAt: res.data.updated_at,
155
+ messagesLoaded: true,
156
+ };
157
+ setChats((prev) => [newRoom, ...prev]);
158
+ setCurrentChatId(newRoom.id);
159
+ return newRoom.id;
160
+ } catch {
161
+ return null;
162
+ }
163
+ }, []);
164
+
165
  const currentChat = chats.find((chat) => chat.id === currentChatId);
166
 
167
+ const handleNewChat = () => setCurrentChatId(null);
 
 
168
 
169
+ const handleDeleteSession = async (chatId: string) => {
170
  if (!user) return;
171
  try {
172
  await deleteRoom(chatId, user.user_id);
173
  } catch {
174
  return;
175
  }
176
+ const updated = chats.filter((c) => c.id !== chatId);
177
+ setChats(updated);
178
  if (currentChatId === chatId) {
179
+ setCurrentChatId(updated.length > 0 ? updated[0].id : null);
180
  }
181
  };
182
 
 
 
 
 
 
 
 
 
 
183
  const handleLogout = () => {
184
  localStorage.removeItem("chatbot_user");
185
  navigate("/login");
186
  };
187
 
188
+ const handleStop = () => {
189
+ abortControllerRef.current?.abort();
190
+ };
191
 
192
+ const handleSend = useCallback(async (text: string, skipReload = false) => {
193
+ // console.log("[handleSend] called, user:", user?.user_id ?? "null", "text:", text.slice(0, 40));
194
+ if (!user) {
195
+ console.warn("[handleSend] early return: no user");
196
+ return;
197
+ }
198
 
199
+ let roomId = await ensureRoom(text.slice(0, 50));
200
+ // console.log("[handleSend] roomId:", roomId);
201
  if (!roomId) {
202
+ // console.warn("[handleSend] early return: no roomId");
203
+ return;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
204
  }
205
 
206
  const userMessage: Message = {
207
  id: crypto.randomUUID(),
208
  role: "user",
209
+ content: text,
210
  timestamp: Date.now(),
211
  };
212
 
 
222
  )
223
  );
224
 
 
 
225
  setIsStreaming(true);
 
226
  const assistantMsgId = crypto.randomUUID();
227
  setStreamingMsgId(assistantMsgId);
228
 
 
238
  role: "assistant",
239
  content: "",
240
  timestamp: Date.now(),
241
+ sources: [] as ChatSource[],
242
  },
243
  ],
244
  }
 
248
 
249
  abortControllerRef.current = new AbortController();
250
 
251
+ let audioText = "";
 
252
 
253
+ try {
254
+ const response = await streamChat(user.user_id, roomId, text);
255
  if (!response.body) throw new Error("No response body");
256
 
257
  const reader = response.body.getReader();
258
  const decoder = new TextDecoder();
259
  let buffer = "";
260
  let currentEvent = "";
261
+ let currentDataLines: string[] = [];
262
+ let streamDone = false;
263
+
264
+ const dispatchEvent = (eventType: string, data: string) => {
265
+ if (eventType === "sources" && data) {
266
+ const sources: ChatSource[] = JSON.parse(data);
267
+ setChats((prev) =>
268
+ prev.map((chat) =>
269
+ chat.id === roomId
270
+ ? {
271
+ ...chat,
272
+ messages: chat.messages.map((m) =>
273
+ m.id === assistantMsgId ? { ...m, sources } : m
274
+ ),
275
+ }
276
+ : chat
277
+ )
278
+ );
279
+ } else if (eventType === "chunk") {
280
+ setChats((prev) =>
281
+ prev.map((chat) =>
282
+ chat.id === roomId
283
+ ? {
284
+ ...chat,
285
+ messages: chat.messages.map((m) =>
286
+ m.id === assistantMsgId
287
+ ? { ...m, content: m.content + data }
288
+ : m
289
+ ),
290
+ }
291
+ : chat
292
+ )
293
+ );
294
+ } else if (eventType === "message" && data) {
295
+ setChats((prev) =>
296
+ prev.map((chat) =>
297
+ chat.id === roomId
298
+ ? {
299
+ ...chat,
300
+ messages: chat.messages.map((m) =>
301
+ m.id === assistantMsgId ? { ...m, content: data } : m
302
+ ),
303
+ }
304
+ : chat
305
+ )
306
+ );
307
+ } else if (eventType === "audio_text" && data) {
308
+ audioText = data;
309
+ } else if (eventType === "done") {
310
+ streamDone = true;
311
+ }
312
+ };
313
 
314
  while (true) {
315
  const { done, value } = await reader.read();
 
320
  buffer = lines.pop() ?? "";
321
 
322
  for (const line of lines) {
323
+ if (line === "") {
324
+ // Blank line = end of SSE event — dispatch with all accumulated data lines joined by \n
325
+ if (currentEvent) {
326
+ dispatchEvent(currentEvent, currentDataLines.join("\n"));
327
+ }
328
+ currentEvent = "";
329
+ currentDataLines = [];
330
+ } else if (line.startsWith("event:")) {
331
  currentEvent = line.replace("event:", "").trim();
332
  } else if (line.startsWith("data:")) {
333
+ currentDataLines.push(line.replace(/^data: ?/, ""));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
334
  }
335
  }
336
+ if (streamDone) break;
337
+ }
338
+
339
+ if (skipReload) {
340
+ // Voice mode: skip GET /room, return immediately so TTS can start without delay.
341
+ // audioText and audioChunks will be stored on the temp assistantMsgId.
342
+ if (audioText) {
343
+ setChats((prev) =>
344
+ prev.map((chat) =>
345
+ chat.id === roomId
346
+ ? {
347
+ ...chat,
348
+ messages: chat.messages.map((m) =>
349
+ m.id === assistantMsgId ? { ...m, audioText } : m
350
+ ),
351
+ }
352
+ : chat
353
+ )
354
+ );
355
+ }
356
+ return { audioText, assistantMsgId };
357
+ }
358
+
359
+ if (audioText) {
360
+ setChats((prev) =>
361
+ prev.map((chat) =>
362
+ chat.id === roomId
363
+ ? {
364
+ ...chat,
365
+ messages: chat.messages.map((m) =>
366
+ m.id === assistantMsgId ? { ...m, audioText } : m
367
+ ),
368
+ }
369
+ : chat
370
+ )
371
+ );
372
  }
373
+ return { audioText, assistantMsgId };
374
  } catch (err: unknown) {
375
  if ((err as Error).name !== "AbortError") {
376
+ console.error("[handleSend] streamChat error:", err);
377
  setChats((prev) =>
378
  prev.map((chat) =>
379
  chat.id === roomId
 
383
  m.id === assistantMsgId
384
  ? {
385
  ...m,
386
+ content: "Sorry, I couldn't get a response. Please try again.",
 
387
  }
388
  : m
389
  ),
 
391
  : chat
392
  )
393
  );
394
+ audioText = "";
395
  }
396
  } finally {
397
+ // console.log("[handleSend] finally: clearing streamingMsgId →", assistantMsgId, "| skipReload:", skipReload);
398
  setIsStreaming(false);
399
  setStreamingMsgId(null);
400
  abortControllerRef.current = null;
401
  }
402
+ }, [user, ensureRoom]);
403
 
404
+ const cancelTtsRef = useRef<(() => void) | null>(null);
405
+
406
+ const playTtsAudio = useCallback(async (
407
+ ttsText: string,
408
+ onStarted?: () => void,
409
+ ): Promise<{ chunks: ArrayBuffer[]; sampleRate: number }> => {
410
+ // Stop any in-flight TTS before starting a new one (e.g. rapid re-query).
411
+ cancelTtsRef.current?.();
412
+ cancelTtsRef.current = null;
413
+ try {
414
+ const { pcm, sampleRate } = await textToSpeech(ttsText);
415
+ const durationMs = (pcm.byteLength / 2 / sampleRate) * 1000;
416
+ // Use the same proven playback path as the speaker button: schedule the
417
+ // full response at once via replayAudio, avoiding all streaming complexity.
418
+ const cancel = replayAudio([pcm], sampleRate);
419
+ cancelTtsRef.current = cancel;
420
+ onStarted?.();
421
+ await new Promise<void>((resolve) => setTimeout(resolve, durationMs + 150));
422
+ cancel();
423
+ cancelTtsRef.current = null;
424
+ return { chunks: [pcm], sampleRate };
425
+ } catch {
426
+ // TTS failure is non-fatal
427
+ return { chunks: [], sampleRate: 24000 };
428
  }
429
+ }, []);
430
 
431
+ const { voiceState, start, stop, stopRecording, setStateExternal, isActive: isVoiceActive } = useVoiceSession({
432
+ onTranscript: async (text: string) => {
433
+ // console.log("[onTranscript] received:", text);
434
+ // Pass skipReload=true so handleSend returns immediately after streaming
435
+ // without waiting for GET /room — TTS starts with zero extra delay.
436
+ const result = await handleSend(text, true);
437
+ const { audioText = "", assistantMsgId } = result ?? {};
438
+ // console.log("[onTranscript] handleSend done, audioText:", audioText ? audioText.slice(0, 40) : "(empty)");
439
+ if (audioText && isVoiceActiveRef.current) {
440
+ const { chunks, sampleRate } = await playTtsAudio(audioText, () => {
441
+ if (isVoiceActiveRef.current) setVoiceStateRef.current?.("SPEAKING");
442
+ });
443
+ if (assistantMsgId && chunks.length > 0) {
444
+ setChats((prev) =>
445
+ prev.map((chat) =>
446
+ chat.id === currentChatIdRef.current
447
+ ? {
448
+ ...chat,
449
+ messages: chat.messages.map((m) =>
450
+ m.id === assistantMsgId
451
+ ? { ...m, audioChunks: chunks, audioSampleRate: sampleRate }
452
+ : m
453
+ ),
454
+ }
455
+ : chat
456
+ )
457
+ );
458
+ }
459
+ }
460
+ if (isVoiceActiveRef.current) setVoiceStateRef.current?.("IDLE");
461
+ },
462
+ sessionParams: {},
463
+ });
464
 
465
+ // Keep refs in sync with latest values
466
+ useEffect(() => { isVoiceActiveRef.current = isVoiceActive; }, [isVoiceActive]);
467
+ useEffect(() => { setVoiceStateRef.current = setStateExternal; }, [setStateExternal]);
468
+
469
+ const handleVoiceToggle = useCallback(() => {
470
+ if (!isVoiceActive) {
471
+ start();
472
+ } else if (voiceState === "LISTENING") {
473
+ stopRecording();
474
+ } else {
475
+ stop();
476
+ }
477
+ }, [isVoiceActive, voiceState, start, stop, stopRecording]);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
478
 
479
+ const sessions: ChatSession[] = chats.map((c) => ({
480
+ id: c.id,
481
+ title: c.title,
482
+ createdAt: c.createdAt,
483
+ updatedAt: c.updatedAt,
484
+ }));
485
+
486
+ return (
487
+ <ChatLayout
488
+ sidebar={
489
+ <Sidebar
490
+ sessions={sessions}
491
+ activeSessionId={currentChatId}
492
+ isLoadingSessions={roomsLoading}
493
+ user={user}
494
+ onNewChat={handleNewChat}
495
+ onSelectSession={setCurrentChatId}
496
+ onDeleteSession={handleDeleteSession}
497
+ onLogout={handleLogout}
498
+ mobileOpen={mobileSidebarOpen}
499
+ onMobileClose={() => setMobileSidebarOpen(false)}
500
+ />
501
+ }
502
+ >
503
+ {/* Background decoration */}
504
+ <div className="absolute inset-0 pointer-events-none z-0 overflow-hidden">
505
+ <div
506
+ className="absolute inset-0 opacity-[0.13]"
507
+ style={{
508
+ backgroundImage: "radial-gradient(circle, #94a3b8 1px, transparent 1px)",
509
+ backgroundSize: "28px 28px",
510
+ }}
511
+ />
512
+ <div
513
+ className="absolute -top-28 -right-28 w-80 h-80 rounded-full blur-3xl opacity-[0.08]"
514
+ style={{ background: "#0ea5e9" }}
515
+ />
516
+ <div
517
+ className="absolute -bottom-28 -left-28 w-96 h-96 rounded-full blur-3xl opacity-[0.07]"
518
+ style={{ background: "#f97316" }}
519
+ />
520
+ <div
521
+ className="absolute top-1/2 -left-32 w-64 h-64 rounded-full blur-3xl opacity-[0.05]"
522
+ style={{ background: "#10b981" }}
523
+ />
524
  </div>
525
 
526
+ {/* Header */}
527
+ <div className="relative z-10 bg-white border-b border-neutral-100 px-4 py-3 flex items-center gap-3">
528
+ {/* Hamburger — mobile only */}
529
+ <button
530
+ className="md:hidden flex-shrink-0 p-1.5 rounded-lg text-neutral-500 hover:text-brand-green hover:bg-brand-green-50 transition-all"
531
+ onClick={() => setMobileSidebarOpen(true)}
532
+ aria-label="Open menu"
533
+ >
534
+ <Menu className="w-5 h-5" />
535
+ </button>
536
+
537
+ <h1 className="text-base font-bold text-neutral-900 flex-1 truncate">
538
+ {currentChat?.title ?? "New Chat"}
539
+ </h1>
540
+
541
+ <button
542
+ onClick={() => setKnowledgeOpen(true)}
543
+ className="flex items-center gap-2 bg-brand-amber text-white px-3 py-2 rounded-lg text-sm font-semibold hover:brightness-105 hover:scale-105 transition-all duration-200 flex-shrink-0"
544
+ >
545
+ <Database className="w-4 h-4" />
546
+ <span className="hidden sm:inline">Knowledge</span>
547
+ </button>
548
+ </div>
 
 
549
 
550
+ {/* Chat window */}
551
+ <div className="relative z-10 flex-1 flex flex-col min-h-0">
552
+ <ChatWindow
553
+ messages={currentChat?.messages ?? []}
554
+ isLoading={isStreaming}
555
+ streamingMsgId={streamingMsgId}
556
+ userName={user?.name}
557
+ />
558
+ </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
559
 
560
+ {/* Input area */}
561
+ <div className="relative z-10 border-t border-neutral-100 bg-white/80 backdrop-blur-sm">
562
+ <div className="w-full max-w-4xl xl:max-w-5xl mx-auto flex flex-col gap-2 px-3 sm:px-4 pt-3 pb-4">
563
+ <AnimatePresence>
564
+ {isVoiceActive && (
565
+ <VoiceStatusBar voiceState={voiceState} onStop={stop} />
566
+ )}
567
+ </AnimatePresence>
568
+ <ChatInput
569
+ onSend={handleSend}
570
+ onStop={handleStop}
571
+ isLoading={isStreaming}
572
+ voiceState={voiceState}
573
+ onVoiceToggle={handleVoiceToggle}
574
+ />
 
 
 
 
 
 
 
 
 
 
 
575
  </div>
576
  </div>
577
 
 
578
  <KnowledgeManagement
579
  open={knowledgeOpen}
580
  onClose={() => setKnowledgeOpen(false)}
581
  />
582
+ </ChatLayout>
583
  );
584
  }
src/app/components/chat/ChatInput.tsx ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useRef, useEffect } from "react";
2
+ import { SendHorizontal, Square } from "lucide-react";
3
+ import type { VoiceState } from "../../../hooks/useVoiceSession";
4
+ import VoiceMicButton from "./VoiceMicButton";
5
+
6
+ interface ChatInputProps {
7
+ onSend: (text: string) => void;
8
+ onStop?: () => void;
9
+ isLoading: boolean;
10
+ disabled?: boolean;
11
+ voiceState: VoiceState;
12
+ onVoiceToggle: () => void;
13
+ }
14
+
15
+ export default function ChatInput({
16
+ onSend,
17
+ onStop,
18
+ isLoading,
19
+ disabled,
20
+ voiceState,
21
+ onVoiceToggle,
22
+ }: ChatInputProps) {
23
+ const [value, setValue] = useState("");
24
+ const textareaRef = useRef<HTMLTextAreaElement>(null);
25
+
26
+ const isVoiceActive = voiceState !== "IDLE" && voiceState !== "ERROR";
27
+
28
+ useEffect(() => {
29
+ const el = textareaRef.current;
30
+ if (!el) return;
31
+ el.style.height = "auto";
32
+ el.style.height = Math.min(el.scrollHeight, 50) + "px";
33
+ }, [value]);
34
+
35
+ const handleSend = () => {
36
+ const trimmed = value.trim();
37
+ if (!trimmed || isLoading || disabled || isVoiceActive) return;
38
+ onSend(trimmed);
39
+ setValue("");
40
+ };
41
+
42
+ const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
43
+ if (e.key === "Enter" && !e.shiftKey) {
44
+ e.preventDefault();
45
+ handleSend();
46
+ }
47
+ };
48
+
49
+ const canSend = value.trim().length > 0 && !disabled && !isVoiceActive;
50
+
51
+ return (
52
+ <div
53
+ className={`bg-white border rounded-2xl shadow-sm px-4 py-3 transition-all ${
54
+ disabled || isVoiceActive
55
+ ? "opacity-80 border-neutral-200"
56
+ : "border-neutral-200 focus-within:border-brand-green focus-within:shadow-md focus-within:shadow-brand-green/10"
57
+ }`}
58
+ >
59
+ <div className="flex items-end gap-3">
60
+ <VoiceMicButton
61
+ voiceState={voiceState}
62
+ onToggle={onVoiceToggle}
63
+ disabled={isLoading}
64
+ />
65
+
66
+ <textarea
67
+ ref={textareaRef}
68
+ value={value}
69
+ onChange={(e) => setValue(e.target.value)}
70
+ onKeyDown={handleKeyDown}
71
+ placeholder={
72
+ isVoiceActive
73
+ ? "Voice session active — speak to interact"
74
+ : "Ask me anything… (Enter to send, Shift+Enter for newline)"
75
+ }
76
+ disabled={isLoading || disabled || isVoiceActive}
77
+ rows={1}
78
+ className="flex-1 resize-none bg-transparent text-sm text-neutral-900 leading-6 placeholder:text-neutral-400 outline-none overflow-y-auto"
79
+ style={{ minHeight: "24px", maxHeight: "50px" }}
80
+ />
81
+
82
+ {isLoading ? (
83
+ <button
84
+ onClick={onStop}
85
+ className="w-9 h-9 flex-shrink-0 rounded-xl bg-gradient-to-br from-brand-green-light to-brand-green text-white shadow-md shadow-brand-green/25 hover:brightness-105 flex items-center justify-center transition-all"
86
+ aria-label="Stop"
87
+ >
88
+ <Square className="h-3.5 w-3.5" />
89
+ </button>
90
+ ) : (
91
+ <button
92
+ onClick={handleSend}
93
+ disabled={!canSend}
94
+ className={`w-9 h-9 flex-shrink-0 rounded-xl flex items-center justify-center transition-all ${
95
+ canSend
96
+ ? "bg-gradient-to-br from-brand-green-light to-brand-green text-white shadow-md shadow-brand-green/25 hover:brightness-105"
97
+ : "bg-neutral-100 text-neutral-300 cursor-not-allowed"
98
+ }`}
99
+ aria-label="Send"
100
+ >
101
+ <SendHorizontal className="h-4 w-4" />
102
+ </button>
103
+ )}
104
+ </div>
105
+ </div>
106
+ );
107
+ }
src/app/components/chat/ChatLayout.tsx ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { ReactNode } from "react";
2
+
3
+ interface ChatLayoutProps {
4
+ sidebar: ReactNode;
5
+ children: ReactNode;
6
+ }
7
+
8
+ export default function ChatLayout({ sidebar, children }: ChatLayoutProps) {
9
+ return (
10
+ <div className="flex h-screen bg-neutral-50 overflow-hidden">
11
+ {sidebar}
12
+ <div className="flex-1 flex flex-col min-w-0 relative">
13
+ {children}
14
+ </div>
15
+ </div>
16
+ );
17
+ }
src/app/components/chat/ChatWindow.tsx ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useRef } from "react";
2
+ import { motion } from "motion/react";
3
+ import { Bot } from "lucide-react";
4
+ import type { Message } from "./types";
5
+ import MessageBubble from "./MessageBubble";
6
+ import maintivalogo from "@/assets/maintiva-logo.jpg";
7
+
8
+ const WELCOME_FEATURES = [
9
+ { emoji: "📊", title: "Data Analysis", desc: "Get actionable insights and root cause" },
10
+ { emoji: "🧠", title: "Knowledge Based", desc: "Handbook, SOP, Documents, or Databases" },
11
+ { emoji: "🎤", title: "Voice Mode", desc: "Ask questions using your voice" },
12
+ { emoji: "💬", title: "Natural language", desc: "Natural interaction" },
13
+ ];
14
+
15
+ interface ChatWindowProps {
16
+ messages: Message[];
17
+ isLoading: boolean;
18
+ streamingMsgId: string | null;
19
+ userName?: string;
20
+ }
21
+
22
+ export default function ChatWindow({ messages, isLoading, streamingMsgId, userName }: ChatWindowProps) {
23
+ const firstName = userName?.split(" ")[0] ?? "";
24
+ const bottomRef = useRef<HTMLDivElement>(null);
25
+
26
+ useEffect(() => {
27
+ bottomRef.current?.scrollIntoView({ behavior: "smooth" });
28
+ }, [messages, isLoading]);
29
+
30
+ if (messages.length === 0 && !isLoading) {
31
+ return (
32
+ <div className="flex-1 overflow-y-auto flex flex-col items-center justify-center gap-6 px-4 sm:px-6 xl:px-8 pb-4">
33
+ <motion.div
34
+ className="flex flex-col items-center gap-4 xl:gap-6 w-full max-w-sm sm:max-w-md xl:max-w-2xl"
35
+ initial={{ opacity: 0, scale: 0.9 }}
36
+ animate={{ opacity: 1, scale: 1 }}
37
+ transition={{ duration: 0.5, ease: "easeOut" }}
38
+ >
39
+ <div className="w-20 h-20 xl:w-28 xl:h-28 rounded-3xl bg-gradient-to-br from-brand-green-light to-brand-green shadow-xl shadow-brand-green/25 flex items-center justify-center">
40
+ <Bot className="h-10 w-10 xl:h-14 xl:w-14 text-white" />
41
+ </div>
42
+
43
+ <div className="text-center">
44
+ <h2 className="text-2xl xl:text-4xl font-bold text-neutral-900">
45
+ {firstName ? `Hi, ${firstName}! 👋` : "Hello! 👋"}
46
+ </h2>
47
+ <p className="mt-2 text-neutral-500 text-sm xl:text-base leading-relaxed">
48
+ Welcome to{" "}
49
+ <span className="inline-flex items-center gap-1 font-bold text-neutral-800 align-middle">
50
+ <img src={maintivalogo} alt="Maintiva" className="h-4 w-4 xl:h-5 xl:w-5 rounded-sm object-contain" />
51
+ Maintiva Agent
52
+ </span>{" "}
53
+ Turn your data into fast, intelligent decisions. Instantly uncover root causes, spot hidden patterns, and resolve issues before they escalate.
54
+ </p>
55
+ </div>
56
+
57
+ <div className="grid grid-cols-2 xl:grid-cols-4 gap-3 xl:gap-4 w-full mt-2">
58
+ {WELCOME_FEATURES.map((f) => (
59
+ <div
60
+ key={f.title}
61
+ className="flex flex-col gap-1 xl:gap-2 p-3 xl:p-4 rounded-xl bg-white border border-neutral-100 shadow-sm text-left"
62
+ >
63
+ <span className="text-xl xl:text-3xl">{f.emoji}</span>
64
+ <span className="text-sm xl:text-base font-semibold text-neutral-800">{f.title}</span>
65
+ <span className="text-xs xl:text-sm text-neutral-400">{f.desc}</span>
66
+ </div>
67
+ ))}
68
+ </div>
69
+ </motion.div>
70
+ </div>
71
+ );
72
+ }
73
+
74
+ return (
75
+ <div className="flex-1 overflow-y-auto py-4 space-y-1">
76
+ {/* Constrain message width on large/seminar screens */}
77
+ <div className="w-full max-w-4xl xl:max-w-5xl mx-auto">
78
+ {messages.map((message) => (
79
+ <MessageBubble
80
+ key={message.id}
81
+ message={message}
82
+ isStreamingPlaceholder={streamingMsgId === message.id}
83
+ />
84
+ ))}
85
+ <div ref={bottomRef} />
86
+ </div>
87
+ </div>
88
+ );
89
+ }
src/app/components/chat/FeedbackWidget.tsx ADDED
@@ -0,0 +1,148 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useRef } from "react";
2
+ import { ThumbsUp, ThumbsDown, Copy, Check, Loader2, Volume2, VolumeX } from "lucide-react";
3
+ import { replayAudio } from "../../../audio/AudioPlayer";
4
+ import { textToSpeech } from "../../../services/voiceApi";
5
+
6
+ interface FeedbackWidgetProps {
7
+ messageId: string;
8
+ content: string;
9
+ audioText?: string;
10
+ audioChunks?: ArrayBuffer[];
11
+ audioSampleRate?: number;
12
+ }
13
+
14
+ export default function FeedbackWidget({
15
+ content,
16
+ audioText,
17
+ audioChunks,
18
+ audioSampleRate,
19
+ }: FeedbackWidgetProps) {
20
+ const [thumbsUp, setThumbsUp] = useState(false);
21
+ const [thumbsDown, setThumbsDown] = useState(false);
22
+ const [copyState, setCopyState] = useState<"idle" | "loading" | "success">("idle");
23
+ const [speakerState, setSpeakerState] = useState<"idle" | "loading" | "playing">("idle");
24
+ const cancelPlayRef = useRef<(() => void) | null>(null);
25
+
26
+ const handleThumbsUp = () => {
27
+ setThumbsUp((v) => !v);
28
+ if (thumbsDown) setThumbsDown(false);
29
+ };
30
+
31
+ const handleThumbsDown = () => {
32
+ setThumbsDown((v) => !v);
33
+ if (thumbsUp) setThumbsUp(false);
34
+ };
35
+
36
+ const handleCopy = async () => {
37
+ if (copyState !== "idle") return;
38
+ setCopyState("loading");
39
+ try {
40
+ await navigator.clipboard.writeText(content);
41
+ setCopyState("success");
42
+ setTimeout(() => setCopyState("idle"), 2000);
43
+ } catch {
44
+ setCopyState("idle");
45
+ }
46
+ };
47
+
48
+ const handleSpeaker = async () => {
49
+ if (speakerState === "playing") {
50
+ cancelPlayRef.current?.();
51
+ cancelPlayRef.current = null;
52
+ setSpeakerState("idle");
53
+ return;
54
+ }
55
+
56
+ if (speakerState === "loading") return;
57
+
58
+ if (audioChunks && audioChunks.length > 0) {
59
+ // Voice mode: replay pre-stored audio immediately
60
+ setSpeakerState("playing");
61
+ const cancel = replayAudio(audioChunks, audioSampleRate ?? 24000);
62
+ cancelPlayRef.current = cancel;
63
+ // Calculate approx duration to reset state
64
+ const totalSamples = audioChunks.reduce((acc, c) => acc + c.byteLength / 2, 0);
65
+ const duration = (totalSamples / (audioSampleRate ?? 24000)) * 1000;
66
+ setTimeout(() => {
67
+ cancelPlayRef.current = null;
68
+ setSpeakerState("idle");
69
+ }, duration + 200);
70
+ } else {
71
+ // Text mode: request TTS now
72
+ setSpeakerState("loading");
73
+ try {
74
+ const { pcm, sampleRate } = await textToSpeech(audioText || content);
75
+ setSpeakerState("playing");
76
+ const cancel = replayAudio([pcm], sampleRate);
77
+ cancelPlayRef.current = cancel;
78
+ const duration = (pcm.byteLength / 2 / sampleRate) * 1000;
79
+ setTimeout(() => {
80
+ cancelPlayRef.current = null;
81
+ setSpeakerState("idle");
82
+ }, duration + 200);
83
+ } catch {
84
+ setSpeakerState("idle");
85
+ }
86
+ }
87
+ };
88
+
89
+ return (
90
+ <div className="flex items-center gap-1 ml-10 mt-1 px-4">
91
+ <button
92
+ onClick={handleThumbsUp}
93
+ className={`p-1 rounded transition-colors ${
94
+ thumbsUp ? "text-brand-green" : "text-neutral-300 hover:text-neutral-500"
95
+ }`}
96
+ aria-label="Thumbs up"
97
+ >
98
+ <ThumbsUp className="h-3.5 w-3.5" />
99
+ </button>
100
+
101
+ <button
102
+ onClick={handleThumbsDown}
103
+ className={`p-1 rounded transition-colors ${
104
+ thumbsDown ? "text-red-400" : "text-neutral-300 hover:text-neutral-500"
105
+ }`}
106
+ aria-label="Thumbs down"
107
+ >
108
+ <ThumbsDown className="h-3.5 w-3.5" />
109
+ </button>
110
+
111
+ <button
112
+ onClick={handleCopy}
113
+ className={`p-1 rounded transition-colors ${
114
+ copyState === "success"
115
+ ? "text-brand-green"
116
+ : "text-neutral-300 hover:text-neutral-500"
117
+ }`}
118
+ aria-label="Copy message"
119
+ >
120
+ {copyState === "loading" ? (
121
+ <Loader2 className="h-3.5 w-3.5 animate-spin text-neutral-400" />
122
+ ) : copyState === "success" ? (
123
+ <Check className="h-3.5 w-3.5" />
124
+ ) : (
125
+ <Copy className="h-3.5 w-3.5" />
126
+ )}
127
+ </button>
128
+
129
+ <button
130
+ onClick={handleSpeaker}
131
+ className={`p-1 rounded transition-colors ${
132
+ speakerState === "playing"
133
+ ? "text-brand-green"
134
+ : "text-neutral-300 hover:text-neutral-500"
135
+ }`}
136
+ aria-label="Play audio"
137
+ >
138
+ {speakerState === "loading" ? (
139
+ <Loader2 className="h-3.5 w-3.5 animate-spin text-neutral-400" />
140
+ ) : speakerState === "playing" ? (
141
+ <VolumeX className="h-3.5 w-3.5" />
142
+ ) : (
143
+ <Volume2 className="h-3.5 w-3.5" />
144
+ )}
145
+ </button>
146
+ </div>
147
+ );
148
+ }
src/app/components/chat/MessageBubble.tsx ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect } from "react";
2
+ import { motion } from "motion/react";
3
+ import { Bot } from "lucide-react";
4
+ import type { Message } from "./types";
5
+ import TypingIndicator from "./TypingIndicator";
6
+ import FeedbackWidget from "./FeedbackWidget";
7
+ import MarkdownRenderer from "./renderers/MarkdownRenderer";
8
+
9
+ interface MessageBubbleProps {
10
+ message: Message;
11
+ isStreamingPlaceholder?: boolean;
12
+ }
13
+
14
+ export default function MessageBubble({ message, isStreamingPlaceholder }: MessageBubbleProps) {
15
+ useEffect(() => {
16
+ if (message.role === "assistant") {
17
+ // console.log(
18
+ // `[MessageBubble] id=...${message.id.slice(-6)} isStreamingPlaceholder=${isStreamingPlaceholder} contentLen=${message.content.length}`
19
+ // );
20
+ }
21
+ }, [isStreamingPlaceholder, message.id, message.role, message.content.length]);
22
+
23
+ if (message.role === "user") {
24
+ return (
25
+ <motion.div
26
+ className="flex justify-end px-3 sm:px-4 py-1"
27
+ initial={{ opacity: 0, y: 12 }}
28
+ animate={{ opacity: 1, y: 0 }}
29
+ transition={{ duration: 0.3, ease: "easeOut" }}
30
+ >
31
+ <div className="max-w-[88%] sm:max-w-[75%] xl:max-w-[65%] px-4 py-3 rounded-2xl rounded-br-sm bg-gradient-to-br from-brand-green-light to-brand-green text-white text-sm xl:text-base leading-relaxed shadow-md shadow-brand-green/20">
32
+ {message.content}
33
+ </div>
34
+ </motion.div>
35
+ );
36
+ }
37
+
38
+ // Show TypingIndicator when the assistant placeholder has no content yet
39
+ if (isStreamingPlaceholder && message.content === "") {
40
+ return <TypingIndicator />;
41
+ }
42
+
43
+ return (
44
+ <motion.div
45
+ initial={{ opacity: 0, y: 12 }}
46
+ animate={{ opacity: 1, y: 0 }}
47
+ transition={{ duration: 0.3, ease: "easeOut" }}
48
+ >
49
+ <div className="flex gap-3 px-3 sm:px-4 py-2">
50
+ <div className="w-7 h-7 xl:w-8 xl:h-8 rounded-full bg-gradient-to-br from-brand-green-light to-brand-green flex-shrink-0 flex items-center justify-center shadow-sm">
51
+ <Bot className="h-4 w-4 text-white" />
52
+ </div>
53
+
54
+ <div className="max-w-[92%] sm:max-w-[88%] xl:max-w-[80%] bg-white border border-neutral-100 rounded-2xl rounded-tl-sm shadow-sm px-4 xl:px-5 py-3 xl:py-4">
55
+ <MarkdownRenderer
56
+ key={isStreamingPlaceholder ? "streaming" : "final"}
57
+ content={message.content}
58
+ skipPreprocess={isStreamingPlaceholder}
59
+ />
60
+
61
+ {message.sources && message.sources.length > 0 && (
62
+ <div className="mt-3 pt-2 border-t border-neutral-100">
63
+ <p className="text-[10px] text-neutral-400 mb-1.5">Sources:</p>
64
+ <div className="flex flex-wrap gap-1">
65
+ {message.sources.map((src, i) => (
66
+ <span
67
+ key={i}
68
+ className="text-[10px] bg-neutral-100 text-neutral-600 px-2 py-0.5 rounded-full border border-neutral-200"
69
+ title={src.page_label ? `Page ${src.page_label}` : undefined}
70
+ >
71
+ 📄 {src.filename}
72
+ {src.page_label ? ` p.${src.page_label}` : ""}
73
+ </span>
74
+ ))}
75
+ </div>
76
+ </div>
77
+ )}
78
+ </div>
79
+ </div>
80
+
81
+ {!isStreamingPlaceholder && (
82
+ <FeedbackWidget
83
+ messageId={message.id}
84
+ content={message.content}
85
+ audioText={message.audioText}
86
+ audioChunks={message.audioChunks}
87
+ audioSampleRate={message.audioSampleRate}
88
+ />
89
+ )}
90
+ </motion.div>
91
+ );
92
+ }
src/app/components/chat/Sidebar.tsx ADDED
@@ -0,0 +1,282 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState } from "react";
2
+ import { motion, AnimatePresence } from "motion/react";
3
+ import {
4
+ Plus,
5
+ MessageSquare,
6
+ Trash2,
7
+ ChevronLeft,
8
+ ChevronRight,
9
+ Settings,
10
+ LogOut,
11
+ } from "lucide-react";
12
+ import maintivalogoUrl from "../../../assets/maintiva-logo.jpg";
13
+ import type { ChatSession, StoredUser } from "./types";
14
+
15
+ interface SidebarProps {
16
+ sessions: ChatSession[];
17
+ activeSessionId: string | null;
18
+ isLoadingSessions: boolean;
19
+ user: StoredUser | null;
20
+ onNewChat: () => void;
21
+ onSelectSession: (id: string) => void;
22
+ onDeleteSession: (id: string) => void;
23
+ onLogout: () => void;
24
+ mobileOpen: boolean;
25
+ onMobileClose: () => void;
26
+ }
27
+
28
+ function formatTime(dateStr: string | null | undefined): string {
29
+ if (!dateStr) return "";
30
+ const d = new Date(dateStr);
31
+ const now = new Date();
32
+ const diffMs = now.getTime() - d.getTime();
33
+ const diffDays = Math.floor(diffMs / 86400000);
34
+ if (diffDays === 0) {
35
+ return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
36
+ }
37
+ if (diffDays === 1) return "Yesterday";
38
+ if (diffDays < 7) return `${diffDays}d ago`;
39
+ return d.toLocaleDateString([], { month: "short", day: "numeric" });
40
+ }
41
+
42
+ export default function Sidebar({
43
+ sessions,
44
+ activeSessionId,
45
+ isLoadingSessions,
46
+ user,
47
+ onNewChat,
48
+ onSelectSession,
49
+ onDeleteSession,
50
+ onLogout,
51
+ mobileOpen,
52
+ onMobileClose,
53
+ }: SidebarProps) {
54
+ const [collapsed, setCollapsed] = useState(false);
55
+ const [hoveredSession, setHoveredSession] = useState<string | null>(null);
56
+
57
+ return (
58
+ <>
59
+ {/* Mobile backdrop */}
60
+ <AnimatePresence>
61
+ {mobileOpen && (
62
+ <motion.div
63
+ className="fixed inset-0 bg-black/40 z-40 md:hidden"
64
+ initial={{ opacity: 0 }}
65
+ animate={{ opacity: 1 }}
66
+ exit={{ opacity: 0 }}
67
+ transition={{ duration: 0.2 }}
68
+ onClick={onMobileClose}
69
+ />
70
+ )}
71
+ </AnimatePresence>
72
+
73
+ <motion.div
74
+ className={[
75
+ "flex flex-col h-full flex-shrink-0 bg-white border-r border-neutral-100 overflow-hidden",
76
+ // Mobile: fixed overlay drawer; desktop: in-flow
77
+ "fixed inset-y-0 left-0 z-50 md:relative md:inset-auto md:z-auto",
78
+ // Mobile slide in/out; always visible on desktop
79
+ mobileOpen ? "translate-x-0" : "-translate-x-full",
80
+ "md:translate-x-0",
81
+ // CSS transition for mobile only; framer-motion handles desktop width
82
+ "transition-transform duration-300 ease-in-out md:transition-none",
83
+ // Never overflow phone screen
84
+ "max-w-[85vw] md:max-w-none",
85
+ ].join(" ")}
86
+ animate={{ width: collapsed ? 72 : 280 }}
87
+ transition={{ duration: 0.3, ease: "easeInOut" }}
88
+ >
89
+ {/* Collapse toggle — desktop only */}
90
+ <button
91
+ onClick={() => setCollapsed((v) => !v)}
92
+ className="hidden md:flex absolute -right-3 top-1/2 -translate-y-1/2 z-20 w-6 h-6 rounded-full bg-white border border-neutral-200 shadow-sm text-neutral-500 hover:text-brand-green hover:border-brand-green transition-all items-center justify-center"
93
+ aria-label={collapsed ? "Expand sidebar" : "Collapse sidebar"}
94
+ >
95
+ {collapsed ? (
96
+ <ChevronRight className="h-3.5 w-3.5" />
97
+ ) : (
98
+ <ChevronLeft className="h-3.5 w-3.5" />
99
+ )}
100
+ </button>
101
+
102
+ {/* Header */}
103
+ <div className="flex items-center gap-3 px-4 py-4 border-b border-neutral-100">
104
+ <motion.div
105
+ className="w-9 h-9 rounded-xl overflow-hidden flex-shrink-0 shadow-md"
106
+ whileHover={{ scale: 1.05 }}
107
+ >
108
+ <img src={maintivalogoUrl} alt="Maintiva" className="w-full h-full object-cover" />
109
+ </motion.div>
110
+
111
+ <AnimatePresence>
112
+ {!collapsed && (
113
+ <motion.div
114
+ initial={{ opacity: 0, width: 0 }}
115
+ animate={{ opacity: 1, width: "auto" }}
116
+ exit={{ opacity: 0, width: 0 }}
117
+ transition={{ duration: 0.2 }}
118
+ className="overflow-hidden"
119
+ >
120
+ <p className="font-bold text-neutral-900 text-base whitespace-nowrap">Maintiva Agent</p>
121
+ <p className="text-xs text-neutral-400 whitespace-nowrap">AI Data Assistant</p>
122
+ </motion.div>
123
+ )}
124
+ </AnimatePresence>
125
+ </div>
126
+
127
+ {/* New Chat button */}
128
+ <div className="px-3 py-3">
129
+ <button
130
+ onClick={() => { onNewChat(); onMobileClose(); }}
131
+ className={`w-full flex items-center gap-3 rounded-xl px-3 py-2.5 bg-gradient-to-br from-brand-green-light to-brand-green text-white text-sm font-semibold shadow-md shadow-brand-green/20 hover:shadow-lg hover:shadow-brand-green/25 hover:brightness-105 transition-all ${
132
+ collapsed ? "justify-center px-0" : ""
133
+ }`}
134
+ >
135
+ <Plus className="h-4 w-4 flex-shrink-0" />
136
+ <AnimatePresence>
137
+ {!collapsed && (
138
+ <motion.span
139
+ initial={{ opacity: 0, width: 0 }}
140
+ animate={{ opacity: 1, width: "auto" }}
141
+ exit={{ opacity: 0, width: 0 }}
142
+ transition={{ duration: 0.2 }}
143
+ className="overflow-hidden whitespace-nowrap"
144
+ >
145
+ New Chat
146
+ </motion.span>
147
+ )}
148
+ </AnimatePresence>
149
+ </button>
150
+ </div>
151
+
152
+ {/* Sessions */}
153
+ <div className="flex-1 overflow-y-auto">
154
+ {!collapsed && sessions.length > 0 && (
155
+ <p className="text-xs font-semibold text-neutral-400 uppercase tracking-wider px-4 pt-2 pb-1">
156
+ Recent
157
+ </p>
158
+ )}
159
+
160
+ {isLoadingSessions ? (
161
+ <div className="flex justify-center py-4">
162
+ <div className="w-4 h-4 rounded-full border-2 border-brand-green border-t-transparent animate-spin" />
163
+ </div>
164
+ ) : (
165
+ <div className="px-2 space-y-0.5">
166
+ {sessions.map((session) => {
167
+ const isActive = session.id === activeSessionId;
168
+ return (
169
+ <div
170
+ key={session.id}
171
+ className={`relative flex items-center gap-3 rounded-xl px-3 py-2.5 cursor-pointer transition-all duration-150 ${
172
+ isActive
173
+ ? "bg-brand-green-50 text-brand-green"
174
+ : "text-neutral-700 hover:bg-neutral-50"
175
+ }`}
176
+ onClick={() => { onSelectSession(session.id); onMobileClose(); }}
177
+ onMouseEnter={() => setHoveredSession(session.id)}
178
+ onMouseLeave={() => setHoveredSession(null)}
179
+ >
180
+ <MessageSquare
181
+ className={`h-4 w-4 flex-shrink-0 ${
182
+ isActive ? "text-brand-green" : "text-neutral-400"
183
+ }`}
184
+ />
185
+
186
+ <AnimatePresence>
187
+ {!collapsed && (
188
+ <motion.div
189
+ initial={{ opacity: 0, width: 0 }}
190
+ animate={{ opacity: 1, width: "auto" }}
191
+ exit={{ opacity: 0, width: 0 }}
192
+ transition={{ duration: 0.2 }}
193
+ className="flex-1 min-w-0 overflow-hidden"
194
+ >
195
+ <p className="text-sm font-medium truncate">{session.title}</p>
196
+ <p className="text-xs text-neutral-400 truncate">
197
+ {formatTime(session.updatedAt ?? session.createdAt)}
198
+ </p>
199
+ </motion.div>
200
+ )}
201
+ </AnimatePresence>
202
+
203
+ {!collapsed && hoveredSession === session.id && (
204
+ <button
205
+ onClick={(e) => {
206
+ e.stopPropagation();
207
+ onDeleteSession(session.id);
208
+ }}
209
+ className="absolute right-2 top-1/2 -translate-y-1/2 p-1.5 rounded-lg text-neutral-400 hover:text-red-500 hover:bg-red-50 transition-all"
210
+ aria-label="Delete session"
211
+ >
212
+ <Trash2 className="h-3.5 w-3.5" />
213
+ </button>
214
+ )}
215
+ </div>
216
+ );
217
+ })}
218
+ </div>
219
+ )}
220
+ </div>
221
+
222
+ {/* Footer */}
223
+ <div className="border-t border-neutral-100 p-3">
224
+ <div className="flex items-center gap-3 rounded-xl px-2 py-2">
225
+ <div className="h-7 w-7 rounded-full bg-gradient-to-br from-brand-green-light to-brand-green flex items-center justify-center flex-shrink-0">
226
+ <span className="text-xs font-bold text-white">
227
+ {user?.name?.[0]?.toUpperCase() ?? "U"}
228
+ </span>
229
+ </div>
230
+
231
+ <AnimatePresence>
232
+ {!collapsed && (
233
+ <motion.div
234
+ initial={{ opacity: 0, width: 0 }}
235
+ animate={{ opacity: 1, width: "auto" }}
236
+ exit={{ opacity: 0, width: 0 }}
237
+ transition={{ duration: 0.2 }}
238
+ className="flex-1 min-w-0 overflow-hidden"
239
+ >
240
+ <p className="text-sm font-semibold text-neutral-900 truncate whitespace-nowrap">
241
+ {user?.name ?? "User"}
242
+ </p>
243
+ <p className="text-xs text-neutral-400 truncate whitespace-nowrap">
244
+ {user?.email ?? ""}
245
+ </p>
246
+ </motion.div>
247
+ )}
248
+ </AnimatePresence>
249
+
250
+ {!collapsed && (
251
+ <div className="flex items-center gap-0.5">
252
+ <button
253
+ className="p-1.5 rounded-lg text-neutral-400 hover:text-brand-green hover:bg-brand-green-50 transition-all"
254
+ aria-label="Settings"
255
+ >
256
+ <Settings className="h-4 w-4" />
257
+ </button>
258
+ <button
259
+ onClick={onLogout}
260
+ className="p-1.5 rounded-lg text-neutral-400 hover:text-red-500 hover:bg-red-50 transition-all"
261
+ aria-label="Logout"
262
+ >
263
+ <LogOut className="h-4 w-4" />
264
+ </button>
265
+ </div>
266
+ )}
267
+
268
+ {collapsed && (
269
+ <button
270
+ onClick={onLogout}
271
+ className="p-1.5 rounded-lg text-neutral-400 hover:text-red-500 hover:bg-red-50 transition-all"
272
+ aria-label="Logout"
273
+ >
274
+ <LogOut className="h-4 w-4" />
275
+ </button>
276
+ )}
277
+ </div>
278
+ </div>
279
+ </motion.div>
280
+ </>
281
+ );
282
+ }
src/app/components/chat/TypingIndicator.tsx ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useEffect } from "react";
2
+ import { motion } from "motion/react";
3
+ import { Bot } from "lucide-react";
4
+
5
+ function useLoadingMessages() {
6
+ const [messages, setMessages] = useState<string[]>([]);
7
+
8
+ useEffect(() => {
9
+ fetch("/loading-messages.yaml")
10
+ .then((r) => r.text())
11
+ .then((text) => {
12
+ const parsed = text
13
+ .split("\n")
14
+ .filter((l) => l.trimStart().startsWith("- "))
15
+ .map((l) => l.replace(/^\s*- /, "").trim())
16
+ .filter(Boolean);
17
+ if (parsed.length > 0) setMessages(parsed);
18
+ })
19
+ .catch(() => {});
20
+ }, []);
21
+
22
+ return messages;
23
+ }
24
+
25
+ export default function TypingIndicator() {
26
+ const loadingMessages = useLoadingMessages();
27
+ const [phraseIndex, setPhraseIndex] = useState(0);
28
+
29
+ useEffect(() => {
30
+ if (loadingMessages.length === 0) return;
31
+ setPhraseIndex(Math.floor(Math.random() * loadingMessages.length));
32
+ const id = setInterval(() => {
33
+ setPhraseIndex((prev) => {
34
+ let next: number;
35
+ do {
36
+ next = Math.floor(Math.random() * loadingMessages.length);
37
+ } while (loadingMessages.length > 1 && next === prev);
38
+ return next;
39
+ });
40
+ }, 300);
41
+ return () => clearInterval(id);
42
+ }, [loadingMessages]);
43
+
44
+ return (
45
+ <motion.div
46
+ className="flex gap-3 px-4 py-2"
47
+ initial={{ opacity: 0, y: 8 }}
48
+ animate={{ opacity: 1, y: 0 }}
49
+ transition={{ duration: 0.25, ease: "easeOut" }}
50
+ >
51
+ <div className="w-7 h-7 rounded-full bg-gradient-to-br from-brand-green-light to-brand-green flex-shrink-0 flex items-center justify-center shadow-sm">
52
+ <Bot className="h-4 w-4 text-white" />
53
+ </div>
54
+
55
+ <div className="bg-white border border-neutral-100 rounded-2xl rounded-tl-sm shadow-sm px-4 py-3 min-w-[6rem]">
56
+ {loadingMessages.length > 0 ? (
57
+ <span className="text-sm text-neutral-400 font-medium">
58
+ {loadingMessages[phraseIndex]}…
59
+ </span>
60
+ ) : (
61
+ <div className="flex items-center gap-1.5 animate-pulse">
62
+ <span className="w-2 h-2 rounded-full bg-neutral-300" />
63
+ <span className="w-2 h-2 rounded-full bg-neutral-300" style={{ animationDelay: "150ms" }} />
64
+ <span className="w-2 h-2 rounded-full bg-neutral-300" style={{ animationDelay: "300ms" }} />
65
+ </div>
66
+ )}
67
+ </div>
68
+ </motion.div>
69
+ );
70
+ }
src/app/components/chat/VoiceMicButton.tsx ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { motion } from "motion/react";
2
+ import { Loader2, Mic, MicOff, Volume2 } from "lucide-react";
3
+ import type { VoiceState } from "../../../hooks/useVoiceSession";
4
+
5
+ interface VoiceMicButtonProps {
6
+ voiceState: VoiceState;
7
+ onToggle: () => void;
8
+ disabled?: boolean;
9
+ }
10
+
11
+ export default function VoiceMicButton({ voiceState, onToggle, disabled }: VoiceMicButtonProps) {
12
+ const isDisabled = disabled ?? false;
13
+
14
+ const stateConfig: Record<
15
+ VoiceState,
16
+ { icon: React.ReactNode; className: string; title: string; pulse: boolean; scalePulse: boolean }
17
+ > = {
18
+ IDLE: {
19
+ icon: <Mic className="h-4 w-4" />,
20
+ className: "bg-neutral-100 text-neutral-400 hover:bg-brand-green/10 hover:text-brand-green",
21
+ title: "Start voice session",
22
+ pulse: false,
23
+ scalePulse: false,
24
+ },
25
+ CONNECTING: {
26
+ icon: <Loader2 className="h-4 w-4 animate-spin" />,
27
+ className: "bg-brand-green/10 text-brand-green/60",
28
+ title: "Connecting...",
29
+ pulse: false,
30
+ scalePulse: false,
31
+ },
32
+ LISTENING: {
33
+ icon: <Mic className="h-4 w-4" />,
34
+ className: "bg-brand-green text-white shadow-md shadow-brand-green/25",
35
+ title: "Listening — click to stop",
36
+ pulse: true,
37
+ scalePulse: false,
38
+ },
39
+ PROCESSING: {
40
+ icon: <Loader2 className="h-4 w-4 animate-spin" />,
41
+ className: "bg-brand-amber text-white",
42
+ title: "Processing...",
43
+ pulse: false,
44
+ scalePulse: false,
45
+ },
46
+ SPEAKING: {
47
+ icon: <Volume2 className="h-4 w-4" />,
48
+ className: "bg-brand-cyan text-white",
49
+ title: "Agent is speaking — click to stop",
50
+ pulse: false,
51
+ scalePulse: true,
52
+ },
53
+ ERROR: {
54
+ icon: <MicOff className="h-4 w-4" />,
55
+ className: "bg-red-100 text-red-400 hover:bg-red-200",
56
+ title: "Connection failed — click to retry",
57
+ pulse: false,
58
+ scalePulse: false,
59
+ },
60
+ };
61
+
62
+ const cfg = stateConfig[voiceState];
63
+
64
+ return (
65
+ <div className="relative flex-shrink-0">
66
+ {cfg.pulse && (
67
+ <motion.span
68
+ className="absolute inset-0 rounded-xl bg-brand-green/30"
69
+ animate={{ scale: [1, 1.6], opacity: [0.6, 0] }}
70
+ transition={{ duration: 1.2, repeat: Infinity, ease: "easeOut" }}
71
+ />
72
+ )}
73
+ <motion.button
74
+ onClick={onToggle}
75
+ disabled={isDisabled}
76
+ title={cfg.title}
77
+ animate={cfg.scalePulse ? { scale: [1, 1.05, 1] } : { scale: 1 }}
78
+ transition={cfg.scalePulse ? { duration: 1.4, repeat: Infinity, ease: "easeInOut" } : {}}
79
+ className={`relative w-9 h-9 rounded-xl flex items-center justify-center transition-all duration-200 ${cfg.className} ${isDisabled ? "pointer-events-none" : ""}`}
80
+ aria-label={cfg.title}
81
+ >
82
+ {cfg.icon}
83
+ </motion.button>
84
+ </div>
85
+ );
86
+ }
src/app/components/chat/VoiceStatusBar.tsx ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { X } from "lucide-react";
2
+ import { motion } from "motion/react";
3
+ import type { VoiceState } from "../../../hooks/useVoiceSession";
4
+
5
+ interface VoiceStatusBarProps {
6
+ voiceState: VoiceState;
7
+ onStop: () => void;
8
+ }
9
+
10
+ const STATE_LABELS: Record<VoiceState, string> = {
11
+ IDLE: "",
12
+ CONNECTING: "Connecting...",
13
+ LISTENING: "Listening...",
14
+ PROCESSING: "Processing...",
15
+ SPEAKING: "Agent is speaking",
16
+ ERROR: "Connection error",
17
+ };
18
+
19
+ const STATE_COLORS: Record<VoiceState, string> = {
20
+ IDLE: "",
21
+ CONNECTING: "bg-brand-green/10 text-brand-green border-brand-green/20",
22
+ LISTENING: "bg-brand-green/10 text-brand-green border-brand-green/20",
23
+ PROCESSING: "bg-brand-amber/10 text-brand-amber border-brand-amber/20",
24
+ SPEAKING: "bg-brand-cyan/10 text-brand-cyan border-brand-cyan/20",
25
+ ERROR: "bg-red-50 text-red-500 border-red-200",
26
+ };
27
+
28
+ export default function VoiceStatusBar({ voiceState, onStop }: VoiceStatusBarProps) {
29
+ return (
30
+ <motion.div
31
+ initial={{ opacity: 0, y: 6 }}
32
+ animate={{ opacity: 1, y: 0 }}
33
+ exit={{ opacity: 0, y: 6 }}
34
+ transition={{ duration: 0.2 }}
35
+ className={`flex items-center justify-between gap-2 px-3 py-1.5 rounded-xl border text-xs font-medium ${STATE_COLORS[voiceState]}`}
36
+ >
37
+ <div className="flex items-center gap-2">
38
+ <span className="relative flex h-2 w-2">
39
+ <span
40
+ className={`animate-ping absolute inline-flex h-full w-full rounded-full opacity-75 ${
41
+ voiceState === "LISTENING"
42
+ ? "bg-brand-green"
43
+ : voiceState === "SPEAKING"
44
+ ? "bg-brand-cyan"
45
+ : voiceState === "PROCESSING"
46
+ ? "bg-brand-amber"
47
+ : "bg-neutral-400"
48
+ }`}
49
+ />
50
+ <span
51
+ className={`relative inline-flex rounded-full h-2 w-2 ${
52
+ voiceState === "LISTENING"
53
+ ? "bg-brand-green"
54
+ : voiceState === "SPEAKING"
55
+ ? "bg-brand-cyan"
56
+ : voiceState === "PROCESSING"
57
+ ? "bg-brand-amber"
58
+ : "bg-neutral-400"
59
+ }`}
60
+ />
61
+ </span>
62
+ <span>{STATE_LABELS[voiceState]}</span>
63
+ </div>
64
+ <button
65
+ onClick={onStop}
66
+ className="p-0.5 rounded hover:bg-black/10 transition-colors"
67
+ aria-label="End voice session"
68
+ >
69
+ <X className="h-3.5 w-3.5" />
70
+ </button>
71
+ </motion.div>
72
+ );
73
+ }
src/app/components/chat/renderers/MarkdownRenderer.tsx ADDED
@@ -0,0 +1,134 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import ReactMarkdown from "react-markdown";
2
+ import remarkGfm from "remark-gfm";
3
+ import remarkMath from "remark-math";
4
+ import rehypeKatex from "rehype-katex";
5
+ import type { Components } from "react-markdown";
6
+
7
+ function preprocessMarkdown(content: string): string {
8
+ let result = content.replace(/\|\|/g, "|\n|");
9
+ const lines = result.split("\n");
10
+ const processed = lines.map((line) => {
11
+ const tableStart = line.indexOf("|");
12
+ if (tableStart > 0) {
13
+ const tableContent = line.slice(tableStart);
14
+ if ((tableContent.match(/\|/g) ?? []).length >= 2) {
15
+ return line.slice(0, tableStart).trimEnd() + "\n\n" + tableContent;
16
+ }
17
+ }
18
+ return line;
19
+ });
20
+ result = processed.join("\n");
21
+ result = result.replace(/([^|\n])\n(\|)/g, "$1\n\n$2");
22
+ return result;
23
+ }
24
+
25
+ const components: Components = {
26
+ p: ({ children }) => (
27
+ <p className="text-sm text-neutral-800 leading-relaxed mb-3 last:mb-0">{children}</p>
28
+ ),
29
+ h1: ({ children }) => (
30
+ <h1 className="text-xl font-bold text-neutral-900 mt-4 mb-2 first:mt-0">{children}</h1>
31
+ ),
32
+ h2: ({ children }) => (
33
+ <h2 className="text-lg font-semibold text-neutral-800 mt-4 mb-2 first:mt-0">{children}</h2>
34
+ ),
35
+ h3: ({ children }) => (
36
+ <h3 className="text-base font-semibold text-neutral-700 mt-3 mb-1.5 first:mt-0">{children}</h3>
37
+ ),
38
+ ul: ({ children }) => (
39
+ <ul className="list-disc list-outside pl-5 mb-3 space-y-1 text-sm text-neutral-800">{children}</ul>
40
+ ),
41
+ ol: ({ children }) => (
42
+ <ol className="list-decimal list-outside pl-5 mb-3 space-y-1 text-sm text-neutral-800">{children}</ol>
43
+ ),
44
+ li: ({ children }) => <li className="leading-relaxed">{children}</li>,
45
+ code: ({ children, className }) => {
46
+ const isBlock = className?.startsWith("language-");
47
+ const language = className?.replace("language-", "") ?? "";
48
+ if (isBlock) {
49
+ return (
50
+ <div className="my-3 rounded-xl overflow-hidden border border-neutral-200">
51
+ {language && (
52
+ <div className="bg-neutral-50 border-b border-neutral-200 px-4 py-2">
53
+ <span className="text-xs font-mono text-neutral-500">{language}</span>
54
+ </div>
55
+ )}
56
+ <div className="p-4 bg-neutral-50 overflow-x-auto">
57
+ <code className="text-xs font-mono leading-relaxed text-neutral-800 whitespace-pre">
58
+ {children}
59
+ </code>
60
+ </div>
61
+ </div>
62
+ );
63
+ }
64
+ return (
65
+ <code className="px-1.5 py-0.5 rounded-md bg-neutral-100 text-brand-green font-mono text-xs">
66
+ {children}
67
+ </code>
68
+ );
69
+ },
70
+ pre: ({ children }) => <>{children}</>,
71
+ blockquote: ({ children }) => (
72
+ <blockquote className="border-l-4 border-brand-green pl-4 py-1 my-3 text-sm text-neutral-600 italic bg-brand-green-50 rounded-r-xl">
73
+ {children}
74
+ </blockquote>
75
+ ),
76
+ a: ({ children, href }) => (
77
+ <a
78
+ href={href}
79
+ target="_blank"
80
+ rel="noopener noreferrer"
81
+ className="text-brand-green underline underline-offset-2 hover:text-brand-green/80 transition-colors"
82
+ >
83
+ {children}
84
+ </a>
85
+ ),
86
+ strong: ({ children }) => <strong className="font-semibold">{children}</strong>,
87
+ hr: () => <hr className="border-neutral-200 my-3" />,
88
+ table: ({ children }) => (
89
+ <div className="my-3 overflow-x-auto rounded-xl border border-neutral-200 shadow-sm">
90
+ <table className="w-full text-sm border-collapse">{children}</table>
91
+ </div>
92
+ ),
93
+ thead: ({ children }) => (
94
+ <thead className="bg-neutral-50 border-b border-neutral-200">{children}</thead>
95
+ ),
96
+ th: ({ children }) => (
97
+ <th className="px-4 py-3 text-left text-xs font-semibold text-neutral-600 uppercase tracking-wider">
98
+ {children}
99
+ </th>
100
+ ),
101
+ td: ({ children }) => (
102
+ <td className="px-4 py-3 text-sm text-neutral-800 border-b border-neutral-100">{children}</td>
103
+ ),
104
+ tr: ({ children }) => (
105
+ <tr className="hover:bg-neutral-50 transition-colors">{children}</tr>
106
+ ),
107
+ };
108
+
109
+ interface MarkdownRendererProps {
110
+ content: string;
111
+ skipPreprocess?: boolean;
112
+ }
113
+
114
+ export default function MarkdownRenderer({ content, skipPreprocess }: MarkdownRendererProps) {
115
+ const processed = skipPreprocess ? content : preprocessMarkdown(content);
116
+ if (!skipPreprocess) {
117
+ const hasCR = content.includes("\r");
118
+ const hasDoubleNewline = content.includes("\n\n");
119
+ // console.log(
120
+ // `[MarkdownRenderer] skipPreprocess=false contentLen=${content.length} same=${content === processed} hasCR=${hasCR} hasDoubleNewline=${hasDoubleNewline}`
121
+ // );
122
+ // console.log("[MarkdownRenderer] CONTENT JSON →", JSON.stringify(content.slice(0, 600)));
123
+ }
124
+
125
+ return (
126
+ <ReactMarkdown
127
+ remarkPlugins={[remarkGfm, remarkMath]}
128
+ rehypePlugins={[rehypeKatex]}
129
+ components={components}
130
+ >
131
+ {processed}
132
+ </ReactMarkdown>
133
+ );
134
+ }
src/app/components/chat/types.ts ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { ChatSource } from "../../../services/api";
2
+
3
+ export interface Message {
4
+ id: string;
5
+ role: "user" | "assistant";
6
+ content: string;
7
+ audioText?: string;
8
+ timestamp: number;
9
+ sources?: ChatSource[];
10
+ /** PCM audio chunks from TTS — only populated when sent via voice mode */
11
+ audioChunks?: ArrayBuffer[];
12
+ audioSampleRate?: number;
13
+ }
14
+
15
+ export interface ChatSession {
16
+ id: string;
17
+ title: string;
18
+ createdAt: string;
19
+ updatedAt: string | null;
20
+ }
21
+
22
+ export interface StoredUser {
23
+ user_id: string;
24
+ email: string;
25
+ name: string;
26
+ loginTime: string;
27
+ }
src/assets/logo.png ADDED
src/assets/maintiva-logo.jpg ADDED
src/audio/AudioPlayer.ts ADDED
@@ -0,0 +1,158 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const DEFAULT_SAMPLE_RATE = 16000;
2
+
3
+ export class AudioPlayer {
4
+ private context: AudioContext | null = null;
5
+ private nextPlayTime = 0;
6
+ private started = false;
7
+ private resumed = false;
8
+ private pendingBytes = 0;
9
+ private bufferThresholdBytes = 6400; // 200ms at 16kHz default
10
+ private pendingBuffers: AudioBuffer[] = [];
11
+ // Carries the leftover byte when a chunk has odd byte length, so PCM sample
12
+ // boundaries stay aligned across chunk splits from the HTTP stream.
13
+ private leftoverByte: number | null = null;
14
+
15
+ init(sampleRate = DEFAULT_SAMPLE_RATE): void {
16
+ if (this.context) {
17
+ if (this.context.sampleRate === sampleRate) return;
18
+ this.context.close();
19
+ this.context = null;
20
+ }
21
+ this.context = new AudioContext({ sampleRate });
22
+ this.bufferThresholdBytes = Math.floor(sampleRate * 0.5) * 2; // 500ms
23
+ this.nextPlayTime = 0;
24
+ this.started = false;
25
+ this.resumed = false;
26
+ this.pendingBytes = 0;
27
+ this.pendingBuffers = [];
28
+ this.leftoverByte = null;
29
+ }
30
+
31
+ enqueue(rawPcm: ArrayBuffer, onStarted?: () => void): void {
32
+ if (!this.context) return;
33
+
34
+ // Prepend any leftover byte from the previous chunk so that Int16 sample
35
+ // boundaries are always aligned, regardless of how the HTTP stream splits.
36
+ let pcmBytes: Uint8Array;
37
+ if (this.leftoverByte !== null) {
38
+ const combined = new Uint8Array(1 + rawPcm.byteLength);
39
+ combined[0] = this.leftoverByte;
40
+ combined.set(new Uint8Array(rawPcm), 1);
41
+ pcmBytes = combined;
42
+ this.leftoverByte = null;
43
+ } else {
44
+ pcmBytes = new Uint8Array(rawPcm);
45
+ }
46
+
47
+ // If still odd, save the trailing byte for the next chunk.
48
+ if (pcmBytes.byteLength % 2 !== 0) {
49
+ this.leftoverByte = pcmBytes[pcmBytes.byteLength - 1];
50
+ pcmBytes = pcmBytes.slice(0, pcmBytes.byteLength - 1);
51
+ }
52
+
53
+ if (pcmBytes.byteLength === 0) return;
54
+
55
+ const int16 = new Int16Array(pcmBytes.buffer, pcmBytes.byteOffset, pcmBytes.byteLength / 2);
56
+ const float32 = new Float32Array(int16.length);
57
+ for (let i = 0; i < int16.length; i++) {
58
+ float32[i] = int16[i] / 32768;
59
+ }
60
+
61
+ const audioBuffer = this.context.createBuffer(1, float32.length, this.context.sampleRate);
62
+ audioBuffer.copyToChannel(float32, 0);
63
+
64
+ this.pendingBytes += rawPcm.byteLength;
65
+
66
+ if (this.resumed) {
67
+ // AudioContext is running — schedule immediately
68
+ const source = this.context.createBufferSource();
69
+ source.buffer = audioBuffer;
70
+ source.connect(this.context.destination);
71
+ source.start(this.nextPlayTime);
72
+ this.nextPlayTime += audioBuffer.duration;
73
+ } else {
74
+ // Still buffering: waiting for threshold or waiting for ctx.resume() to complete
75
+ this.pendingBuffers.push(audioBuffer);
76
+
77
+ if (!this.started && this.pendingBytes >= this.bufferThresholdBytes) {
78
+ this.started = true;
79
+ void this.context.resume().then(() => {
80
+ if (!this.context) return;
81
+ this.resumed = true;
82
+ this.nextPlayTime = this.context.currentTime;
83
+ onStarted?.();
84
+ const toSchedule = this.pendingBuffers.splice(0);
85
+ this.pendingBuffers = [];
86
+ for (const buf of toSchedule) {
87
+ const source = this.context.createBufferSource();
88
+ source.buffer = buf;
89
+ source.connect(this.context.destination);
90
+ source.start(this.nextPlayTime);
91
+ this.nextPlayTime += buf.duration;
92
+ }
93
+ });
94
+ }
95
+ }
96
+ }
97
+
98
+ // Call after the stream ends to play any buffered audio that hasn't started yet
99
+ // (handles responses shorter than the buffer threshold)
100
+ flush(onStarted?: () => void): void {
101
+ if (!this.context || this.started || this.pendingBuffers.length === 0) return;
102
+ this.started = true;
103
+ void this.context.resume().then(() => {
104
+ if (!this.context) return;
105
+ this.resumed = true;
106
+ this.nextPlayTime = this.context.currentTime;
107
+ onStarted?.();
108
+ const toSchedule = this.pendingBuffers.splice(0);
109
+ this.pendingBuffers = [];
110
+ for (const buf of toSchedule) {
111
+ const source = this.context.createBufferSource();
112
+ source.buffer = buf;
113
+ source.connect(this.context.destination);
114
+ source.start(this.nextPlayTime);
115
+ this.nextPlayTime += buf.duration;
116
+ }
117
+ });
118
+ }
119
+
120
+ drain(): void {
121
+ // Let queued buffers play out — nothing to do, the AudioContext schedule handles it
122
+ }
123
+
124
+ stopImmediately(): void {
125
+ if (!this.context) return;
126
+ this.context.close();
127
+ this.context = null;
128
+ this.nextPlayTime = 0;
129
+ this.started = false;
130
+ this.resumed = false;
131
+ this.pendingBytes = 0;
132
+ this.pendingBuffers = [];
133
+ this.leftoverByte = null;
134
+ }
135
+ }
136
+
137
+ export function replayAudio(
138
+ chunks: ArrayBuffer[],
139
+ sampleRate: number
140
+ ): () => void {
141
+ const ctx = new AudioContext({ sampleRate });
142
+ let nextTime = ctx.currentTime + 0.05;
143
+
144
+ for (const chunk of chunks) {
145
+ const int16 = new Int16Array(chunk, 0, Math.floor(chunk.byteLength / 2));
146
+ const float32 = new Float32Array(int16.length);
147
+ for (let i = 0; i < int16.length; i++) float32[i] = int16[i] / 32768;
148
+ const buf = ctx.createBuffer(1, float32.length, sampleRate);
149
+ buf.copyToChannel(float32, 0);
150
+ const src = ctx.createBufferSource();
151
+ src.buffer = buf;
152
+ src.connect(ctx.destination);
153
+ src.start(nextTime);
154
+ nextTime += buf.duration;
155
+ }
156
+
157
+ return () => ctx.close();
158
+ }
src/audio/AudioRecorder.ts ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const TARGET_SAMPLE_RATE = 16000;
2
+ const CHUNK_SAMPLES = 3200; // 100ms at 16kHz
3
+
4
+ export class AudioRecorder {
5
+ private context: AudioContext | null = null;
6
+ private stream: MediaStream | null = null;
7
+ private workletNode: AudioWorkletNode | null = null;
8
+ private accumulator: Float32Array = new Float32Array(0);
9
+ micLevel = 0;
10
+
11
+ async start(onChunk: (pcm: ArrayBuffer) => void): Promise<void> {
12
+ this.stream = await navigator.mediaDevices.getUserMedia({
13
+ audio: {
14
+ channelCount: 1,
15
+ echoCancellation: true,
16
+ noiseSuppression: true,
17
+ },
18
+ });
19
+
20
+ this.context = new AudioContext({ sampleRate: TARGET_SAMPLE_RATE });
21
+ await this.context.resume();
22
+
23
+ await this.context.audioWorklet.addModule(
24
+ new URL("./RecorderWorkletProcessor.js", import.meta.url).href
25
+ );
26
+
27
+ const source = this.context.createMediaStreamSource(this.stream);
28
+ this.workletNode = new AudioWorkletNode(this.context, "recorder-processor");
29
+
30
+ this.workletNode.port.onmessage = (e: MessageEvent<Float32Array>) => {
31
+ const input = e.data;
32
+ const combined = new Float32Array(this.accumulator.length + input.length);
33
+ combined.set(this.accumulator);
34
+ combined.set(input, this.accumulator.length);
35
+ this.accumulator = combined;
36
+
37
+ while (this.accumulator.length >= CHUNK_SAMPLES) {
38
+ const chunk = this.accumulator.slice(0, CHUNK_SAMPLES);
39
+ this.accumulator = this.accumulator.slice(CHUNK_SAMPLES);
40
+
41
+ // RMS for barge-in detection
42
+ let sumSq = 0;
43
+ for (let i = 0; i < chunk.length; i++) sumSq += chunk[i] * chunk[i];
44
+ this.micLevel = Math.sqrt(sumSq / chunk.length) * 32767;
45
+
46
+ // Convert float32 → int16 PCM
47
+ const int16 = new Int16Array(chunk.length);
48
+ for (let i = 0; i < chunk.length; i++) {
49
+ const s = Math.max(-1, Math.min(1, chunk[i]));
50
+ int16[i] = s < 0 ? s * 32768 : s * 32767;
51
+ }
52
+
53
+ onChunk(int16.buffer);
54
+ }
55
+ };
56
+
57
+ source.connect(this.workletNode);
58
+ }
59
+
60
+ stop(): void {
61
+ this.workletNode?.disconnect();
62
+ this.workletNode = null;
63
+ this.stream?.getTracks().forEach((t) => t.stop());
64
+ this.stream = null;
65
+ this.context?.close();
66
+ this.context = null;
67
+ this.accumulator = new Float32Array(0);
68
+ this.micLevel = 0;
69
+ }
70
+ }
src/audio/RecorderWorkletProcessor.js ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ class RecorderProcessor extends AudioWorkletProcessor {
2
+ process(inputs) {
3
+ const channel = inputs[0]?.[0];
4
+ if (channel?.length) {
5
+ const copy = channel.slice();
6
+ this.port.postMessage(copy, [copy.buffer]);
7
+ }
8
+ return true;
9
+ }
10
+ }
11
+
12
+ registerProcessor("recorder-processor", RecorderProcessor);
src/hooks/useVoiceSession.ts ADDED
@@ -0,0 +1,195 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useRef, useEffect, useCallback } from "react";
2
+ import { AudioRecorder } from "../audio/AudioRecorder";
3
+ import { AudioPlayer } from "../audio/AudioPlayer";
4
+ import { createWavBlob, speechToText } from "../services/voiceApi";
5
+
6
+ export type VoiceState =
7
+ | "IDLE"
8
+ | "CONNECTING"
9
+ | "LISTENING"
10
+ | "PROCESSING"
11
+ | "SPEAKING"
12
+ | "ERROR";
13
+
14
+ export interface VoiceSessionParams {
15
+ sttProvider?: string;
16
+ ttsProvider?: string;
17
+ }
18
+
19
+ interface UseVoiceSessionOptions {
20
+ onTranscript: (text: string) => void;
21
+ onError?: (code: string, message: string) => void;
22
+ sessionParams?: VoiceSessionParams;
23
+ }
24
+
25
+ export interface UseVoiceSessionReturn {
26
+ voiceState: VoiceState;
27
+ start: () => Promise<void>;
28
+ stop: () => void;
29
+ stopRecording: () => void;
30
+ setStateExternal: (s: VoiceState) => void;
31
+ isActive: boolean;
32
+ }
33
+
34
+ const BUFFER_SOUNDS = [
35
+ "/sounds/01_Baik_Saya_sedang_memproses_pertanyaanmu.wav",
36
+ "/sounds/02_Oke_mohon_ditunggu_saya_sedang_siapkan_P.wav",
37
+ "/sounds/03_Sip_saya_terima_Sedang_saya_proses_Pesan.wav",
38
+ ];
39
+
40
+ const RECORDER_SAMPLE_RATE = 16000;
41
+
42
+ function getVoiceHttpBaseUrl(): string {
43
+ const w = window as unknown as { __APP_CONFIG__?: { VOICE_API_URL?: string } };
44
+ return (
45
+ w.__APP_CONFIG__?.VOICE_API_URL ||
46
+ (import.meta as unknown as { env: Record<string, string> }).env.VITE_API_BASE_VOICE_URL ||
47
+ "http://localhost:7861"
48
+ );
49
+ }
50
+
51
+ export function useVoiceSession(opts: UseVoiceSessionOptions): UseVoiceSessionReturn {
52
+ const [voiceState, setVoiceState] = useState<VoiceState>("IDLE");
53
+ const stateRef = useRef<VoiceState>("IDLE");
54
+
55
+ const recorderRef = useRef<AudioRecorder | null>(null);
56
+ const playerRef = useRef<AudioPlayer | null>(null);
57
+ const chunksRef = useRef<ArrayBuffer[]>([]);
58
+
59
+ const bufferAudioRef = useRef<HTMLAudioElement | null>(null);
60
+ const lastBufferIndexRef = useRef<number>(-1);
61
+
62
+ const optsRef = useRef(opts);
63
+ useEffect(() => { optsRef.current = opts; });
64
+
65
+ // Defined before setState so setState can call it without circular deps.
66
+ const stopBufferSound = useCallback(() => {
67
+ if (bufferAudioRef.current) {
68
+ bufferAudioRef.current.pause();
69
+ bufferAudioRef.current.currentTime = 0;
70
+ bufferAudioRef.current = null;
71
+ }
72
+ }, []);
73
+
74
+ // Auto-stops the buffer audio when the waiting phase ends (TTS about to start, or session ends).
75
+ const setState = useCallback((s: VoiceState) => {
76
+ if (s === "SPEAKING" || s === "IDLE" || s === "ERROR") {
77
+ stopBufferSound();
78
+ }
79
+ stateRef.current = s;
80
+ setVoiceState(s);
81
+ }, [stopBufferSound]);
82
+
83
+ const playBufferSound = useCallback(() => {
84
+ stopBufferSound();
85
+ let idx: number;
86
+ do {
87
+ idx = Math.floor(Math.random() * BUFFER_SOUNDS.length);
88
+ } while (BUFFER_SOUNDS.length > 1 && idx === lastBufferIndexRef.current);
89
+ lastBufferIndexRef.current = idx;
90
+ const audio = new Audio(BUFFER_SOUNDS[idx]);
91
+ bufferAudioRef.current = audio;
92
+ audio.play().catch(() => {});
93
+ }, [stopBufferSound]);
94
+
95
+ const stopSession = useCallback(() => {
96
+ recorderRef.current?.stop();
97
+ playerRef.current?.stopImmediately();
98
+ chunksRef.current = [];
99
+ setState("IDLE"); // setState("IDLE") calls stopBufferSound internally
100
+ }, [setState]);
101
+
102
+ const stopRecording = useCallback(() => {
103
+ if (stateRef.current !== "LISTENING") return;
104
+ setState("PROCESSING");
105
+ recorderRef.current?.stop();
106
+ // Play buffer audio — it keeps playing through STT and chatbot processing.
107
+ // It stops automatically when setState("SPEAKING"), setState("IDLE"), or setState("ERROR") is called.
108
+ playBufferSound();
109
+
110
+ const chunks = chunksRef.current;
111
+ chunksRef.current = [];
112
+
113
+ void (async () => {
114
+ try {
115
+ if (chunks.length === 0) {
116
+ setState("IDLE");
117
+ return;
118
+ }
119
+
120
+ const wav = createWavBlob(chunks, RECORDER_SAMPLE_RATE);
121
+ const { text } = await speechToText(wav, optsRef.current.sessionParams?.sttProvider ?? "chirp3");
122
+
123
+ // Guard: session may have been cancelled while STT was in flight.
124
+ if (stateRef.current !== "PROCESSING") return;
125
+
126
+ if (text.trim()) {
127
+ console.log("[Voice] STT transcript →", text);
128
+ // Buffer audio continues to play while Main.tsx calls the chatbot API.
129
+ // It will stop when setStateExternal("SPEAKING") or setStateExternal("IDLE") is called.
130
+ optsRef.current.onTranscript(text);
131
+ } else {
132
+ setState("IDLE");
133
+ }
134
+ } catch (err) {
135
+ console.error("[STT] Request failed:", err);
136
+ optsRef.current.onError?.("STT_ERROR", (err as Error).message);
137
+ setState("ERROR"); // setState("ERROR") calls stopBufferSound internally
138
+ }
139
+ })();
140
+ }, [playBufferSound, setState]);
141
+
142
+ const start = useCallback(async () => {
143
+ if (stateRef.current !== "IDLE" && stateRef.current !== "ERROR") return;
144
+ setState("CONNECTING");
145
+
146
+ try {
147
+ const res = await fetch(`${getVoiceHttpBaseUrl()}/health`);
148
+ if (res.ok) {
149
+ const data: { status: string; message?: string } = await res.json();
150
+ if (data.status !== "ok") {
151
+ setState("ERROR");
152
+ optsRef.current.onError?.("HEALTH_CHECK_FAILED", data.message ?? "Service not ready");
153
+ return;
154
+ }
155
+ }
156
+ } catch {
157
+ // Network error or CORS — proceed with connect attempt
158
+ }
159
+
160
+ try {
161
+ chunksRef.current = [];
162
+ if (!recorderRef.current) recorderRef.current = new AudioRecorder();
163
+ if (!playerRef.current) playerRef.current = new AudioPlayer();
164
+
165
+ await recorderRef.current.start((chunk: ArrayBuffer) => {
166
+ if (stateRef.current === "LISTENING") {
167
+ chunksRef.current.push(chunk);
168
+ }
169
+ });
170
+
171
+ setState("LISTENING");
172
+ } catch (err) {
173
+ console.error("[Voice] Failed to start recorder:", err);
174
+ recorderRef.current?.stop();
175
+ optsRef.current.onError?.("MIC_ERROR", (err as Error).message ?? "Failed to access microphone");
176
+ setState("ERROR");
177
+ }
178
+ }, [setState]);
179
+
180
+ useEffect(() => {
181
+ return () => {
182
+ stopSession();
183
+ };
184
+ // eslint-disable-next-line react-hooks/exhaustive-deps
185
+ }, []);
186
+
187
+ return {
188
+ voiceState,
189
+ start,
190
+ stop: stopSession,
191
+ stopRecording,
192
+ setStateExternal: setState,
193
+ isActive: voiceState !== "IDLE" && voiceState !== "ERROR",
194
+ };
195
+ }
src/services/api.ts CHANGED
@@ -40,6 +40,7 @@ export interface RoomMessage {
40
  id: string;
41
  role: "user" | "assistant";
42
  content: string;
 
43
  created_at: string;
44
  sources?: ChatSource[];
45
  }
@@ -48,7 +49,7 @@ export interface RoomDetail extends Room {
48
  messages: RoomMessage[];
49
  }
50
 
51
- export type DocumentStatus = "pending" | "processing" | "completed" | "failed";
52
 
53
  export interface ApiDocument {
54
  id: string;
@@ -65,6 +66,13 @@ export interface UploadDocumentResponse {
65
  data: { id: string; filename: string; status: DocumentStatus };
66
  }
67
 
 
 
 
 
 
 
 
68
  // ─── Base Client ──────────────────────────────────────────────────────────────
69
 
70
  const BASE_URL = ((import.meta as unknown as { env: Record<string, string> }).env.VITE_API_BASE_URL) ?? "";
@@ -151,6 +159,87 @@ export const deleteDocument = (userId: string, documentId: string) =>
151
  { method: "DELETE" }
152
  );
153
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
154
  // ─── Chat ─────────────────────────────────────────────────────────────────────
155
 
156
  export const streamChat = (
 
40
  id: string;
41
  role: "user" | "assistant";
42
  content: string;
43
+ audio_text?: string | null;
44
  created_at: string;
45
  sources?: ChatSource[];
46
  }
 
49
  messages: RoomMessage[];
50
  }
51
 
52
+ export type DocumentStatus = "uploaded" | "processing" | "completed" | "failed";
53
 
54
  export interface ApiDocument {
55
  id: string;
 
66
  data: { id: string; filename: string; status: DocumentStatus };
67
  }
68
 
69
+ export interface DocTypeInfo {
70
+ doc_type: string;
71
+ max_size: number;
72
+ status: "active" | "inactive";
73
+ message: string | null;
74
+ }
75
+
76
  // ─── Base Client ──────────────────────────────────────────────────────────────
77
 
78
  const BASE_URL = ((import.meta as unknown as { env: Record<string, string> }).env.VITE_API_BASE_URL) ?? "";
 
159
  { method: "DELETE" }
160
  );
161
 
162
+ export const getDocumentTypes = (): Promise<DocTypeInfo[]> =>
163
+ request<{ status: string; data: DocTypeInfo[] }>("/api/v1/documents/doctypes").then(
164
+ (res) => res.data
165
+ );
166
+
167
+ // ─── Database Clients ─────────────────────────────────────────────────────────
168
+
169
+ export type DbType = "postgres" | "mysql" | "sqlserver" | "supabase" | "bigquery" | "snowflake";
170
+
171
+ export interface DbTypeField {
172
+ name: string;
173
+ type: "string" | "integer" | "select" | "boolean";
174
+ required: boolean;
175
+ default: string | number | boolean | null;
176
+ description: string;
177
+ options?: string[];
178
+ sensitive?: boolean;
179
+ }
180
+
181
+ export interface DbTypeInfo {
182
+ db_type: DbType;
183
+ display_name: string;
184
+ logo: string;
185
+ status: "active" | "inactive";
186
+ message: string | null;
187
+ fields: DbTypeField[];
188
+ }
189
+
190
+ export interface DatabaseClient {
191
+ id: string;
192
+ user_id: string;
193
+ name: string;
194
+ db_type: DbType;
195
+ status: "active" | "inactive";
196
+ created_at: string;
197
+ updated_at: string | null;
198
+ }
199
+
200
+ export interface IngestResponse {
201
+ status: string;
202
+ client_id: string;
203
+ chunks_ingested: number;
204
+ }
205
+
206
+ export const getDatabaseClientTypes = (): Promise<DbTypeInfo[]> =>
207
+ request<DbTypeInfo[]>("/api/v1/database-clients/dbtypes");
208
+
209
+ export const connectDatabase = (
210
+ userId: string,
211
+ dbType: DbType,
212
+ name: string,
213
+ credentials: Record<string, string | number | boolean>
214
+ ): Promise<DatabaseClient> =>
215
+ request<DatabaseClient>(`/api/v1/database-clients?user_id=${userId}`, {
216
+ method: "POST",
217
+ body: JSON.stringify({ name, db_type: dbType, credentials }),
218
+ });
219
+
220
+ export const getDatabaseClients = (userId: string): Promise<DatabaseClient[]> =>
221
+ request<DatabaseClient[]>(`/api/v1/database-clients/${userId}`);
222
+
223
+ export const deleteDatabaseClient = (clientId: string, userId: string) =>
224
+ request<{ status: string; message: string }>(
225
+ `/api/v1/database-clients/${clientId}?user_id=${userId}`,
226
+ { method: "DELETE" }
227
+ );
228
+
229
+ export const ingestDatabaseClient = (clientId: string, userId: string): Promise<IngestResponse> =>
230
+ request<IngestResponse>(
231
+ `/api/v1/database-clients/${clientId}/ingest?user_id=${userId}`,
232
+ { method: "POST" }
233
+ );
234
+
235
+ // ─── Knowledge (Admin) ────────────────────────────────────────────────────────
236
+
237
+ export const rebuildKnowledge = (userId: string) =>
238
+ request<{ status: string; message: string }>(
239
+ `/api/v1/knowledge/rebuild?user_id=${userId}`,
240
+ { method: "POST" }
241
+ );
242
+
243
  // ─── Chat ─────────────────────────────────────────────────────────────────────
244
 
245
  export const streamChat = (
src/services/voiceApi.ts ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ function getVoiceBaseUrl(): string {
2
+ const w = window as unknown as { __APP_CONFIG__?: { VOICE_API_URL?: string } };
3
+ return (
4
+ w.__APP_CONFIG__?.VOICE_API_URL ||
5
+ (import.meta as unknown as { env: Record<string, string> }).env.VITE_API_BASE_VOICE_URL ||
6
+ "http://localhost:7861"
7
+ );
8
+ }
9
+ const VOICE_BASE_URL = getVoiceBaseUrl();
10
+
11
+ function writeString(view: DataView, offset: number, str: string): void {
12
+ for (let i = 0; i < str.length; i++) view.setUint8(offset + i, str.charCodeAt(i));
13
+ }
14
+
15
+ export function createWavBlob(chunks: ArrayBuffer[], sampleRate: number): Blob {
16
+ const pcmByteLength = chunks.reduce((sum, c) => sum + c.byteLength, 0);
17
+ const buffer = new ArrayBuffer(44 + pcmByteLength);
18
+ const view = new DataView(buffer);
19
+ writeString(view, 0, "RIFF");
20
+ view.setUint32(4, 36 + pcmByteLength, true);
21
+ writeString(view, 8, "WAVE");
22
+ writeString(view, 12, "fmt ");
23
+ view.setUint32(16, 16, true);
24
+ view.setUint16(20, 1, true); // PCM
25
+ view.setUint16(22, 1, true); // mono
26
+ view.setUint32(24, sampleRate, true);
27
+ view.setUint32(28, sampleRate * 2, true);
28
+ view.setUint16(32, 2, true);
29
+ view.setUint16(34, 16, true);
30
+ writeString(view, 36, "data");
31
+ view.setUint32(40, pcmByteLength, true);
32
+ let offset = 44;
33
+ for (const chunk of chunks) {
34
+ new Uint8Array(buffer, offset, chunk.byteLength).set(new Uint8Array(chunk));
35
+ offset += chunk.byteLength;
36
+ }
37
+ return new Blob([buffer], { type: "audio/wav" });
38
+ }
39
+
40
+ export async function speechToText(
41
+ wavBlob: Blob,
42
+ provider = "chirp3"
43
+ ): Promise<{ text: string; language: string; duration: number | null }> {
44
+ const form = new FormData();
45
+ form.append("audio", wavBlob, "recording.wav");
46
+ form.append("provider", provider);
47
+ const res = await fetch(`${VOICE_BASE_URL}/stt`, { method: "POST", body: form });
48
+ if (!res.ok) throw new Error(`STT error: ${res.status}`);
49
+ const contentType = res.headers.get("content-type") ?? "";
50
+ if (!contentType.includes("application/json")) {
51
+ const body = await res.text();
52
+ throw new Error(`STT returned non-JSON (${res.status}): ${body.slice(0, 200)}`);
53
+ }
54
+ return res.json();
55
+ }
56
+
57
+ export async function textToSpeechStreaming(
58
+ text: string,
59
+ provider = "gemini"
60
+ ): Promise<{ sampleRate: number; stream: ReadableStream<Uint8Array> }> {
61
+ const abort = new AbortController();
62
+ const timer = setTimeout(() => abort.abort(), 120_000);
63
+ const res = await fetch(`${VOICE_BASE_URL}/tts`, {
64
+ method: "POST",
65
+ headers: { "Content-Type": "application/json" },
66
+ body: JSON.stringify({ text, provider }),
67
+ signal: abort.signal,
68
+ }).finally(() => clearTimeout(timer));
69
+ if (!res.ok) throw new Error(`TTS error: ${res.status}`);
70
+ if (!res.body) throw new Error("TTS response has no body");
71
+ const sampleRate = parseInt(res.headers.get("X-Sample-Rate") ?? "24000", 10);
72
+ return { sampleRate, stream: res.body };
73
+ }
74
+
75
+ export async function textToSpeech(
76
+ text: string,
77
+ provider = "gemini"
78
+ ): Promise<{ pcm: ArrayBuffer; sampleRate: number }> {
79
+ const abort = new AbortController();
80
+ const timer = setTimeout(() => abort.abort(), 90_000);
81
+ const response = await fetch(`${VOICE_BASE_URL}/tts`, {
82
+ method: "POST",
83
+ headers: { "Content-Type": "application/json" },
84
+ body: JSON.stringify({ text, provider }),
85
+ signal: abort.signal,
86
+ }).finally(() => clearTimeout(timer));
87
+ if (!response.ok) throw new Error(`TTS error: ${response.status}`);
88
+ const sampleRate = parseInt(
89
+ response.headers.get("X-Sample-Rate") ?? "24000",
90
+ 10
91
+ );
92
+ const pcm = await response.arrayBuffer();
93
+ return { pcm, sampleRate };
94
+ }
src/styles/theme.css CHANGED
@@ -2,6 +2,8 @@
2
 
3
  :root {
4
  --font-size: 16px;
 
 
5
  --background: #ffffff;
6
  --foreground: oklch(0.145 0 0);
7
  --card: #ffffff;
@@ -117,6 +119,12 @@
117
  --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
118
  --color-sidebar-border: var(--sidebar-border);
119
  --color-sidebar-ring: var(--sidebar-ring);
 
 
 
 
 
 
120
  }
121
 
122
  @layer base {
@@ -126,8 +134,16 @@
126
 
127
  body {
128
  @apply bg-background text-foreground;
 
 
 
129
  }
130
 
 
 
 
 
 
131
  /**
132
  * Default typography styles for HTML elements (h1-h4, p, label, button, input).
133
  * These are in @layer base, so Tailwind utility classes (like text-sm, text-lg) automatically override them.
@@ -135,6 +151,7 @@
135
 
136
  html {
137
  font-size: var(--font-size);
 
138
  }
139
 
140
  h1 {
@@ -179,3 +196,21 @@
179
  line-height: 1.5;
180
  }
181
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
 
3
  :root {
4
  --font-size: 16px;
5
+ --brand-green: #18AF4A;
6
+ --brand-green-light: #1EC75A;
7
  --background: #ffffff;
8
  --foreground: oklch(0.145 0 0);
9
  --card: #ffffff;
 
119
  --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
120
  --color-sidebar-border: var(--sidebar-border);
121
  --color-sidebar-ring: var(--sidebar-ring);
122
+ --color-brand-green: var(--brand-green);
123
+ --color-brand-green-light: var(--brand-green-light);
124
+ --color-brand-green-50: #F0FDF5;
125
+ --color-brand-green-100: #BBF7D0;
126
+ --color-brand-amber: #F5A623;
127
+ --color-brand-cyan: #48C8E8;
128
  }
129
 
130
  @layer base {
 
134
 
135
  body {
136
  @apply bg-background text-foreground;
137
+ font-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
138
+ -webkit-font-smoothing: antialiased;
139
+ -moz-osx-font-smoothing: grayscale;
140
  }
141
 
142
+ ::-webkit-scrollbar { width: 6px; height: 6px; }
143
+ ::-webkit-scrollbar-track { background: transparent; }
144
+ ::-webkit-scrollbar-thumb { background: #D1D5DB; border-radius: 3px; }
145
+ ::-webkit-scrollbar-thumb:hover { background: #9CA3AF; }
146
+
147
  /**
148
  * Default typography styles for HTML elements (h1-h4, p, label, button, input).
149
  * These are in @layer base, so Tailwind utility classes (like text-sm, text-lg) automatically override them.
 
151
 
152
  html {
153
  font-size: var(--font-size);
154
+ scroll-behavior: smooth;
155
  }
156
 
157
  h1 {
 
196
  line-height: 1.5;
197
  }
198
  }
199
+
200
+ @keyframes float {
201
+ 0%, 100% { transform: translateY(0px) rotate(var(--float-rotate, 0deg)); }
202
+ 50% { transform: translateY(-14px) rotate(var(--float-rotate, 0deg)); }
203
+ }
204
+
205
+ @keyframes float-slow {
206
+ 0%, 100% { transform: translateY(0px); }
207
+ 50% { transform: translateY(-8px); }
208
+ }
209
+
210
+ .animate-float {
211
+ animation: float 7s ease-in-out infinite;
212
+ }
213
+
214
+ .animate-float-slow {
215
+ animation: float-slow 10s ease-in-out infinite;
216
+ }
src/vite-env.d.ts ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /// <reference types="vite/client" />
2
+
3
+ declare module "*.jpg" {
4
+ const src: string;
5
+ export default src;
6
+ }
7
+
8
+ declare module "*.jpeg" {
9
+ const src: string;
10
+ export default src;
11
+ }
12
+
13
+ declare module "*.png" {
14
+ const src: string;
15
+ export default src;
16
+ }
17
+
18
+ declare module "*.svg" {
19
+ const src: string;
20
+ export default src;
21
+ }
tsconfig.json ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "useDefineForClassFields": true,
5
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
6
+ "module": "ESNext",
7
+ "skipLibCheck": true,
8
+ "moduleResolution": "bundler",
9
+ "allowImportingTsExtensions": true,
10
+ "resolveJsonModule": true,
11
+ "isolatedModules": true,
12
+ "noEmit": true,
13
+ "jsx": "react-jsx",
14
+ "strict": true,
15
+ "noUnusedLocals": false,
16
+ "noUnusedParameters": false,
17
+ "noFallthroughCasesInSwitch": true,
18
+ "baseUrl": ".",
19
+ "paths": {
20
+ "@/*": ["./src/*"]
21
+ }
22
+ },
23
+ "include": ["src"]
24
+ }