initial commit
This commit is contained in:
2
frontend/.gitignore
vendored
Normal file
2
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
node_modules
|
||||
public/dist
|
||||
45
frontend/AGENTS.md
Normal file
45
frontend/AGENTS.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# Frontend Agent Instructions
|
||||
|
||||
## Stack
|
||||
|
||||
- **Tailwind CSS 4** (no config file, just `style.css`)
|
||||
- **Bun only** (no npm commands)
|
||||
- **TypeScript strict mode**
|
||||
- **Alpine.js** (bundled in `main.js`, not via CDN)
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
bun run build
|
||||
bun run lint
|
||||
```
|
||||
|
||||
## Non-Negotiables
|
||||
|
||||
- ❌ No `any` type - use `unknown` and narrow it
|
||||
- ❌ No `as` type assertions
|
||||
- ❌ No `@ts-ignore` or `@ts-expect-error`
|
||||
- ❌ Fix all TypeScript and ESLint errors - don't ignore them
|
||||
- ❌ No Alpine.js via CDN (it's bundled)
|
||||
|
||||
## Code Style
|
||||
|
||||
- 2 spaces, single quotes, semicolons required
|
||||
- camelCase for variables/functions
|
||||
- PascalCase for types/interfaces
|
||||
- UPPER_SNAKE_CASE for constants
|
||||
- Explicit error handling with try/catch
|
||||
- User-friendly error messages in UI
|
||||
|
||||
## Key Patterns
|
||||
|
||||
- **DRY**: Extract repeated code into shared functions
|
||||
- **API calls**: Centralize in `src/client.ts`
|
||||
- **State**: Use Alpine.js reactivity + localStorage for persistence
|
||||
- **Errors**: Show in UI, don't just console.log
|
||||
|
||||
## What Goes Where
|
||||
|
||||
- Code: `src/`
|
||||
- Styles: Tailwind classes in HTML + `style.css`
|
||||
- Build output: `public/dist/` (don't commit this)
|
||||
271
frontend/bun.lock
Normal file
271
frontend/bun.lock
Normal file
@@ -0,0 +1,271 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "ui",
|
||||
"dependencies": {
|
||||
"alpinejs": "^3.15.3",
|
||||
"highlight.js": "^11.11.1",
|
||||
"marked": "^17.0.1",
|
||||
"marked-highlight": "^2.2.3",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@types/alpinejs": "^3.13.11",
|
||||
"@types/bun": "latest",
|
||||
"@typescript-eslint/eslint-plugin": "^8.52.0",
|
||||
"@typescript-eslint/parser": "^8.52.0",
|
||||
"eslint": "^9.39.2",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="],
|
||||
|
||||
"@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="],
|
||||
|
||||
"@eslint/config-array": ["@eslint/config-array@0.21.1", "", { "dependencies": { "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", "minimatch": "^3.1.2" } }, "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA=="],
|
||||
|
||||
"@eslint/config-helpers": ["@eslint/config-helpers@0.4.2", "", { "dependencies": { "@eslint/core": "^0.17.0" } }, "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw=="],
|
||||
|
||||
"@eslint/core": ["@eslint/core@0.17.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ=="],
|
||||
|
||||
"@eslint/eslintrc": ["@eslint/eslintrc@3.3.3", "", { "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.1", "minimatch": "^3.1.2", "strip-json-comments": "^3.1.1" } }, "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ=="],
|
||||
|
||||
"@eslint/js": ["@eslint/js@9.39.2", "", {}, "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA=="],
|
||||
|
||||
"@eslint/object-schema": ["@eslint/object-schema@2.1.7", "", {}, "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA=="],
|
||||
|
||||
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="],
|
||||
|
||||
"@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
|
||||
|
||||
"@humanfs/node": ["@humanfs/node@0.16.7", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="],
|
||||
|
||||
"@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="],
|
||||
|
||||
"@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="],
|
||||
|
||||
"@tailwindcss/typography": ["@tailwindcss/typography@0.5.19", "", { "dependencies": { "postcss-selector-parser": "6.0.10" }, "peerDependencies": { "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" } }, "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg=="],
|
||||
|
||||
"@types/alpinejs": ["@types/alpinejs@3.13.11", "", {}, "sha512-3KhGkDixCPiLdL3Z/ok1GxHwLxEWqQOKJccgaQL01wc0EVM2tCTaqlC3NIedmxAXkVzt/V6VTM8qPgnOHKJ1MA=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.3.5", "", { "dependencies": { "bun-types": "1.3.5" } }, "sha512-RnygCqNrd3srIPEWBd5LFeUYG7plCoH2Yw9WaZGyNmdTEei+gWaHqydbaIRkIkcbXwhBT94q78QljxN0Sk838w=="],
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
||||
|
||||
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
|
||||
|
||||
"@types/node": ["@types/node@25.0.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA=="],
|
||||
|
||||
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.52.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.52.0", "@typescript-eslint/type-utils": "8.52.0", "@typescript-eslint/utils": "8.52.0", "@typescript-eslint/visitor-keys": "8.52.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.52.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-okqtOgqu2qmZJ5iN4TWlgfF171dZmx2FzdOv2K/ixL2LZWDStL8+JgQerI2sa8eAEfoydG9+0V96m7V+P8yE1Q=="],
|
||||
|
||||
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.52.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.52.0", "@typescript-eslint/types": "8.52.0", "@typescript-eslint/typescript-estree": "8.52.0", "@typescript-eslint/visitor-keys": "8.52.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-iIACsx8pxRnguSYhHiMn2PvhvfpopO9FXHyn1mG5txZIsAaB6F0KwbFnUQN3KCiG3Jcuad/Cao2FAs1Wp7vAyg=="],
|
||||
|
||||
"@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.52.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.52.0", "@typescript-eslint/types": "^8.52.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-xD0MfdSdEmeFa3OmVqonHi+Cciab96ls1UhIF/qX/O/gPu5KXD0bY9lu33jj04fjzrXHcuvjBcBC+D3SNSadaw=="],
|
||||
|
||||
"@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.52.0", "", { "dependencies": { "@typescript-eslint/types": "8.52.0", "@typescript-eslint/visitor-keys": "8.52.0" } }, "sha512-ixxqmmCcc1Nf8S0mS0TkJ/3LKcC8mruYJPOU6Ia2F/zUUR4pApW7LzrpU3JmtePbRUTes9bEqRc1Gg4iyRnDzA=="],
|
||||
|
||||
"@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.52.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-jl+8fzr/SdzdxWJznq5nvoI7qn2tNYV/ZBAEcaFMVXf+K6jmXvAFrgo/+5rxgnL152f//pDEAYAhhBAZGrVfwg=="],
|
||||
|
||||
"@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.52.0", "", { "dependencies": { "@typescript-eslint/types": "8.52.0", "@typescript-eslint/typescript-estree": "8.52.0", "@typescript-eslint/utils": "8.52.0", "debug": "^4.4.3", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-JD3wKBRWglYRQkAtsyGz1AewDu3mTc7NtRjR/ceTyGoPqmdS5oCdx/oZMWD5Zuqmo6/MpsYs0wp6axNt88/2EQ=="],
|
||||
|
||||
"@typescript-eslint/types": ["@typescript-eslint/types@8.52.0", "", {}, "sha512-LWQV1V4q9V4cT4H5JCIx3481iIFxH1UkVk+ZkGGAV1ZGcjGI9IoFOfg3O6ywz8QqCDEp7Inlg6kovMofsNRaGg=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.52.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.52.0", "@typescript-eslint/tsconfig-utils": "8.52.0", "@typescript-eslint/types": "8.52.0", "@typescript-eslint/visitor-keys": "8.52.0", "debug": "^4.4.3", "minimatch": "^9.0.5", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-XP3LClsCc0FsTK5/frGjolyADTh3QmsLp6nKd476xNI9CsSsLnmn4f0jrzNoAulmxlmNIpeXuHYeEQv61Q6qeQ=="],
|
||||
|
||||
"@typescript-eslint/utils": ["@typescript-eslint/utils@8.52.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.52.0", "@typescript-eslint/types": "8.52.0", "@typescript-eslint/typescript-estree": "8.52.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-wYndVMWkweqHpEpwPhwqE2lnD2DxC6WVLupU/DOt/0/v+/+iQbbzO3jOHjmBMnhu0DgLULvOaU4h4pwHYi2oRQ=="],
|
||||
|
||||
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.52.0", "", { "dependencies": { "@typescript-eslint/types": "8.52.0", "eslint-visitor-keys": "^4.2.1" } }, "sha512-ink3/Zofus34nmBsPjow63FP5M7IGff0RKAgqR6+CFpdk22M7aLwC9gOcLGYqr7MczLPzZVERW9hRog3O4n1sQ=="],
|
||||
|
||||
"@vue/reactivity": ["@vue/reactivity@3.1.5", "", { "dependencies": { "@vue/shared": "3.1.5" } }, "sha512-1tdfLmNjWG6t/CsPldh+foumYFo3cpyCHgBYQ34ylaMsJ+SNHQ1kApMIa8jN+i593zQuaw3AdWH0nJTARzCFhg=="],
|
||||
|
||||
"@vue/shared": ["@vue/shared@3.1.5", "", {}, "sha512-oJ4F3TnvpXaQwZJNF3ZK+kLPHKarDmJjJ6jyzVNDKH9md1dptjC7lWR//jrGuLdek/U6iltWxqAnYOu8gCiOvA=="],
|
||||
|
||||
"acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
|
||||
|
||||
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
|
||||
|
||||
"ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="],
|
||||
|
||||
"alpinejs": ["alpinejs@3.15.3", "", { "dependencies": { "@vue/reactivity": "~3.1.1" } }, "sha512-fSI6F5213FdpMC4IWaup92KhuH3jBX0VVqajRJ6cOTCy1cL6888KyXdGO+seAAkn+g6fnrxBqQEx6gRpQ5EZoQ=="],
|
||||
|
||||
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||
|
||||
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
||||
|
||||
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||
|
||||
"brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw=="],
|
||||
|
||||
"callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="],
|
||||
|
||||
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
||||
|
||||
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
|
||||
|
||||
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
|
||||
|
||||
"concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
|
||||
|
||||
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
||||
|
||||
"cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="],
|
||||
|
||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
|
||||
|
||||
"escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
|
||||
|
||||
"eslint": ["eslint@9.39.2", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.1", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.1", "@eslint/js": "9.39.2", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw=="],
|
||||
|
||||
"eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="],
|
||||
|
||||
"eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="],
|
||||
|
||||
"espree": ["espree@10.4.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="],
|
||||
|
||||
"esquery": ["esquery@1.7.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="],
|
||||
|
||||
"esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="],
|
||||
|
||||
"estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="],
|
||||
|
||||
"esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
|
||||
|
||||
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||
|
||||
"fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
|
||||
|
||||
"fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
|
||||
|
||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
|
||||
"file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="],
|
||||
|
||||
"find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="],
|
||||
|
||||
"flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="],
|
||||
|
||||
"flatted": ["flatted@3.3.3", "", {}, "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="],
|
||||
|
||||
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
|
||||
|
||||
"globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="],
|
||||
|
||||
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
|
||||
|
||||
"highlight.js": ["highlight.js@11.11.1", "", {}, "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w=="],
|
||||
|
||||
"ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
|
||||
|
||||
"import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
|
||||
|
||||
"imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
|
||||
|
||||
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
|
||||
|
||||
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
|
||||
|
||||
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||
|
||||
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
|
||||
|
||||
"js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
|
||||
|
||||
"json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="],
|
||||
|
||||
"json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
|
||||
|
||||
"json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="],
|
||||
|
||||
"keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
|
||||
|
||||
"levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
|
||||
|
||||
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
|
||||
|
||||
"lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="],
|
||||
|
||||
"marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="],
|
||||
|
||||
"marked-highlight": ["marked-highlight@2.2.3", "", { "peerDependencies": { "marked": ">=4 <18" } }, "sha512-FCfZRxW/msZAiasCML4isYpxyQWKEEx44vOgdn5Kloae+Qc3q4XR7WjpKKf8oMLk7JP9ZCRd2vhtclJFdwxlWQ=="],
|
||||
|
||||
"minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
|
||||
|
||||
"optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
|
||||
|
||||
"p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
|
||||
|
||||
"p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="],
|
||||
|
||||
"parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="],
|
||||
|
||||
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
|
||||
|
||||
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
|
||||
|
||||
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
|
||||
|
||||
"postcss-selector-parser": ["postcss-selector-parser@6.0.10", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w=="],
|
||||
|
||||
"prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
|
||||
|
||||
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
||||
|
||||
"resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
|
||||
|
||||
"semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
|
||||
|
||||
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
|
||||
|
||||
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
|
||||
|
||||
"strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="],
|
||||
|
||||
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
|
||||
|
||||
"tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="],
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
|
||||
|
||||
"ts-api-utils": ["ts-api-utils@2.4.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA=="],
|
||||
|
||||
"type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
||||
|
||||
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
|
||||
|
||||
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
|
||||
|
||||
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||
|
||||
"word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
|
||||
|
||||
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
|
||||
|
||||
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
|
||||
|
||||
"@eslint/eslintrc/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
|
||||
|
||||
"eslint/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
|
||||
}
|
||||
}
|
||||
29
frontend/eslint.config.js
Normal file
29
frontend/eslint.config.js
Normal file
@@ -0,0 +1,29 @@
|
||||
import tseslint from "@typescript-eslint/eslint-plugin";
|
||||
import tsparser from "@typescript-eslint/parser";
|
||||
|
||||
export default [
|
||||
{
|
||||
files: ["src/**/*.ts"],
|
||||
languageOptions: {
|
||||
parser: tsparser,
|
||||
ecmaVersion: "latest",
|
||||
sourceType: "module",
|
||||
globals: {
|
||||
console: "readonly",
|
||||
document: "readonly",
|
||||
window: "readonly",
|
||||
navigator: "readonly",
|
||||
fetch: "readonly",
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
"@typescript-eslint": tseslint,
|
||||
},
|
||||
rules: {
|
||||
...tseslint.configs.recommended.rules,
|
||||
indent: ["error", 2],
|
||||
quotes: ["error", "single"],
|
||||
semi: ["error", "always"],
|
||||
},
|
||||
},
|
||||
];
|
||||
26
frontend/package.json
Normal file
26
frontend/package.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "aethera",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "bun build src/main.ts --outdir public/dist --target browser --watch & tailwindcss -i styles.css -o public/dist/styles.css --watch",
|
||||
"build": "bun build src/main.ts --outdir public/dist --target browser && tailwindcss -i styles.css -o public/dist/styles.css --minify",
|
||||
"lint": "eslint ./src/**"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@types/alpinejs": "^3.13.11",
|
||||
"@types/bun": "latest",
|
||||
"@typescript-eslint/eslint-plugin": "^8.52.0",
|
||||
"@typescript-eslint/parser": "^8.52.0",
|
||||
"eslint": "^9.39.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5"
|
||||
},
|
||||
"dependencies": {
|
||||
"alpinejs": "^3.15.3",
|
||||
"highlight.js": "^11.11.1",
|
||||
"marked": "^17.0.1",
|
||||
"marked-highlight": "^2.2.3"
|
||||
}
|
||||
}
|
||||
115
frontend/public/index.html
Normal file
115
frontend/public/index.html
Normal file
@@ -0,0 +1,115 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, maximum-scale=1, viewport-fit=cover"
|
||||
/>
|
||||
<title>Aethera - AI Conversation & Image Generator</title>
|
||||
<script type="module" src="./dist/main.js"></script>
|
||||
<link rel="stylesheet" href="./dist/styles.css" />
|
||||
</head>
|
||||
<body class="bg-primary-50" x-data x-init="$store.navigation.init()">
|
||||
<!-- Nav -->
|
||||
<div
|
||||
class="isolate fixed z-50 w-full flex justify-between mt-4 px-4 md:px-6"
|
||||
>
|
||||
<div class="size-9"></div>
|
||||
|
||||
<!-- Main Nav -->
|
||||
<nav class="inline-flex bg-primary-100 rounded-full shadow-sm">
|
||||
<a
|
||||
href="#/chats"
|
||||
:class="[
|
||||
'px-5 py-2 rounded-full text-sm font-medium transition-colors',
|
||||
$store.navigation.activeTab === 'chats'
|
||||
? 'bg-primary-600 text-white'
|
||||
: 'text-primary-700 hover:bg-primary-200'
|
||||
]"
|
||||
>
|
||||
Chats
|
||||
</a>
|
||||
<a
|
||||
href="#/images"
|
||||
:class="[
|
||||
'px-5 py-2 rounded-full text-sm font-medium transition-colors',
|
||||
$store.navigation.activeTab === 'images'
|
||||
? 'bg-primary-600 text-white'
|
||||
: 'text-primary-700 hover:bg-primary-200'
|
||||
]"
|
||||
>
|
||||
Images
|
||||
</a>
|
||||
<a
|
||||
href="#/settings"
|
||||
:class="[
|
||||
'px-5 py-2 rounded-full text-sm font-medium transition-colors',
|
||||
$store.navigation.activeTab === 'settings'
|
||||
? 'bg-primary-600 text-white'
|
||||
: 'text-primary-700 hover:bg-primary-200'
|
||||
]"
|
||||
>
|
||||
Settings
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<!-- Theme Toggle -->
|
||||
<button
|
||||
@click="$store.theme.cycleTheme()"
|
||||
x-init="$store.theme.init()"
|
||||
class="p-2 cursor-pointer rounded-md text-primary-700 hover:bg-primary-300 transition-colors"
|
||||
aria-label="Toggle theme"
|
||||
>
|
||||
<svg
|
||||
x-show="$store.theme.getThemeIcon() === 'sun'"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"
|
||||
/>
|
||||
</svg>
|
||||
<svg
|
||||
x-show="$store.theme.getThemeIcon() === 'moon'"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"
|
||||
/>
|
||||
</svg>
|
||||
<svg
|
||||
x-show="$store.theme.getThemeIcon() === 'system'"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Main Content Area -->
|
||||
<main id="page-content" class="h-dvh"></main>
|
||||
</body>
|
||||
</html>
|
||||
397
frontend/public/pages/chats.html
Normal file
397
frontend/public/pages/chats.html
Normal file
@@ -0,0 +1,397 @@
|
||||
<div x-data="chatManager()">
|
||||
<!-- Chat Content -->
|
||||
<div
|
||||
class="h-dvh pt-16 flex flex-col-reverse pb-36 overflow-scroll mx-auto px-4 md:px-6 max-w-6xl"
|
||||
>
|
||||
<template x-for="message in currentChatMessages" :key="message.content">
|
||||
<div
|
||||
:class="['mb-4', message.role === 'user' ? 'text-right' : 'text-left']"
|
||||
>
|
||||
<div
|
||||
:class="['inline-block px-4 py-3 text-left rounded-lg max-w-[95%] md:max-w-[85%]',
|
||||
message.role === 'user'
|
||||
? 'bg-primary-100 text-primary-900 rounded-br-none'
|
||||
: 'bg-primary-200 text-primary-900 rounded-bl-none'
|
||||
]"
|
||||
>
|
||||
<!-- Thinking Section -->
|
||||
<div
|
||||
x-show="message.thinking"
|
||||
x-data="{ expanded: false }"
|
||||
@click="expanded = !expanded"
|
||||
>
|
||||
<div
|
||||
class="cursor-pointer rounded-lg overflow-hidden bg-primary-100 hover:bg-primary-50"
|
||||
>
|
||||
<div
|
||||
class="flex justify-center w-full px-3 py-2 text-xs text-primary-700 flex items-center gap-2 transition-colors"
|
||||
>
|
||||
<span x-text="expanded ? '▼' : '◀'"></span>
|
||||
<span class="font-medium">Reasoning</span>
|
||||
<span x-text="expanded ? '▼' : '▶'"></span>
|
||||
</div>
|
||||
<div
|
||||
x-show="expanded"
|
||||
class="prose p-4 max-w-none text-sm prose-p:my-1 prose-headings:my-2 prose-ul:my-1 prose-ol:my-1 prose-pre:p-0"
|
||||
x-html="renderMarkdown(message.thinking)"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr x-show="message.thinking" class="my-2 border-primary-400/50" />
|
||||
|
||||
<!-- Main Content -->
|
||||
<div
|
||||
class="prose max-w-none text-sm prose-p:my-1 prose-headings:my-2 prose-ul:my-1 prose-ol:my-1 prose-pre:p-0"
|
||||
x-html="renderMarkdown(message.content)"
|
||||
></div>
|
||||
|
||||
<!-- Timestamp -->
|
||||
<div class="flex items-center justify-between gap-2 mt-2">
|
||||
<div
|
||||
class="text-[10px] opacity-60"
|
||||
x-text="new Date(message.created_at).toLocaleTimeString()"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats Badges (Assistant) -->
|
||||
<div
|
||||
x-show="message.role === 'assistant' && message.stats"
|
||||
class="flex items-center gap-1 py-2 flex-wrap justify-start text-primary-700"
|
||||
>
|
||||
<!-- Cumulative Tokens with Hover Breakdown -->
|
||||
<div
|
||||
x-show="message.stats?.prompt_tokens || message.stats?.generated_tokens"
|
||||
class="group relative px-2 py-0.5 text-[10px] bg-primary-300/50 rounded-full cursor-help"
|
||||
>
|
||||
<span
|
||||
x-text="`${(message.stats?.prompt_tokens || 0) + (message.stats?.generated_tokens || 0)} tokens`"
|
||||
></span>
|
||||
|
||||
<!-- Tokens -->
|
||||
<div
|
||||
class="invisible group-hover:visible absolute bottom-full left-1/2 -translate-x-1/2 m-2 px-2 py-1 bg-gray-900 text-white text-[10px] rounded whitespace-nowrap pointer-events-none grid grid-cols-[auto_1fr] gap-x-2"
|
||||
>
|
||||
<div
|
||||
x-show="message.stats?.prompt_tokens"
|
||||
x-text="message.stats?.prompt_tokens"
|
||||
></div>
|
||||
<div x-show="message.stats?.prompt_tokens">prompt tokens</div>
|
||||
|
||||
<div
|
||||
x-show="message.stats?.generated_tokens"
|
||||
x-text="message.stats?.generated_tokens"
|
||||
></div>
|
||||
<div x-show="message.stats?.generated_tokens">
|
||||
generated tokens
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span
|
||||
x-show="message.stats?.prompt_per_second"
|
||||
class="px-2 py-0.5 text-[10px] bg-primary-300/50 rounded-full"
|
||||
x-text="`${message.stats?.prompt_per_second.toFixed(1)} ppt/s`"
|
||||
></span>
|
||||
<span
|
||||
x-show="message.stats?.generated_per_second"
|
||||
class="px-2 py-0.5 text-[10px] bg-primary-300/50 rounded-full"
|
||||
x-text="`${message.stats?.generated_per_second.toFixed(1)} tgt/s`"
|
||||
></span>
|
||||
<span
|
||||
x-show="message.stats?.time_to_first_token"
|
||||
class="px-2 py-0.5 text-[10px] bg-primary-300/50 rounded-full"
|
||||
x-text="`${(message.stats?.time_to_first_token / 1000).toFixed(2)}s TTFT`"
|
||||
></span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Floating Input and Model Selection -->
|
||||
<div class="fixed bottom-4 w-full flex justify-center px-4 md:px-6">
|
||||
<div class="w-full sm:w-[calc(100%-2rem)] max-w-3xl z-10">
|
||||
<div
|
||||
class="flex flex-col gap-3 p-3 bg-primary-50/95 backdrop-blur-sm rounded-2xl shadow-2xl border border-primary-200"
|
||||
>
|
||||
<!-- Model Select -->
|
||||
<div class="relative">
|
||||
<select
|
||||
x-model="selectedModel"
|
||||
class="w-full appearance-none px-9 py-3 bg-gradient-to-r from-primary-50 to-primary-300 border border-primary-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary-400 text-primary-900 text-sm font-medium cursor-pointer transition-shadow hover:shadow-md"
|
||||
>
|
||||
<option value="">Select Model</option>
|
||||
<template x-for="model in models" :key="model.id">
|
||||
<option
|
||||
:value="model.id"
|
||||
x-text="model.name || model.id"
|
||||
></option>
|
||||
</template>
|
||||
</select>
|
||||
|
||||
<!-- Computer Icon -->
|
||||
<svg
|
||||
class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-primary-400 pointer-events-none"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<!-- Chevron Icon -->
|
||||
<svg
|
||||
class="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-primary-400 pointer-events-none transition-colors hover:text-primary-500"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Message Form -->
|
||||
<form @submit.prevent="sendMessage" class="flex gap-2 items-end">
|
||||
<textarea
|
||||
x-model="inputMessage"
|
||||
placeholder="Type your message..."
|
||||
rows="1"
|
||||
class="scrollbar-hide flex-1 p-3 bg-primary-50 border border-primary-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary-400 text-primary-900 text-sm transition-shadow hover:bg-primary-100 resize-none overflow-y-auto max-h-60"
|
||||
@keydown.enter="if (!$event.shiftKey) { $event.preventDefault(); sendMessage(); }"
|
||||
@input="$el.style.height = 'auto'; $el.style.height = $el.scrollHeight + 'px'"
|
||||
></textarea>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="!inputMessage.trim() || loading"
|
||||
:class="(!inputMessage.trim() || loading) ? 'opacity-50 cursor-not-allowed' : 'hover:shadow-md hover:scale-105'"
|
||||
class="self-stretch w-[44px] bg-gradient-to-r from-primary-600 to-primary-500 text-white rounded-xl transition-all flex items-center justify-center flex-shrink-0"
|
||||
>
|
||||
<template x-if="loading">
|
||||
<div
|
||||
class="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent"
|
||||
></div>
|
||||
</template>
|
||||
<template x-if="!loading">
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- Error Message -->
|
||||
<div
|
||||
x-show="error"
|
||||
class="bg-tertiary-50 border border-tertiary-200 px-4 py-2"
|
||||
>
|
||||
<p class="text-sm text-tertiary-700" x-text="error"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Floating Conversation List Toggle -->
|
||||
<button
|
||||
@click="chatListOpen = !chatListOpen"
|
||||
:aria-expanded="chatListOpen ? 'true' : 'false'"
|
||||
aria-label="Toggle left navigation"
|
||||
class="isolate cursor-pointer fixed z-50 flex justify-between top-4 left-4 md:left-6 p-2 rounded-md text-primary-700 hover:bg-primary-300 transition-colors"
|
||||
>
|
||||
<svg
|
||||
x-show="!chatListOpen"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 6h16M4 12h16M4 18h16"
|
||||
/>
|
||||
</svg>
|
||||
<svg
|
||||
x-show="chatListOpen"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Floating Conversation List -->
|
||||
<div
|
||||
x-show="chatListOpen"
|
||||
x-transition:enter="transform transition-all duration-300 ease-out"
|
||||
x-transition:enter-start="-translate-x-full opacity-0"
|
||||
x-transition:enter-end="translate-x-0 opacity-100"
|
||||
x-transition:leave="transform transition-all duration-300 ease-in"
|
||||
x-transition:leave-start="translate-x-0 opacity-100"
|
||||
x-transition:leave-end="-translate-x-full opacity-0"
|
||||
class="fixed top-16 left-0 right-0 mx-auto md:left-6 md:right-auto md:mx-0 bottom-4 w-86 bg-primary-100 rounded-xl shadow-lg z-20 overflow-hidden flex flex-col"
|
||||
>
|
||||
<div class="px-4 py-3 border-b border-primary-200 flex justify-center">
|
||||
<h4 class="font-semibold text-primary-900">
|
||||
<span>Conversations</span>
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
<!-- Conversation List-->
|
||||
<div id="left-nav-desktop" class="flex-1 overflow-y-auto p-4">
|
||||
<div
|
||||
x-show="chats.length === 0"
|
||||
class="h-full flex flex-col justify-center text-center py-8 text-primary-600"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-10 w-10 mx-auto mb-2 text-primary-300"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
|
||||
/>
|
||||
</svg>
|
||||
<p class="text-sm">No chats yet</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<template x-for="chat in chats" :key="chat.id">
|
||||
<div
|
||||
@click="selectChat(chat.id); chatListOpen = false;"
|
||||
:class="[
|
||||
'p-3 rounded-lg cursor-pointer transition-all border-l-3',
|
||||
selectedChatID === chat.id
|
||||
? 'bg-primary-200 border-l-primary-600'
|
||||
: 'hover:bg-primary-200 border-l-transparent'
|
||||
]"
|
||||
:title="chat.title"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<!-- Icon -->
|
||||
<div class="mt-0.5 shrink-0">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
:class="[
|
||||
'h-4 w-4',
|
||||
selectedChatID === chat.id ? 'text-primary-600' : 'text-primary-400'
|
||||
]"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-medium text-sm text-primary-900 truncate">
|
||||
<span x-text="chat.title || 'New Conversation'"></span>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center gap-2 mt-1.5 text-xs text-primary-600"
|
||||
>
|
||||
<span
|
||||
x-show="chat.message_count > 0"
|
||||
class="shrink-0 bg-primary-300 text-primary-700 px-1.5 py-0.5 rounded text-[10px] font-medium"
|
||||
x-text="chat.message_count"
|
||||
></span>
|
||||
<span class="truncate" x-text="chat.initial_message"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@click.stop="deleteChat($event, chat.id)"
|
||||
class="cursor-pointer shrink-0 p-1 text-primary-400 hover:text-tertiary-600 hover:bg-tertiary-100 rounded transition-colors"
|
||||
title="Delete Chat"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Left Nav Footer -->
|
||||
<div
|
||||
x-show="$store.navigation.activeTab === 'chats'"
|
||||
class="p-4 border-t border-primary-200 shrink-0"
|
||||
>
|
||||
<button
|
||||
@click="selectChat(null)"
|
||||
class="w-full px-4 py-2.5 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors text-sm font-medium flex cursor-pointer items-center justify-center gap-2"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
New Conversation
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
244
frontend/public/pages/images.html
Normal file
244
frontend/public/pages/images.html
Normal file
@@ -0,0 +1,244 @@
|
||||
<div
|
||||
class="flex flex-col gap-4 pt-16 mx-auto px-4 md:px-6 max-w-6xl"
|
||||
x-data="imageGenerator()"
|
||||
>
|
||||
<div>
|
||||
<form @submit.prevent="generateImage" class="flex flex-col gap-4 w-full">
|
||||
<!-- Prompt -->
|
||||
<div class="flex-1">
|
||||
<label for="prompt" class="text-sm font-medium text-primary-700"
|
||||
>Prompt</label
|
||||
>
|
||||
<textarea
|
||||
id="prompt"
|
||||
name="prompt"
|
||||
class="mt-1 p-2 w-full rounded-md border-primary-400 shadow focus:border-secondary-500 focus:ring-secondary-500 sm:text-sm min-h-[100px] overflow-y-auto text-primary-900 resize-none"
|
||||
required
|
||||
x-model="prompt"
|
||||
placeholder="Enter your image generation prompt here..."
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Parameters -->
|
||||
<div class="flex flex-col gap-3">
|
||||
<div>
|
||||
<label
|
||||
for="nav-model"
|
||||
class="block text-sm font-medium text-primary-700"
|
||||
>Model</label
|
||||
>
|
||||
<select
|
||||
id="nav-model"
|
||||
name="model"
|
||||
x-model="selectedModel"
|
||||
class="mt-1 p-2 block w-full rounded-md border-primary-400 shadow focus:border-secondary-500 focus:ring-secondary-500 sm:text-sm text-primary-900"
|
||||
required
|
||||
>
|
||||
<option value="">Select Model</option>
|
||||
<template x-for="model in models" :key="model.id">
|
||||
<option :value="model.id" x-text="model.name"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="size" class="text-sm font-medium text-primary-700"
|
||||
>Size</label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
id="size"
|
||||
name="size"
|
||||
x-model="size"
|
||||
class="mt-1 p-2 block w-full rounded-md border-primary-400 shadow focus:border-secondary-500 focus:ring-secondary-500 sm:text-sm text-primary-900"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<div class="flex-1">
|
||||
<label
|
||||
for="nav-n"
|
||||
class="block text-sm font-medium text-primary-700"
|
||||
>Count</label
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
id="nav-n"
|
||||
name="n"
|
||||
min="1"
|
||||
max="10"
|
||||
x-model="n"
|
||||
class="mt-1 p-2 block w-full rounded-md border-primary-400 shadow focus:border-secondary-500 focus:ring-secondary-500 sm:text-sm text-primary-900"
|
||||
value="1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex-1">
|
||||
<label
|
||||
for="nav-seed"
|
||||
class="block text-sm font-medium text-primary-700"
|
||||
>Seed</label
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
id="nav-seed"
|
||||
name="seed"
|
||||
x-model="seed"
|
||||
class="mt-1 p-2 block w-full rounded-md border-primary-400 shadow focus:border-secondary-500 focus:ring-secondary-500 sm:text-sm text-primary-900"
|
||||
value="-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="image-upload" class="text-sm font-medium text-primary-700"
|
||||
>Upload Image to Edit</label
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
id="image-upload"
|
||||
accept="image/*"
|
||||
@change="startEdit"
|
||||
class="mt-1 p-2 block w-full rounded-md border-primary-400 shadow text-primary-900"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Edit Panel -->
|
||||
<div
|
||||
id="edit-panel"
|
||||
x-show="editMode"
|
||||
class="mt-2 bg-primary-50 p-4 rounded shadow"
|
||||
>
|
||||
<div
|
||||
:class="['flex gap-4', isLandscape ? 'flex-col' : 'flex-col lg:flex-row']"
|
||||
>
|
||||
<!-- Image Preview -->
|
||||
<div class="flex justify-center relative">
|
||||
<img
|
||||
id="editing-image"
|
||||
:src="editingImage?.url"
|
||||
alt="Original image for editing"
|
||||
class="max-h-[75vh] rounded-lg shadow-md"
|
||||
/>
|
||||
<canvas
|
||||
id="mask"
|
||||
class="absolute top-0 left-0 w-full h-full rounded-lg cursor-crosshair"
|
||||
></canvas>
|
||||
</div>
|
||||
|
||||
<!-- Mask Options -->
|
||||
<div class="flex-1 flex flex-col gap-2 mt-auto justify-end">
|
||||
<div class="mt-2">
|
||||
<label
|
||||
for="lineWidthSlider"
|
||||
class="block text-sm font-medium text-gray-700"
|
||||
>
|
||||
Line Width: <span x-text="lineWidth"></span>
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
id="lineWidthSlider"
|
||||
x-model="lineWidth"
|
||||
min="1"
|
||||
max="100"
|
||||
class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
@click="clearMask"
|
||||
class="mt-2 px-3 py-1 cursor-pointer bg-primary-200 text-primary-700 rounded hover:bg-primary-600 hover:text-white text-center transition-colors"
|
||||
>
|
||||
Clear Mask
|
||||
</span>
|
||||
<span
|
||||
@click="cancelEdit"
|
||||
class="mt-2 px-3 py-1 cursor-pointer bg-primary-200 text-primary-700 rounded hover:bg-primary-600 hover:text-white text-center transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
x-bind:disabled="loading || !selectedModel"
|
||||
:class="loading || !selectedModel ? 'cursor-not-allowed' : 'cursor-pointer'"
|
||||
class="inline-flex justify-center items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-primary-50 bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50 gap-2 transition-colors"
|
||||
>
|
||||
<span
|
||||
x-text="loading ? '' : editMode ? 'Edit Image' : 'Generate Image'"
|
||||
></span>
|
||||
<div
|
||||
x-show="loading"
|
||||
class="h-5 w-5 animate-spin rounded-full border-2 border-white border-t-transparent"
|
||||
></div>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="text-lg font-medium text-primary-900 mb-2">Generated Images</h3>
|
||||
|
||||
<div
|
||||
x-show="error"
|
||||
class="bg-tertiary-50 border border-tertiary-200 rounded-md p-4 mb-4"
|
||||
>
|
||||
<p class="text-tertiary-700" x-text="error"></p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
x-show="!generatedImages.length"
|
||||
class="text-center py-8 text-primary-500"
|
||||
>
|
||||
No Images Found
|
||||
</div>
|
||||
|
||||
<div
|
||||
x-show="generatedImages.length"
|
||||
class="columns-2 md:columns-3 lg:columns-4 gap-2"
|
||||
>
|
||||
<template x-for="(image, index) in generatedImages" :key="index">
|
||||
<div
|
||||
class="flex flex-col gap-2 break-inside-avoid border border-primary-200 rounded-lg p-2 mb-2 h-full bg-primary-100 hover:border-primary-300 transition-colors shadow"
|
||||
>
|
||||
<button
|
||||
@click="deleteImage(image.name)"
|
||||
class="text-white hover:text-white text-sm justify-center cursor-pointer p-1 rounded bg-red-600 hover:bg-red-700 flex items-center h-full transition-colors"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
<img
|
||||
:src="image.path"
|
||||
:alt="image.prompt"
|
||||
@click="openLightbox(image.path)"
|
||||
class="rounded-lg shadow-sm max-w-full h-auto cursor-pointer hover:opacity-90 transition-opacity"
|
||||
/>
|
||||
<span
|
||||
class="text-xs text-primary-500 bg-primary-200 px-2 py-1 rounded flex justify-center"
|
||||
x-text="image.date"
|
||||
></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Lightbox -->
|
||||
<div
|
||||
x-show="lightbox.open"
|
||||
x-cloak
|
||||
@click="closeLightbox"
|
||||
@keydown.escape.window="closeLightbox"
|
||||
@keydown.arrow-left.window="prevImage"
|
||||
@keydown.arrow-right.window="nextImage"
|
||||
@touchstart="handleTouchStart"
|
||||
@touchend="handleTouchEnd"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/75 p-4 backdrop-blur-sm"
|
||||
>
|
||||
<img
|
||||
:src="lightbox.imageSrc"
|
||||
@click.stop
|
||||
class="max-w-[90vw] max-h-[90vh] object-contain rounded-lg shadow-2xl"
|
||||
alt="Full size preview"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
108
frontend/public/pages/settings.html
Normal file
108
frontend/public/pages/settings.html
Normal file
@@ -0,0 +1,108 @@
|
||||
<form
|
||||
x-data="settingsManager()"
|
||||
@submit.prevent="saveSettings"
|
||||
class="p-0.5 w-full flex flex-col gap-4 pt-16 mx-auto px-4 md:px-6 max-w-6xl"
|
||||
>
|
||||
<div>
|
||||
<label
|
||||
for="apiEndpoint"
|
||||
class="block text-sm font-semibold text-primary-700"
|
||||
>API Endpoint URL</label
|
||||
>
|
||||
<div class="ml-1">
|
||||
<input
|
||||
type="url"
|
||||
id="apiEndpoint"
|
||||
name="apiEndpoint"
|
||||
x-model="settings.api_endpoint"
|
||||
class="mt-1 p-1 block w-full rounded-md border-primary-400 shadow focus:border-secondary-500 focus:ring-secondary-500 sm:text-sm text-primary-900"
|
||||
placeholder="https://api.example.com/v1"
|
||||
required
|
||||
/>
|
||||
<p class="mt-2 text-xs text-primary-500">URL of your API endpoint</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-sm font-medium font-semibold text-primary-700"
|
||||
>Selectors</span
|
||||
>
|
||||
<div class="flex flex-col md:flex-row pl-1 gap-4 justify-between">
|
||||
<div class="w-full">
|
||||
<label
|
||||
for="generateModelSelector"
|
||||
class="text-sm font-medium text-primary-700"
|
||||
>Image</label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
id="generateModelSelector"
|
||||
name="generateModelSelector"
|
||||
x-model="settings.image_generation_selector"
|
||||
class="mt-1 p-1 block w-full rounded-md border-primary-400 shadow focus:border-secondary-500 focus:ring-secondary-500 sm:text-sm text-primary-900"
|
||||
placeholder=".meta.type: image-generate"
|
||||
/>
|
||||
<p class="mt-2 text-xs text-primary-500">Image generation selector</p>
|
||||
</div>
|
||||
|
||||
<div class="w-full">
|
||||
<label
|
||||
for="editModelSelector"
|
||||
class="text-sm font-medium text-primary-700"
|
||||
>Image Edit</label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
id="editModelSelector"
|
||||
name="editModelSelector"
|
||||
x-model="settings.image_edit_selector"
|
||||
class="mt-1 p-1 block w-full rounded-md border-primary-400 shadow focus:border-secondary-500 focus:ring-secondary-500 sm:text-sm text-primary-900"
|
||||
placeholder=".meta.type: image-edit"
|
||||
/>
|
||||
<p class="mt-2 text-xs text-primary-500">
|
||||
Image edit generation selector
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="w-full">
|
||||
<label
|
||||
for="textModelSelector"
|
||||
class="text-sm font-medium text-primary-700"
|
||||
>Chat</label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
id="textGenerationSelector"
|
||||
name="textGenerationSelector"
|
||||
x-model="settings.text_generation_selector"
|
||||
class="mt-1 p-1 block w-full rounded-md border-primary-400 shadow focus:border-secondary-500 focus:ring-secondary-500 sm:text-sm text-primary-900"
|
||||
placeholder=".meta.type: text-generate"
|
||||
/>
|
||||
<p class="mt-2 text-xs text-primary-500">Text generation selector</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
x-show="error"
|
||||
class="bg-tertiary-50 border border-tertiary-200 rounded-md p-4"
|
||||
>
|
||||
<p class="text-tertiary-700" x-text="error"></p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
x-show="saved"
|
||||
class="bg-secondary-50 border border-secondary-200 rounded-md p-4"
|
||||
>
|
||||
<p class="text-secondary-700">Settings saved successfully!</p>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
x-bind:disabled="loading"
|
||||
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-primary-50 bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50 cursor-pointer transition-colors"
|
||||
>
|
||||
Save Settings
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
180
frontend/src/client.ts
Normal file
180
frontend/src/client.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import {
|
||||
Settings,
|
||||
ImageRecord,
|
||||
GenerateImageRequest,
|
||||
Model,
|
||||
Chat,
|
||||
GenerateTextRequest,
|
||||
ChatListResponse,
|
||||
MessageChunk,
|
||||
} from './types/index';
|
||||
|
||||
export async function saveSettings(settings: Settings): Promise<Settings> {
|
||||
const response = await fetch('/api/settings', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(settings),
|
||||
});
|
||||
const data = await response.json().catch(() => ({}));
|
||||
|
||||
if (!response.ok || data.error) {
|
||||
throw new Error(data.error || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getSettings(): Promise<Settings> {
|
||||
const response = await fetch('/api/settings');
|
||||
const data = await response.json().catch(() => ({}));
|
||||
|
||||
if (!response.ok || data.error) {
|
||||
throw new Error(data.error || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function generateImage(
|
||||
requestData: GenerateImageRequest,
|
||||
): Promise<ImageRecord[]> {
|
||||
const response = await fetch('/api/images', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(requestData),
|
||||
});
|
||||
const data = await response.json().catch(() => ({}));
|
||||
|
||||
if (!response.ok || data.error) {
|
||||
throw new Error(data.error || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getModels(): Promise<Model[]> {
|
||||
const response = await fetch('/api/models');
|
||||
const data = await response.json().catch(() => ({}));
|
||||
|
||||
if (!response.ok || data.error) {
|
||||
throw new Error(data.error || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getGeneratedImages(): Promise<ImageRecord[]> {
|
||||
const response = await fetch('/api/images');
|
||||
const data = await response.json().catch(() => ({}));
|
||||
|
||||
if (!response.ok || data.error) {
|
||||
throw new Error(data.error || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function deleteImage(filename: string): Promise<void> {
|
||||
const response = await fetch(`/api/images/${filename}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.error || `HTTP ${response.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendMessage(
|
||||
chatId: string,
|
||||
requestData: GenerateTextRequest,
|
||||
onChunk: (chunk: MessageChunk) => void,
|
||||
) {
|
||||
const url = chatId ? `/api/chats/${chatId}` : '/api/chats';
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(requestData),
|
||||
});
|
||||
|
||||
return streamMessage(response, onChunk);
|
||||
}
|
||||
|
||||
export async function getChatMessages(chatId: string): Promise<Chat> {
|
||||
const response = await fetch(`/api/chats/${chatId}`);
|
||||
const data = await response.json().catch(() => ({}));
|
||||
|
||||
if (!response.ok || data.error) {
|
||||
throw new Error(data.error || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function listChats(): Promise<ChatListResponse> {
|
||||
const response = await fetch('/api/chats');
|
||||
const data = await response.json().catch(() => ({}));
|
||||
|
||||
if (!response.ok || data.error) {
|
||||
throw new Error(data.error || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function deleteChat(chatId: string): Promise<void> {
|
||||
const response = await fetch(`/api/chats/${chatId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.error || `HTTP ${response.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function streamMessage(
|
||||
response: Response,
|
||||
onChunk: (chunk: MessageChunk) => void,
|
||||
) {
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.error || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
// Add Buffer
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
// Split
|
||||
const lines = buffer.split('\n');
|
||||
|
||||
// Keep Incomplete Line
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
// Parse Complete Lines
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
|
||||
try {
|
||||
const msgChunk: MessageChunk = JSON.parse(trimmed);
|
||||
onChunk(msgChunk);
|
||||
} catch (e) {
|
||||
console.error('Failed to parse:', trimmed);
|
||||
throw new Error(`JSON Metadata Parsing ${e}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
243
frontend/src/components/chatManager.ts
Normal file
243
frontend/src/components/chatManager.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
import Alpine from 'alpinejs';
|
||||
import { Marked } from 'marked';
|
||||
import { markedHighlight } from 'marked-highlight';
|
||||
import hljs from 'highlight.js/lib/common';
|
||||
import {
|
||||
getSettings,
|
||||
getModels,
|
||||
sendMessage,
|
||||
getChatMessages,
|
||||
listChats,
|
||||
deleteChat,
|
||||
} from '../client';
|
||||
import { Chat, Message, MessageChunk, Model, Settings } from '../types';
|
||||
import { applyFilter } from '../utils';
|
||||
|
||||
const CHAT_ROUTE = '#/chats';
|
||||
const MODEL_KEY = 'aethera-chat-model';
|
||||
const IN_PROGRESS_UUID = '00000000-0000-0000-0000-000000000000';
|
||||
|
||||
// Markdown Renderer
|
||||
const marked = new Marked(
|
||||
markedHighlight({
|
||||
emptyLangClass: 'hljs',
|
||||
langPrefix: 'hljs language-',
|
||||
highlight(code, lang) {
|
||||
const language = hljs.getLanguage(lang) ? lang : 'plaintext';
|
||||
return hljs.highlight(code, { language }).value;
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
Alpine.data('chatManager', () => ({
|
||||
chats: [] as Chat[],
|
||||
settings: {} as Settings,
|
||||
_models: [] as Model[],
|
||||
|
||||
selectedModel: '',
|
||||
inputMessage: '',
|
||||
error: '',
|
||||
|
||||
selectedChatID: null as string | null,
|
||||
chatListOpen: false,
|
||||
loading: false,
|
||||
|
||||
async init() {
|
||||
// Acquire Data
|
||||
this._models = await getModels();
|
||||
this.settings = await getSettings();
|
||||
this.selectedModel = localStorage.getItem(MODEL_KEY) || '';
|
||||
await this.loadChats();
|
||||
|
||||
// Route Chat
|
||||
const chatID = window.location.hash.split('/')[2];
|
||||
if (chatID) await this.selectChat(chatID);
|
||||
},
|
||||
|
||||
async loadChats() {
|
||||
try {
|
||||
const response = await listChats();
|
||||
this.chats = response.chats || [];
|
||||
} catch (err) {
|
||||
console.error('Error loading conversations:', err);
|
||||
}
|
||||
},
|
||||
|
||||
async deleteChat(event: Event, chatId: string) {
|
||||
event.stopPropagation();
|
||||
try {
|
||||
await deleteChat(chatId);
|
||||
|
||||
// Delete Chat
|
||||
const chatIndex = this.chats.findIndex((c) => c.id == chatId);
|
||||
this.chats.splice(chatIndex, 1);
|
||||
|
||||
// Update Index
|
||||
if (this.selectedChatID == chatId) {
|
||||
const newIndex = Math.min(chatIndex, this.chats.length - 1);
|
||||
this.selectChat(this.chats[newIndex]?.id);
|
||||
if (!this.selectedChatID) this.chatListOpen = false;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error deleting conversation:', err);
|
||||
this.error = 'Failed to delete conversation';
|
||||
}
|
||||
},
|
||||
|
||||
async sendMessage() {
|
||||
const message = this.inputMessage.trim();
|
||||
if (!message || this.loading) return;
|
||||
|
||||
// Update State
|
||||
this.inputMessage = '';
|
||||
this.loading = true;
|
||||
this.error = '';
|
||||
|
||||
// Save Model
|
||||
if (this.selectedModel) localStorage.setItem(MODEL_KEY, this.selectedModel);
|
||||
|
||||
// New Chat
|
||||
if (!this.selectedChatID) {
|
||||
this.chats.unshift({
|
||||
id: IN_PROGRESS_UUID,
|
||||
created_at: new Date().toISOString(),
|
||||
title: '',
|
||||
initial_message: message,
|
||||
message_count: 0,
|
||||
messages: [],
|
||||
});
|
||||
this.selectedChatID = IN_PROGRESS_UUID;
|
||||
}
|
||||
|
||||
// New User Message
|
||||
let userMessage: Message = {
|
||||
id: IN_PROGRESS_UUID,
|
||||
chat_id: this.selectedChatID,
|
||||
role: 'user',
|
||||
thinking: '',
|
||||
content: message,
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// Get Chat
|
||||
let currentChat: Chat = this.chats.find(
|
||||
(c) => c.id === this.selectedChatID,
|
||||
)!;
|
||||
|
||||
// Add User Message
|
||||
currentChat.messages.push(userMessage);
|
||||
currentChat.message_count += 1;
|
||||
|
||||
// Assistant Message Placeholder
|
||||
let assistantMessage: Message | undefined;
|
||||
|
||||
try {
|
||||
await sendMessage(
|
||||
this.selectedChatID === IN_PROGRESS_UUID ? '' : this.selectedChatID,
|
||||
{ model: this.selectedModel, prompt: message },
|
||||
(chunk: MessageChunk) => {
|
||||
// Handle Chat
|
||||
if (chunk.chat) {
|
||||
Object.assign(currentChat, {
|
||||
...chunk.chat,
|
||||
messages: currentChat.messages,
|
||||
});
|
||||
this.selectedChatID = chunk.chat.id;
|
||||
this.updateHash(chunk.chat.id);
|
||||
}
|
||||
|
||||
// Handle User Message
|
||||
if (chunk.user_message) {
|
||||
Object.assign(userMessage, chunk.user_message);
|
||||
}
|
||||
|
||||
// Handle Assistant Message
|
||||
if (chunk.assistant_message) {
|
||||
if (!assistantMessage) {
|
||||
assistantMessage = chunk.assistant_message;
|
||||
currentChat.messages.push(assistantMessage);
|
||||
} else {
|
||||
const index = currentChat.messages.findIndex(
|
||||
(m) => m.id === assistantMessage!.id,
|
||||
);
|
||||
if (index !== -1) {
|
||||
currentChat.messages[index] = {
|
||||
...assistantMessage,
|
||||
...chunk.assistant_message,
|
||||
};
|
||||
currentChat.messages = [...currentChat.messages];
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('Error sending message:', err);
|
||||
this.error = parseError(err);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
updateHash(chatID: string | null) {
|
||||
const newRoute = CHAT_ROUTE + (chatID ? '/' + chatID : '');
|
||||
window.history.pushState(null, '', newRoute);
|
||||
},
|
||||
|
||||
async selectChat(chatID: string | null) {
|
||||
this.updateHash(chatID);
|
||||
|
||||
// Load Messages
|
||||
this.selectedChatID = chatID;
|
||||
if (!this.selectedChatID) this.chatListOpen = false;
|
||||
else this.loadChatMessages();
|
||||
},
|
||||
|
||||
async loadChatMessages() {
|
||||
if (!this.selectedChatID) return;
|
||||
|
||||
try {
|
||||
const response = await getChatMessages(this.selectedChatID);
|
||||
const chatIndex = this.chats.findIndex(
|
||||
(c) => c.id == this.selectedChatID,
|
||||
);
|
||||
|
||||
this.chats[chatIndex].messages = response.messages || [];
|
||||
} catch (err) {
|
||||
console.error('Error loading chat messages:', err);
|
||||
this.error = 'Failed to load messages';
|
||||
}
|
||||
},
|
||||
|
||||
get models(): Model[] {
|
||||
if (!this.settings.text_generation_selector) return this._models;
|
||||
return applyFilter(this._models, this.settings.text_generation_selector);
|
||||
},
|
||||
|
||||
get currentChatMessages(): Message[] {
|
||||
if (!this.selectedChatID) return [];
|
||||
const currentChat =
|
||||
this.chats.find((c) => c.id === this.selectedChatID) ?? null;
|
||||
if (!currentChat) return [];
|
||||
return [...currentChat.messages].reverse();
|
||||
},
|
||||
|
||||
renderMarkdown(content: string) {
|
||||
return marked.parse(content);
|
||||
},
|
||||
}));
|
||||
|
||||
function parseError(err: unknown): string {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
|
||||
if (msg.includes('401'))
|
||||
return 'Authentication failed. Please check your API settings.';
|
||||
if (msg.includes('404'))
|
||||
return 'API endpoint not found. Please check your configuration.';
|
||||
if (msg.includes('500'))
|
||||
return 'Server error. The text generation service is unavailable.';
|
||||
if (msg.includes('network') || msg.includes('failed to fetch'))
|
||||
return 'Network error. Please check your internet connection.';
|
||||
|
||||
return msg || 'Failed to send message';
|
||||
}
|
||||
396
frontend/src/components/imageManager.ts
Normal file
396
frontend/src/components/imageManager.ts
Normal file
@@ -0,0 +1,396 @@
|
||||
import Alpine from 'alpinejs';
|
||||
import {
|
||||
deleteImage,
|
||||
generateImage,
|
||||
getGeneratedImages,
|
||||
getModels,
|
||||
getSettings,
|
||||
} from '../client';
|
||||
import { ImageRecord } from '../types';
|
||||
import { applyFilter } from '../utils';
|
||||
|
||||
// Constants
|
||||
const STORAGE_KEYS = {
|
||||
MODEL: 'aethera-model',
|
||||
N: 'aethera-n',
|
||||
SEED: 'aethera-seed',
|
||||
SIZE: 'aethera-size',
|
||||
};
|
||||
|
||||
// Types
|
||||
interface StoredSettings {
|
||||
model: string | null;
|
||||
n: string | null;
|
||||
seed: string | null;
|
||||
size: string | null;
|
||||
}
|
||||
|
||||
// Utilities
|
||||
const fileToDataURL = (file: File): Promise<string> =>
|
||||
new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => resolve(e.target?.result as string);
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
|
||||
// Storage Manager
|
||||
const storageManager = {
|
||||
load(): StoredSettings {
|
||||
return {
|
||||
model: localStorage.getItem(STORAGE_KEYS.MODEL),
|
||||
n: localStorage.getItem(STORAGE_KEYS.N),
|
||||
seed: localStorage.getItem(STORAGE_KEYS.SEED),
|
||||
size: localStorage.getItem(STORAGE_KEYS.SIZE),
|
||||
};
|
||||
},
|
||||
|
||||
save({
|
||||
model,
|
||||
n,
|
||||
seed,
|
||||
size,
|
||||
}: {
|
||||
model: string;
|
||||
n: number;
|
||||
seed: number;
|
||||
size: string;
|
||||
}) {
|
||||
localStorage.setItem(STORAGE_KEYS.MODEL, model);
|
||||
localStorage.setItem(STORAGE_KEYS.N, n.toString());
|
||||
localStorage.setItem(STORAGE_KEYS.SEED, seed.toString());
|
||||
localStorage.setItem(STORAGE_KEYS.SIZE, size);
|
||||
},
|
||||
};
|
||||
|
||||
// Canvas Manager
|
||||
const canvasManager = (canvasId: string) => {
|
||||
let canvas: HTMLCanvasElement | null = null;
|
||||
let ctx: CanvasRenderingContext2D | null = null;
|
||||
|
||||
const getCanvas = () => {
|
||||
if (!canvas)
|
||||
canvas = document.getElementById(canvasId) as HTMLCanvasElement;
|
||||
return canvas;
|
||||
};
|
||||
|
||||
const getContext = () => {
|
||||
if (!ctx) ctx = getCanvas()?.getContext('2d');
|
||||
return ctx;
|
||||
};
|
||||
|
||||
const clearContext = () => {
|
||||
const context = getContext();
|
||||
if (!context) return;
|
||||
context.fillStyle = 'rgba(0, 0, 0, 0)';
|
||||
context.fillRect(0, 0, context.canvas.width, context.canvas.height);
|
||||
};
|
||||
|
||||
return {
|
||||
getCanvas,
|
||||
getContext,
|
||||
|
||||
clear() {
|
||||
const context = getContext();
|
||||
if (!context) return;
|
||||
context.clearRect(0, 0, context.canvas.width, context.canvas.height);
|
||||
},
|
||||
|
||||
reset() {
|
||||
clearContext();
|
||||
},
|
||||
|
||||
toDataURL(format = 'image/png') {
|
||||
return getCanvas()?.toDataURL(format);
|
||||
},
|
||||
|
||||
async resizeToImage(imageUrl: string) {
|
||||
const cnv = getCanvas();
|
||||
if (!cnv) return { width: 0, height: 0 };
|
||||
|
||||
const img = new Image();
|
||||
return new Promise<{ width: number; height: number }>((resolve) => {
|
||||
img.onload = () => {
|
||||
cnv.width = img.width;
|
||||
cnv.height = img.height;
|
||||
resolve({ width: img.width, height: img.height });
|
||||
};
|
||||
img.src = imageUrl;
|
||||
});
|
||||
},
|
||||
|
||||
initDrawing(lineWidthGetter: () => number) {
|
||||
const cnv = getCanvas();
|
||||
const context = getContext();
|
||||
if (!cnv || !context) return;
|
||||
|
||||
let isDrawing = false;
|
||||
clearContext();
|
||||
|
||||
const getCoords = (e: MouseEvent | TouchEvent) => {
|
||||
const rect = cnv.getBoundingClientRect();
|
||||
const scaleX = cnv.width / rect.width;
|
||||
const scaleY = cnv.height / rect.height;
|
||||
const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX;
|
||||
const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY;
|
||||
|
||||
return {
|
||||
x: (clientX - rect.left) * scaleX,
|
||||
y: (clientY - rect.top) * scaleY,
|
||||
scaleX,
|
||||
scaleY,
|
||||
};
|
||||
};
|
||||
|
||||
const startDrawing = (e: MouseEvent | TouchEvent) => {
|
||||
e.preventDefault();
|
||||
isDrawing = true;
|
||||
const { x, y } = getCoords(e);
|
||||
context.beginPath();
|
||||
context.moveTo(x, y);
|
||||
};
|
||||
|
||||
const draw = (e: MouseEvent | TouchEvent) => {
|
||||
if (!isDrawing) return;
|
||||
e.preventDefault();
|
||||
const { x, y, scaleX, scaleY } = getCoords(e);
|
||||
|
||||
context.lineWidth = lineWidthGetter() * Math.max(scaleX, scaleY);
|
||||
context.lineCap = 'round';
|
||||
context.strokeStyle = 'black';
|
||||
context.lineTo(x, y);
|
||||
context.stroke();
|
||||
context.beginPath();
|
||||
context.moveTo(x, y);
|
||||
};
|
||||
|
||||
const stopDrawing = () => {
|
||||
isDrawing = false;
|
||||
context.beginPath();
|
||||
};
|
||||
|
||||
cnv.addEventListener('mousedown', startDrawing as EventListener);
|
||||
cnv.addEventListener('mousemove', draw as EventListener);
|
||||
cnv.addEventListener('mouseup', stopDrawing);
|
||||
cnv.addEventListener('mouseout', stopDrawing);
|
||||
cnv.addEventListener('touchstart', startDrawing as EventListener);
|
||||
cnv.addEventListener('touchmove', draw as EventListener);
|
||||
cnv.addEventListener('touchend', stopDrawing);
|
||||
cnv.addEventListener('touchcancel', stopDrawing);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
// Main Component
|
||||
Alpine.data('imageGenerator', () => {
|
||||
const canvas = canvasManager('mask');
|
||||
|
||||
return {
|
||||
// Generation State
|
||||
prompt: '',
|
||||
n: 1,
|
||||
seed: -1,
|
||||
selectedModel: '',
|
||||
size: 'auto',
|
||||
|
||||
// Editing State
|
||||
editingImage: null as { url: string; name: string } | null,
|
||||
editMode: false,
|
||||
isLandscape: false,
|
||||
lineWidth: 20,
|
||||
|
||||
// Object State
|
||||
generatedImages: [] as ImageRecord[],
|
||||
_settings: {} as Record<string, unknown>,
|
||||
_models: [] as unknown[],
|
||||
|
||||
// API State
|
||||
loading: false,
|
||||
error: '',
|
||||
|
||||
// Lightbox State
|
||||
lightbox: {
|
||||
open: false,
|
||||
imageSrc: '',
|
||||
currentIndex: 0,
|
||||
touchStartX: 0,
|
||||
touchEndX: 0,
|
||||
},
|
||||
|
||||
async init() {
|
||||
[this._models, this._settings, this.generatedImages] = await Promise.all([
|
||||
getModels(),
|
||||
getSettings(),
|
||||
getGeneratedImages(),
|
||||
]);
|
||||
|
||||
this.loadStoredSettings();
|
||||
canvas.initDrawing(() => this.lineWidth);
|
||||
},
|
||||
|
||||
get models() {
|
||||
return applyFilter(
|
||||
this._models,
|
||||
this._settings.image_generation_selector as string,
|
||||
);
|
||||
},
|
||||
|
||||
loadStoredSettings() {
|
||||
const saved = storageManager.load();
|
||||
if (saved.model) this.selectedModel = saved.model;
|
||||
if (saved.n) this.n = parseInt(saved.n);
|
||||
if (saved.seed) this.seed = parseInt(saved.seed);
|
||||
if (saved.size) this.size = saved.size;
|
||||
},
|
||||
|
||||
saveSettings() {
|
||||
storageManager.save({
|
||||
model: this.selectedModel,
|
||||
n: this.n,
|
||||
seed: this.seed,
|
||||
size: this.size,
|
||||
});
|
||||
},
|
||||
|
||||
async buildRequestData() {
|
||||
const requestData: any = {
|
||||
prompt: this.prompt,
|
||||
n: parseInt(this.n.toString()),
|
||||
seed: parseInt(this.seed.toString()),
|
||||
size: this.size || 'auto',
|
||||
model: this.selectedModel,
|
||||
};
|
||||
|
||||
if (this.editMode) {
|
||||
const imageUploader = document.querySelector(
|
||||
'#image-upload',
|
||||
) as HTMLInputElement;
|
||||
requestData.mask = canvas.toDataURL();
|
||||
requestData.image = await fileToDataURL(imageUploader.files![0]);
|
||||
}
|
||||
|
||||
return requestData;
|
||||
},
|
||||
|
||||
async generateImage() {
|
||||
this.loading = true;
|
||||
this.error = '';
|
||||
this.saveSettings();
|
||||
|
||||
try {
|
||||
const requestData = await this.buildRequestData();
|
||||
const data = await generateImage(requestData);
|
||||
this.generatedImages.unshift(...data);
|
||||
} catch (err: any) {
|
||||
this.error = err;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async deleteImage(filename: string) {
|
||||
try {
|
||||
await deleteImage(filename);
|
||||
this.generatedImages = this.generatedImages.filter(
|
||||
(img: ImageRecord) => img.name !== filename,
|
||||
);
|
||||
} catch (err: any) {
|
||||
this.error = err;
|
||||
}
|
||||
},
|
||||
|
||||
async startEdit(e: Event) {
|
||||
const target = e.target as HTMLInputElement;
|
||||
const file = target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
if (!file.type.match('image/*')) {
|
||||
this.error = 'Please select a valid image file';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const imageUrl = URL.createObjectURL(file);
|
||||
this.editMode = true;
|
||||
this.editingImage = { url: imageUrl, name: file.name };
|
||||
|
||||
canvas.reset();
|
||||
|
||||
document
|
||||
.getElementById('edit-panel')
|
||||
?.scrollIntoView({ behavior: 'smooth' });
|
||||
|
||||
const dimensions = await canvas.resizeToImage(imageUrl);
|
||||
this.isLandscape = dimensions.width > dimensions.height;
|
||||
} catch (err) {
|
||||
console.error('Error starting image edit:', err);
|
||||
this.error = 'Failed to start editing uploaded image';
|
||||
}
|
||||
},
|
||||
|
||||
cancelEdit() {
|
||||
this.editMode = false;
|
||||
this.editingImage = null;
|
||||
const fileInput = document.getElementById(
|
||||
'image-upload',
|
||||
) as HTMLInputElement;
|
||||
if (fileInput) fileInput.value = '';
|
||||
},
|
||||
|
||||
clearMask() {
|
||||
canvas.clear();
|
||||
},
|
||||
|
||||
openLightbox(imageSrc: string) {
|
||||
this.lightbox.currentIndex = this.generatedImages.findIndex(
|
||||
(img: ImageRecord) => img.path === imageSrc,
|
||||
);
|
||||
this.lightbox.imageSrc = imageSrc;
|
||||
this.lightbox.open = true;
|
||||
document.body.style.overflow = 'hidden';
|
||||
},
|
||||
|
||||
closeLightbox() {
|
||||
this.lightbox.open = false;
|
||||
document.body.style.overflow = '';
|
||||
},
|
||||
|
||||
nextImage() {
|
||||
if (this.lightbox.currentIndex < this.generatedImages.length - 1) {
|
||||
this.lightbox.currentIndex++;
|
||||
this.lightbox.imageSrc =
|
||||
this.generatedImages[this.lightbox.currentIndex].path;
|
||||
}
|
||||
},
|
||||
|
||||
prevImage() {
|
||||
if (this.lightbox.currentIndex > 0) {
|
||||
this.lightbox.currentIndex--;
|
||||
this.lightbox.imageSrc =
|
||||
this.generatedImages[this.lightbox.currentIndex].path;
|
||||
}
|
||||
},
|
||||
|
||||
handleTouchStart(e: TouchEvent) {
|
||||
this.lightbox.touchStartX = e.changedTouches[0].screenX;
|
||||
},
|
||||
|
||||
handleTouchEnd(e: TouchEvent) {
|
||||
this.lightbox.touchEndX = e.changedTouches[0].screenX;
|
||||
this.handleSwipe();
|
||||
},
|
||||
|
||||
handleSwipe() {
|
||||
const swipeThreshold = 50;
|
||||
const diff = this.lightbox.touchStartX - this.lightbox.touchEndX;
|
||||
|
||||
if (Math.abs(diff) > swipeThreshold) {
|
||||
if (diff > 0) {
|
||||
this.nextImage();
|
||||
} else {
|
||||
this.prevImage();
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
48
frontend/src/components/navigationManager.ts
Normal file
48
frontend/src/components/navigationManager.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import Alpine from 'alpinejs';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
Alpine: typeof Alpine;
|
||||
}
|
||||
}
|
||||
|
||||
interface NavigationStore {
|
||||
activeTab: string;
|
||||
|
||||
init(): void;
|
||||
loadPage(): Promise<void>;
|
||||
}
|
||||
|
||||
const navigationStore: NavigationStore = {
|
||||
activeTab: '',
|
||||
|
||||
async init() {
|
||||
await this.loadPage();
|
||||
window.addEventListener('hashchange', () => this.loadPage());
|
||||
},
|
||||
|
||||
async loadPage() {
|
||||
const tab = window.location.hash.split('/')[1] || 'chats';
|
||||
if (this.activeTab === tab) return;
|
||||
this.activeTab = tab;
|
||||
|
||||
const pageContent = document.getElementById('page-content');
|
||||
if (!pageContent) throw new Error('Failed to find #page-content');
|
||||
|
||||
try {
|
||||
const response = await fetch(`/pages/${tab}.html`);
|
||||
if (!response.ok) throw new Error('Failed to load page');
|
||||
|
||||
pageContent.innerHTML = await response.text();
|
||||
} catch (error) {
|
||||
console.error('Page load error:', error);
|
||||
pageContent.innerHTML = `
|
||||
<div class="bg-tertiary-50 border border-tertiary-200 rounded-lg p-4">
|
||||
<p class="text-tertiary-700">Failed to load page. Please try again.</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
Alpine.store('navigation', navigationStore);
|
||||
29
frontend/src/components/settingsManager.ts
Normal file
29
frontend/src/components/settingsManager.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import Alpine from 'alpinejs';
|
||||
import { getSettings, saveSettings } from '../client';
|
||||
import { Settings } from '../types';
|
||||
|
||||
Alpine.data('settingsManager', () => ({
|
||||
settings: {} as Settings,
|
||||
loading: false,
|
||||
saved: false,
|
||||
error: '',
|
||||
|
||||
async init() {
|
||||
this.settings = await getSettings();
|
||||
},
|
||||
|
||||
async saveSettings() {
|
||||
this.loading = true;
|
||||
this.saved = false;
|
||||
this.error = '';
|
||||
|
||||
try {
|
||||
await saveSettings(this.settings);
|
||||
this.saved = true;
|
||||
} catch (err) {
|
||||
this.error = String(err);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
}));
|
||||
49
frontend/src/components/themeManager.ts
Normal file
49
frontend/src/components/themeManager.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import Alpine from 'alpinejs';
|
||||
import {
|
||||
type ThemeMode,
|
||||
loadTheme,
|
||||
saveThemeMode,
|
||||
applyTheme,
|
||||
getNextThemeMode,
|
||||
} from '../theme';
|
||||
|
||||
interface ThemeStore {
|
||||
mode: ThemeMode;
|
||||
|
||||
init(): void;
|
||||
cycleTheme(): void;
|
||||
getThemeIcon(): string;
|
||||
}
|
||||
|
||||
const themeStore: ThemeStore = {
|
||||
mode: 'system',
|
||||
|
||||
init() {
|
||||
const { mode } = loadTheme();
|
||||
this.mode = mode;
|
||||
applyTheme(mode);
|
||||
|
||||
// System Theme Changes
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
mediaQuery.addEventListener('change', () => {
|
||||
if (this.mode === 'system') {
|
||||
applyTheme('system');
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
cycleTheme() {
|
||||
const nextMode = getNextThemeMode(this.mode);
|
||||
this.mode = nextMode;
|
||||
saveThemeMode(nextMode);
|
||||
applyTheme(nextMode);
|
||||
},
|
||||
|
||||
getThemeIcon() {
|
||||
if (this.mode === 'dark') return 'moon';
|
||||
if (this.mode === 'light') return 'sun';
|
||||
return 'system';
|
||||
},
|
||||
};
|
||||
|
||||
Alpine.store('theme', themeStore);
|
||||
19
frontend/src/main.ts
Normal file
19
frontend/src/main.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import Alpine from 'alpinejs';
|
||||
|
||||
// Define Window
|
||||
declare global {
|
||||
interface Window {
|
||||
Alpine: typeof Alpine;
|
||||
}
|
||||
}
|
||||
|
||||
// Import Components
|
||||
import './components/chatManager';
|
||||
import './components/imageManager';
|
||||
import './components/settingsManager';
|
||||
import './components/themeManager';
|
||||
import './components/navigationManager';
|
||||
|
||||
// Start Alpine
|
||||
window.Alpine = Alpine;
|
||||
Alpine.start();
|
||||
83
frontend/src/theme.ts
Normal file
83
frontend/src/theme.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
export const AETHERA_THEME_KEY = 'aethera-theme';
|
||||
|
||||
export type ThemeMode = 'light' | 'dark' | 'system';
|
||||
|
||||
export interface ThemeState {
|
||||
mode: ThemeMode;
|
||||
}
|
||||
|
||||
export function getSystemTheme(): 'light' | 'dark' {
|
||||
if (typeof window !== 'undefined') {
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
? 'dark'
|
||||
: 'light';
|
||||
}
|
||||
return 'light';
|
||||
}
|
||||
|
||||
export function getEffectiveTheme(themeMode: ThemeMode): 'light' | 'dark' {
|
||||
if (themeMode === 'system') {
|
||||
return getSystemTheme();
|
||||
}
|
||||
return themeMode;
|
||||
}
|
||||
|
||||
export function loadTheme(): ThemeState {
|
||||
if (typeof localStorage === 'undefined') {
|
||||
return { mode: 'system' };
|
||||
}
|
||||
|
||||
const stored = localStorage.getItem(AETHERA_THEME_KEY);
|
||||
if (stored === 'light' || stored === 'dark' || stored === 'system') {
|
||||
return { mode: stored };
|
||||
}
|
||||
|
||||
return { mode: 'system' };
|
||||
}
|
||||
|
||||
export function saveThemeMode(mode: ThemeMode): void {
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
localStorage.setItem(AETHERA_THEME_KEY, mode);
|
||||
}
|
||||
}
|
||||
|
||||
export function applyTheme(mode: ThemeMode): void {
|
||||
const effectiveTheme = getEffectiveTheme(mode);
|
||||
if (typeof document !== 'undefined') {
|
||||
if (effectiveTheme === 'dark') {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
}
|
||||
applySyntaxTheme(effectiveTheme);
|
||||
}
|
||||
|
||||
export function applySyntaxTheme(theme: 'light' | 'dark'): void {
|
||||
if (typeof document === 'undefined') return;
|
||||
|
||||
const linkId = 'hljs-theme';
|
||||
let link = document.getElementById(linkId) as HTMLLinkElement;
|
||||
|
||||
const cssFile =
|
||||
theme === 'dark'
|
||||
? 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/stackoverflow-dark.css'
|
||||
: 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.css';
|
||||
|
||||
if (!link) {
|
||||
link = document.createElement('link');
|
||||
link.id = linkId;
|
||||
link.rel = 'stylesheet';
|
||||
link.href = cssFile;
|
||||
document.head.appendChild(link);
|
||||
} else if (link.href !== cssFile) {
|
||||
link.href = cssFile;
|
||||
}
|
||||
}
|
||||
|
||||
export function getNextThemeMode(currentMode: ThemeMode): ThemeMode {
|
||||
const cycle: ThemeMode[] = ['light', 'dark', 'system'];
|
||||
const currentIndex = cycle.indexOf(currentMode);
|
||||
const nextIndex = (currentIndex + 1) % cycle.length;
|
||||
return cycle[nextIndex];
|
||||
}
|
||||
73
frontend/src/types/index.ts
Normal file
73
frontend/src/types/index.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
export interface Chat {
|
||||
id: string;
|
||||
created_at: string;
|
||||
title: string;
|
||||
initial_message: string;
|
||||
message_count: number;
|
||||
messages: Message[];
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
id: string;
|
||||
chat_id: string;
|
||||
created_at: string;
|
||||
role: 'user' | 'assistant';
|
||||
thinking: string;
|
||||
content: string;
|
||||
stats?: MessageStats;
|
||||
}
|
||||
|
||||
export interface MessageStats {
|
||||
start_time: string;
|
||||
end_time?: string;
|
||||
prompt_tokens?: number;
|
||||
generated_tokens?: number;
|
||||
prompt_per_second?: number;
|
||||
generated_per_second?: number;
|
||||
time_to_first_token?: number;
|
||||
time_to_last_token?: number;
|
||||
}
|
||||
|
||||
export interface Model {
|
||||
name: string;
|
||||
meta?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface Settings {
|
||||
api_endpoint?: string;
|
||||
image_edit_selector?: string;
|
||||
image_generation_selector?: string;
|
||||
text_generation_selector?: string;
|
||||
}
|
||||
|
||||
export interface ImageRecord {
|
||||
name: string;
|
||||
path: string;
|
||||
size: number;
|
||||
date: string;
|
||||
}
|
||||
|
||||
export interface MessageChunk {
|
||||
chat?: Chat;
|
||||
user_message?: Message;
|
||||
assistant_message?: Message;
|
||||
}
|
||||
|
||||
export interface GenerateImageRequest {
|
||||
model: string;
|
||||
prompt: string;
|
||||
n: number;
|
||||
size: string;
|
||||
mask?: string;
|
||||
image?: string;
|
||||
seed?: number;
|
||||
}
|
||||
|
||||
export interface GenerateTextRequest {
|
||||
model: string;
|
||||
prompt: string;
|
||||
}
|
||||
|
||||
export interface ChatListResponse {
|
||||
chats: Chat[];
|
||||
}
|
||||
37
frontend/src/utils.ts
Normal file
37
frontend/src/utils.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Model } from './types';
|
||||
|
||||
export const parseFilter = (filterStr: string) => {
|
||||
const colonIndex = (filterStr || '').indexOf(':');
|
||||
if (colonIndex === -1) return null;
|
||||
|
||||
const path = filterStr.slice(0, colonIndex).trim().replace(/^\./, '');
|
||||
const value = filterStr
|
||||
.slice(colonIndex + 1)
|
||||
.trim()
|
||||
.replace(/^["']|["']$/g, '');
|
||||
|
||||
return { path, value };
|
||||
};
|
||||
|
||||
export const matchesFilter = <T>(
|
||||
obj: T,
|
||||
path: string,
|
||||
value: string,
|
||||
): boolean => {
|
||||
const fieldValue = path
|
||||
.split('.')
|
||||
.reduce<unknown>(
|
||||
(o, key) => (o as Record<string, unknown>)?.[key],
|
||||
obj as Record<string, unknown>,
|
||||
);
|
||||
return Array.isArray(fieldValue)
|
||||
? fieldValue.includes(value)
|
||||
: fieldValue === value;
|
||||
};
|
||||
|
||||
export const applyFilter = (data: Model[], filterStr: string) => {
|
||||
const filter = parseFilter(filterStr);
|
||||
return filter
|
||||
? data.filter((item) => matchesFilter(item, filter.path, filter.value))
|
||||
: data;
|
||||
};
|
||||
153
frontend/styles.css
Normal file
153
frontend/styles.css
Normal file
@@ -0,0 +1,153 @@
|
||||
@import "tailwindcss";
|
||||
@plugin "@tailwindcss/typography";
|
||||
|
||||
[x-cloak] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.scrollbar-hide {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.scrollbar-hide::-webkit-scrollbar-thumb {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@variant dark (&:where(.dark, .dark *));
|
||||
|
||||
@theme {
|
||||
/* Light mode - light backgrounds, dark text */
|
||||
--color-primary-50: oklch(98% 0.02 290);
|
||||
--color-primary-100: oklch(95% 0.04 290);
|
||||
--color-primary-200: oklch(90% 0.08 290);
|
||||
--color-primary-300: oklch(82% 0.14 290);
|
||||
--color-primary-400: oklch(70% 0.18 290);
|
||||
--color-primary-500: oklch(60% 0.2 290);
|
||||
--color-primary-600: oklch(50% 0.18 290);
|
||||
--color-primary-700: oklch(42% 0.15 290);
|
||||
--color-primary-800: oklch(35% 0.12 290);
|
||||
--color-primary-900: oklch(28% 0.1 290);
|
||||
|
||||
--color-secondary-50: oklch(98% 0.02 180);
|
||||
--color-secondary-100: oklch(94% 0.04 180);
|
||||
--color-secondary-200: oklch(88% 0.08 180);
|
||||
--color-secondary-300: oklch(80% 0.12 180);
|
||||
--color-secondary-400: oklch(68% 0.14 180);
|
||||
--color-secondary-500: oklch(58% 0.15 180);
|
||||
--color-secondary-600: oklch(48% 0.13 180);
|
||||
--color-secondary-700: oklch(40% 0.11 180);
|
||||
--color-secondary-800: oklch(33% 0.09 180);
|
||||
--color-secondary-900: oklch(27% 0.07 180);
|
||||
|
||||
--color-tertiary-50: oklch(98% 0.005 60);
|
||||
--color-tertiary-100: oklch(95% 0.01 60);
|
||||
--color-tertiary-200: oklch(90% 0.015 60);
|
||||
--color-tertiary-300: oklch(82% 0.02 60);
|
||||
--color-tertiary-400: oklch(70% 0.025 60);
|
||||
--color-tertiary-500: oklch(58% 0.03 60);
|
||||
--color-tertiary-600: oklch(48% 0.025 60);
|
||||
--color-tertiary-700: oklch(40% 0.02 60);
|
||||
--color-tertiary-800: oklch(33% 0.015 60);
|
||||
--color-tertiary-900: oklch(26% 0.01 60);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
.dark {
|
||||
/* Dark mode - dark backgrounds (50-300), light text (700-900) */
|
||||
--color-primary-50: oklch(15% 0.08 290);
|
||||
--color-primary-100: oklch(18% 0.1 290);
|
||||
--color-primary-200: oklch(22% 0.12 290);
|
||||
--color-primary-300: oklch(28% 0.15 290);
|
||||
--color-primary-400: oklch(36% 0.18 290);
|
||||
--color-primary-500: oklch(45% 0.2 290);
|
||||
--color-primary-600: oklch(55% 0.18 290);
|
||||
--color-primary-700: oklch(65% 0.15 290);
|
||||
--color-primary-800: oklch(75% 0.12 290);
|
||||
--color-primary-900: oklch(85% 0.08 290);
|
||||
|
||||
--color-secondary-50: oklch(15% 0.05 180);
|
||||
--color-secondary-100: oklch(18% 0.07 180);
|
||||
--color-secondary-200: oklch(22% 0.09 180);
|
||||
--color-secondary-300: oklch(28% 0.11 180);
|
||||
--color-secondary-400: oklch(36% 0.13 180);
|
||||
--color-secondary-500: oklch(45% 0.15 180);
|
||||
--color-secondary-600: oklch(55% 0.14 180);
|
||||
--color-secondary-700: oklch(65% 0.12 180);
|
||||
--color-secondary-800: oklch(75% 0.09 180);
|
||||
--color-secondary-900: oklch(85% 0.06 180);
|
||||
|
||||
--color-tertiary-50: oklch(15% 0.008 60);
|
||||
--color-tertiary-100: oklch(18% 0.01 60);
|
||||
--color-tertiary-200: oklch(22% 0.015 60);
|
||||
--color-tertiary-300: oklch(28% 0.02 60);
|
||||
--color-tertiary-400: oklch(36% 0.025 60);
|
||||
--color-tertiary-500: oklch(45% 0.03 60);
|
||||
--color-tertiary-600: oklch(55% 0.025 60);
|
||||
--color-tertiary-700: oklch(65% 0.02 60);
|
||||
--color-tertiary-800: oklch(75% 0.015 60);
|
||||
--color-tertiary-900: oklch(85% 0.01 60);
|
||||
}
|
||||
}
|
||||
|
||||
.prose {
|
||||
--tw-prose-body: theme("colors.primary.900");
|
||||
--tw-prose-headings: theme("colors.primary.900");
|
||||
--tw-prose-links: theme("colors.primary.600");
|
||||
--tw-prose-bold: theme("colors.primary.900");
|
||||
--tw-prose-counters: theme("colors.primary.700");
|
||||
--tw-prose-bullets: theme("colors.primary.700");
|
||||
--tw-prose-hr: theme("colors.primary.200");
|
||||
--tw-prose-quotes: theme("colors.primary.700");
|
||||
--tw-prose-quote-borders: theme("colors.primary.400");
|
||||
--tw-prose-captions: theme("colors.primary.700");
|
||||
--tw-prose-code: theme("colors.primary.900");
|
||||
--tw-prose-pre-code: theme("colors.primary.900");
|
||||
--tw-prose-pre-bg: theme("colors.primary.100");
|
||||
--tw-prose-th-borders: theme("colors.primary.300");
|
||||
--tw-prose-td-borders: theme("colors.primary.300");
|
||||
}
|
||||
|
||||
.dark .prose {
|
||||
--tw-prose-body: theme("colors.primary.100");
|
||||
--tw-prose-headings: theme("colors.primary.100");
|
||||
--tw-prose-links: theme("colors.primary.400");
|
||||
--tw-prose-bold: theme("colors.primary.100");
|
||||
--tw-prose-counters: theme("colors.primary.300");
|
||||
--tw-prose-bullets: theme("colors.primary.300");
|
||||
--tw-prose-hr: theme("colors.primary.700");
|
||||
--tw-prose-quotes: theme("colors.primary.300");
|
||||
--tw-prose-quote-borders: theme("colors.primary.500");
|
||||
--tw-prose-captions: theme("colors.primary.300");
|
||||
--tw-prose-code: theme("colors.primary.100");
|
||||
--tw-prose-pre-code: theme("colors.primary.100");
|
||||
--tw-prose-pre-bg: theme("colors.primary.800");
|
||||
--tw-prose-th-borders: theme("colors.primary.700");
|
||||
--tw-prose-td-borders: theme("colors.primary.700");
|
||||
}
|
||||
|
||||
.prose pre {
|
||||
background: theme("colors.primary.100");
|
||||
border: 1px solid theme("colors.primary.200");
|
||||
}
|
||||
|
||||
.dark .prose pre {
|
||||
background: theme("colors.primary.800");
|
||||
border: 1px solid theme("colors.primary.700");
|
||||
}
|
||||
|
||||
.prose code:not(pre code) {
|
||||
background: theme("colors.primary.300");
|
||||
color: theme("colors.primary.900");
|
||||
padding: 0.125rem 0.25rem;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.dark .prose code:not(pre code) {
|
||||
background: theme("colors.primary.700");
|
||||
color: theme("colors.primary.100");
|
||||
}
|
||||
13
frontend/tsconfig.json
Normal file
13
frontend/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ES2020",
|
||||
"lib": ["ES2020", "DOM"],
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"esModuleInterop": true,
|
||||
"noImplicitAny": true,
|
||||
"moduleResolution": "bundler"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user