added: grade, subject and chapter in new chat, fixed that shitty card bug

This commit is contained in:
2026-08-18 04:44:10 -04:00
parent ab88ae30f1
commit 93a35a7343
24 changed files with 2683 additions and 66 deletions
+8
View File
@@ -0,0 +1,8 @@
{
"mcpServers": {
"chrome-devtools": {
"command": "npx",
"args": ["-y", "chrome-devtools-mcp@latest", "--executablePath", "/usr/bin/brave"]
}
}
}
+18
View File
@@ -0,0 +1,18 @@
# AI Provider: "openai" | "anthropic" | "google"
AI_PROVIDER=openai
# OpenAI
OPENAI_API_KEY=sk-your-key-here
OPENAI_MODEL=gpt-4o
# Anthropic (optional)
ANTHROPIC_API_KEY=sk-ant-your-key-here
ANTHROPIC_MODEL=claude-sonnet-4-20250514
# Google (optional)
GOOGLE_API_KEY=your-google-api-key-here
GOOGLE_MODEL=gemini-2.0-flash
# Server
PORT=3001
CORS_ORIGIN=http://localhost:5173
+2
View File
@@ -0,0 +1,2 @@
node_modules/
.env
+927
View File
@@ -0,0 +1,927 @@
{
"name": "padhle-backend",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "padhle-backend",
"version": "0.1.0",
"dependencies": {
"cors": "^2.8.5",
"dotenv": "^16.6.0",
"express": "^5.1.0",
"openai": "^5.11.0"
}
},
"node_modules/accepts": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
"integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
"license": "MIT",
"dependencies": {
"mime-types": "^3.0.0",
"negotiator": "^1.0.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/body-parser": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
"integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==",
"license": "MIT",
"dependencies": {
"bytes": "^3.1.2",
"content-type": "^2.0.0",
"debug": "^4.4.3",
"http-errors": "^2.0.1",
"iconv-lite": "^0.7.2",
"on-finished": "^2.4.1",
"qs": "^6.15.2",
"raw-body": "^3.0.2",
"type-is": "^2.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/body-parser/node_modules/content-type": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz",
"integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/call-bound": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"get-intrinsic": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/content-disposition": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
"integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/content-type": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie-signature": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
"integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
"license": "MIT",
"engines": {
"node": ">=6.6.0"
}
},
"node_modules/cors": {
"version": "2.8.6",
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
"integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==",
"license": "MIT",
"dependencies": {
"object-assign": "^4",
"vary": "^1"
},
"engines": {
"node": ">= 0.10"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/dotenv": {
"version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
"integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
"license": "MIT"
},
"node_modules/encodeurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"license": "MIT"
},
"node_modules/etag": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/express": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"license": "MIT",
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.1",
"content-disposition": "^1.0.0",
"content-type": "^1.0.5",
"cookie": "^0.7.1",
"cookie-signature": "^1.2.1",
"debug": "^4.4.0",
"depd": "^2.0.0",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"etag": "^1.8.1",
"finalhandler": "^2.1.0",
"fresh": "^2.0.0",
"http-errors": "^2.0.0",
"merge-descriptors": "^2.0.0",
"mime-types": "^3.0.0",
"on-finished": "^2.4.1",
"once": "^1.4.0",
"parseurl": "^1.3.3",
"proxy-addr": "^2.0.7",
"qs": "^6.14.0",
"range-parser": "^1.2.1",
"router": "^2.2.0",
"send": "^1.1.0",
"serve-static": "^2.2.0",
"statuses": "^2.0.1",
"type-is": "^2.0.1",
"vary": "^1.1.2"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/finalhandler": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
"integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==",
"license": "MIT",
"dependencies": {
"debug": "^4.4.0",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"on-finished": "^2.4.1",
"parseurl": "^1.3.3",
"statuses": "^2.0.1"
},
"engines": {
"node": ">= 18.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/fresh": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
"integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
"license": "MIT",
"dependencies": {
"depd": "~2.0.0",
"inherits": "~2.0.4",
"setprototypeof": "~1.2.0",
"statuses": "~2.0.2",
"toidentifier": "~1.0.1"
},
"engines": {
"node": ">= 0.8"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/iconv-lite": {
"version": "0.7.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
"integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
"license": "MIT",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/is-promise": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
"license": "MIT"
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/media-typer": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz",
"integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/merge-descriptors": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
"integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/mime-db": {
"version": "1.54.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
"integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
"integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
"license": "MIT",
"dependencies": {
"mime-db": "^1.54.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/negotiator": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
"integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
"license": "MIT",
"dependencies": {
"ee-first": "1.1.1"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"license": "ISC",
"dependencies": {
"wrappy": "1"
}
},
"node_modules/openai": {
"version": "5.23.2",
"resolved": "https://registry.npmjs.org/openai/-/openai-5.23.2.tgz",
"integrity": "sha512-MQBzmTulj+MM5O8SKEk/gL8a7s5mktS9zUtAkU257WjvobGc9nKcBuVwjyEEcb9SI8a8Y2G/mzn3vm9n1Jlleg==",
"license": "Apache-2.0",
"bin": {
"openai": "bin/cli"
},
"peerDependencies": {
"ws": "^8.18.0",
"zod": "^3.23.8"
},
"peerDependenciesMeta": {
"ws": {
"optional": true
},
"zod": {
"optional": true
}
}
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/path-to-regexp": {
"version": "8.4.2",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
"integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
"license": "MIT",
"dependencies": {
"forwarded": "0.2.0",
"ipaddr.js": "1.9.1"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/qs": {
"version": "6.15.3",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
"integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
"license": "BSD-3-Clause",
"dependencies": {
"es-define-property": "^1.0.1",
"side-channel": "^1.1.1"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/range-parser": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz",
"integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/raw-body": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
"integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==",
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
"http-errors": "~2.0.1",
"iconv-lite": "~0.7.0",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/router": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
"integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
"license": "MIT",
"dependencies": {
"debug": "^4.4.0",
"depd": "^2.0.0",
"is-promise": "^4.0.0",
"parseurl": "^1.3.3",
"path-to-regexp": "^8.0.0"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/send": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
"integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==",
"license": "MIT",
"dependencies": {
"debug": "^4.4.3",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"etag": "^1.8.1",
"fresh": "^2.0.0",
"http-errors": "^2.0.1",
"mime-types": "^3.0.2",
"ms": "^2.1.3",
"on-finished": "^2.4.1",
"range-parser": "^1.2.1",
"statuses": "^2.0.2"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/serve-static": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz",
"integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==",
"license": "MIT",
"dependencies": {
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"parseurl": "^1.3.3",
"send": "^1.2.0"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC"
},
"node_modules/side-channel": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
"integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.4",
"side-channel-list": "^1.0.1",
"side-channel-map": "^1.0.1",
"side-channel-weakmap": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-list": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.4"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-map": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-weakmap": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3",
"side-channel-map": "^1.0.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
"license": "MIT",
"engines": {
"node": ">=0.6"
}
},
"node_modules/type-is": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz",
"integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==",
"license": "MIT",
"dependencies": {
"content-type": "^2.0.0",
"media-typer": "^1.1.0",
"mime-types": "^3.0.0"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/type-is/node_modules/content-type": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz",
"integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
}
}
}
+16
View File
@@ -0,0 +1,16 @@
{
"name": "padhle-backend",
"version": "0.1.0",
"type": "module",
"description": "AI-powered educational chat backend for padhle",
"scripts": {
"dev": "node --watch src/index.js",
"start": "node src/index.js"
},
"dependencies": {
"cors": "^2.8.5",
"express": "^5.1.0",
"openai": "^5.11.0",
"dotenv": "^16.6.0"
}
}
+35
View File
@@ -0,0 +1,35 @@
import dotenv from "dotenv";
import cors from "cors";
import express from "express";
import chatRoutes from "./routes/chat.js";
import sessionRoutes from "./routes/sessions.js";
dotenv.config();
const app = express();
const PORT = process.env.PORT || 3001;
const CORS_ORIGIN = process.env.CORS_ORIGIN || "http://localhost:5173";
// Middleware
app.use(cors({ origin: CORS_ORIGIN, credentials: true }));
app.use(express.json());
// Health check
app.get("/health", (_req, res) => {
res.json({ status: "ok", provider: process.env.AI_PROVIDER });
});
// Routes
app.use("/api/chat", chatRoutes);
app.use("/api/sessions", sessionRoutes);
// 404 handler
app.use((_req, res) => {
res.status(404).json({ error: "Endpoint not found" });
});
app.listen(PORT, () => {
console.log(`\n 📚 padhle backend running on http://localhost:${PORT}`);
console.log(` Provider: ${process.env.AI_PROVIDER || "openai"}`);
console.log(` Frontend: ${CORS_ORIGIN}\n`);
});
+132
View File
@@ -0,0 +1,132 @@
import express from "express";
import { streamChatResponse } from "../services/ai.js";
import { createSession, addMessage, getMessages, getSession, listSessions } from "../stores/sessionStore.js";
const router = express.Router();
/**
* POST /api/chat — Send a message and stream back an AI response.
*
* Request body:
* {
* text: "What is photosynthesis?",
* chatId: "optional_existing_session_id",
* grade: "Grade 10",
* subject: "Biology",
* chapter: "Nutrition in Plants"
* }
*/
router.post("/", async (req, res) => {
const { text, chatId, grade, subject, chapter } = req.body;
// Validate input
if (!text || !text.trim()) {
return res.status(400).json({ error: "Message text is required" });
}
// Resolve or create session
let session;
if (chatId) {
session = getSession(chatId);
if (!session) {
return res.status(404).json({ error: "Session not found" });
}
} else {
session = createSession(grade, subject, chapter);
}
const currentChatId = session.id;
// Get conversation history
const chatMessages = getMessages(currentChatId);
// Add user message to history
const userMsg = {
id: `user_${Date.now()}`,
role: "user",
text: text.trim(),
timestamp: new Date().toISOString(),
};
addMessage(currentChatId, userMsg);
// Set SSE headers
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
res.setHeader("X-Accel-Buffering", "no");
let assistantText = "";
let errorOccurred = false;
// Stream the AI response
try {
await streamChatResponse(
chatMessages,
grade || session.grade,
subject || session.subject,
chapter || session.chapter,
(chunk) => {
assistantText += chunk;
// Send chunk as SSE event
res.write(`data: ${JSON.stringify({ type: "chunk", content: chunk })}\n\n`);
},
(err) => {
errorOccurred = true;
res.write(`data: ${JSON.stringify({ type: "error", message: err })}\n\n`);
res.end();
}
);
// Send end event
res.write(`data: ${JSON.stringify({ type: "done" })}\n\n`);
// Save assistant message to history
const assistantMsg = {
id: `assistant_${Date.now()}`,
role: "assistant",
text: assistantText,
timestamp: new Date().toISOString(),
};
addMessage(currentChatId, assistantMsg);
} catch (err) {
res.write(`data: ${JSON.stringify({ type: "error", message: err.message })}\n\n`);
} finally {
res.end();
}
});
/**
* GET /api/chat/:chatId — Get conversation messages for a session
*/
router.get("/:chatId", (req, res) => {
const { chatId } = req.params;
const session = getSession(chatId);
if (!session) {
return res.status(404).json({ error: "Session not found" });
}
const msgHistory = getMessages(chatId);
res.json({
session: {
id: session.id,
grade: session.grade,
subject: session.subject,
chapter: session.chapter,
createdAt: session.createdAt,
updatedAt: session.updatedAt,
},
messages: msgHistory,
});
});
/**
* GET /api/chat/sessions — List all chat sessions
*/
router.get("/sessions", (_req, res) => {
const sessions = listSessions();
res.json(sessions);
});
export default router;
+58
View File
@@ -0,0 +1,58 @@
import express from "express";
import { listSessions, getSession, deleteSession, clearMessages, createSession } from "../stores/sessionStore.js";
const router = express.Router();
/**
* GET /api/sessions — List all sessions
*/
router.get("/", (_req, res) => {
const sessions = listSessions();
res.json(sessions);
});
/**
* POST /api/sessions — Create a new session
*/
router.post("/", (req, res) => {
const { grade, subject, chapter } = req.body;
const session = createSession(grade, subject, chapter);
res.status(201).json(session);
});
/**
* GET /api/sessions/:id — Get a single session
*/
router.get("/:id", (req, res) => {
const session = getSession(req.params.id);
if (!session) {
return res.status(404).json({ error: "Session not found" });
}
res.json(session);
});
/**
* DELETE /api/sessions/:id — Delete a session
*/
router.delete("/:id", (req, res) => {
const deleted = deleteSession(req.params.id);
if (!deleted) {
return res.status(404).json({ error: "Session not found" });
}
res.json({ message: "Session deleted", id: req.params.id });
});
/**
* PATCH /api/sessions/:id/clear — Clear messages but keep session
*/
router.patch("/:id/clear", (req, res) => {
const { id } = req.params;
const session = getSession(id);
if (!session) {
return res.status(404).json({ error: "Session not found" });
}
clearMessages(id);
res.json({ message: "Messages cleared", id });
});
export default router;
+295
View File
@@ -0,0 +1,295 @@
import OpenAI from "openai";
import dotenv from "dotenv";
dotenv.config();
const provider = process.env.AI_PROVIDER || "openai";
// --- OpenAI client ---
const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY,
});
// --- Anthropic (via OpenAI SDK compatible endpoint or raw) ---
const ANTHROPIC_BASE_URL = "https://api.anthropic.com";
// --- Google (via OpenAI SDK compatible endpoint) ---
const GOOGLE_BASE_URL = "https://generativelanguage.googleapis.com/v1beta/openai/";
/**
* Build the system prompt based on grade/subject/chapter context.
*/
function buildSystemPrompt(grade, subject, chapter) {
const context = [];
if (grade && grade !== "General") context.push(`You are teaching students in grade ${grade}.`);
if (subject && subject !== "General") context.push(`The subject is ${subject}.`);
if (chapter && chapter !== "General") context.push(`The relevant chapter is ${chapter}.`);
context.push(
"You are padhle, an AI tutor designed to help students learn.",
"Be clear, patient, and encouraging. Use simple language appropriate for the student's grade level.",
"When explaining concepts, use examples, analogies, and step-by-step breakdowns.",
"If a question is outside the selected subject/chapter, politely redirect to the topic or answer briefly while noting the scope.",
"Never just give answers for homework — explain the reasoning so the student learns.",
"Format your responses using markdown: headings for structure, bullet points for lists, and code blocks for examples."
);
return context.join("\n");
}
/**
* Build the messages array for the AI call.
*/
function buildMessages(chatMessages, grade, subject, chapter) {
const systemMsg = {
role: "system",
content: buildSystemPrompt(grade, subject, chapter),
};
const userMessages = chatMessages.map((m) => ({
role: m.role === "user" ? "user" : "assistant",
content: m.text,
}));
return [systemMsg, ...userMessages];
}
/**
* Call OpenAI and stream the response via a callback.
*/
async function streamOpenai(messages, onChunk, onError) {
const stream = await openai.chat.completions.create({
model: process.env.OPENAI_MODEL || "gpt-4o",
messages,
stream: true,
max_tokens: 2048,
});
let fullResponse = "";
for await (const chunk of stream) {
const content = chunk.choices[0]?.delta?.content || "";
if (content) {
fullResponse += content;
onChunk(content);
}
}
return fullResponse;
}
/**
* Call Anthropic and stream the response.
*/
async function streamAnthropic(messages, onChunk, onError) {
const userMsgs = messages.filter((m) => m.role === "user");
const systemMsg = messages.find((m) => m.role === "system");
const body = {
model: process.env.ANTHROPIC_MODEL || "claude-sonnet-4-20250514",
max_tokens: 2048,
system: systemMsg?.content || "",
messages: userMsgs.map((m) => ({ role: "user", content: m.content })),
stream: true,
};
const response = await fetch(ANTHROPIC_BASE_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": process.env.ANTHROPIC_API_KEY,
"anthropic-version": "2023-06-01",
},
body: JSON.stringify(body),
});
if (!response.ok) {
const err = await response.text();
onError(`Anthropic API error: ${err}`);
return "";
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let fullResponse = "";
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed.startsWith("data: ")) continue;
const jsonStr = trimmed.slice(6);
if (jsonStr === "[DONE]") continue;
try {
const json = JSON.parse(jsonStr);
const content = json.delta?.text || "";
if (content) {
fullResponse += content;
onChunk(content);
}
} catch {}
}
}
return fullResponse;
}
/**
* Call Google Gemini and stream the response.
*/
async function streamGoogle(messages, onChunk, onError) {
const model = process.env.GOOGLE_MODEL || "gemini-2.0-flash";
const apiKey = process.env.GOOGLE_API_KEY;
const url = `${GOOGLE_BASE_URL}${apiKey}/chat/models/${model}:streamGenerateContent?key=${apiKey}`;
const systemMsg = messages.find((m) => m.role === "system");
const userMsgs = messages.filter((m) => m.role !== "system");
// Convert messages to Gemini format
const contents = userMsgs.map((m) => ({
role: m.role === "user" ? "user" : "model",
parts: [{ text: m.content }],
}));
const body = {
contents,
systemInstruction: systemMsg ? { parts: [{ text: systemMsg.content }] } : undefined,
generationConfig: { maxOutputTokens: 2048 },
};
const response = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!response.ok) {
const err = await response.text();
onError(`Google AI error: ${err}`);
return "";
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let fullResponse = "";
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
const json = JSON.parse(trimmed);
const candidates = json.candidates?.[0];
const parts = candidates?.content?.parts;
const text = parts?.[0]?.text || "";
if (text) {
fullResponse += text;
onChunk(text);
}
} catch {}
}
}
return fullResponse;
}
/**
* Stream a chat response.
* @param {Array} chatMessages - Array of {role, text} messages
* @param {string} grade
* @param {string} subject
* @param {string} chapter
* @param {Function} onChunk - Called with each text chunk
* @param {Function} onError - Called on error
* @returns {Promise<string>} The full response text
*/
export async function streamChatResponse(chatMessages, grade, subject, chapter, onChunk, onError) {
const messages = buildMessages(chatMessages, grade, subject, chapter);
switch (provider) {
case "anthropic":
return streamAnthropic(messages, onChunk, onError);
case "google":
return streamGoogle(messages, onChunk, onError);
case "openai":
default:
return streamOpenai(messages, onChunk, onError);
}
}
/**
* Get a non-streaming response (fallback for simple queries).
*/
export async function getChatResponse(chatMessages, grade, subject, chapter) {
const messages = buildMessages(chatMessages, grade, subject, chapter);
let response;
switch (provider) {
case "anthropic": {
const userMsgs = messages.filter((m) => m.role === "user");
const systemMsg = messages.find((m) => m.role === "system");
const res = await fetch(ANTHROPIC_BASE_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": process.env.ANTHROPIC_API_KEY,
"anthropic-version": "2023-06-01",
},
body: JSON.stringify({
model: process.env.ANTHROPIC_MODEL || "claude-sonnet-4-20250514",
max_tokens: 2048,
system: systemMsg?.content || "",
messages: userMsgs.map((m) => ({ role: "user", content: m.content })),
}),
});
const data = await res.json();
return data.content?.[0]?.text || "No response";
}
case "google": {
const systemMsg = messages.find((m) => m.role === "system");
const userMsgs = messages.filter((m) => m.role !== "system");
const contents = userMsgs.map((m) => ({
role: m.role === "user" ? "user" : "model",
parts: [{ text: m.content }],
}));
const url = `${GOOGLE_BASE_URL}${process.env.GOOGLE_API_KEY}/chat/models/${process.env.GOOGLE_MODEL || "gemini-2.0-flash"}:generateContent?key=${process.env.GOOGLE_API_KEY}`;
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
contents,
systemInstruction: systemMsg ? { parts: [{ text: systemMsg.content }] } : undefined,
generationConfig: { maxOutputTokens: 2048 },
}),
});
const data = await res.json();
return data.candidates?.[0]?.content?.parts?.[0]?.text || "No response";
}
case "openai":
default: {
const res = await openai.chat.completions.create({
model: process.env.OPENAI_MODEL || "gpt-4o",
messages,
max_tokens: 2048,
});
return res.choices?.[0]?.message?.content || "No response";
}
}
}
+114
View File
@@ -0,0 +1,114 @@
/**
* In-memory session store.
* Each session holds a chatId, metadata (grade/subject/chapter),
* and a list of messages. Swappable for a DB backend later.
*/
const sessions = new Map();
const messages = new Map(); // chatId -> [Message]
function generateId() {
return `chat_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
}
/**
* Create a new chat session
*/
export function createSession(grade, subject, chapter) {
const chatId = generateId();
const session = {
id: chatId,
grade: grade || "General",
subject: subject || "General",
chapter: chapter || "General",
preview: "New Chat",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
messageCount: 0,
};
sessions.set(chatId, session);
messages.set(chatId, []);
return session;
}
/**
* Get a session by ID
*/
export function getSession(chatId) {
return sessions.get(chatId) || null;
}
/**
* Get all sessions (most recent first)
*/
export function listSessions() {
const all = Array.from(sessions.values());
return all.sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt));
}
/**
* Get messages for a chat
*/
export function getMessages(chatId) {
return messages.get(chatId) || [];
}
/**
* Add a message to a chat session
*/
export function addMessage(chatId, message) {
const msgs = messages.get(chatId);
if (msgs) {
msgs.push(message);
}
// Update session metadata
const session = sessions.get(chatId);
if (session) {
session.updatedAt = new Date().toISOString();
session.messageCount = msgs ? msgs.length : 0;
// Set preview from the first user message
if (session.messageCount <= 1 && message.role === "user") {
session.preview = message.text.substring(0, 60) + (message.text.length > 60 ? "..." : "");
}
}
return message;
}
/**
* Delete a session and its messages
*/
export function deleteSession(chatId) {
const deleted = sessions.delete(chatId);
messages.delete(chatId);
return deleted;
}
/**
* Clear all messages for a session (keep session metadata)
*/
export function clearMessages(chatId) {
messages.set(chatId, []);
const session = sessions.get(chatId);
if (session) {
session.messageCount = 0;
session.updatedAt = new Date().toISOString();
}
}
/**
* Get stats (for monitoring)
*/
export function getStats() {
return {
activeSessions: sessions.size,
totalMessages: Array.from(messages.values()).reduce(
(sum, msgs) => sum + msgs.length,
0
),
};
}
+3
View File
@@ -27,3 +27,6 @@ build
*.ntvs* *.ntvs*
*.njsproj *.njsproj
*.sln *.sln
# LLM memory / documentation
markdown/
+281 -24
View File
@@ -1,48 +1,299 @@
/* ======================================== /* ========================================
App Component — padhle (Refined Workspace) App Component — padhle (Backend-Connected)
======================================== */ ======================================== */
import { useState } from "react"; import { useState, useRef, useCallback, useEffect } from "react";
import Sidebar from "./components/sidebar/Sidebar"; import Sidebar from "./components/sidebar/Sidebar";
import TopNav from "./components/top-nav/TopNav"; import TopNav from "./components/top-nav/TopNav";
import ChatHistory from "./components/chat-history/ChatHistory"; import ChatHistory from "./components/chat-history/ChatHistory";
import ChatInput from "./components/chat-input/ChatInput"; import ChatInput from "./components/chat-input/ChatInput";
import SelectorFlow from "./components/selector-flow/SelectorFlow";
const API_BASE = ""; // Uses Vite proxy → localhost:3001 in dev
// Default selectors
const DEFAULTS = {
grade: "Choose standard",
subject: "Choose Subject",
chapter: "Choose Chapter",
};
function App() { function App() {
const [messages, setMessages] = useState([]); const [messages, setMessages] = useState([]);
const [activeSubject, setActiveSubject] = useState("Choose Subject"); const [activeSubject, setActiveSubject] = useState(DEFAULTS.subject);
const [activeChat, setActiveChat] = useState(null); const [activeChat, setActiveChat] = useState(null);
const [activeGrade, setActiveGrade] = useState("Choose standard"); const [activeGrade, setActiveGrade] = useState(DEFAULTS.grade);
const [activeChapter, setActiveChapter] = useState("Choose Chapter"); const [activeChapter, setActiveChapter] = useState(DEFAULTS.chapter);
const [isTyping, setIsTyping] = useState(false);
const [sessions, setSessions] = useState([]);
const [isLoadingSessions, setIsLoadingSessions] = useState(false);
const [showSelectorFlow, setShowSelectorFlow] = useState(false);
const abortRef = useRef(null);
const handleSend = (text) => { // Load sessions on mount
if (!text.trim()) return; useEffect(() => {
loadSessions();
}, []);
setMessages((prev) => [...prev, { id: Date.now(), role: "user", text }]); const loadSessions = async () => {
setIsLoadingSessions(true);
setTimeout(() => { try {
setMessages((prev) => [ const res = await fetch(`${API_BASE}/api/sessions`);
...prev, if (res.ok) {
{ const data = await res.json();
id: Date.now() + 1, setSessions(data);
role: "assistant", }
text: "This is a simulated response. Connect the backend to get real AI answers!", } catch (err) {
}, console.error("Failed to load sessions:", err);
]); } finally {
}, 1000); setIsLoadingSessions(false);
}
}; };
const handlePromptClick = (promptText) => { const handleNewChat = () => {
handleSend(promptText); // Abort any in-flight request
if (abortRef.current) {
abortRef.current.abort();
abortRef.current = null;
}
// Clear everything
setMessages([]);
setActiveChat(null);
setActiveGrade(DEFAULTS.grade);
setActiveSubject(DEFAULTS.subject);
setActiveChapter(DEFAULTS.chapter);
setIsTyping(false);
// Show the selector flow
setShowSelectorFlow(true);
// Refresh sessions list from backend
loadSessions();
};
const handleChatSelect = async (sessionId) => {
// Abort any in-flight request
if (abortRef.current) {
abortRef.current.abort();
abortRef.current = null;
}
setIsTyping(true);
setMessages([]);
try {
const res = await fetch(`${API_BASE}/api/chat/${sessionId}`);
if (!res.ok) {
throw new Error("Session not found");
}
const data = await res.json();
// Set the session's context (grade, subject, chapter)
if (data.session) {
setActiveGrade(data.session.grade || DEFAULTS.grade);
setActiveSubject(data.session.subject || DEFAULTS.subject);
setActiveChapter(data.session.chapter || DEFAULTS.chapter);
}
// Hide selector flow when loading an existing chat
setShowSelectorFlow(false);
// Load the messages
if (data.messages) {
setMessages(data.messages.map((m) => ({ ...m, streaming: false })));
}
setActiveChat(sessionId);
} catch (err) {
console.error("Failed to load session:", err);
setMessages([
{
id: Date.now(),
role: "assistant",
text: `⚠️ Could not load that chat. It may have been deleted.`,
},
]);
} finally {
setIsTyping(false);
}
};
const handleDeleteChat = async (sessionId) => {
try {
const res = await fetch(`${API_BASE}/api/sessions/${sessionId}`, {
method: "DELETE",
});
if (res.ok) {
// If we were on the deleted chat, clear it
if (activeChat === sessionId) {
handleNewChat();
} else {
loadSessions();
}
}
} catch (err) {
console.error("Failed to delete session:", err);
}
};
const handleSend = useCallback(
(text) => {
if (!text.trim() || isTyping) return;
// Abort any previous in-flight request
if (abortRef.current) {
abortRef.current.abort();
}
// Add user message immediately
const userMsg = { id: Date.now(), role: "user", text };
setMessages((prev) => [...prev, userMsg]);
setIsTyping(true);
const fetchChatId = activeChat;
// Build request body
const body = {
text: text.trim(),
chatId: fetchChatId,
grade: activeGrade !== DEFAULTS.grade ? activeGrade : undefined,
subject: activeSubject !== DEFAULTS.subject ? activeSubject : undefined,
chapter: activeChapter !== DEFAULTS.chapter ? activeChapter : undefined,
};
// Open SSE connection
const controller = new AbortController();
abortRef.current = controller;
fetch(`${API_BASE}/api/chat`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
signal: controller.signal,
})
.then(async (response) => {
if (!response.ok) {
throw new Error(`Server error: ${response.status}`);
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let assistantText = "";
// Create placeholder assistant message
const assistantId = Date.now() + 1;
setMessages((prev) => [
...prev,
{ id: assistantId, role: "assistant", text: "", streaming: true },
]);
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) {
if (!line.startsWith("data: ")) continue;
const jsonStr = line.slice(6);
if (jsonStr === "[DONE]") continue;
try {
const data = JSON.parse(jsonStr);
if (data.type === "chunk") {
assistantText += data.content;
setMessages((prev) =>
prev.map((m) =>
m.id === assistantId
? { ...m, text: assistantText }
: m
)
);
} else if (data.type === "done") {
// Mark as non-streaming
setMessages((prev) =>
prev.map((m) =>
m.id === assistantId
? { ...m, streaming: false }
: m
)
);
} else if (data.type === "error") {
setMessages((prev) =>
prev.map((m) =>
m.id === assistantId
? {
...m,
text: `⚠️ Error: ${data.message}`,
streaming: false,
}
: m
)
);
}
} catch {
// Skip malformed SSE frames
}
}
}
setIsTyping(false);
abortRef.current = null;
// Refresh sessions to update timestamps
loadSessions();
})
.catch((err) => {
if (err.name === "AbortError") return; // intentional abort
setIsTyping(false);
setMessages((prev) => [
...prev,
{
id: Date.now() + 1,
role: "assistant",
text: `⚠️ Connection error: ${err.message}. Make sure the backend is running on port 3001.`,
},
]);
abortRef.current = null;
});
},
[isTyping, activeChat, activeGrade, activeSubject, activeChapter]
);
const handlePromptClick = useCallback(
(promptText) => {
handleSend(promptText);
},
[handleSend]
);
const handleGradeChange = (gradeLabel) => {
setActiveGrade(gradeLabel);
};
const handleSubjectChange = (subjectId) => {
setActiveSubject(subjectId);
};
const handleChapterChange = (chapterLabel) => {
setActiveChapter(chapterLabel);
}; };
return ( return (
<div className="page"> <div className="page">
<Sidebar <Sidebar
activeSubject={activeSubject} activeSubject={activeSubject}
onSubjectChange={setActiveSubject} onSubjectChange={handleSubjectChange}
activeChat={activeChat} activeChat={activeChat}
onChatSelect={setActiveChat} onChatSelect={handleChatSelect}
onNewChat={handleNewChat}
sessions={sessions}
onDeleteChat={handleDeleteChat}
isLoadingSessions={isLoadingSessions}
/> />
<div className="page__main"> <div className="page__main">
@@ -50,7 +301,7 @@ function App() {
activeGrade={activeGrade} activeGrade={activeGrade}
onGradeChange={setActiveGrade} onGradeChange={setActiveGrade}
activeSubject={activeSubject} activeSubject={activeSubject}
onSubjectChange={setActiveSubject} onSubjectChange={handleSubjectChange}
activeChapter={activeChapter} activeChapter={activeChapter}
onChapterChange={setActiveChapter} onChapterChange={setActiveChapter}
/> />
@@ -62,12 +313,18 @@ function App() {
activeGrade={activeGrade} activeGrade={activeGrade}
activeChapter={activeChapter} activeChapter={activeChapter}
onPromptClick={handlePromptClick} onPromptClick={handlePromptClick}
isTyping={isTyping}
showSelectorFlow={showSelectorFlow}
onGradeChange={handleGradeChange}
onSubjectChange={handleSubjectChange}
onChapterChange={handleChapterChange}
/> />
</div> </div>
<ChatInput <ChatInput
onSend={handleSend} onSend={handleSend}
placeholder={`Ask anything about ${activeGrade} ${activeSubject} ${activeChapter}...`} placeholder={`Ask anything about ${activeGrade} ${activeSubject} ${activeChapter}...`}
disabled={isTyping}
/> />
</div> </div>
</div> </div>
@@ -3,9 +3,17 @@
======================================== */ ======================================== */
import Message from "../message/Message"; import Message from "../message/Message";
import SelectorFlow from "../selector-flow/SelectorFlow";
import "./ChatHistory.css"; import "./ChatHistory.css";
const ChatHistory = ({ messages, activeSubject, activeGrade, activeChapter, onPromptClick }) => { const ChatHistory = ({
messages,
activeGrade,
onPromptClick,
isTyping,
showSelectorFlow,
onGradeChange,
}) => {
const hasMessages = messages.length > 0; const hasMessages = messages.length > 0;
const suggestions = [ const suggestions = [
@@ -53,6 +61,8 @@ const ChatHistory = ({ messages, activeSubject, activeGrade, activeChapter, onPr
const fullPromptTexts = suggestions.map((s) => s.text + s.keywords.join(" ") + s.textEnd); const fullPromptTexts = suggestions.map((s) => s.text + s.keywords.join(" ") + s.textEnd);
const streamingMsg = messages.find((m) => m.streaming);
if (hasMessages) { if (hasMessages) {
return ( return (
<div className="chat-history"> <div className="chat-history">
@@ -60,11 +70,23 @@ const ChatHistory = ({ messages, activeSubject, activeGrade, activeChapter, onPr
{messages.map((message) => ( {messages.map((message) => (
<Message key={message.id} message={message} /> <Message key={message.id} message={message} />
))} ))}
{isTyping && !streamingMsg && (
<Message message={{ id: "typing", role: "assistant", text: "", streaming: true }} />
)}
<div ref={(el) => el && el.scrollIntoView({ behavior: "smooth", block: "end" })} />
</div> </div>
</div> </div>
); );
} }
if (showSelectorFlow) {
return (
<div className="chat-history">
<SelectorFlow grade={activeGrade} onGradeChange={onGradeChange} />
</div>
);
}
return ( return (
<div className="chat-history"> <div className="chat-history">
<div className="chat-history__focus"> <div className="chat-history__focus">
@@ -88,6 +88,11 @@
font-weight: var(--font-weight-regular); font-weight: var(--font-weight-regular);
} }
.chat-input__field:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Actions Container */ /* Actions Container */
.chat-input__actions { .chat-input__actions {
display: flex; display: flex;
@@ -150,6 +155,11 @@
opacity: 0.9; opacity: 0.9;
} }
.chat-input__send-btn:disabled {
cursor: not-allowed;
opacity: 0.3;
}
.chat-input__send-btn .material-symbols-outlined { .chat-input__send-btn .material-symbols-outlined {
font-size: 20px; font-size: 20px;
line-height: 1; line-height: 1;
@@ -5,7 +5,7 @@
import { useState, useRef, useEffect } from "react"; import { useState, useRef, useEffect } from "react";
import "./ChatInput.css"; import "./ChatInput.css";
const ChatInput = ({ onSend, placeholder }) => { const ChatInput = ({ onSend, placeholder, disabled }) => {
const [text, setText] = useState(""); const [text, setText] = useState("");
const textareaRef = useRef(null); const textareaRef = useRef(null);
@@ -75,9 +75,9 @@ const ChatInput = ({ onSend, placeholder }) => {
{/* Send */} {/* Send */}
<button <button
className={`chat-input__send-btn ${text.trim() ? "chat-input__send-btn--active" : ""}`} className={`chat-input__send-btn ${text.trim() && !disabled ? "chat-input__send-btn--active" : ""}`}
type="submit" type="submit"
disabled={!text.trim()} disabled={!text.trim() || disabled}
aria-label="Send message" aria-label="Send message"
> >
<span className="material-symbols-outlined" style={{ fontWeight: text.trim() ? 700 : 200 }}> <span className="material-symbols-outlined" style={{ fontWeight: text.trim() ? 700 : 200 }}>
@@ -131,3 +131,43 @@
.message__action--primary:hover { .message__action--primary:hover {
opacity: 0.9; opacity: 0.9;
} }
/* --- TYPING INDICATOR --- */
.message__typing-dots {
display: inline-flex;
align-items: center;
gap: 4px;
margin-left: 4px;
padding-bottom: 2px;
}
.message__typing-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background-color: var(--color-primary);
animation: typingBounce 1.4s infinite ease-in-out;
}
.message__typing-dot:nth-child(1) {
animation-delay: 0s;
}
.message__typing-dot:nth-child(2) {
animation-delay: 0.2s;
}
.message__typing-dot:nth-child(3) {
animation-delay: 0.4s;
}
@keyframes typingBounce {
0%, 80%, 100% {
transform: scale(0.6);
opacity: 0.4;
}
40% {
transform: scale(1);
opacity: 1;
}
}
+20 -3
View File
@@ -2,11 +2,20 @@
Message Component — padhle (Refined Workspace) Message Component — padhle (Refined Workspace)
======================================== */ ======================================== */
import { useEffect, useRef } from "react";
import "./Message.css"; import "./Message.css";
const Message = ({ message }) => { const Message = ({ message }) => {
const { role, text } = message; const { role, text, streaming } = message;
const isUser = role === "user"; const isUser = role === "user";
const endRef = useRef(null);
// Auto-scroll to bottom when text updates
useEffect(() => {
if (endRef.current) {
endRef.current.scrollIntoView({ behavior: "smooth", block: "end" });
}
}, [text, streaming]);
return ( return (
<div className={`message ${isUser ? "message--user" : "message--assistant"}`}> <div className={`message ${isUser ? "message--user" : "message--assistant"}`}>
@@ -18,11 +27,18 @@ const Message = ({ message }) => {
</div> </div>
)} )}
<div className="message__bubble"> <div className="message__bubble">
<div className="message__text">{text}</div> <div className="message__text">
{text || (streaming ? "" : null)}
{streaming && <span className="message__typing-dots">
<span className="message__typing-dot"></span>
<span className="message__typing-dot"></span>
<span className="message__typing-dot"></span>
</span>}
</div>
</div> </div>
{/* Action buttons for assistant messages */} {/* Action buttons for assistant messages */}
{!isUser && ( {!isUser && text && (
<div className="message__actions"> <div className="message__actions">
<button className="message__action" aria-label="Helpful" title="Helpful"> <button className="message__action" aria-label="Helpful" title="Helpful">
<span className="material-symbols-outlined">thumb_up</span> <span className="material-symbols-outlined">thumb_up</span>
@@ -35,6 +51,7 @@ const Message = ({ message }) => {
</button> </button>
</div> </div>
)} )}
<div ref={endRef} />
</div> </div>
); );
}; };
@@ -0,0 +1,79 @@
.selector-flow {
display: flex;
justify-content: center;
padding: 48px 24px;
width: 100%;
min-height: 320px;
}
.selector-flow__content {
width: 100%;
max-width: 640px;
}
.selector-flow__title {
font-family: var(--font-heading);
font-size: 24px;
font-weight: var(--font-weight-bold);
color: var(--color-on-surface);
text-align: center;
margin-bottom: 4px;
}
.selector-flow__subtitle {
font-size: 14px;
font-weight: var(--font-weight-regular);
color: var(--color-on-surface-variant);
text-align: center;
margin-bottom: 32px;
}
.selector-flow__options {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
}
.selector-flow__option {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
padding: 20px 12px;
border: var(--border-thin);
border-radius: var(--radius-md);
background-color: var(--color-surface);
cursor: pointer;
font-family: var(--font-body);
font-size: 14px;
font-weight: var(--font-weight-medium);
color: var(--color-on-surface);
text-align: center;
}
.selector-flow__option:hover:not(.selector-flow__option--selected) {
background-color: var(--color-surface-low);
border-color: var(--color-outline);
}
.selector-flow__option--selected {
background-color: var(--color-primary);
color: var(--color-on-primary);
border-color: var(--color-primary);
}
.selector-flow__option--selected:hover {
background-color: var(--color-primary);
border-color: var(--color-primary);
box-shadow: var(--shadow-card-hover);
}
.selector-flow__icon {
font-size: 28px;
color: var(--color-primary);
}
.selector-flow__option--selected .selector-flow__icon {
color: var(--color-on-primary);
}
@@ -0,0 +1,29 @@
import { useState } from "react";
import "./SelectorFlow.css";
const grades = ["Grade 6", "Grade 7", "Grade 8", "Grade 9", "Grade 10", "Grade 11", "Grade 12"];
export default function SelectorFlow({ grade, onGradeChange }) {
const handleClick = (g) => onGradeChange(g);
return (
<div className="selector-flow">
<div className="selector-flow__content">
<h3 className="selector-flow__title">Select your standard</h3>
<p className="selector-flow__subtitle">Choose the grade you are studying in</p>
<div className="selector-flow__options">
{grades.map((g) => (
<button
key={g}
className={`selector-flow__option${grade === g ? " selector-flow__option--selected" : ""}`}
onClick={() => handleClick(g)}
>
<span className="selector-flow__icon">school</span>
<span className="selector-flow__label">{g}</span>
</button>
))}
</div>
</div>
</div>
);
}
+194 -8
View File
@@ -62,12 +62,42 @@
font-size: var(--font-size-label-md); font-size: var(--font-size-label-md);
font-weight: var(--font-weight-medium); font-weight: var(--font-weight-medium);
cursor: pointer; cursor: pointer;
transition: opacity var(--transition-fast); transition: all var(--transition-normal);
margin-bottom: 24px; margin-bottom: 24px;
position: relative;
overflow: hidden;
}
.sidebar__new-chat::before {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.1) 0%, transparent 50%);
opacity: 0;
transition: opacity var(--transition-fast);
} }
.sidebar__new-chat:hover { .sidebar__new-chat:hover {
opacity: 0.9; background-color: #1a1a1a;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.sidebar__new-chat:hover::before {
opacity: 1;
}
.sidebar__new-chat:active {
transform: translateY(0);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
}
.sidebar__new-chat-icon {
transition: transform var(--transition-normal);
}
.sidebar__new-chat:hover .sidebar__new-chat-icon {
transform: rotate(90deg);
} }
/* Section */ /* Section */
@@ -78,9 +108,28 @@
.sidebar__section--history { .sidebar__section--history {
flex: 1; flex: 1;
min-height: 0; min-height: 0;
display: flex;
flex-direction: column;
overflow-y: auto; overflow-y: auto;
} }
.sidebar__section-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.sidebar__chat-count {
font-size: var(--font-size-label-xs);
font-weight: var(--font-weight-bold);
color: var(--color-on-surface-variant);
background-color: var(--color-surface-container);
padding: 2px 8px;
border-radius: var(--radius-sm);
margin-left: auto;
}
.sidebar__section-title { .sidebar__section-title {
font-size: 11px; font-size: 11px;
font-weight: var(--font-weight-bold); font-weight: var(--font-weight-bold);
@@ -88,7 +137,6 @@
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.1em; letter-spacing: 0.1em;
padding: 0 24px; padding: 0 24px;
margin-bottom: 8px;
} }
/* Subject List */ /* Subject List */
@@ -100,6 +148,7 @@
padding: 0 8px; padding: 0 8px;
} }
/* Subject Link — Enhanced Hover Effects */
.sidebar__link { .sidebar__link {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -115,23 +164,60 @@
font-weight: var(--font-weight-medium); font-weight: var(--font-weight-medium);
color: var(--color-on-surface-variant); color: var(--color-on-surface-variant);
text-align: left; text-align: left;
transition: background-color var(--transition-fast), color var(--transition-fast); transition: all var(--transition-fast);
position: relative;
overflow: hidden;
}
/* Slide indicator — hidden by default, slides in on hover */
.sidebar__link-indicator {
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%) scaleY(0);
width: 3px;
height: 60%;
background-color: var(--color-primary);
border-radius: 0 2px 2px 0;
transition: transform var(--transition-fast);
opacity: 0;
} }
.sidebar__link:hover { .sidebar__link:hover {
background-color: var(--color-surface-low); background-color: var(--color-surface-low);
color: var(--color-on-surface);
padding-left: 20px;
}
.sidebar__link:hover .sidebar__link-indicator {
transform: translateY(-50%) scaleY(1);
opacity: 1;
}
.sidebar__link:hover .sidebar__icon {
color: var(--color-primary);
} }
.sidebar__link--active { .sidebar__link--active {
background-color: var(--color-surface-low); background-color: var(--color-surface-low);
color: var(--color-primary); color: var(--color-primary);
font-weight: var(--font-weight-bold); font-weight: var(--font-weight-bold);
border-left: 2px solid var(--color-primary); }
.sidebar__link--active .sidebar__link-indicator {
transform: translateY(-50%) scaleY(1);
opacity: 1;
background-color: var(--color-primary);
}
.sidebar__link--active .sidebar__icon {
color: var(--color-primary);
} }
.sidebar__icon { .sidebar__icon {
font-size: 20px; font-size: 20px;
line-height: 1; line-height: 1;
transition: color var(--transition-fast);
} }
/* Chat List */ /* Chat List */
@@ -143,10 +229,24 @@
padding: 0 8px; padding: 0 8px;
} }
/* Chat Item — wrapper for link + delete */
.sidebar__chat-item {
position: relative;
display: flex;
align-items: center;
border-radius: var(--radius-md);
transition: all var(--transition-fast);
}
.sidebar__chat-item:hover {
background-color: var(--color-surface-low);
}
/* Chat Link */
.sidebar__chat-link { .sidebar__chat-link {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
width: 100%; width: calc(100% - 32px);
padding: 8px 16px; padding: 8px 16px;
border: none; border: none;
border-radius: var(--radius-md); border-radius: var(--radius-md);
@@ -154,7 +254,8 @@
cursor: pointer; cursor: pointer;
font-family: var(--font-body); font-family: var(--font-body);
text-align: left; text-align: left;
transition: background-color var(--transition-fast); transition: all var(--transition-fast);
z-index: 1;
} }
.sidebar__chat-link:hover { .sidebar__chat-link:hover {
@@ -163,6 +264,7 @@
.sidebar__chat-link--active { .sidebar__chat-link--active {
background-color: var(--color-surface-low); background-color: var(--color-surface-low);
border-left: 2px solid var(--color-primary);
} }
.sidebar__chat-preview { .sidebar__chat-preview {
@@ -172,6 +274,7 @@
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
display: block;
} }
.sidebar__chat-time { .sidebar__chat-time {
@@ -181,6 +284,87 @@
margin-top: 2px; margin-top: 2px;
} }
/* Delete Button — hidden until hover */
.sidebar__chat-delete {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
border-radius: var(--radius-sm);
background: transparent;
cursor: pointer;
color: var(--color-on-surface-variant);
opacity: 0;
transform: translateX(4px);
transition: all var(--transition-fast);
flex-shrink: 0;
margin-left: auto;
z-index: 2;
}
.sidebar__chat-item:hover .sidebar__chat-delete {
opacity: 1;
transform: translateX(0);
}
.sidebar__chat-delete:hover {
background-color: #fee2e2;
color: #dc2626;
}
.sidebar__chat-delete .material-symbols-outlined {
font-size: 16px;
line-height: 1;
}
.sidebar__chat-delete--visible {
opacity: 0.6;
transform: translateX(0);
}
.sidebar__chat-delete--visible:hover {
opacity: 1;
background-color: #fee2e2;
color: #dc2626;
}
/* Loading State */
.sidebar__loading {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 24px 16px;
color: var(--color-on-surface-variant);
font-size: var(--font-size-label-md);
}
.sidebar__loading-spinner {
width: 20px;
height: 20px;
border: 2px solid var(--color-outline-variant);
border-top-color: var(--color-primary);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* Empty State */
.sidebar__empty {
padding: 24px 16px;
color: var(--color-on-surface-variant);
font-size: var(--font-size-label-md);
text-align: center;
font-style: italic;
}
/* Footer */ /* Footer */
.sidebar__footer { .sidebar__footer {
display: flex; display: flex;
@@ -234,11 +418,12 @@
gap: 8px; gap: 8px;
position: relative; position: relative;
cursor: pointer; cursor: pointer;
transition: border-color var(--transition-fast); transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
} }
.sidebar__upgrade:hover { .sidebar__upgrade:hover {
border-color: var(--color-outline); border-color: var(--color-outline);
box-shadow: var(--shadow-card-hover);
} }
.sidebar__upgrade-header { .sidebar__upgrade-header {
@@ -278,4 +463,5 @@
.sidebar__upgrade:hover .sidebar__upgrade-arrow { .sidebar__upgrade:hover .sidebar__upgrade-arrow {
color: var(--color-primary); color: var(--color-primary);
transform: translateY(-50%) translateX(2px);
} }
+69 -23
View File
@@ -4,7 +4,16 @@
import "./Sidebar.css"; import "./Sidebar.css";
const Sidebar = ({ activeSubject, onSubjectChange, activeChat, onChatSelect }) => { const Sidebar = ({
activeSubject,
onSubjectChange,
activeChat,
onChatSelect,
onNewChat,
sessions,
onDeleteChat,
isLoadingSessions,
}) => {
const subjects = [ const subjects = [
{ id: "math", name: "Mathematics", icon: "functions" }, { id: "math", name: "Mathematics", icon: "functions" },
{ id: "science", name: "Science", icon: "science" }, { id: "science", name: "Science", icon: "science" },
@@ -14,12 +23,20 @@ const Sidebar = ({ activeSubject, onSubjectChange, activeChat, onChatSelect }) =
{ id: "geography", name: "Geography", icon: "public" }, { id: "geography", name: "Geography", icon: "public" },
]; ];
const chats = [ const formatTime = (isoString) => {
{ id: 1, preview: "Explain photosynthesis", time: "Today, 10:42 AM" }, const date = new Date(isoString);
{ id: 2, preview: "Help with integrals", time: "Yesterday, 3:15 PM" }, const now = new Date();
{ id: 3, preview: "What is cell respiration?", time: "Yesterday, 11:08 AM" }, const diffMs = now - date;
{ id: 4, preview: "Newton's laws of motion", time: "May 18, 6:20 PM" }, const diffMins = Math.floor(diffMs / 60000);
]; const diffHrs = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return "Just now";
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHrs < 24) return `${diffHrs}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return date.toLocaleDateString("en-US", { month: "short", day: "numeric" });
};
return ( return (
<aside className="sidebar"> <aside className="sidebar">
@@ -32,8 +49,8 @@ const Sidebar = ({ activeSubject, onSubjectChange, activeChat, onChatSelect }) =
</div> </div>
{/* New Chat Button */} {/* New Chat Button */}
<button className="sidebar__new-chat"> <button className="sidebar__new-chat" onClick={onNewChat} aria-label="Start new chat">
<span className="material-symbols-outlined">add</span> <span className="material-symbols-outlined sidebar__new-chat-icon">add</span>
<span>New Chat</span> <span>New Chat</span>
</button> </button>
@@ -47,6 +64,7 @@ const Sidebar = ({ activeSubject, onSubjectChange, activeChat, onChatSelect }) =
className={`sidebar__link ${activeSubject === subject.id ? "sidebar__link--active" : ""}`} className={`sidebar__link ${activeSubject === subject.id ? "sidebar__link--active" : ""}`}
onClick={() => onSubjectChange(subject.id)} onClick={() => onSubjectChange(subject.id)}
> >
<span className={`sidebar__link-indicator`} />
<span className={`material-symbols-outlined sidebar__icon`}>{subject.icon}</span> <span className={`material-symbols-outlined sidebar__icon`}>{subject.icon}</span>
<span className="sidebar__label">{subject.name}</span> <span className="sidebar__label">{subject.name}</span>
</button> </button>
@@ -57,20 +75,48 @@ const Sidebar = ({ activeSubject, onSubjectChange, activeChat, onChatSelect }) =
{/* Chat History Section */} {/* Chat History Section */}
<div className="sidebar__section sidebar__section--history"> <div className="sidebar__section sidebar__section--history">
<h2 className="sidebar__section-title">Chat History</h2> <div className="sidebar__section-header">
<ul className="sidebar__chat-list"> <h2 className="sidebar__section-title">Chat History</h2>
{chats.map((chat) => ( <span className="sidebar__chat-count">{sessions.length}</span>
<li key={chat.id}> </div>
<button {isLoadingSessions ? (
className={`sidebar__chat-link ${activeChat === chat.id ? "sidebar__chat-link--active" : ""}`} <div className="sidebar__loading">
onClick={() => onChatSelect(chat.id)} <span className="sidebar__loading-spinner" />
> <span>Loading...</span>
<span className="sidebar__chat-preview">{chat.preview}</span> </div>
<span className="sidebar__chat-time">{chat.time}</span> ) : sessions.length === 0 ? (
</button> <p className="sidebar__empty">No chats yet</p>
</li> ) : (
))} <ul className="sidebar__chat-list">
</ul> {sessions.map((session) => {
const preview = session.preview || session.subject || "New Chat";
const isActive = activeChat === session.id;
return (
<li key={session.id} className="sidebar__chat-item">
<button
className={`sidebar__chat-link ${isActive ? "sidebar__chat-link--active" : ""}`}
onClick={() => onChatSelect(session.id)}
>
<span className="sidebar__chat-preview">{preview}</span>
<span className="sidebar__chat-time">{formatTime(session.updatedAt)}</span>
</button>
<button
className={`sidebar__chat-delete ${isActive ? "sidebar__chat-delete--visible" : ""}`}
onClick={(e) => {
e.stopPropagation();
onDeleteChat(session.id);
}}
aria-label="Delete chat"
title="Delete chat"
>
<span className="material-symbols-outlined">delete</span>
</button>
</li>
);
})}
</ul>
)}
</div> </div>
{/* Footer */} {/* Footer */}
+7 -1
View File
@@ -6,7 +6,13 @@ export default defineConfig({
root: ".", root: ".",
publicDir: "public", publicDir: "public",
server: { server: {
port: 3000, port: 5173,
open: true, open: true,
proxy: {
"/api": {
target: "http://localhost:3001",
changeOrigin: true,
},
},
}, },
}); });
+315
View File
@@ -15,6 +15,7 @@
"@types/react": "^19.2.17", "@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.4", "@vitejs/plugin-react": "^6.0.4",
"concurrently": "^9.2.1",
"oxlint": "^1.75.0", "oxlint": "^1.75.0",
"vite": "^8.2.0" "vite": "^8.2.0"
} }
@@ -685,6 +686,122 @@
} }
} }
}, },
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.1.0",
"supports-color": "^7.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/chalk/node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/cliui": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
"integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
"dev": true,
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.1",
"wrap-ansi": "^7.0.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
},
"engines": {
"node": ">=7.0.0"
}
},
"node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"license": "MIT"
},
"node_modules/concurrently": {
"version": "9.2.4",
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.4.tgz",
"integrity": "sha512-TZ0CEhyzvFjgtAvHTusDMgj7wNdihCh7LLLrzdUOXIhdlnL2JBBGA9eJxR24rtqgmdjh3OA3hrN1rCHj6HM8qA==",
"dev": true,
"license": "MIT",
"dependencies": {
"chalk": "4.1.2",
"rxjs": "7.8.2",
"shell-quote": "1.9.0",
"supports-color": "8.1.1",
"tree-kill": "1.2.2",
"yargs": "17.7.2"
},
"bin": {
"conc": "dist/bin/concurrently.js",
"concurrently": "dist/bin/concurrently.js"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
}
},
"node_modules/csstype": { "node_modules/csstype": {
"version": "3.2.3", "version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -702,6 +819,23 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true,
"license": "MIT"
},
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/fdir": { "node_modules/fdir": {
"version": "6.5.0", "version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -735,6 +869,36 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0" "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
} }
}, },
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"dev": true,
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/lightningcss": { "node_modules/lightningcss": {
"version": "1.33.0", "version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
@@ -1146,6 +1310,16 @@
"react": "^19.2.8" "react": "^19.2.8"
} }
}, },
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/rolldown": { "node_modules/rolldown": {
"version": "1.2.4", "version": "1.2.4",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.4.tgz", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.4.tgz",
@@ -1179,12 +1353,35 @@
"@rolldown/binding-win32-x64-msvc": "1.2.4" "@rolldown/binding-win32-x64-msvc": "1.2.4"
} }
}, },
"node_modules/rxjs": {
"version": "7.8.2",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.1.0"
}
},
"node_modules/scheduler": { "node_modules/scheduler": {
"version": "0.27.0", "version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/shell-quote": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.9.0.tgz",
"integrity": "sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/source-map-js": { "node_modules/source-map-js": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -1195,6 +1392,50 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/supports-color": {
"version": "8.1.1",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
"integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/supports-color?sponsor=1"
}
},
"node_modules/tinyglobby": { "node_modules/tinyglobby": {
"version": "0.2.17", "version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
@@ -1212,6 +1453,23 @@
"url": "https://github.com/sponsors/SuperchupuDev" "url": "https://github.com/sponsors/SuperchupuDev"
} }
}, },
"node_modules/tree-kill": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
"integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==",
"dev": true,
"license": "MIT",
"bin": {
"tree-kill": "cli.js"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD"
},
"node_modules/vite": { "node_modules/vite": {
"version": "8.2.1", "version": "8.2.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.2.1.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.1.tgz",
@@ -1289,6 +1547,63 @@
"optional": true "optional": true
} }
} }
},
"node_modules/wrap-ansi": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=10"
}
},
"node_modules/yargs": {
"version": "17.7.2",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
"integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
"dev": true,
"license": "MIT",
"dependencies": {
"cliui": "^8.0.1",
"escalade": "^3.1.1",
"get-caller-file": "^2.0.5",
"require-directory": "^2.1.1",
"string-width": "^4.2.3",
"y18n": "^5.0.5",
"yargs-parser": "^21.1.1"
},
"engines": {
"node": ">=12"
}
},
"node_modules/yargs-parser": {
"version": "21.1.1",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=12"
}
} }
} }
} }
+5 -3
View File
@@ -4,9 +4,10 @@
"version": "0.0.0", "version": "0.0.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "concurrently \"npm run dev:backend\" \"npm run dev:frontend\"",
"build": "vite build", "dev:frontend": "cd frontend && vite",
"lint": "oxlint", "dev:backend": "cd backend && npm run dev",
"build": "cd frontend && vite build",
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
@@ -17,6 +18,7 @@
"@types/react": "^19.2.17", "@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.4", "@vitejs/plugin-react": "^6.0.4",
"concurrently": "^9.2.1",
"oxlint": "^1.75.0", "oxlint": "^1.75.0",
"vite": "^8.2.0" "vite": "^8.2.0"
} }