initial commit

This commit is contained in:
2025-12-31 15:33:16 -05:00
commit 4641e7d0ef
51 changed files with 4779 additions and 0 deletions

2
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
node_modules
public/dist

45
frontend/AGENTS.md Normal file
View 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
View 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
View 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
View 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
View 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>

View 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>

View 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>

View 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
View 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}`);
}
}
}
}

View 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';
}

View 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();
}
}
},
};
});

View 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);

View 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;
}
},
}));

View 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
View 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
View 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];
}

View 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
View 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
View 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
View 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"
}
}