Compare commits

..

5 Commits

Author SHA1 Message Date
naomi 5c65eb9ae5 release: v1.1.0
CI / Lint & Check (push) Successful in 13m32s
CI / Build Windows (push) Successful in 25m52s
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 2m41s
2026-04-13 21:00:16 -07:00
hikari 4776f00e51 feat: generate adjective-noun combo names for threads (#19)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m10s
CI / Lint & Check (push) Successful in 12m45s
CI / Build Windows (push) Has been cancelled
Replaces the boring timestamp/prompt-truncation thread naming with randomly generated adjective-noun combos (e.g. *Crimson Reverie*, *Neon Phantom*, *Gilded Silence*).

50 adjectives × 50 nouns = 2,500 unique possible thread names, applied consistently across all three modes.

 This issue was created with help from Hikari~ 🌸

Reviewed-on: #19
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-04-13 20:45:15 -07:00
hikari 9f45ee329d feat: add user-selectable aspect ratio and resolution per thread (#18)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m14s
CI / Lint & Check (push) Successful in 12m50s
CI / Build Windows (push) Successful in 28m36s
## Summary

- Adds a two-step thread creation modal — step 1 picks the mode, step 2 configures generation options
- Art mode now supports user-selectable aspect ratio (1:1, 4:3, 3:4, 16:9, 9:16, 21:9)
- All three modes (Art, Avatar, Replace) now support user-selectable resolution (1K, 2K, 4K)
- Mode label in the input area reflects the chosen settings (e.g. `🩷 Art Mode (16:9) · 4K`)
- Backend cost calculation now scales with resolution
- Regenerated `icon.ico` with clean BMP-only entries to fix Windows local builds

 This PR was created with help from Hikari~ 🌸

Reviewed-on: #18
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-04-13 18:24:46 -07:00
hikari 5bfd25e60d fix(ci): pin LLVM 18 for Windows cross-compilation (#17)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m13s
CI / Lint & Check (push) Successful in 13m23s
CI / Build Windows (push) Successful in 36m51s
## Summary

The Windows build job has been failing on every PR since the initial release. The root cause is that the default `clang`/`lld`/`llvm` apt packages resolve to an older version of `llvm-rc` that cannot handle PNG-compressed entries in `.ico` files — and all six images in `icon.ico` use PNG compression.

Pinning to `clang-18`, `lld-18`, and `llvm-18` (available in Ubuntu 24.04's default repos) and registering them via `update-alternatives` ensures `llvm-rc`, `clang-cl`, `lld-link`, and friends all resolve to a version that handles the icon resource correctly.

 This PR was created with help from Hikari~ 🌸

Reviewed-on: #17
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-04-13 16:21:42 -07:00
hikari 662d6119fa feat: add image input to art/avatar modes and notes to replace mode (#16)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m8s
CI / Lint & Check (push) Successful in 12m40s
CI / Build Windows (push) Failing after 25m14s
## Summary

- Art and Avatar modes now support optional image uploads (paste or file picker), sent to Gemini alongside the text prompt
- Initial Replace mode gains an optional textarea for notes to include with the uploaded image
- Backend updated to send user images for all modes, appending replace instructions only when in replace mode
- Send validation updated to allow image-only messages in art and avatar modes

 This PR was created with help from Hikari~ 🌸

Reviewed-on: #16
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-04-13 10:18:35 -07:00
14 changed files with 789 additions and 239 deletions
+2 -2
View File
@@ -1,7 +1,7 @@
{ {
"name": "tatsumi", "name": "tatsumi",
"private": true, "private": true,
"version": "1.0.0", "version": "1.1.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
@@ -24,7 +24,7 @@
"@types/react": "19.1.2", "@types/react": "19.1.2",
"@types/react-dom": "19.1.2", "@types/react-dom": "19.1.2",
"autoprefixer": "10.4.21", "autoprefixer": "10.4.21",
"eslint": "10.1.0", "eslint": "9.25.1",
"postcss": "8.5.3", "postcss": "8.5.3",
"tailwindcss": "3.4.17", "tailwindcss": "3.4.17",
"typescript": "5.8.3", "typescript": "5.8.3",
+185 -126
View File
@@ -26,7 +26,7 @@ importers:
devDependencies: devDependencies:
'@nhcarrigan/eslint-config': '@nhcarrigan/eslint-config':
specifier: 5.2.0 specifier: 5.2.0
version: 5.2.0(@typescript-eslint/utils@8.58.1(eslint@10.1.0(jiti@1.21.7))(typescript@5.8.3))(eslint@10.1.0(jiti@1.21.7))(playwright@1.59.1)(react@19.1.0)(typescript@5.8.3)(vitest@4.1.4(vite@6.3.2(jiti@1.21.7)(yaml@2.8.3))) version: 5.2.0(@typescript-eslint/utils@8.58.1(eslint@9.25.1(jiti@1.21.7))(typescript@5.8.3))(eslint@9.25.1(jiti@1.21.7))(playwright@1.59.1)(react@19.1.0)(typescript@5.8.3)(vitest@4.1.4(vite@6.3.2(jiti@1.21.7)(yaml@2.8.3)))
'@nhcarrigan/typescript-config': '@nhcarrigan/typescript-config':
specifier: 4.0.0 specifier: 4.0.0
version: 4.0.0(typescript@5.8.3) version: 4.0.0(typescript@5.8.3)
@@ -46,8 +46,8 @@ importers:
specifier: 10.4.21 specifier: 10.4.21
version: 10.4.21(postcss@8.5.3) version: 10.4.21(postcss@8.5.3)
eslint: eslint:
specifier: 10.1.0 specifier: 9.25.1
version: 10.1.0(jiti@1.21.7) version: 9.25.1(jiti@1.21.7)
postcss: postcss:
specifier: 8.5.3 specifier: 8.5.3
version: 8.5.3 version: 8.5.3
@@ -335,33 +335,41 @@ packages:
eslint: eslint:
optional: true optional: true
'@eslint/config-array@0.23.5': '@eslint/config-array@0.20.1':
resolution: {integrity: sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==} resolution: {integrity: sha512-OL0RJzC/CBzli0DrrR31qzj6d6i6Mm3HByuhflhl4LOBiWxN+3i6/t/ZQQNii4tjksXi8r2CRW1wMpWA2ULUEw==}
engines: {node: ^20.19.0 || ^22.13.0 || >=24} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@eslint/config-helpers@0.5.5': '@eslint/config-helpers@0.2.3':
resolution: {integrity: sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w==} resolution: {integrity: sha512-u180qk2Um1le4yf0ruXH3PYFeEZeYC3p/4wCTKrr2U1CmGdzGi3KtY0nuPDH48UJxlKCC5RDzbcbh4X0XlqgHg==}
engines: {node: ^20.19.0 || ^22.13.0 || >=24} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@eslint/core@1.2.1': '@eslint/core@0.13.0':
resolution: {integrity: sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==} resolution: {integrity: sha512-yfkgDw1KR66rkT5A8ci4irzDysN7FRpq3ttJolR88OqQikAWqwA8j5VZyas+vjyBNFIJ7MfybJ9plMILI2UrCw==}
engines: {node: ^20.19.0 || ^22.13.0 || >=24} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@eslint/eslintrc@3.2.0': '@eslint/eslintrc@3.2.0':
resolution: {integrity: sha512-grOjVNN8P3hjJn/eIETF1wwd12DdnwFDoyceUJLYYdkpbwq3nLi+4fqrTAONx7XDALqlL220wC/RHSC/QTI/0w==} resolution: {integrity: sha512-grOjVNN8P3hjJn/eIETF1wwd12DdnwFDoyceUJLYYdkpbwq3nLi+4fqrTAONx7XDALqlL220wC/RHSC/QTI/0w==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@eslint/eslintrc@3.3.5':
resolution: {integrity: sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@eslint/js@9.17.0': '@eslint/js@9.17.0':
resolution: {integrity: sha512-Sxc4hqcs1kTu0iID3kcZDW3JHq2a77HO9P8CP6YEA/FpH3Ll8UXE2r/86Rz9YJLKme39S9vU5OWNjC6Xl0Cr3w==} resolution: {integrity: sha512-Sxc4hqcs1kTu0iID3kcZDW3JHq2a77HO9P8CP6YEA/FpH3Ll8UXE2r/86Rz9YJLKme39S9vU5OWNjC6Xl0Cr3w==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@eslint/object-schema@3.0.5': '@eslint/js@9.25.1':
resolution: {integrity: sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==} resolution: {integrity: sha512-dEIwmjntEx8u3Uvv+kr3PDeeArL8Hw07H9kyYxCjnM9pBjfEhk6uLXSchxxzgiwtRhhzVzqmUSDFBOi1TuZ7qg==}
engines: {node: ^20.19.0 || ^22.13.0 || >=24} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@eslint/plugin-kit@0.6.1': '@eslint/object-schema@2.1.7':
resolution: {integrity: sha512-iH1B076HoAshH1mLpHMgwdGeTs0CYwL0SPMkGuSebZrwBp16v415e9NZXg2jtrqPVQjf6IANe2Vtlr5KswtcZQ==} resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==}
engines: {node: ^20.19.0 || ^22.13.0 || >=24} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@eslint/plugin-kit@0.2.8':
resolution: {integrity: sha512-ZAoA40rNMPwSm+AeHpCq8STiNAwzWLJuP8Xv4CHIc9wv/PSuExjMrmjfYNj682vW0OOiZ1HKxzvjQr9XZIisQA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@humanfs/core@0.19.1': '@humanfs/core@0.19.1':
resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
@@ -665,9 +673,6 @@ packages:
'@types/deep-eql@4.0.2': '@types/deep-eql@4.0.2':
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
'@types/esrecurse@4.3.1':
resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==}
'@types/estree@1.0.8': '@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
@@ -865,6 +870,10 @@ packages:
ajv@6.14.0: ajv@6.14.0:
resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==} resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==}
ansi-styles@4.3.0:
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
engines: {node: '>=8'}
any-promise@1.3.0: any-promise@1.3.0:
resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==}
@@ -1003,6 +1012,10 @@ packages:
resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
engines: {node: '>=18'} engines: {node: '>=18'}
chalk@4.1.2:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'}
chokidar@3.6.0: chokidar@3.6.0:
resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
engines: {node: '>= 8.10.0'} engines: {node: '>= 8.10.0'}
@@ -1015,6 +1028,13 @@ packages:
resolution: {integrity: sha512-GfisEZEJvzKrmGWkvfhgzcz/BllN1USeqD2V6tg14OAOgaCD2Z/PUEuxnAZ/nPvmaHRG7a8y77p1T/IRQ4D1Hw==} resolution: {integrity: sha512-GfisEZEJvzKrmGWkvfhgzcz/BllN1USeqD2V6tg14OAOgaCD2Z/PUEuxnAZ/nPvmaHRG7a8y77p1T/IRQ4D1Hw==}
engines: {node: '>=4'} engines: {node: '>=4'}
color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'}
color-name@1.1.4:
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
commander@4.1.1: commander@4.1.1:
resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==}
engines: {node: '>= 6'} engines: {node: '>= 6'}
@@ -1231,9 +1251,9 @@ packages:
peerDependencies: peerDependencies:
eslint: '>=8.56.0' eslint: '>=8.56.0'
eslint-scope@9.1.2: eslint-scope@8.4.0:
resolution: {integrity: sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==} resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==}
engines: {node: ^20.19.0 || ^22.13.0 || >=24} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
eslint-visitor-keys@1.3.0: eslint-visitor-keys@1.3.0:
resolution: {integrity: sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ==} resolution: {integrity: sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ==}
@@ -1251,9 +1271,9 @@ packages:
resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==}
engines: {node: ^20.19.0 || ^22.13.0 || >=24} engines: {node: ^20.19.0 || ^22.13.0 || >=24}
eslint@10.1.0: eslint@9.25.1:
resolution: {integrity: sha512-S9jlY/ELKEUwwQnqWDO+f+m6sercqOPSqXM5Go94l7DOmxHVDgmSFGWEzeE/gwgTAr0W103BWt0QLe/7mabIvA==} resolution: {integrity: sha512-E6Mtz9oGQWDCpV12319d59n4tx9zOTXSTmc8BLVxBx+G/0RdM5MvEEJLU9c0+aleoePYYgVTOsRblx433qmhWQ==}
engines: {node: ^20.19.0 || ^22.13.0 || >=24} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
hasBin: true hasBin: true
peerDependencies: peerDependencies:
jiti: '*' jiti: '*'
@@ -1265,10 +1285,6 @@ packages:
resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
espree@11.2.0:
resolution: {integrity: sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==}
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
espree@6.2.1: espree@6.2.1:
resolution: {integrity: sha512-ysCxRQY3WaXJz9tdbWOwuWr5Y/XrPTGX9Kiz3yoUXwW0VZ4w30HTkQLaGx/+ttFjF8i+ACbArnB4ce68a9m5hw==} resolution: {integrity: sha512-ysCxRQY3WaXJz9tdbWOwuWr5Y/XrPTGX9Kiz3yoUXwW0VZ4w30HTkQLaGx/+ttFjF8i+ACbArnB4ce68a9m5hw==}
engines: {node: '>=6.0.0'} engines: {node: '>=6.0.0'}
@@ -1430,6 +1446,10 @@ packages:
resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
has-flag@4.0.0:
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
engines: {node: '>=8'}
has-property-descriptors@1.0.2: has-property-descriptors@1.0.2:
resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==}
@@ -1664,6 +1684,9 @@ packages:
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
engines: {node: '>=10'} engines: {node: '>=10'}
lodash.merge@4.6.2:
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
loose-envify@1.4.0: loose-envify@1.4.0:
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
hasBin: true hasBin: true
@@ -2141,6 +2164,10 @@ packages:
engines: {node: '>=16 || 14 >=14.17'} engines: {node: '>=16 || 14 >=14.17'}
hasBin: true hasBin: true
supports-color@7.2.0:
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
engines: {node: '>=8'}
supports-preserve-symlinks-flag@1.0.0: supports-preserve-symlinks-flag@1.0.0:
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -2580,36 +2607,34 @@ snapshots:
'@esbuild/win32-x64@0.25.12': '@esbuild/win32-x64@0.25.12':
optional: true optional: true
'@eslint-community/eslint-plugin-eslint-comments@4.4.1(eslint@10.1.0(jiti@1.21.7))': '@eslint-community/eslint-plugin-eslint-comments@4.4.1(eslint@9.25.1(jiti@1.21.7))':
dependencies: dependencies:
escape-string-regexp: 4.0.0 escape-string-regexp: 4.0.0
eslint: 10.1.0(jiti@1.21.7) eslint: 9.25.1(jiti@1.21.7)
ignore: 5.3.2 ignore: 5.3.2
'@eslint-community/eslint-utils@4.9.1(eslint@10.1.0(jiti@1.21.7))': '@eslint-community/eslint-utils@4.9.1(eslint@9.25.1(jiti@1.21.7))':
dependencies: dependencies:
eslint: 10.1.0(jiti@1.21.7) eslint: 9.25.1(jiti@1.21.7)
eslint-visitor-keys: 3.4.3 eslint-visitor-keys: 3.4.3
'@eslint-community/regexpp@4.12.2': {} '@eslint-community/regexpp@4.12.2': {}
'@eslint/compat@1.2.4(eslint@10.1.0(jiti@1.21.7))': '@eslint/compat@1.2.4(eslint@9.25.1(jiti@1.21.7))':
optionalDependencies: optionalDependencies:
eslint: 10.1.0(jiti@1.21.7) eslint: 9.25.1(jiti@1.21.7)
'@eslint/config-array@0.23.5': '@eslint/config-array@0.20.1':
dependencies: dependencies:
'@eslint/object-schema': 3.0.5 '@eslint/object-schema': 2.1.7
debug: 4.4.3 debug: 4.4.3
minimatch: 10.2.5 minimatch: 3.1.5
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@eslint/config-helpers@0.5.5': '@eslint/config-helpers@0.2.3': {}
dependencies:
'@eslint/core': 1.2.1
'@eslint/core@1.2.1': '@eslint/core@0.13.0':
dependencies: dependencies:
'@types/json-schema': 7.0.15 '@types/json-schema': 7.0.15
@@ -2627,13 +2652,29 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@eslint/eslintrc@3.3.5':
dependencies:
ajv: 6.14.0
debug: 4.4.3
espree: 10.4.0
globals: 14.0.0
ignore: 5.3.2
import-fresh: 3.3.1
js-yaml: 4.1.1
minimatch: 3.1.5
strip-json-comments: 3.1.1
transitivePeerDependencies:
- supports-color
'@eslint/js@9.17.0': {} '@eslint/js@9.17.0': {}
'@eslint/object-schema@3.0.5': {} '@eslint/js@9.25.1': {}
'@eslint/plugin-kit@0.6.1': '@eslint/object-schema@2.1.7': {}
'@eslint/plugin-kit@0.2.8':
dependencies: dependencies:
'@eslint/core': 1.2.1 '@eslint/core': 0.13.0
levn: 0.4.1 levn: 0.4.1
'@humanfs/core@0.19.1': {} '@humanfs/core@0.19.1': {}
@@ -2666,24 +2707,24 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2 '@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/sourcemap-codec': 1.5.5
'@nhcarrigan/eslint-config@5.2.0(@typescript-eslint/utils@8.58.1(eslint@10.1.0(jiti@1.21.7))(typescript@5.8.3))(eslint@10.1.0(jiti@1.21.7))(playwright@1.59.1)(react@19.1.0)(typescript@5.8.3)(vitest@4.1.4(vite@6.3.2(jiti@1.21.7)(yaml@2.8.3)))': '@nhcarrigan/eslint-config@5.2.0(@typescript-eslint/utils@8.58.1(eslint@9.25.1(jiti@1.21.7))(typescript@5.8.3))(eslint@9.25.1(jiti@1.21.7))(playwright@1.59.1)(react@19.1.0)(typescript@5.8.3)(vitest@4.1.4(vite@6.3.2(jiti@1.21.7)(yaml@2.8.3)))':
dependencies: dependencies:
'@eslint-community/eslint-plugin-eslint-comments': 4.4.1(eslint@10.1.0(jiti@1.21.7)) '@eslint-community/eslint-plugin-eslint-comments': 4.4.1(eslint@9.25.1(jiti@1.21.7))
'@eslint/compat': 1.2.4(eslint@10.1.0(jiti@1.21.7)) '@eslint/compat': 1.2.4(eslint@9.25.1(jiti@1.21.7))
'@eslint/eslintrc': 3.2.0 '@eslint/eslintrc': 3.2.0
'@eslint/js': 9.17.0 '@eslint/js': 9.17.0
'@stylistic/eslint-plugin': 2.12.1(eslint@10.1.0(jiti@1.21.7))(typescript@5.8.3) '@stylistic/eslint-plugin': 2.12.1(eslint@9.25.1(jiti@1.21.7))(typescript@5.8.3)
'@typescript-eslint/eslint-plugin': 8.19.0(@typescript-eslint/parser@8.19.0(eslint@10.1.0(jiti@1.21.7))(typescript@5.8.3))(eslint@10.1.0(jiti@1.21.7))(typescript@5.8.3) '@typescript-eslint/eslint-plugin': 8.19.0(@typescript-eslint/parser@8.19.0(eslint@9.25.1(jiti@1.21.7))(typescript@5.8.3))(eslint@9.25.1(jiti@1.21.7))(typescript@5.8.3)
'@typescript-eslint/parser': 8.19.0(eslint@10.1.0(jiti@1.21.7))(typescript@5.8.3) '@typescript-eslint/parser': 8.19.0(eslint@9.25.1(jiti@1.21.7))(typescript@5.8.3)
'@vitest/eslint-plugin': 1.1.24(@typescript-eslint/utils@8.58.1(eslint@10.1.0(jiti@1.21.7))(typescript@5.8.3))(eslint@10.1.0(jiti@1.21.7))(typescript@5.8.3)(vitest@4.1.4(vite@6.3.2(jiti@1.21.7)(yaml@2.8.3))) '@vitest/eslint-plugin': 1.1.24(@typescript-eslint/utils@8.58.1(eslint@9.25.1(jiti@1.21.7))(typescript@5.8.3))(eslint@9.25.1(jiti@1.21.7))(typescript@5.8.3)(vitest@4.1.4(vite@6.3.2(jiti@1.21.7)(yaml@2.8.3)))
eslint: 10.1.0(jiti@1.21.7) eslint: 9.25.1(jiti@1.21.7)
eslint-plugin-deprecation: 3.0.0(eslint@10.1.0(jiti@1.21.7))(typescript@5.8.3) eslint-plugin-deprecation: 3.0.0(eslint@9.25.1(jiti@1.21.7))(typescript@5.8.3)
eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.19.0(eslint@10.1.0(jiti@1.21.7))(typescript@5.8.3))(eslint@10.1.0(jiti@1.21.7)) eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.19.0(eslint@9.25.1(jiti@1.21.7))(typescript@5.8.3))(eslint@9.25.1(jiti@1.21.7))
eslint-plugin-jsdoc: 50.6.1(eslint@10.1.0(jiti@1.21.7)) eslint-plugin-jsdoc: 50.6.1(eslint@9.25.1(jiti@1.21.7))
eslint-plugin-playwright: 2.1.0(eslint@10.1.0(jiti@1.21.7)) eslint-plugin-playwright: 2.1.0(eslint@9.25.1(jiti@1.21.7))
eslint-plugin-react: 7.37.3(eslint@10.1.0(jiti@1.21.7)) eslint-plugin-react: 7.37.3(eslint@9.25.1(jiti@1.21.7))
eslint-plugin-sort-keys-fix: 1.1.2 eslint-plugin-sort-keys-fix: 1.1.2
eslint-plugin-unicorn: 56.0.1(eslint@10.1.0(jiti@1.21.7)) eslint-plugin-unicorn: 56.0.1(eslint@9.25.1(jiti@1.21.7))
globals: 15.14.0 globals: 15.14.0
playwright: 1.59.1 playwright: 1.59.1
react: 19.1.0 react: 19.1.0
@@ -2792,10 +2833,10 @@ snapshots:
'@standard-schema/spec@1.1.0': {} '@standard-schema/spec@1.1.0': {}
'@stylistic/eslint-plugin@2.12.1(eslint@10.1.0(jiti@1.21.7))(typescript@5.8.3)': '@stylistic/eslint-plugin@2.12.1(eslint@9.25.1(jiti@1.21.7))(typescript@5.8.3)':
dependencies: dependencies:
'@typescript-eslint/utils': 8.58.1(eslint@10.1.0(jiti@1.21.7))(typescript@5.8.3) '@typescript-eslint/utils': 8.58.1(eslint@9.25.1(jiti@1.21.7))(typescript@5.8.3)
eslint: 10.1.0(jiti@1.21.7) eslint: 9.25.1(jiti@1.21.7)
eslint-visitor-keys: 4.2.1 eslint-visitor-keys: 4.2.1
espree: 10.4.0 espree: 10.4.0
estraverse: 5.3.0 estraverse: 5.3.0
@@ -2891,8 +2932,6 @@ snapshots:
'@types/deep-eql@4.0.2': {} '@types/deep-eql@4.0.2': {}
'@types/esrecurse@4.3.1': {}
'@types/estree@1.0.8': {} '@types/estree@1.0.8': {}
'@types/json-schema@7.0.15': {} '@types/json-schema@7.0.15': {}
@@ -2909,15 +2948,15 @@ snapshots:
dependencies: dependencies:
csstype: 3.2.3 csstype: 3.2.3
'@typescript-eslint/eslint-plugin@8.19.0(@typescript-eslint/parser@8.19.0(eslint@10.1.0(jiti@1.21.7))(typescript@5.8.3))(eslint@10.1.0(jiti@1.21.7))(typescript@5.8.3)': '@typescript-eslint/eslint-plugin@8.19.0(@typescript-eslint/parser@8.19.0(eslint@9.25.1(jiti@1.21.7))(typescript@5.8.3))(eslint@9.25.1(jiti@1.21.7))(typescript@5.8.3)':
dependencies: dependencies:
'@eslint-community/regexpp': 4.12.2 '@eslint-community/regexpp': 4.12.2
'@typescript-eslint/parser': 8.19.0(eslint@10.1.0(jiti@1.21.7))(typescript@5.8.3) '@typescript-eslint/parser': 8.19.0(eslint@9.25.1(jiti@1.21.7))(typescript@5.8.3)
'@typescript-eslint/scope-manager': 8.19.0 '@typescript-eslint/scope-manager': 8.19.0
'@typescript-eslint/type-utils': 8.19.0(eslint@10.1.0(jiti@1.21.7))(typescript@5.8.3) '@typescript-eslint/type-utils': 8.19.0(eslint@9.25.1(jiti@1.21.7))(typescript@5.8.3)
'@typescript-eslint/utils': 8.19.0(eslint@10.1.0(jiti@1.21.7))(typescript@5.8.3) '@typescript-eslint/utils': 8.19.0(eslint@9.25.1(jiti@1.21.7))(typescript@5.8.3)
'@typescript-eslint/visitor-keys': 8.19.0 '@typescript-eslint/visitor-keys': 8.19.0
eslint: 10.1.0(jiti@1.21.7) eslint: 9.25.1(jiti@1.21.7)
graphemer: 1.4.0 graphemer: 1.4.0
ignore: 5.3.2 ignore: 5.3.2
natural-compare: 1.4.0 natural-compare: 1.4.0
@@ -2926,14 +2965,14 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@typescript-eslint/parser@8.19.0(eslint@10.1.0(jiti@1.21.7))(typescript@5.8.3)': '@typescript-eslint/parser@8.19.0(eslint@9.25.1(jiti@1.21.7))(typescript@5.8.3)':
dependencies: dependencies:
'@typescript-eslint/scope-manager': 8.19.0 '@typescript-eslint/scope-manager': 8.19.0
'@typescript-eslint/types': 8.19.0 '@typescript-eslint/types': 8.19.0
'@typescript-eslint/typescript-estree': 8.19.0(typescript@5.8.3) '@typescript-eslint/typescript-estree': 8.19.0(typescript@5.8.3)
'@typescript-eslint/visitor-keys': 8.19.0 '@typescript-eslint/visitor-keys': 8.19.0
debug: 4.4.3 debug: 4.4.3
eslint: 10.1.0(jiti@1.21.7) eslint: 9.25.1(jiti@1.21.7)
typescript: 5.8.3 typescript: 5.8.3
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -2966,12 +3005,12 @@ snapshots:
dependencies: dependencies:
typescript: 5.8.3 typescript: 5.8.3
'@typescript-eslint/type-utils@8.19.0(eslint@10.1.0(jiti@1.21.7))(typescript@5.8.3)': '@typescript-eslint/type-utils@8.19.0(eslint@9.25.1(jiti@1.21.7))(typescript@5.8.3)':
dependencies: dependencies:
'@typescript-eslint/typescript-estree': 8.19.0(typescript@5.8.3) '@typescript-eslint/typescript-estree': 8.19.0(typescript@5.8.3)
'@typescript-eslint/utils': 8.19.0(eslint@10.1.0(jiti@1.21.7))(typescript@5.8.3) '@typescript-eslint/utils': 8.19.0(eslint@9.25.1(jiti@1.21.7))(typescript@5.8.3)
debug: 4.4.3 debug: 4.4.3
eslint: 10.1.0(jiti@1.21.7) eslint: 9.25.1(jiti@1.21.7)
ts-api-utils: 1.4.3(typescript@5.8.3) ts-api-utils: 1.4.3(typescript@5.8.3)
typescript: 5.8.3 typescript: 5.8.3
transitivePeerDependencies: transitivePeerDependencies:
@@ -3027,35 +3066,35 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@typescript-eslint/utils@7.18.0(eslint@10.1.0(jiti@1.21.7))(typescript@5.8.3)': '@typescript-eslint/utils@7.18.0(eslint@9.25.1(jiti@1.21.7))(typescript@5.8.3)':
dependencies: dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@10.1.0(jiti@1.21.7)) '@eslint-community/eslint-utils': 4.9.1(eslint@9.25.1(jiti@1.21.7))
'@typescript-eslint/scope-manager': 7.18.0 '@typescript-eslint/scope-manager': 7.18.0
'@typescript-eslint/types': 7.18.0 '@typescript-eslint/types': 7.18.0
'@typescript-eslint/typescript-estree': 7.18.0(typescript@5.8.3) '@typescript-eslint/typescript-estree': 7.18.0(typescript@5.8.3)
eslint: 10.1.0(jiti@1.21.7) eslint: 9.25.1(jiti@1.21.7)
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
- typescript - typescript
'@typescript-eslint/utils@8.19.0(eslint@10.1.0(jiti@1.21.7))(typescript@5.8.3)': '@typescript-eslint/utils@8.19.0(eslint@9.25.1(jiti@1.21.7))(typescript@5.8.3)':
dependencies: dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@10.1.0(jiti@1.21.7)) '@eslint-community/eslint-utils': 4.9.1(eslint@9.25.1(jiti@1.21.7))
'@typescript-eslint/scope-manager': 8.19.0 '@typescript-eslint/scope-manager': 8.19.0
'@typescript-eslint/types': 8.19.0 '@typescript-eslint/types': 8.19.0
'@typescript-eslint/typescript-estree': 8.19.0(typescript@5.8.3) '@typescript-eslint/typescript-estree': 8.19.0(typescript@5.8.3)
eslint: 10.1.0(jiti@1.21.7) eslint: 9.25.1(jiti@1.21.7)
typescript: 5.8.3 typescript: 5.8.3
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@typescript-eslint/utils@8.58.1(eslint@10.1.0(jiti@1.21.7))(typescript@5.8.3)': '@typescript-eslint/utils@8.58.1(eslint@9.25.1(jiti@1.21.7))(typescript@5.8.3)':
dependencies: dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@10.1.0(jiti@1.21.7)) '@eslint-community/eslint-utils': 4.9.1(eslint@9.25.1(jiti@1.21.7))
'@typescript-eslint/scope-manager': 8.58.1 '@typescript-eslint/scope-manager': 8.58.1
'@typescript-eslint/types': 8.58.1 '@typescript-eslint/types': 8.58.1
'@typescript-eslint/typescript-estree': 8.58.1(typescript@5.8.3) '@typescript-eslint/typescript-estree': 8.58.1(typescript@5.8.3)
eslint: 10.1.0(jiti@1.21.7) eslint: 9.25.1(jiti@1.21.7)
typescript: 5.8.3 typescript: 5.8.3
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -3086,10 +3125,10 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@vitest/eslint-plugin@1.1.24(@typescript-eslint/utils@8.58.1(eslint@10.1.0(jiti@1.21.7))(typescript@5.8.3))(eslint@10.1.0(jiti@1.21.7))(typescript@5.8.3)(vitest@4.1.4(vite@6.3.2(jiti@1.21.7)(yaml@2.8.3)))': '@vitest/eslint-plugin@1.1.24(@typescript-eslint/utils@8.58.1(eslint@9.25.1(jiti@1.21.7))(typescript@5.8.3))(eslint@9.25.1(jiti@1.21.7))(typescript@5.8.3)(vitest@4.1.4(vite@6.3.2(jiti@1.21.7)(yaml@2.8.3)))':
dependencies: dependencies:
'@typescript-eslint/utils': 8.58.1(eslint@10.1.0(jiti@1.21.7))(typescript@5.8.3) '@typescript-eslint/utils': 8.58.1(eslint@9.25.1(jiti@1.21.7))(typescript@5.8.3)
eslint: 10.1.0(jiti@1.21.7) eslint: 9.25.1(jiti@1.21.7)
optionalDependencies: optionalDependencies:
typescript: 5.8.3 typescript: 5.8.3
vitest: 4.1.4(vite@6.3.2(jiti@1.21.7)(yaml@2.8.3)) vitest: 4.1.4(vite@6.3.2(jiti@1.21.7)(yaml@2.8.3))
@@ -3154,6 +3193,10 @@ snapshots:
json-schema-traverse: 0.4.1 json-schema-traverse: 0.4.1
uri-js: 4.4.1 uri-js: 4.4.1
ansi-styles@4.3.0:
dependencies:
color-convert: 2.0.1
any-promise@1.3.0: {} any-promise@1.3.0: {}
anymatch@3.1.3: anymatch@3.1.3:
@@ -3314,6 +3357,11 @@ snapshots:
chai@6.2.2: {} chai@6.2.2: {}
chalk@4.1.2:
dependencies:
ansi-styles: 4.3.0
supports-color: 7.2.0
chokidar@3.6.0: chokidar@3.6.0:
dependencies: dependencies:
anymatch: 3.1.3 anymatch: 3.1.3
@@ -3332,6 +3380,12 @@ snapshots:
dependencies: dependencies:
escape-string-regexp: 1.0.5 escape-string-regexp: 1.0.5
color-convert@2.0.1:
dependencies:
color-name: 1.1.4
color-name@1.1.4: {}
commander@4.1.1: {} commander@4.1.1: {}
comment-parser@1.4.1: {} comment-parser@1.4.1: {}
@@ -3567,27 +3621,27 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
eslint-module-utils@2.12.1(@typescript-eslint/parser@8.19.0(eslint@10.1.0(jiti@1.21.7))(typescript@5.8.3))(eslint-import-resolver-node@0.3.10)(eslint@10.1.0(jiti@1.21.7)): eslint-module-utils@2.12.1(@typescript-eslint/parser@8.19.0(eslint@9.25.1(jiti@1.21.7))(typescript@5.8.3))(eslint-import-resolver-node@0.3.10)(eslint@9.25.1(jiti@1.21.7)):
dependencies: dependencies:
debug: 3.2.7 debug: 3.2.7
optionalDependencies: optionalDependencies:
'@typescript-eslint/parser': 8.19.0(eslint@10.1.0(jiti@1.21.7))(typescript@5.8.3) '@typescript-eslint/parser': 8.19.0(eslint@9.25.1(jiti@1.21.7))(typescript@5.8.3)
eslint: 10.1.0(jiti@1.21.7) eslint: 9.25.1(jiti@1.21.7)
eslint-import-resolver-node: 0.3.10 eslint-import-resolver-node: 0.3.10
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
eslint-plugin-deprecation@3.0.0(eslint@10.1.0(jiti@1.21.7))(typescript@5.8.3): eslint-plugin-deprecation@3.0.0(eslint@9.25.1(jiti@1.21.7))(typescript@5.8.3):
dependencies: dependencies:
'@typescript-eslint/utils': 7.18.0(eslint@10.1.0(jiti@1.21.7))(typescript@5.8.3) '@typescript-eslint/utils': 7.18.0(eslint@9.25.1(jiti@1.21.7))(typescript@5.8.3)
eslint: 10.1.0(jiti@1.21.7) eslint: 9.25.1(jiti@1.21.7)
ts-api-utils: 1.4.3(typescript@5.8.3) ts-api-utils: 1.4.3(typescript@5.8.3)
tslib: 2.8.1 tslib: 2.8.1
typescript: 5.8.3 typescript: 5.8.3
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.19.0(eslint@10.1.0(jiti@1.21.7))(typescript@5.8.3))(eslint@10.1.0(jiti@1.21.7)): eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.19.0(eslint@9.25.1(jiti@1.21.7))(typescript@5.8.3))(eslint@9.25.1(jiti@1.21.7)):
dependencies: dependencies:
'@rtsao/scc': 1.1.0 '@rtsao/scc': 1.1.0
array-includes: 3.1.9 array-includes: 3.1.9
@@ -3596,9 +3650,9 @@ snapshots:
array.prototype.flatmap: 1.3.3 array.prototype.flatmap: 1.3.3
debug: 3.2.7 debug: 3.2.7
doctrine: 2.1.0 doctrine: 2.1.0
eslint: 10.1.0(jiti@1.21.7) eslint: 9.25.1(jiti@1.21.7)
eslint-import-resolver-node: 0.3.10 eslint-import-resolver-node: 0.3.10
eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.19.0(eslint@10.1.0(jiti@1.21.7))(typescript@5.8.3))(eslint-import-resolver-node@0.3.10)(eslint@10.1.0(jiti@1.21.7)) eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.19.0(eslint@9.25.1(jiti@1.21.7))(typescript@5.8.3))(eslint-import-resolver-node@0.3.10)(eslint@9.25.1(jiti@1.21.7))
hasown: 2.0.2 hasown: 2.0.2
is-core-module: 2.16.1 is-core-module: 2.16.1
is-glob: 4.0.3 is-glob: 4.0.3
@@ -3610,20 +3664,20 @@ snapshots:
string.prototype.trimend: 1.0.9 string.prototype.trimend: 1.0.9
tsconfig-paths: 3.15.0 tsconfig-paths: 3.15.0
optionalDependencies: optionalDependencies:
'@typescript-eslint/parser': 8.19.0(eslint@10.1.0(jiti@1.21.7))(typescript@5.8.3) '@typescript-eslint/parser': 8.19.0(eslint@9.25.1(jiti@1.21.7))(typescript@5.8.3)
transitivePeerDependencies: transitivePeerDependencies:
- eslint-import-resolver-typescript - eslint-import-resolver-typescript
- eslint-import-resolver-webpack - eslint-import-resolver-webpack
- supports-color - supports-color
eslint-plugin-jsdoc@50.6.1(eslint@10.1.0(jiti@1.21.7)): eslint-plugin-jsdoc@50.6.1(eslint@9.25.1(jiti@1.21.7)):
dependencies: dependencies:
'@es-joy/jsdoccomment': 0.49.0 '@es-joy/jsdoccomment': 0.49.0
are-docs-informative: 0.0.2 are-docs-informative: 0.0.2
comment-parser: 1.4.1 comment-parser: 1.4.1
debug: 4.4.3 debug: 4.4.3
escape-string-regexp: 4.0.0 escape-string-regexp: 4.0.0
eslint: 10.1.0(jiti@1.21.7) eslint: 9.25.1(jiti@1.21.7)
espree: 10.4.0 espree: 10.4.0
esquery: 1.7.0 esquery: 1.7.0
parse-imports: 2.2.1 parse-imports: 2.2.1
@@ -3633,12 +3687,12 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
eslint-plugin-playwright@2.1.0(eslint@10.1.0(jiti@1.21.7)): eslint-plugin-playwright@2.1.0(eslint@9.25.1(jiti@1.21.7)):
dependencies: dependencies:
eslint: 10.1.0(jiti@1.21.7) eslint: 9.25.1(jiti@1.21.7)
globals: 13.24.0 globals: 13.24.0
eslint-plugin-react@7.37.3(eslint@10.1.0(jiti@1.21.7)): eslint-plugin-react@7.37.3(eslint@9.25.1(jiti@1.21.7)):
dependencies: dependencies:
array-includes: 3.1.9 array-includes: 3.1.9
array.prototype.findlast: 1.2.5 array.prototype.findlast: 1.2.5
@@ -3646,7 +3700,7 @@ snapshots:
array.prototype.tosorted: 1.1.4 array.prototype.tosorted: 1.1.4
doctrine: 2.1.0 doctrine: 2.1.0
es-iterator-helpers: 1.3.1 es-iterator-helpers: 1.3.1
eslint: 10.1.0(jiti@1.21.7) eslint: 9.25.1(jiti@1.21.7)
estraverse: 5.3.0 estraverse: 5.3.0
hasown: 2.0.2 hasown: 2.0.2
jsx-ast-utils: 3.3.5 jsx-ast-utils: 3.3.5
@@ -3667,14 +3721,14 @@ snapshots:
natural-compare: 1.4.0 natural-compare: 1.4.0
requireindex: 1.2.0 requireindex: 1.2.0
eslint-plugin-unicorn@56.0.1(eslint@10.1.0(jiti@1.21.7)): eslint-plugin-unicorn@56.0.1(eslint@9.25.1(jiti@1.21.7)):
dependencies: dependencies:
'@babel/helper-validator-identifier': 7.28.5 '@babel/helper-validator-identifier': 7.28.5
'@eslint-community/eslint-utils': 4.9.1(eslint@10.1.0(jiti@1.21.7)) '@eslint-community/eslint-utils': 4.9.1(eslint@9.25.1(jiti@1.21.7))
ci-info: 4.4.0 ci-info: 4.4.0
clean-regexp: 1.0.0 clean-regexp: 1.0.0
core-js-compat: 3.49.0 core-js-compat: 3.49.0
eslint: 10.1.0(jiti@1.21.7) eslint: 9.25.1(jiti@1.21.7)
esquery: 1.7.0 esquery: 1.7.0
globals: 15.14.0 globals: 15.14.0
indent-string: 4.0.0 indent-string: 4.0.0
@@ -3687,10 +3741,8 @@ snapshots:
semver: 7.7.4 semver: 7.7.4
strip-indent: 3.0.0 strip-indent: 3.0.0
eslint-scope@9.1.2: eslint-scope@8.4.0:
dependencies: dependencies:
'@types/esrecurse': 4.3.1
'@types/estree': 1.0.8
esrecurse: 4.3.0 esrecurse: 4.3.0
estraverse: 5.3.0 estraverse: 5.3.0
@@ -3702,25 +3754,29 @@ snapshots:
eslint-visitor-keys@5.0.1: {} eslint-visitor-keys@5.0.1: {}
eslint@10.1.0(jiti@1.21.7): eslint@9.25.1(jiti@1.21.7):
dependencies: dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@10.1.0(jiti@1.21.7)) '@eslint-community/eslint-utils': 4.9.1(eslint@9.25.1(jiti@1.21.7))
'@eslint-community/regexpp': 4.12.2 '@eslint-community/regexpp': 4.12.2
'@eslint/config-array': 0.23.5 '@eslint/config-array': 0.20.1
'@eslint/config-helpers': 0.5.5 '@eslint/config-helpers': 0.2.3
'@eslint/core': 1.2.1 '@eslint/core': 0.13.0
'@eslint/plugin-kit': 0.6.1 '@eslint/eslintrc': 3.3.5
'@eslint/js': 9.25.1
'@eslint/plugin-kit': 0.2.8
'@humanfs/node': 0.16.7 '@humanfs/node': 0.16.7
'@humanwhocodes/module-importer': 1.0.1 '@humanwhocodes/module-importer': 1.0.1
'@humanwhocodes/retry': 0.4.3 '@humanwhocodes/retry': 0.4.3
'@types/estree': 1.0.8 '@types/estree': 1.0.8
'@types/json-schema': 7.0.15
ajv: 6.14.0 ajv: 6.14.0
chalk: 4.1.2
cross-spawn: 7.0.6 cross-spawn: 7.0.6
debug: 4.4.3 debug: 4.4.3
escape-string-regexp: 4.0.0 escape-string-regexp: 4.0.0
eslint-scope: 9.1.2 eslint-scope: 8.4.0
eslint-visitor-keys: 5.0.1 eslint-visitor-keys: 4.2.1
espree: 11.2.0 espree: 10.4.0
esquery: 1.7.0 esquery: 1.7.0
esutils: 2.0.3 esutils: 2.0.3
fast-deep-equal: 3.1.3 fast-deep-equal: 3.1.3
@@ -3731,7 +3787,8 @@ snapshots:
imurmurhash: 0.1.4 imurmurhash: 0.1.4
is-glob: 4.0.3 is-glob: 4.0.3
json-stable-stringify-without-jsonify: 1.0.1 json-stable-stringify-without-jsonify: 1.0.1
minimatch: 10.2.5 lodash.merge: 4.6.2
minimatch: 3.1.5
natural-compare: 1.4.0 natural-compare: 1.4.0
optionator: 0.9.4 optionator: 0.9.4
optionalDependencies: optionalDependencies:
@@ -3745,12 +3802,6 @@ snapshots:
acorn-jsx: 5.3.2(acorn@8.16.0) acorn-jsx: 5.3.2(acorn@8.16.0)
eslint-visitor-keys: 4.2.1 eslint-visitor-keys: 4.2.1
espree@11.2.0:
dependencies:
acorn: 8.16.0
acorn-jsx: 5.3.2(acorn@8.16.0)
eslint-visitor-keys: 5.0.1
espree@6.2.1: espree@6.2.1:
dependencies: dependencies:
acorn: 7.4.1 acorn: 7.4.1
@@ -3911,6 +3962,8 @@ snapshots:
has-bigints@1.1.0: {} has-bigints@1.1.0: {}
has-flag@4.0.0: {}
has-property-descriptors@1.0.2: has-property-descriptors@1.0.2:
dependencies: dependencies:
es-define-property: 1.0.1 es-define-property: 1.0.1
@@ -4135,6 +4188,8 @@ snapshots:
dependencies: dependencies:
p-locate: 5.0.0 p-locate: 5.0.0
lodash.merge@4.6.2: {}
loose-envify@1.4.0: loose-envify@1.4.0:
dependencies: dependencies:
js-tokens: 4.0.0 js-tokens: 4.0.0
@@ -4675,6 +4730,10 @@ snapshots:
tinyglobby: 0.2.16 tinyglobby: 0.2.16
ts-interface-checker: 0.1.13 ts-interface-checker: 0.1.13
supports-color@7.2.0:
dependencies:
has-flag: 4.0.0
supports-preserve-symlinks-flag@1.0.0: {} supports-preserve-symlinks-flag@1.0.0: {}
synckit@0.9.3: synckit@0.9.3:
+1 -1
View File
@@ -3716,7 +3716,7 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
[[package]] [[package]]
name = "tatsumi" name = "tatsumi"
version = "1.0.0" version = "1.1.0"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"dirs 5.0.1", "dirs 5.0.1",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "tatsumi" name = "tatsumi"
version = "1.0.0" version = "1.1.0"
description = "Tatsumi - AI art generation using Google Gemini" description = "Tatsumi - AI art generation using Google Gemini"
authors = ["Naomi Carrigan"] authors = ["Naomi Carrigan"]
edition = "2021" edition = "2021"
Binary file not shown.

Before

Width:  |  Height:  |  Size: 155 KiB

After

Width:  |  Height:  |  Size: 298 KiB

+56 -21
View File
@@ -46,12 +46,19 @@ fn build_safety_settings() -> Value {
]) ])
} }
fn build_generation_config(mode: &str) -> Value { fn build_generation_config(
mode: &str,
aspect_ratio: Option<&str>,
image_size: &str,
) -> Value {
let image_config = match mode { let image_config = match mode {
"avatar" => json!({ "aspectRatio": "1:1", "imageSize": "4K" }), "avatar" => json!({ "aspectRatio": "1:1", "imageSize": image_size }),
"art" => json!({ "aspectRatio": "16:9", "imageSize": "4K" }), "art" => {
let ratio = aspect_ratio.unwrap_or("16:9");
json!({ "aspectRatio": ratio, "imageSize": image_size })
}
// replace mode: omit aspectRatio so the model infers it from the source image // replace mode: omit aspectRatio so the model infers it from the source image
_ => json!({ "imageSize": "4K" }), _ => json!({ "imageSize": image_size }),
}; };
json!({ json!({
"imageConfig": image_config, "imageConfig": image_config,
@@ -83,37 +90,56 @@ fn build_user_gemini_parts(
user_image_base64: &Option<String>, user_image_base64: &Option<String>,
user_image_mime: &Option<String>, user_image_mime: &Option<String>,
) -> Vec<Value> { ) -> Vec<Value> {
if mode == "replace" && user_image_base64.is_some() { if let Some(image_data) = user_image_base64.as_deref() {
let mime = user_image_mime.as_deref().unwrap_or("image/png"); let mime = user_image_mime.as_deref().unwrap_or("image/png");
let data = user_image_base64.as_deref().unwrap_or("");
let base_text = user_text.as_deref().unwrap_or(""); let base_text = user_text.as_deref().unwrap_or("");
let final_text = if base_text.is_empty() { let final_text = if mode == "replace" {
REPLACE_MODE_APPEND.to_string() if base_text.is_empty() {
REPLACE_MODE_APPEND.to_string()
} else {
format!("{}\n{}", base_text, REPLACE_MODE_APPEND)
}
} else { } else {
format!("{}\n{}", base_text, REPLACE_MODE_APPEND) base_text.to_string()
}; };
vec![ let mut parts = vec![json!({"inlineData": {"mimeType": mime, "data": image_data}})];
json!({"inlineData": {"mimeType": mime, "data": data}}), if !final_text.is_empty() {
json!({"text": final_text}), parts.push(json!({"text": final_text}));
] }
parts
} else { } else {
// Art/avatar mode, or replace mode follow-up correction (text only) // No image: text-only message
let text = user_text.as_deref().unwrap_or(""); let text = user_text.as_deref().unwrap_or("");
vec![json!({"text": text})] vec![json!({"text": text})]
} }
} }
pub struct GeminiCallParams {
pub mode: String,
pub aspect_ratio: Option<String>,
pub image_size: String,
pub user_text: Option<String>,
pub user_image_base64: Option<String>,
pub user_image_mime: Option<String>,
}
pub async fn call_gemini( pub async fn call_gemini(
api_key: String, api_key: String,
mode: String,
history: Vec<ThreadMessage>, history: Vec<ThreadMessage>,
user_text: Option<String>, params: GeminiCallParams,
user_image_base64: Option<String>,
user_image_mime: Option<String>,
) -> Result<(Vec<MessagePart>, f64), String> { ) -> Result<(Vec<MessagePart>, f64), String> {
let client = reqwest::Client::new(); let client = reqwest::Client::new();
let GeminiCallParams {
mode,
aspect_ratio,
image_size,
user_text,
user_image_base64,
user_image_mime,
} = params;
let is_first_message = history.is_empty(); let is_first_message = history.is_empty();
let mut contents: Vec<Value> = history let mut contents: Vec<Value> = history
@@ -156,7 +182,11 @@ pub async fn call_gemini(
contents.push(json!({"role": "user", "parts": user_parts})); contents.push(json!({"role": "user", "parts": user_parts}));
let generation_config = build_generation_config(mode.as_str()); let generation_config = build_generation_config(
mode.as_str(),
aspect_ratio.as_deref(),
image_size.as_str(),
);
let safety_settings = build_safety_settings(); let safety_settings = build_safety_settings();
let request_body = json!({ let request_body = json!({
@@ -248,8 +278,13 @@ pub async fn call_gemini(
let candidates_tokens = usage["candidatesTokenCount"].as_u64().unwrap_or(0); let candidates_tokens = usage["candidatesTokenCount"].as_u64().unwrap_or(0);
let image_part_count = result_parts.iter().filter(|p| p.part_type == "image").count() as u64; let image_part_count = result_parts.iter().filter(|p| p.part_type == "image").count() as u64;
// Image output tokens (4K = 2000 tokens each) billed at $120/1M tokens // Image output tokens per image vary by resolution, billed at $120/1M tokens
let image_output_tokens = image_part_count * 2_000_u64; let tokens_per_image: u64 = match image_size.as_str() {
"1K" => 500,
"2K" => 1_000,
_ => 2_000, // 4K default
};
let image_output_tokens = image_part_count * tokens_per_image;
// Remaining candidates tokens are text/thinking, billed at $12/1M tokens // Remaining candidates tokens are text/thinking, billed at $12/1M tokens
let text_output_tokens = candidates_tokens.saturating_sub(image_output_tokens); let text_output_tokens = candidates_tokens.saturating_sub(image_output_tokens);
+17 -3
View File
@@ -1,7 +1,7 @@
mod gemini; mod gemini;
mod storage; mod storage;
use gemini::{call_gemini, read_reference_image_base64}; use gemini::{call_gemini, read_reference_image_base64, GeminiCallParams};
use serde::Serialize; use serde::Serialize;
use storage::{ use storage::{
delete_thread_from_disk, load_config_from_disk, load_threads_from_disk, save_config_to_disk, delete_thread_from_disk, load_config_from_disk, load_threads_from_disk, save_config_to_disk,
@@ -46,16 +46,30 @@ async fn save_config(config: Config) -> Result<(), String> {
} }
#[tauri::command] #[tauri::command]
#[allow(clippy::too_many_arguments)]
async fn send_message( async fn send_message(
api_key: String, api_key: String,
mode: String, mode: String,
aspect_ratio: Option<String>,
image_size: String,
history: Vec<ThreadMessage>, history: Vec<ThreadMessage>,
user_text: Option<String>, user_text: Option<String>,
user_image_base64: Option<String>, user_image_base64: Option<String>,
user_image_mime: Option<String>, user_image_mime: Option<String>,
) -> Result<SendMessageResult, String> { ) -> Result<SendMessageResult, String> {
let (parts, cost_usd) = let (parts, cost_usd) = call_gemini(
call_gemini(api_key, mode, history, user_text, user_image_base64, user_image_mime).await?; api_key,
history,
GeminiCallParams {
mode,
aspect_ratio,
image_size,
user_text,
user_image_base64,
user_image_mime,
},
)
.await?;
Ok(SendMessageResult { parts, cost_usd }) Ok(SendMessageResult { parts, cost_usd })
} }
+8
View File
@@ -25,11 +25,19 @@ pub struct ThreadMessage {
pub cost_usd: Option<f64>, pub cost_usd: Option<f64>,
} }
fn default_image_size() -> String {
"4K".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Thread { pub struct Thread {
pub id: String, pub id: String,
pub name: String, pub name: String,
pub mode: String, pub mode: String,
#[serde(rename = "aspectRatio", skip_serializing_if = "Option::is_none")]
pub aspect_ratio: Option<String>,
#[serde(rename = "imageSize", default = "default_image_size")]
pub image_size: String,
pub messages: Vec<ThreadMessage>, pub messages: Vec<ThreadMessage>,
#[serde(rename = "createdAt")] #[serde(rename = "createdAt")]
pub created_at: i64, pub created_at: i64,
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "tatsumi", "productName": "tatsumi",
"version": "1.0.0", "version": "1.1.0",
"identifier": "com.naomi.tatsumi", "identifier": "com.naomi.tatsumi",
"build": { "build": {
"beforeDevCommand": "pnpm dev", "beforeDevCommand": "pnpm dev",
+129 -18
View File
@@ -16,17 +16,127 @@ import { SettingsModal } from "./components/settingsModal.tsx";
import { Sidebar } from "./components/sidebar.tsx"; import { Sidebar } from "./components/sidebar.tsx";
import { ThreadView } from "./components/threadView.tsx"; import { ThreadView } from "./components/threadView.tsx";
import { WelcomeScreen } from "./components/welcomeScreen.tsx"; import { WelcomeScreen } from "./components/welcomeScreen.tsx";
import type { Config, Mode, PendingInput, Thread } from "./types/index.ts"; import type {
AspectRatio,
Config,
ImageSize,
Mode,
PendingInput,
Thread,
} from "./types/index.ts";
const generateThreadName = (mode: Mode, text?: string): string => { const adjectives = [
if ( "Amber",
mode === "replace" "Ancient",
|| text === undefined "Arctic",
|| text.trim().length === 0 "Azure",
) { "Blazing",
return `Replace - ${new Date().toLocaleString()}`; "Celestial",
} "Cobalt",
return text.trim().slice(0, 40); "Crimson",
"Crystal",
"Dark",
"Distant",
"Ember",
"Emerald",
"Eternal",
"Ethereal",
"Fleeting",
"Frosted",
"Gilded",
"Golden",
"Hidden",
"Hollow",
"Indigo",
"Ivory",
"Jade",
"Lunar",
"Midnight",
"Mystic",
"Neon",
"Obsidian",
"Onyx",
"Opal",
"Pale",
"Pearl",
"Radiant",
"Rusted",
"Sapphire",
"Scarlet",
"Shattered",
"Silent",
"Silver",
"Spectral",
"Twilight",
"Velvet",
"Verdant",
"Veiled",
"Violet",
"Wandering",
"Wild",
"Woven",
"Arcane",
];
const nouns = [
"Abyss",
"Archive",
"Aria",
"Bloom",
"Cascade",
"Chronicle",
"Cipher",
"Compass",
"Crown",
"Dawn",
"Dream",
"Echo",
"Elegy",
"Ember",
"Equinox",
"Flame",
"Fragment",
"Garden",
"Horizon",
"Labyrinth",
"Lantern",
"Mirage",
"Mist",
"Murmur",
"Nexus",
"Nocturne",
"Oracle",
"Phantom",
"Prism",
"Requiem",
"Reverie",
"Ruin",
"Shadow",
"Shard",
"Silence",
"Solstice",
"Sonata",
"Specter",
"Storm",
"Tempest",
"Throne",
"Tide",
"Veil",
"Vortex",
"Whisper",
"Zenith",
"Sigil",
"Glyph",
"Dusk",
"Omen",
];
const generateThreadName = (): string => {
const adjectiveIndex = Math.floor(Math.random() * adjectives.length);
const nounIndex = Math.floor(Math.random() * nouns.length);
const adjective = adjectives[adjectiveIndex] ?? "Arcane";
const noun = nouns[nounIndex] ?? "Reverie";
return `${adjective} ${noun}`;
}; };
const generateId = (): string => { const generateId = (): string => {
@@ -67,13 +177,7 @@ const resolveUpdatedThread = (updatedThread: Thread): Thread => {
return updatedThread; return updatedThread;
} }
const firstTextPart = firstMessage.parts.find((part) => { const name = generateThreadName();
return part.type === "text";
});
const name = generateThreadName(
updatedThread.mode,
firstTextPart?.text,
);
return { ...updatedThread, name }; return { ...updatedThread, name };
}; };
@@ -120,11 +224,18 @@ const app = (): JSX.Element => {
}); });
const handleNewThread = useCallback( const handleNewThread = useCallback(
(mode: Mode): void => { (
mode: Mode,
aspectRatio: AspectRatio | undefined,
imageSize: ImageSize,
): void => {
const now = Date.now(); const now = Date.now();
const createdThread: Thread = { const createdThread: Thread = {
/* eslint-disable-next-line stylistic/no-extra-parens -- Required for conditional spread with exactOptionalPropertyTypes */
...(aspectRatio !== undefined && { aspectRatio }),
createdAt: now, createdAt: now,
id: generateId(), id: generateId(),
imageSize: imageSize,
messages: [], messages: [],
mode: mode, mode: mode,
name: `New ${mode} thread`, name: `New ${mode} thread`,
+68 -40
View File
@@ -19,7 +19,12 @@ import {
type JSX, type JSX,
type KeyboardEvent, type KeyboardEvent,
} from "react"; } from "react";
import type { Mode, PendingInput } from "../types/index.ts"; import type {
AspectRatio,
ImageSize,
Mode,
PendingInput,
} from "../types/index.ts";
const dropZoneBaseClass = [ const dropZoneBaseClass = [
"border-2 border-dashed rounded-xl p-8", "border-2 border-dashed rounded-xl p-8",
@@ -113,14 +118,19 @@ const readClipboardImage = async(
onFileRead(new File([ blob ], "clipboard.png", { type: imageType })); onFileRead(new File([ blob ], "clipboard.png", { type: imageType }));
}; };
const modeLabelText = (mode: Mode): string => { const modeLabelText = (
mode: Mode,
aspectRatio: AspectRatio | undefined,
imageSize: ImageSize,
): string => {
if (mode === "avatar") { if (mode === "avatar") {
return "🟣 Avatar Mode (1:1)"; return `🟣 Avatar Mode · ${imageSize}`;
} }
if (mode === "art") { if (mode === "art") {
return "🩷 Art Mode (16:9)"; const ratio = aspectRatio ?? "16:9";
return `🩷 Art Mode (${ratio}) · ${imageSize}`;
} }
return "🔵 Replace Mode"; return `🔵 Replace Mode · ${imageSize}`;
}; };
type OnSendCallback = ( type OnSendCallback = (
@@ -130,7 +140,9 @@ type OnSendCallback = (
)=> void; )=> void;
interface InputAreaProperties { interface InputAreaProperties {
readonly aspectRatio?: AspectRatio;
readonly hasMessages: boolean; readonly hasMessages: boolean;
readonly imageSize: ImageSize;
readonly initialImageBase64?: string; readonly initialImageBase64?: string;
readonly initialImageMime?: string; readonly initialImageMime?: string;
readonly initialImagePreview?: string; readonly initialImagePreview?: string;
@@ -144,7 +156,9 @@ interface InputAreaProperties {
/** /**
* Renders the input area for composing and sending messages. * Renders the input area for composing and sending messages.
* @param props - The component props. * @param props - The component props.
* @param props.aspectRatio - The thread's aspect ratio setting (Art mode only).
* @param props.hasMessages - Whether the thread already has messages (affects replace mode UI). * @param props.hasMessages - Whether the thread already has messages (affects replace mode UI).
* @param props.imageSize - The thread's resolution setting.
* @param props.initialImageBase64 - Initial base64 image data to pre-populate. * @param props.initialImageBase64 - Initial base64 image data to pre-populate.
* @param props.initialImageMime - Initial image MIME type to pre-populate. * @param props.initialImageMime - Initial image MIME type to pre-populate.
* @param props.initialImagePreview - Initial image preview URL to pre-populate. * @param props.initialImagePreview - Initial image preview URL to pre-populate.
@@ -156,7 +170,9 @@ interface InputAreaProperties {
* @returns The JSX element. * @returns The JSX element.
*/ */
const inputArea = ({ const inputArea = ({
aspectRatio,
hasMessages, hasMessages,
imageSize,
initialImageBase64, initialImageBase64,
initialImageMime, initialImageMime,
initialImagePreview, initialImagePreview,
@@ -256,7 +272,8 @@ const inputArea = ({
if (isInitialReplace && imageBase64 === undefined) { if (isInitialReplace && imageBase64 === undefined) {
return; return;
} }
if (!isInitialReplace && text.trim().length === 0) { const hasContent = text.trim().length > 0 || imageBase64 !== undefined;
if (!isInitialReplace && !hasContent) {
return; return;
} }
@@ -332,11 +349,14 @@ const inputArea = ({
: dropZoneInactiveClass, : dropZoneInactiveClass,
].join(" "); ].join(" ");
const hasNoContent = text.trim().length === 0 && imageBase64 === undefined;
const isSendDisabled = isLoading || hasNoContent;
return ( return (
<div className="border-t border-purple-900/30 p-4 bg-[#0f0a1a]"> <div className="border-t border-purple-900/30 p-4 bg-[#0f0a1a]">
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2">
<span className="text-xs text-gray-500 uppercase tracking-wider"> <span className="text-xs text-gray-500 uppercase tracking-wider">
{modeLabelText(mode)} {modeLabelText(mode, aspectRatio, imageSize)}
</span> </span>
</div> </div>
@@ -397,6 +417,15 @@ const inputArea = ({
type="file" type="file"
/> />
<textarea
className={textareaClass}
disabled={isLoading}
onChange={handleTextChange}
placeholder="Add notes to include with the image (optional)..."
rows={2}
value={text}
/>
<button <button
className={replaceButtonClass} className={replaceButtonClass}
disabled={isLoading || imageBase64 === undefined} disabled={isLoading || imageBase64 === undefined}
@@ -414,41 +443,40 @@ const inputArea = ({
</div> </div>
: <div className="flex flex-col gap-3"> : <div className="flex flex-col gap-3">
{mode === "replace" <div className="flex flex-col gap-2">
? <div className="flex flex-col gap-2"> {imagePreview === undefined
{imagePreview === undefined ? <button
? <button className={pasteButtonClass}
className={pasteButtonClass} onClick={handlePasteButtonClick}
onClick={handlePasteButtonClick} type="button"
>
{mode === "replace"
? "📋 Paste replacement image (optional)"
: "📋 Paste image (optional)"}
</button>
: <div className="relative inline-block">
<img
alt="Upload preview"
className="max-h-32 rounded-lg border border-purple-700/40"
src={imagePreview}
/>
<button
className={clearButtonClass}
onClick={clearImage}
type="button" type="button"
> >
{"📋 Paste replacement image (optional)"} {"×"}
</button> </button>
</div>}
: <div className="relative inline-block"> <input
<img accept="image/*"
alt="Upload preview" className="hidden"
className="max-h-32 rounded-lg border border-purple-700/40" onChange={handleFileChange}
src={imagePreview} ref={fileInputReference}
/> type="file"
<button />
className={clearButtonClass} </div>
onClick={clearImage}
type="button"
>
{"×"}
</button>
</div>}
<input
accept="image/*"
className="hidden"
onChange={handleFileChange}
ref={fileInputReference}
type="file"
/>
</div>
: null}
<div className="flex gap-3 items-end"> <div className="flex gap-3 items-end">
<textarea <textarea
@@ -464,7 +492,7 @@ const inputArea = ({
/> />
<button <button
className={sendButtonClass} className={sendButtonClass}
disabled={isLoading || text.trim().length === 0} disabled={isSendDisabled}
onClick={handleSend} onClick={handleSend}
type="button" type="button"
> >
+267 -10
View File
@@ -1,12 +1,18 @@
/** /**
* @file Modal for selecting a new thread generation mode. * @file Modal for selecting a new thread generation mode and options.
* @copyright Naomi Carrigan * @copyright Naomi Carrigan
* @license Naomi's Public License * @license Naomi's Public License
* @author Naomi Carrigan * @author Naomi Carrigan
*/ */
/* eslint-disable max-lines-per-function -- Component JSX inherently requires many lines */ /* eslint-disable max-lines-per-function -- Component JSX inherently requires many lines */
import { useCallback, type JSX, type MouseEvent } from "react"; /* eslint-disable max-lines -- Two-step modal requires many option definitions */
import type { Mode } from "../types/index.ts"; import {
useCallback,
useState,
type JSX,
type MouseEvent,
} from "react";
import type { AspectRatio, ImageSize, Mode } from "../types/index.ts";
interface ModeOption { interface ModeOption {
colour: string; colour: string;
@@ -16,14 +22,25 @@ interface ModeOption {
mode: Mode; mode: Mode;
} }
interface AspectRatioOption {
label: string;
value: AspectRatio;
}
interface ImageSizeOption {
description: string;
label: string;
value: ImageSize;
}
const avatarDescription = [ const avatarDescription = [
"Generate a square 1:1 portrait.", "Generate a square 1:1 portrait.",
"Perfect for profile pictures and avatars.", "Perfect for profile pictures and avatars.",
].join(" "); ].join(" ");
const artDescription = [ const artDescription = [
"Generate wide 16:9 landscape artwork.", "Generate landscape or portrait artwork.",
"Great for wallpapers and banners.", "Choose your aspect ratio and resolution.",
].join(" "); ].join(" ");
const replaceDescription = [ const replaceDescription = [
@@ -55,6 +72,21 @@ const modeOptionList: Array<ModeOption> = [
}, },
]; ];
const aspectRatioOptions: Array<AspectRatioOption> = [
{ label: "1:1 Square", value: "1:1" },
{ label: "4:3 Standard", value: "4:3" },
{ label: "3:4 Portrait", value: "3:4" },
{ label: "16:9 Widescreen", value: "16:9" },
{ label: "9:16 Wallpaper", value: "9:16" },
{ label: "21:9 Ultrawide", value: "21:9" },
];
const imageSizeOptions: Array<ImageSizeOption> = [
{ description: "Faster, cheaper", label: "1K", value: "1K" },
{ description: "Balanced", label: "2K", value: "2K" },
{ description: "Best quality", label: "4K", value: "4K" },
];
const colourMap: Record< const colourMap: Record<
string, string,
{ badge: string; button: string; hover: string } { badge: string; button: string; hover: string }
@@ -82,6 +114,20 @@ const isMode = (value: string): value is Mode => {
return validModesSet.has(value); return validModesSet.has(value);
}; };
const validAspectRatios = new Set<string>([
"1:1", "3:4", "4:3", "9:16", "16:9", "21:9",
]);
const isAspectRatio = (value: string): value is AspectRatio => {
return validAspectRatios.has(value);
};
const validImageSizes = new Set<string>([ "1K", "2K", "4K" ]);
const isImageSize = (value: string): value is ImageSize => {
return validImageSizes.has(value);
};
const overlayClass = [ const overlayClass = [
"fixed inset-0 bg-black/70 backdrop-blur-sm", "fixed inset-0 bg-black/70 backdrop-blur-sm",
"flex items-center justify-center z-50", "flex items-center justify-center z-50",
@@ -93,32 +139,243 @@ const panelClass = [
"shadow-2xl shadow-purple-900/30", "shadow-2xl shadow-purple-900/30",
].join(" "); ].join(" ");
const pillBaseClass = [
"px-4 py-2 rounded-xl border text-sm font-medium",
"transition-all duration-200 cursor-pointer",
].join(" ");
const pillActiveClass = "border-purple-500 bg-purple-600/30 text-white";
const pillInactiveClass = [
"border-purple-900/40 bg-transparent text-gray-400",
"hover:border-purple-500/60 hover:text-gray-200",
].join(" ");
const startButtonClass = [
"w-full bg-gradient-to-r from-purple-600 to-pink-600",
"hover:from-purple-500 hover:to-pink-500",
"text-white font-semibold py-3 rounded-xl",
"transition-all duration-200 mt-2",
].join(" ");
const sizeButtonActiveClass = "border-purple-500 bg-purple-600/30 text-white";
const sizeButtonInactiveClass = [
"border-purple-900/40 bg-transparent text-gray-400",
"hover:border-purple-500/60 hover:text-gray-200",
].join(" ");
const backButtonClass = [
"text-gray-400 hover:text-white",
"transition-colors text-xl",
].join(" ");
const sectionLabelClass = [
"text-sm text-gray-400",
"uppercase tracking-wider mb-3",
].join(" ");
interface NewThreadModalProperties { interface NewThreadModalProperties {
readonly onClose: ()=> void; readonly onClose: ()=> void;
readonly onSelect: (mode: Mode)=> void; readonly onSelect: (
mode: Mode,
aspectRatio: AspectRatio | undefined,
imageSize: ImageSize,
)=> void;
} }
/** /**
* Renders the new thread modal for selecting a generation mode. * Renders the new thread modal for selecting a generation mode and options.
* @param props - The component props. * @param props - The component props.
* @param props.onClose - Callback to close the modal. * @param props.onClose - Callback to close the modal.
* @param props.onSelect - Callback when a mode is selected. * @param props.onSelect - Callback when a mode and options are confirmed.
* @returns The JSX element. * @returns The JSX element.
*/ */
const threadModal = ({ const threadModal = ({
onClose, onClose,
onSelect, onSelect,
}: NewThreadModalProperties): JSX.Element => { }: NewThreadModalProperties): JSX.Element => {
const [ step, setStep ] = useState<"mode" | "options">("mode");
const [ selectedMode, setSelectedMode ] = useState<Mode | undefined>(
undefined,
);
const [ aspectRatio, setAspectRatio ] = useState<AspectRatio>("16:9");
const [ imageSize, setImageSize ] = useState<ImageSize>("4K");
const handleModeSelect = useCallback( const handleModeSelect = useCallback(
(event: MouseEvent<HTMLButtonElement>): void => { (event: MouseEvent<HTMLButtonElement>): void => {
const rawMode = event.currentTarget.dataset.mode; const rawMode = event.currentTarget.dataset.mode;
if (rawMode !== undefined && isMode(rawMode)) { if (rawMode !== undefined && isMode(rawMode)) {
onSelect(rawMode); setSelectedMode(rawMode);
setStep("options");
} }
}, },
[ onSelect ], [],
); );
const handleBack = useCallback((): void => {
setStep("mode");
}, []);
const handleStart = useCallback((): void => {
if (selectedMode === undefined) {
return;
}
const ratio = selectedMode === "art"
? aspectRatio
: undefined;
onSelect(selectedMode, ratio, imageSize);
}, [ selectedMode, aspectRatio, imageSize, onSelect ]);
const handleAspectRatioSelect = useCallback(
(event: MouseEvent<HTMLButtonElement>): void => {
const rawRatio = event.currentTarget.dataset.ratio;
if (rawRatio !== undefined && isAspectRatio(rawRatio)) {
setAspectRatio(rawRatio);
}
},
[],
);
const handleImageSizeSelect = useCallback(
(event: MouseEvent<HTMLButtonElement>): void => {
const rawSize = event.currentTarget.dataset.size;
if (rawSize !== undefined && isImageSize(rawSize)) {
setImageSize(rawSize);
}
},
[],
);
const selectedModeOption = modeOptionList.find((o) => {
return o.mode === selectedMode;
});
const selectedColours = selectedModeOption === undefined
? colourMap.purple
: colourMap[selectedModeOption.colour];
if (step === "options" && selectedModeOption !== undefined) {
return (
<div className={overlayClass}>
<div className={panelClass}>
<div className="flex items-center gap-3 mb-6">
<button
className={backButtonClass}
onClick={handleBack}
type="button"
>
{"←"}
</button>
<h2 className="text-2xl font-bold text-white flex-1">
{"Configure Thread"}
</h2>
<button
className="text-gray-400 hover:text-white transition-colors"
onClick={onClose}
type="button"
>
{"×"}
</button>
</div>
<div
className={[
"flex items-center gap-3 mb-6 p-4",
"rounded-xl bg-[#241836] border border-purple-900/30",
].join(" ")}
>
<span className="text-3xl">{selectedModeOption.icon}</span>
<div>
<div className="flex items-center gap-2">
<span className="text-white font-semibold">
{selectedModeOption.label}
</span>
<span
className={`text-xs px-2 py-0.5 rounded-full ${selectedColours?.badge ?? ""}`}
>
{selectedModeOption.mode}
</span>
</div>
<p className="text-gray-400 text-xs mt-0.5">
{selectedModeOption.description}
</p>
</div>
</div>
{selectedMode === "art"
? <div className="mb-6">
<p className={sectionLabelClass}>
{"Aspect Ratio"}
</p>
<div className="flex flex-wrap gap-2">
{aspectRatioOptions.map((option) => {
const pillClass = [
pillBaseClass,
aspectRatio === option.value
? pillActiveClass
: pillInactiveClass,
].join(" ");
return (
<button
className={pillClass}
data-ratio={option.value}
key={option.value}
onClick={handleAspectRatioSelect}
type="button"
>
{option.label}
</button>
);
})}
</div>
</div>
: null}
<div className="mb-6">
<p className={sectionLabelClass}>
{"Resolution"}
</p>
<div className="flex gap-3">
{imageSizeOptions.map((option) => {
const sizeClass = [
"flex-1 flex flex-col items-center py-3 px-2",
"rounded-xl border text-sm font-medium",
"transition-all duration-200 cursor-pointer",
imageSize === option.value
? sizeButtonActiveClass
: sizeButtonInactiveClass,
].join(" ");
return (
<button
className={sizeClass}
data-size={option.value}
key={option.value}
onClick={handleImageSizeSelect}
type="button"
>
<span className="text-base font-bold">{option.label}</span>
<span className="text-xs mt-0.5 opacity-70">
{option.description}
</span>
</button>
);
})}
</div>
</div>
<button
className={startButtonClass}
onClick={handleStart}
type="button"
>
{"Start Thread →"}
</button>
</div>
</div>
);
}
return ( return (
<div className={overlayClass}> <div className={overlayClass}>
<div className={panelClass}> <div className={panelClass}>
+34 -9
View File
@@ -68,19 +68,19 @@ const modeLabel = (mode: Mode): string => {
interface BuildUserPartsOptions { interface BuildUserPartsOptions {
readonly imageBase64?: string; readonly imageBase64?: string;
readonly imageMime?: string; readonly imageMime?: string;
readonly mode: Mode;
readonly text: string; readonly text: string;
} }
const buildUserParts = ({ const buildUserParts = ({
imageBase64, imageBase64,
imageMime, imageMime,
mode,
text, text,
}: BuildUserPartsOptions): Array<MessagePart> => { }: BuildUserPartsOptions): Array<MessagePart> => {
const userParts: Array<MessagePart> = []; const userParts: Array<MessagePart> = [];
if (mode === "replace" && imageBase64 !== undefined) { if (imageBase64 === undefined) {
userParts.push({ text: text, type: "text" });
} else {
userParts.push({ userParts.push({
imageData: imageBase64, imageData: imageBase64,
mimeType: imageMime ?? "image/png", mimeType: imageMime ?? "image/png",
@@ -89,8 +89,6 @@ const buildUserParts = ({
if (text.length > 0) { if (text.length > 0) {
userParts.push({ text: text, type: "text" }); userParts.push({ text: text, type: "text" });
} }
} else {
userParts.push({ text: text, type: "text" });
} }
return userParts; return userParts;
@@ -174,13 +172,18 @@ const threadView = ({
onLoadingChange(true); onLoadingChange(true);
onErrorChange(undefined); onErrorChange(undefined);
const { messages, mode } = thread; const {
aspectRatio,
imageSize: rawImageSize,
messages,
mode,
} = thread;
const imageSize = rawImageSize ?? "4K";
const userParts = buildUserParts({ const userParts = buildUserParts({
/* eslint-disable-next-line stylistic/no-extra-parens -- Required for conditional spread with exactOptionalPropertyTypes */ /* eslint-disable-next-line stylistic/no-extra-parens -- Required for conditional spread with exactOptionalPropertyTypes */
...(imageBase64 !== undefined && { imageBase64 }), ...(imageBase64 !== undefined && { imageBase64 }),
/* eslint-disable-next-line stylistic/no-extra-parens -- Required for conditional spread with exactOptionalPropertyTypes */ /* eslint-disable-next-line stylistic/no-extra-parens -- Required for conditional spread with exactOptionalPropertyTypes */
...(imageMime !== undefined && { imageMime }), ...(imageMime !== undefined && { imageMime }),
mode,
text, text,
}); });
@@ -217,7 +220,9 @@ const threadView = ({
"send_message", "send_message",
{ {
apiKey, apiKey,
aspectRatio,
history, history,
imageSize,
mode, mode,
userImageBase64, userImageBase64,
userImageMime, userImageMime,
@@ -253,7 +258,13 @@ const threadView = ({
const handleRetry = useCallback( const handleRetry = useCallback(
(modelMessageIndex: number): void => { (modelMessageIndex: number): void => {
const { messages, mode } = thread; const {
aspectRatio,
imageSize: rawImageSize,
messages,
mode,
} = thread;
const imageSize = rawImageSize ?? "4K";
const userMessageIndex = modelMessageIndex - 1; const userMessageIndex = modelMessageIndex - 1;
const userMessage = messages[userMessageIndex]; const userMessage = messages[userMessageIndex];
@@ -300,7 +311,9 @@ const threadView = ({
} }
const response = await invoke<SendResult>("send_message", { const response = await invoke<SendResult>("send_message", {
apiKey, apiKey,
aspectRatio,
history, history,
imageSize,
mode, mode,
userImageBase64, userImageBase64,
userImageMime, userImageMime,
@@ -353,7 +366,13 @@ const threadView = ({
const handleEditCommit = useCallback( const handleEditCommit = useCallback(
(messageIndex: number, editedText: string): void => { (messageIndex: number, editedText: string): void => {
const { messages, mode } = thread; const {
aspectRatio,
imageSize: rawImageSize,
messages,
mode,
} = thread;
const imageSize = rawImageSize ?? "4K";
const originalMessage = messages[messageIndex]; const originalMessage = messages[messageIndex];
if (originalMessage === undefined) { if (originalMessage === undefined) {
return; return;
@@ -404,7 +423,9 @@ const threadView = ({
} }
const response = await invoke<SendResult>("send_message", { const response = await invoke<SendResult>("send_message", {
apiKey, apiKey,
aspectRatio,
history, history,
imageSize,
mode, mode,
userImageBase64, userImageBase64,
userImageMime, userImageMime,
@@ -536,7 +557,11 @@ const threadView = ({
{...(pendingInput?.text !== undefined && { {...(pendingInput?.text !== undefined && {
initialText: pendingInput.text, initialText: pendingInput.text,
})} })}
{...(thread.aspectRatio !== undefined && {
aspectRatio: thread.aspectRatio,
})}
hasMessages={thread.messages.length > 0} hasMessages={thread.messages.length > 0}
imageSize={thread.imageSize ?? "4K"}
isLoading={isLoading} isLoading={isLoading}
mode={thread.mode} mode={thread.mode}
onInputChange={onPendingInputChange} onInputChange={onPendingInputChange}
+20 -7
View File
@@ -5,6 +5,8 @@
* @author Naomi Carrigan * @author Naomi Carrigan
*/ */
type AspectRatio = "1:1" | "3:4" | "4:3" | "9:16" | "16:9" | "21:9";
type ImageSize = "1K" | "2K" | "4K";
type Mode = "avatar" | "art" | "replace"; type Mode = "avatar" | "art" | "replace";
interface Config { interface Config {
@@ -33,12 +35,23 @@ interface ThreadMessage {
} }
interface Thread { interface Thread {
createdAt: number; aspectRatio?: AspectRatio;
id: string; createdAt: number;
messages: Array<ThreadMessage>; id: string;
mode: Mode; imageSize?: ImageSize;
name: string; messages: Array<ThreadMessage>;
updatedAt: number; mode: Mode;
name: string;
updatedAt: number;
} }
export type { Config, MessagePart, Mode, PendingInput, Thread, ThreadMessage }; export type {
AspectRatio,
Config,
ImageSize,
MessagePart,
Mode,
PendingInput,
Thread,
ThreadMessage,
};