Feat/updated settings layout (#38)

* wip: updating settings layout

* most issues resolved

* settings layout revamped

* fix:multiple unrequired toast on formatter settings resolved

* feat: updated ai-models tabs to extract common components and logic

* chore: formatting fix

* chore: code cleanup

* fix: forward navigation handling

* fix: model selections ux

* fix: vocabulary update flow

* chore: update lang list

* chore: cleanup migrations

* fix: invalid drag class on back buttont

* chore: swap to tanstack router

* fix: history navigation

* chore: clean up + models unification

* chore: cleanup migrations

* refactor: settings deep merges

---------

Co-authored-by: amadeus-x1 <45001978+amadeus-x1@users.noreply.github.com>
This commit is contained in:
Haritabh 2025-09-10 01:16:12 +05:30 committed by GitHub
parent ebf649310e
commit f5686d45be
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
104 changed files with 9257 additions and 2827 deletions

1
.gitignore vendored
View file

@ -40,6 +40,7 @@ CLAUDE.md
.serena
.local
.claude
amical.db
# Temp files
/tmp

View file

@ -19,3 +19,4 @@ package-lock.json
.contentlayer
.content-collections
.source
routeTree.gen.ts

View file

@ -1,10 +1,13 @@
import type { Config } from "drizzle-kit";
import * as path from "path";
const dbPath = path.join(process.cwd(), "amical.db");
export default {
schema: "./src/db/schema.ts",
out: "./src/db/migrations",
dialect: "sqlite",
dbCredentials: {
url: "file:./amical.db",
url: `file:${dbPath}`,
},
} satisfies Config;

View file

@ -61,8 +61,9 @@
"@electron/fuses": "^1.8.0",
"@rollup/plugin-commonjs": "^28.0.6",
"@tailwindcss/vite": "^4.1.6",
"@types/react": "^19.1.3",
"@types/react-dom": "^19.1.3",
"@tanstack/router-devtools": "^1.131.36",
"@types/react": "^19.1.12",
"@types/react-dom": "^19.1.9",
"bumpp": "^10.2.3",
"electron": "36.2.0",
"eslint": "^9.26.0",
@ -79,6 +80,7 @@
"dependencies": {
"@ai-sdk/openai": "^1.3.22",
"@amical/eslint-config": "workspace:*",
"@amical/smart-whisper": "workspace:*",
"@amical/types": "workspace:*",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
@ -117,7 +119,9 @@
"@radix-ui/react-tooltip": "^1.2.7",
"@tabler/icons-react": "^3.34.0",
"@tanstack/react-query": "^5.81.2",
"@tanstack/react-router": "^1.131.36",
"@tanstack/react-table": "^8.21.3",
"@tanstack/router-vite-plugin": "^1.131.36",
"@trpc/client": "^11.4.2",
"@trpc/react-query": "^11.4.2",
"@trpc/server": "^11.4.2",
@ -146,13 +150,12 @@
"next-themes": "^0.4.6",
"onnxruntime-node": "^1.20.1",
"openai": "^4.98.0",
"react": "^19.1.0",
"react": "^19.1.1",
"react-day-picker": "8.10.1",
"react-dom": "^19.1.0",
"react-dom": "^19.1.1",
"react-hook-form": "^7.56.3",
"react-resizable-panels": "^3.0.2",
"recharts": "^2.15.3",
"@amical/smart-whisper": "workspace:*",
"sonner": "^2.0.3",
"split2": "^4.2.0",
"superjson": "^2.2.2",

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 162 KiB

View file

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 -22.5 256 256" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" preserveAspectRatio="xMidYMid">
<g>
<polygon fill="#00832D" points="144.822496 105.321856 169.778926 133.848796 203.341343 155.294133 209.178931 105.50196 203.341343 56.8331137 169.136495 75.6715889">
</polygon>
<path d="M0.000557021739,150.659712 L0.000557021739,193.089915 C0.000557021739,202.77838 7.86384724,210.643527 17.5541688,210.643527 L59.9843714,210.643527 L68.7704609,178.585069 L59.9843714,150.659712 L30.8744153,141.873623 L0.000557021739,150.659712 Z" fill="#0066DA">
</path>
<polygon fill="#E94235" points="59.9838143 9.9475983e-14 0 59.9838143 30.875715 68.7494798 59.9838143 59.9838143 68.6102243 32.4390893">
</polygon>
<polygon fill="#2684FC" points="0.000557021739 150.679394 59.9843714 150.679394 59.9843714 59.9832573 0.000557021739 59.9832573">
</polygon>
<path d="M241.658683,25.3977775 L203.341157,56.8342278 L203.341157,155.29339 L241.818362,186.852385 C247.577967,191.364261 256.003849,187.251584 256.003849,179.930462 L256.003849,32.1785888 C256.003849,24.7757699 247.377439,20.6835169 241.658683,25.3977775" fill="#00AC47">
</path>
<path d="M144.822496,105.321856 L144.822496,150.659712 L59.9843714,150.659712 L59.9843714,210.643527 L185.787731,210.643527 C195.478053,210.643527 203.341343,202.77838 203.341343,193.089915 L203.341343,155.294133 L144.822496,105.321856 Z" fill="#00AC47">
</path>
<path d="M185.787731,0 L59.9843714,0 L59.9843714,59.9838143 L144.822496,59.9838143 L144.822496,105.32167 L203.341343,56.832928 L203.341343,17.5536117 C203.341343,7.86329022 195.478053,0 185.787731,0" fill="#FFBA00">
</path>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 256 256" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" preserveAspectRatio="xMidYMid">
<g>
<path d="M53.8412698,161.320635 C53.8412698,176.152381 41.8539683,188.139683 27.0222222,188.139683 C12.1904762,188.139683 0.203174603,176.152381 0.203174603,161.320635 C0.203174603,146.488889 12.1904762,134.501587 27.0222222,134.501587 L53.8412698,134.501587 L53.8412698,161.320635 Z M67.2507937,161.320635 C67.2507937,146.488889 79.2380952,134.501587 94.0698413,134.501587 C108.901587,134.501587 120.888889,146.488889 120.888889,161.320635 L120.888889,228.368254 C120.888889,243.2 108.901587,255.187302 94.0698413,255.187302 C79.2380952,255.187302 67.2507937,243.2 67.2507937,228.368254 L67.2507937,161.320635 Z" fill="#E01E5A">
</path>
<path d="M94.0698413,53.6380952 C79.2380952,53.6380952 67.2507937,41.6507937 67.2507937,26.8190476 C67.2507937,11.9873016 79.2380952,-7.10542736e-15 94.0698413,-7.10542736e-15 C108.901587,-7.10542736e-15 120.888889,11.9873016 120.888889,26.8190476 L120.888889,53.6380952 L94.0698413,53.6380952 Z M94.0698413,67.2507937 C108.901587,67.2507937 120.888889,79.2380952 120.888889,94.0698413 C120.888889,108.901587 108.901587,120.888889 94.0698413,120.888889 L26.8190476,120.888889 C11.9873016,120.888889 0,108.901587 0,94.0698413 C0,79.2380952 11.9873016,67.2507937 26.8190476,67.2507937 L94.0698413,67.2507937 Z" fill="#36C5F0">
</path>
<path d="M201.549206,94.0698413 C201.549206,79.2380952 213.536508,67.2507937 228.368254,67.2507937 C243.2,67.2507937 255.187302,79.2380952 255.187302,94.0698413 C255.187302,108.901587 243.2,120.888889 228.368254,120.888889 L201.549206,120.888889 L201.549206,94.0698413 Z M188.139683,94.0698413 C188.139683,108.901587 176.152381,120.888889 161.320635,120.888889 C146.488889,120.888889 134.501587,108.901587 134.501587,94.0698413 L134.501587,26.8190476 C134.501587,11.9873016 146.488889,-1.42108547e-14 161.320635,-1.42108547e-14 C176.152381,-1.42108547e-14 188.139683,11.9873016 188.139683,26.8190476 L188.139683,94.0698413 Z" fill="#2EB67D">
</path>
<path d="M161.320635,201.549206 C176.152381,201.549206 188.139683,213.536508 188.139683,228.368254 C188.139683,243.2 176.152381,255.187302 161.320635,255.187302 C146.488889,255.187302 134.501587,243.2 134.501587,228.368254 L134.501587,201.549206 L161.320635,201.549206 Z M161.320635,188.139683 C146.488889,188.139683 134.501587,176.152381 134.501587,161.320635 C134.501587,146.488889 146.488889,134.501587 161.320635,134.501587 L228.571429,134.501587 C243.403175,134.501587 255.390476,146.488889 255.390476,161.320635 C255.390476,176.152381 243.403175,188.139683 228.571429,188.139683 L161.320635,188.139683 Z" fill="#ECB22E">
</path>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 -28.5 256 256" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" preserveAspectRatio="xMidYMid">
<g>
<path d="M216.856339,16.5966031 C200.285002,8.84328665 182.566144,3.2084988 164.041564,0 C161.766523,4.11318106 159.108624,9.64549908 157.276099,14.0464379 C137.583995,11.0849896 118.072967,11.0849896 98.7430163,14.0464379 C96.9108417,9.64549908 94.1925838,4.11318106 91.8971895,0 C73.3526068,3.2084988 55.6133949,8.86399117 39.0420583,16.6376612 C5.61752293,67.146514 -3.4433191,116.400813 1.08711069,164.955721 C23.2560196,181.510915 44.7403634,191.567697 65.8621325,198.148576 C71.0772151,190.971126 75.7283628,183.341335 79.7352139,175.300261 C72.104019,172.400575 64.7949724,168.822202 57.8887866,164.667963 C59.7209612,163.310589 61.5131304,161.891452 63.2445898,160.431257 C105.36741,180.133187 151.134928,180.133187 192.754523,160.431257 C194.506336,161.891452 196.298154,163.310589 198.110326,164.667963 C191.183787,168.842556 183.854737,172.420929 176.223542,175.320965 C180.230393,183.341335 184.861538,190.991831 190.096624,198.16893 C211.238746,191.588051 232.743023,181.531619 254.911949,164.955721 C260.227747,108.668201 245.831087,59.8662432 216.856339,16.5966031 Z M85.4738752,135.09489 C72.8290281,135.09489 62.4592217,123.290155 62.4592217,108.914901 C62.4592217,94.5396472 72.607595,82.7145587 85.4738752,82.7145587 C98.3405064,82.7145587 108.709962,94.5189427 108.488529,108.914901 C108.508531,123.290155 98.3405064,135.09489 85.4738752,135.09489 Z M170.525237,135.09489 C157.88039,135.09489 147.510584,123.290155 147.510584,108.914901 C147.510584,94.5396472 157.658606,82.7145587 170.525237,82.7145587 C183.391518,82.7145587 193.761324,94.5189427 193.539891,108.914901 C193.539891,123.290155 183.391518,135.09489 170.525237,135.09489 Z" fill="#5865F2" fill-rule="nonzero">
</path>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2 KiB

View file

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 73 73" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>team-collaboration/version-control/github</title>
<desc>Created with Sketch.</desc>
<defs>
</defs>
<g id="team-collaboration/version-control/github" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="container" transform="translate(2.000000, 2.000000)" fill-rule="nonzero">
<rect id="mask" stroke="#000000" stroke-width="2" fill="#000000" x="-1" y="-1" width="71" height="71" rx="14">
</rect>
<path d="M58.3067362,21.4281798 C55.895743,17.2972267 52.6253846,14.0267453 48.4948004,11.615998 C44.3636013,9.20512774 39.8535636,8 34.9614901,8 C30.0700314,8 25.5585181,9.20549662 21.4281798,11.615998 C17.2972267,14.0266224 14.0269912,17.2972267 11.615998,21.4281798 C9.20537366,25.5590099 8,30.0699084 8,34.9607523 C8,40.8357654 9.71405782,46.1187277 13.1430342,50.8109917 C16.5716416,55.5036246 21.0008949,58.7507436 26.4304251,60.5527176 C27.0624378,60.6700211 27.5302994,60.5875152 27.8345016,60.3072901 C28.1388268,60.0266961 28.290805,59.6752774 28.290805,59.2545094 C28.290805,59.1842994 28.2847799,58.5526556 28.2730988,57.3588401 C28.2610487,56.1650247 28.2553926,55.1235563 28.2553926,54.2349267 L27.4479164,54.3746089 C26.9330843,54.468919 26.2836113,54.5088809 25.4994975,54.4975686 C24.7157525,54.4866252 23.9021284,54.4044881 23.0597317,54.2517722 C22.2169661,54.1004088 21.4330982,53.749359 20.7075131,53.1993604 C19.982297,52.6493618 19.4674649,51.9294329 19.1631397,51.0406804 L18.8120898,50.2328353 C18.5780976,49.6950097 18.2097104,49.0975487 17.7064365,48.4426655 C17.2031625,47.7871675 16.6942324,47.3427912 16.1794003,47.108799 L15.9336039,46.9328437 C15.7698216,46.815909 15.6178435,46.6748743 15.4773006,46.511215 C15.3368806,46.3475556 15.2317501,46.1837734 15.1615401,46.0197452 C15.0912072,45.855594 15.1494901,45.7209532 15.3370036,45.6153308 C15.5245171,45.5097084 15.8633939,45.4584343 16.3551097,45.4584343 L17.0569635,45.5633189 C17.5250709,45.6571371 18.104088,45.9373622 18.7947525,46.4057156 C19.4850481,46.8737001 20.052507,47.4821045 20.4972521,48.230683 C21.0358155,49.1905062 21.6846737,49.9218703 22.4456711,50.4251443 C23.2060537,50.9284182 23.9727072,51.1796248 24.744894,51.1796248 C25.5170807,51.1796248 26.1840139,51.121096 26.7459396,51.0046532 C27.3072505,50.8875956 27.8338868,50.7116403 28.3256025,50.477771 C28.5362325,48.9090515 29.1097164,47.7039238 30.0455624,46.8615271 C28.7116959,46.721353 27.5124702,46.5102313 26.4472706,46.2295144 C25.3826858,45.9484285 24.2825656,45.4922482 23.1476478,44.8597436 C22.0121153,44.2280998 21.0701212,43.44374 20.3214198,42.5080169 C19.5725954,41.571802 18.9580429,40.3426971 18.4786232,38.821809 C17.9989575,37.300306 17.7590632,35.5451796 17.7590632,33.5559381 C17.7590632,30.7235621 18.6837199,28.3133066 20.5326645,26.3238191 C19.6665366,24.1944035 19.7483048,21.8072644 20.778215,19.1626478 C21.4569523,18.951772 22.4635002,19.1100211 23.7973667,19.6364115 C25.1314792,20.1630477 26.1082708,20.6141868 26.7287253,20.9882301 C27.3491798,21.3621504 27.8463057,21.6790175 28.2208409,21.9360032 C30.3978419,21.3277217 32.644438,21.0235195 34.9612442,21.0235195 C37.2780503,21.0235195 39.5251383,21.3277217 41.7022622,21.9360032 L43.0362517,21.0938524 C43.9484895,20.5319267 45.0257392,20.0169716 46.2654186,19.5488642 C47.5058357,19.0810026 48.4543466,18.9521409 49.1099676,19.1630167 C50.1627483,21.8077563 50.2565666,24.1947724 49.3901927,26.324188 C51.2390143,28.3136755 52.1640399,30.7245457 52.1640399,33.556307 C52.1640399,35.5455485 51.9232849,37.3062081 51.444357,38.8393922 C50.9648143,40.3728223 50.3449746,41.6006975 49.5845919,42.5256002 C48.8233486,43.4503799 47.8753296,44.2285916 46.7404118,44.8601125 C45.6052481,45.4921252 44.504759,45.9483056 43.4401742,46.2293914 C42.3750975,46.5104772 41.1758719,46.7217219 39.8420054,46.8621419 C41.0585683,47.9149226 41.6669728,49.5767225 41.6669728,51.846804 L41.6669728,59.2535257 C41.6669728,59.6742937 41.8132948,60.0255895 42.1061847,60.3063064 C42.3987058,60.5865315 42.8606653,60.6690374 43.492678,60.5516109 C48.922946,58.7498829 53.3521992,55.5026409 56.7806837,50.810008 C60.2087994,46.117744 61.923472,40.8347817 61.923472,34.9597686 C61.9222424,30.0695396 60.7162539,25.5590099 58.3067362,21.4281798 Z" id="Shape" fill="#FFFFFF">
</path>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.5 KiB

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
<circle cx="512" cy="512" r="512" style="fill:#ff7a59"/>
<path d="M623.8 624.94c-38.23 0-69.24-30.67-69.24-68.51s31-68.52 69.24-68.52 69.26 30.67 69.26 68.52-31 68.51-69.26 68.51m20.74-200.42v-61a46.83 46.83 0 0 0 27.33-42.29v-1.41c0-25.78-21.32-46.86-47.35-46.86h-1.43c-26 0-47.35 21.09-47.35 46.86v1.41a46.85 46.85 0 0 0 27.33 42.29v61a135.08 135.08 0 0 0-63.86 27.79l-169.1-130.17A52.49 52.49 0 0 0 372 309c0-29.21-23.89-52.92-53.4-53s-53.45 23.59-53.48 52.81 23.85 52.88 53.36 52.93a53.29 53.29 0 0 0 26.33-7.09l166.38 128.1a132.14 132.14 0 0 0 2.07 150.3l-50.62 50.1A43.42 43.42 0 1 0 450.1 768c24.24 0 43.9-19.46 43.9-43.45a42.24 42.24 0 0 0-2-12.42l50-49.52a135.28 135.28 0 0 0 81.8 27.47c74.61 0 135.06-59.83 135.06-133.65 0-66.82-49.62-122-114.33-131.91" style="fill:#ffffff;fill-rule:evenodd"/>
</svg>

After

Width:  |  Height:  |  Size: 1 KiB

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg fill="#000000" width="800px" height="800px" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
<path d="M5.948 5.609c0.99 0.807 1.365 0.75 3.234 0.625l17.62-1.057c0.375 0 0.063-0.375-0.063-0.438l-2.927-2.115c-0.557-0.438-1.307-0.932-2.74-0.813l-17.057 1.25c-0.625 0.057-0.75 0.37-0.5 0.62zM7.005 9.719v18.536c0 0.995 0.495 1.37 1.615 1.307l19.365-1.12c1.12-0.063 1.25-0.745 1.25-1.557v-18.411c0-0.813-0.313-1.245-1-1.182l-20.234 1.182c-0.75 0.063-0.995 0.432-0.995 1.24zM26.12 10.708c0.125 0.563 0 1.12-0.563 1.188l-0.932 0.188v13.682c-0.813 0.438-1.557 0.688-2.177 0.688-1 0-1.25-0.313-1.995-1.245l-6.104-9.583v9.271l1.932 0.438c0 0 0 1.12-1.557 1.12l-4.297 0.25c-0.125-0.25 0-0.875 0.438-0.995l1.12-0.313v-12.255l-1.557-0.125c-0.125-0.563 0.188-1.37 1.057-1.432l4.609-0.313 6.354 9.708v-8.589l-1.62-0.188c-0.125-0.682 0.37-1.182 0.995-1.24zM2.583 1.38l17.745-1.307c2.177-0.188 2.74-0.063 4.109 0.932l5.667 3.984c0.932 0.682 1.245 0.87 1.245 1.615v21.839c0 1.37-0.5 2.177-2.24 2.302l-20.615 1.245c-1.302 0.063-1.927-0.125-2.615-0.995l-4.172-5.417c-0.745-0.995-1.057-1.74-1.057-2.609v-19.411c0-1.12 0.5-2.052 1.932-2.177z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="none">
<g fill-rule="evenodd" clip-rule="evenodd">
<path fill="#E01E5A" d="M2.471 11.318a1.474 1.474 0 001.47-1.471v-1.47h-1.47A1.474 1.474 0 001 9.846c.001.811.659 1.469 1.47 1.47zm3.682-2.942a1.474 1.474 0 00-1.47 1.471v3.683c.002.811.66 1.468 1.47 1.47a1.474 1.474 0 001.47-1.47V9.846a1.474 1.474 0 00-1.47-1.47z"/>
<path fill="#36C5F0" d="M4.683 2.471c.001.811.659 1.469 1.47 1.47h1.47v-1.47A1.474 1.474 0 006.154 1a1.474 1.474 0 00-1.47 1.47zm2.94 3.682a1.474 1.474 0 00-1.47-1.47H2.47A1.474 1.474 0 001 6.153c.002.812.66 1.469 1.47 1.47h3.684a1.474 1.474 0 001.47-1.47z"/>
<path fill="#2EB67D" d="M9.847 7.624a1.474 1.474 0 001.47-1.47V2.47A1.474 1.474 0 009.848 1a1.474 1.474 0 00-1.47 1.47v3.684c.002.81.659 1.468 1.47 1.47zm3.682-2.941a1.474 1.474 0 00-1.47 1.47v1.47h1.47A1.474 1.474 0 0015 6.154a1.474 1.474 0 00-1.47-1.47z"/>
<path fill="#ECB22E" d="M8.377 9.847c.002.811.659 1.469 1.47 1.47h3.683A1.474 1.474 0 0015 9.848a1.474 1.474 0 00-1.47-1.47H9.847a1.474 1.474 0 00-1.47 1.47zm2.94 3.682a1.474 1.474 0 00-1.47-1.47h-1.47v1.47c.002.812.659 1.469 1.47 1.47a1.474 1.474 0 001.47-1.47z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 256 256" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g>
<path d="M128.080089,-0.000183105 C135.311053,0.0131003068 142.422517,0.624138494 149.335663,1.77979593 L149.335663,1.77979593 L149.335663,76.2997796 L202.166953,23.6044907 C208.002065,27.7488446 213.460883,32.3582023 218.507811,37.3926715 C223.557281,42.4271407 228.192318,47.8867213 232.346817,53.7047992 L232.346817,53.7047992 L179.512985,106.400063 L254.227854,106.400063 C255.387249,113.29414 256,120.36111 256,127.587243 L256,127.587243 L256,127.759881 C256,134.986013 255.387249,142.066204 254.227854,148.960282 L254.227854,148.960282 L179.500273,148.960282 L232.346817,201.642324 C228.192318,207.460402 223.557281,212.919983 218.523066,217.954452 L218.523066,217.954452 L218.507811,217.954452 C213.460883,222.988921 208.002065,227.6115 202.182208,231.742607 L202.182208,231.742607 L149.335663,179.04709 L149.335663,253.5672 C142.435229,254.723036 135.323765,255.333244 128.092802,255.348499 L128.092802,255.348499 L127.907197,255.348499 C120.673691,255.333244 113.590195,254.723036 106.677048,253.5672 L106.677048,253.5672 L106.677048,179.04709 L53.8457596,231.742607 C42.1780766,223.466917 31.977435,213.278734 23.6658953,201.642324 L23.6658953,201.642324 L76.4997269,148.960282 L1.78485803,148.960282 C0.612750404,142.052729 0,134.946095 0,127.719963 L0,127.719963 L0,127.349037 C0.0121454869,125.473817 0.134939797,123.182933 0.311311815,120.812834 L0.36577283,120.099764 C0.887996182,113.428547 1.78485803,106.400063 1.78485803,106.400063 L1.78485803,106.400063 L76.4997269,106.400063 L23.6658953,53.7047992 C27.8076812,47.8867213 32.4300059,42.4403618 37.4769335,37.4193681 L37.4769335,37.4193681 L37.5023588,37.3926715 C42.5391163,32.3582023 48.0106469,27.7488446 53.8457596,23.6044907 L53.8457596,23.6044907 L106.677048,76.2997796 L106.677048,1.77979593 C113.590195,0.624138494 120.688946,0.0131003068 127.932622,-0.000183105 L127.932622,-0.000183105 L128.080089,-0.000183105 Z M128.067377,95.7600714 L127.945335,95.7600714 C118.436262,95.7600714 109.32891,97.5001809 100.910584,100.661566 C97.7553011,109.043534 96.0085811,118.129275 95.9958684,127.613685 L95.9958684,127.733184 C96.0085811,137.217594 97.7553011,146.303589 100.923296,154.685303 C109.32891,157.846943 118.436262,159.587052 127.945335,159.587052 L128.067377,159.587052 C137.576449,159.587052 146.683802,157.846943 155.089415,154.685303 C158.257411,146.290368 160.004131,137.217594 160.004131,127.733184 L160.004131,127.613685 C160.004131,118.129275 158.257411,109.043534 155.089415,100.661566 C146.683802,97.5001809 137.576449,95.7600714 128.067377,95.7600714 Z" fill="#FF4A00" fill-rule="nonzero">
</path>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" x="0" y="0" viewBox="0 0 304 182"><path fill="#ffffff" d="m86 66 2 9c0 3 1 5 3 8v2l-1 3-7 4-2 1-3-1-4-5-3-6c-8 9-18 14-29 14-9 0-16-3-20-8-5-4-8-11-8-19s3-15 9-20c6-6 14-8 25-8a79 79 0 0 1 22 3v-7c0-8-2-13-5-16-3-4-8-5-16-5l-11 1a80 80 0 0 0-14 5h-2c-1 0-2-1-2-3v-5l1-3c0-1 1-2 3-2l12-5 16-2c12 0 20 3 26 8 5 6 8 14 8 25v32zM46 82l10-2c4-1 7-4 10-7l3-6 1-9v-4a84 84 0 0 0-19-2c-6 0-11 1-15 4-3 2-4 6-4 11s1 8 3 11c3 2 6 4 11 4zm80 10-4-1-2-3-23-78-1-4 2-2h10l4 1 2 4 17 66 15-66 2-4 4-1h8l4 1 2 4 16 67 17-67 2-4 4-1h9c2 0 3 1 3 2v2l-1 2-24 78-2 4-4 1h-9l-4-1-1-4-16-65-15 64-2 4-4 1h-9zm129 3a66 66 0 0 1-27-6l-3-3-1-2v-5c0-2 1-3 2-3h2l3 1a54 54 0 0 0 23 5c6 0 11-2 14-4 4-2 5-5 5-9l-2-7-10-5-15-5c-7-2-13-6-16-10a24 24 0 0 1 5-34l10-5a44 44 0 0 1 20-2 110 110 0 0 1 12 3l4 2 3 2 1 4v4c0 3-1 4-2 4l-4-2c-6-2-12-3-19-3-6 0-11 0-14 2s-4 5-4 9c0 3 1 5 3 7s5 4 11 6l14 4c7 3 12 6 15 10s5 9 5 14l-3 12-7 8c-3 3-7 5-11 6l-14 2z"/><path d="M274 144A220 220 0 0 1 4 124c-4-3-1-6 2-4a300 300 0 0 0 263 16c5-2 10 4 5 8z" fill="#f90"/><path d="M287 128c-4-5-28-3-38-1-4 0-4-3-1-5 19-13 50-9 53-5 4 5-1 36-18 51-3 2-6 1-5-2 5-10 13-33 9-38z" fill="#f90"/></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View file

@ -0,0 +1 @@
<svg viewBox="0 0 96 96" xmlns="http://www.w3.org/2000/svg"><defs><linearGradient id="azure__a" x1="-1032.17" x2="-1059.21" y1="145.31" y2="65.43" gradientTransform="matrix(1 0 0 -1 1075 158)" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#114a8b"/><stop offset="1" stop-color="#0669bc"/></linearGradient><linearGradient id="azure__b" x1="-1023.73" x2="-1029.98" y1="108.08" y2="105.97" gradientTransform="matrix(1 0 0 -1 1075 158)" gradientUnits="userSpaceOnUse"><stop offset="0" stop-opacity=".3"/><stop offset=".07" stop-opacity=".2"/><stop offset=".32" stop-opacity=".1"/><stop offset=".62" stop-opacity=".05"/><stop offset="1" stop-opacity="0"/></linearGradient><linearGradient id="azure__c" x1="-1027.16" x2="-997.48" y1="147.64" y2="68.56" gradientTransform="matrix(1 0 0 -1 1075 158)" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#3ccbf4"/><stop offset="1" stop-color="#2892df"/></linearGradient></defs><path fill="url(#azure__a)" d="M33.34 6.54h26.04l-27.03 80.1a4.15 4.15 0 0 1-3.94 2.81H8.15a4.14 4.14 0 0 1-3.93-5.47L29.4 9.38a4.15 4.15 0 0 1 3.94-2.83z"/><path fill="#0078d4" d="M71.17 60.26H29.88a1.91 1.91 0 0 0-1.3 3.31l26.53 24.76a4.17 4.17 0 0 0 2.85 1.13h23.38z"/><path fill="url(#azure__b)" d="M33.34 6.54a4.12 4.12 0 0 0-3.95 2.88L4.25 83.92a4.14 4.14 0 0 0 3.91 5.54h20.79a4.44 4.44 0 0 0 3.4-2.9l5.02-14.78 17.91 16.7a4.24 4.24 0 0 0 2.67.97h23.29L71.02 60.26H41.24L59.47 6.55z"/><path fill="url(#azure__c)" d="M66.6 9.36a4.14 4.14 0 0 0-3.93-2.82H33.65a4.15 4.15 0 0 1 3.93 2.82l25.18 74.62a4.15 4.15 0 0 1-3.93 5.48h29.02a4.15 4.15 0 0 0 3.93-5.48z"/></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View file

@ -0,0 +1 @@
<svg width="256" height="262" viewBox="0 0 256 262" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid"><path d="M255.878 133.451c0-10.734-.871-18.567-2.756-26.69H130.55v48.448h71.947c-1.45 12.04-9.283 30.172-26.69 42.356l-.244 1.622 38.755 30.023 2.685.268c24.659-22.774 38.875-56.282 38.875-96.027" fill="#4285F4"/><path d="M130.55 261.1c35.248 0 64.839-11.605 86.453-31.622l-41.196-31.913c-11.024 7.688-25.82 13.055-45.257 13.055-34.523 0-63.824-22.773-74.269-54.25l-1.531.13-40.298 31.187-.527 1.465C35.393 231.798 79.49 261.1 130.55 261.1" fill="#34A853"/><path d="M56.281 156.37c-2.756-8.123-4.351-16.827-4.351-25.82 0-8.994 1.595-17.697 4.206-25.82l-.073-1.73L15.26 71.312l-1.335.635C5.077 89.644 0 109.517 0 130.55s5.077 40.905 13.925 58.602l42.356-32.782" fill="#FBBC05"/><path d="M130.55 50.479c24.514 0 41.05 10.589 50.479 19.438l36.844-35.974C195.245 12.91 165.798 0 130.55 0 79.49 0 35.393 29.301 13.925 71.947l42.211 32.783c10.59-31.477 39.891-54.251 74.414-54.251" fill="#EB4335"/></svg>

After

Width:  |  Height:  |  Size: 1,016 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="171" preserveAspectRatio="xMidYMid" viewBox="0 0 256 171"><defs><linearGradient id="meta__a" x1="13.878%" x2="89.144%" y1="55.934%" y2="58.694%"><stop offset="0%" stop-color="#0064E1"/><stop offset="40%" stop-color="#0064E1"/><stop offset="83%" stop-color="#0073EE"/><stop offset="100%" stop-color="#0082FB"/></linearGradient><linearGradient id="meta__b" x1="54.315%" x2="54.315%" y1="82.782%" y2="39.307%"><stop offset="0%" stop-color="#0082FB"/><stop offset="100%" stop-color="#0064E0"/></linearGradient></defs><path fill="#0081FB" d="M27.651 112.136c0 9.775 2.146 17.28 4.95 21.82 3.677 5.947 9.16 8.466 14.751 8.466 7.211 0 13.808-1.79 26.52-19.372 10.185-14.092 22.186-33.874 30.26-46.275l13.675-21.01c9.499-14.591 20.493-30.811 33.1-41.806C161.196 4.985 172.298 0 183.47 0c18.758 0 36.625 10.87 50.3 31.257C248.735 53.584 256 81.707 256 110.729c0 17.253-3.4 29.93-9.187 39.946-5.591 9.686-16.488 19.363-34.818 19.363v-27.616c15.695 0 19.612-14.422 19.612-30.927 0-23.52-5.484-49.623-17.564-68.273-8.574-13.23-19.684-21.313-31.907-21.313-13.22 0-23.859 9.97-35.815 27.75-6.356 9.445-12.882 20.956-20.208 33.944l-8.066 14.289c-16.203 28.728-20.307 35.271-28.408 46.07-14.2 18.91-26.324 26.076-42.287 26.076-18.935 0-30.91-8.2-38.325-20.556C2.973 139.413 0 126.202 0 111.148l27.651.988Z"/><path fill="url(#meta__a)" d="M21.802 33.206C34.48 13.666 52.774 0 73.757 0 85.91 0 97.99 3.597 110.605 13.897c13.798 11.261 28.505 29.805 46.853 60.368l6.58 10.967c15.881 26.459 24.917 40.07 30.205 46.49 6.802 8.243 11.565 10.7 17.752 10.7 15.695 0 19.612-14.422 19.612-30.927l24.393-.766c0 17.253-3.4 29.93-9.187 39.946-5.591 9.686-16.488 19.363-34.818 19.363-11.395 0-21.49-2.475-32.654-13.007-8.582-8.083-18.615-22.443-26.334-35.352l-22.96-38.352C118.528 64.08 107.96 49.73 101.845 43.23c-6.578-6.988-15.036-15.428-28.532-15.428-10.923 0-20.2 7.666-27.963 19.39L21.802 33.206Z"/><path fill="url(#meta__b)" d="M73.312 27.802c-10.923 0-20.2 7.666-27.963 19.39-10.976 16.568-17.698 41.245-17.698 64.944 0 9.775 2.146 17.28 4.95 21.82L9.027 149.482C2.973 139.413 0 126.202 0 111.148 0 83.772 7.514 55.24 21.802 33.206 34.48 13.666 52.774 0 73.757 0l-.445 27.802Z"/></svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="260" preserveAspectRatio="xMidYMid" viewBox="0 0 256 260"><path fill="#fff" d="M239.184 106.203a64.716 64.716 0 0 0-5.576-53.103C219.452 28.459 191 15.784 163.213 21.74A65.586 65.586 0 0 0 52.096 45.22a64.716 64.716 0 0 0-43.23 31.36c-14.31 24.602-11.061 55.634 8.033 76.74a64.665 64.665 0 0 0 5.525 53.102c14.174 24.65 42.644 37.324 70.446 31.36a64.72 64.72 0 0 0 48.754 21.744c28.481.025 53.714-18.361 62.414-45.481a64.767 64.767 0 0 0 43.229-31.36c14.137-24.558 10.875-55.423-8.083-76.483Zm-97.56 136.338a48.397 48.397 0 0 1-31.105-11.255l1.535-.87 51.67-29.825a8.595 8.595 0 0 0 4.247-7.367v-72.85l21.845 12.636c.218.111.37.32.409.563v60.367c-.056 26.818-21.783 48.545-48.601 48.601Zm-104.466-44.61a48.345 48.345 0 0 1-5.781-32.589l1.534.921 51.722 29.826a8.339 8.339 0 0 0 8.441 0l63.181-36.425v25.221a.87.87 0 0 1-.358.665l-52.335 30.184c-23.257 13.398-52.97 5.431-66.404-17.803ZM23.549 85.38a48.499 48.499 0 0 1 25.58-21.333v61.39a8.288 8.288 0 0 0 4.195 7.316l62.874 36.272-21.845 12.636a.819.819 0 0 1-.767 0L41.353 151.53c-23.211-13.454-31.171-43.144-17.804-66.405v.256Zm179.466 41.695-63.08-36.63L161.73 77.86a.819.819 0 0 1 .768 0l52.233 30.184a48.6 48.6 0 0 1-7.316 87.635v-61.391a8.544 8.544 0 0 0-4.4-7.213Zm21.742-32.69-1.535-.922-51.619-30.081a8.39 8.39 0 0 0-8.492 0L99.98 99.808V74.587a.716.716 0 0 1 .307-.665l52.233-30.133a48.652 48.652 0 0 1 72.236 50.391v.205ZM88.061 139.097l-21.845-12.585a.87.87 0 0 1-.41-.614V65.685a48.652 48.652 0 0 1 79.757-37.346l-1.535.87-51.67 29.825a8.595 8.595 0 0 0-4.246 7.367l-.051 72.697Zm11.868-25.58 28.138-16.217 28.188 16.218v32.434l-28.086 16.218-28.188-16.218-.052-32.434Z"/></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View file

@ -1,6 +1,6 @@
import { IconCirclePlusFilled, IconMail, type Icon } from "@tabler/icons-react";
import { type Icon } from "@tabler/icons-react";
import { Link, useLocation } from "@tanstack/react-router";
import { Button } from "@/components/ui/button";
import {
SidebarGroup,
SidebarGroupContent,
@ -11,30 +11,34 @@ import {
export function NavMain({
items,
onNavigate,
currentView,
}: {
items: {
title: string;
url: string;
icon?: Icon;
}[];
onNavigate?: (item: { title: string }) => void;
currentView?: string;
}) {
const location = useLocation();
return (
<SidebarGroup>
<SidebarGroupContent className="flex flex-col gap-2">
<SidebarMenu>
{items.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuItem key={item.url}>
<SidebarMenuButton
asChild
tooltip={item.title}
isActive={currentView === item.title}
onClick={() => onNavigate?.(item)}
isActive={location.pathname.startsWith(item.url)}
>
{item.icon && <item.icon />}
<span>{item.title}</span>
<Link
to={item.url}
aria-label={item.title}
activeProps={{
className: "active",
}}
>
{item.icon && <item.icon />} <span>{item.title}</span>{" "}
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
))}

View file

@ -1,5 +1,3 @@
"use client";
import * as React from "react";
import { type Icon } from "@tabler/icons-react";
@ -13,8 +11,6 @@ import {
export function NavSecondary({
items,
onNavigate,
currentView,
...props
}: {
items: {
@ -23,8 +19,6 @@ export function NavSecondary({
icon: Icon;
external?: boolean;
}[];
onNavigate?: (item: { title: string }) => void;
currentView?: string;
} & React.ComponentPropsWithoutRef<typeof SidebarGroup>) {
return (
<SidebarGroup {...props}>
@ -33,12 +27,9 @@ export function NavSecondary({
{items.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton
isActive={currentView === item.title}
onClick={async () => {
if (item.external && item.url) {
await window.electronAPI.openExternal(item.url);
} else {
onNavigate?.(item);
}
}}
>

View file

@ -1,26 +1,174 @@
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { SidebarTrigger } from "@/components/ui/sidebar";
import { useRouter } from "@tanstack/react-router";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { useEffect, useState } from "react";
interface SiteHeaderProps {
currentView?: string;
}
const dragRegion = { WebkitAppRegion: "drag" } as React.CSSProperties;
const noDragRegion = { WebkitAppRegion: "no-drag" } as React.CSSProperties;
export function SiteHeader({ currentView }: SiteHeaderProps) {
const router = useRouter();
const [canGoBack, setCanGoBack] = useState(false);
const [canGoForward, setCanGoForward] = useState(false);
useEffect(() => {
// Track navigation history in session storage
const HISTORY_KEY = "navigation-history";
const INDEX_KEY = "navigation-index";
// Initialize or get existing history
let history: string[] = JSON.parse(
sessionStorage.getItem(HISTORY_KEY) || "[]",
);
// If this is the first load, initialize with current path
if (history.length === 0) {
history = [router.state.location.pathname];
sessionStorage.setItem(HISTORY_KEY, JSON.stringify(history));
sessionStorage.setItem(INDEX_KEY, "0");
}
const updateNavigationState = () => {
const storedHistory = JSON.parse(
sessionStorage.getItem(HISTORY_KEY) || "[]",
);
const storedIndex = parseInt(sessionStorage.getItem(INDEX_KEY) || "0");
setCanGoBack(storedIndex > 0);
setCanGoForward(storedIndex < storedHistory.length - 1);
};
let isNavigatingProgrammatically = false;
const handleNavigation = () => {
const currentPath = router.state.location.pathname;
let storedHistory: string[] = JSON.parse(
sessionStorage.getItem(HISTORY_KEY) || "[]",
);
let storedIndex = parseInt(sessionStorage.getItem(INDEX_KEY) || "0");
if (isNavigatingProgrammatically) {
// Navigation was triggered by back/forward buttons, index already updated
isNavigatingProgrammatically = false;
} else {
// Check if this is a back/forward navigation by comparing with history
const previousPath = storedHistory[storedIndex - 1];
const nextPath = storedHistory[storedIndex + 1];
if (previousPath === currentPath) {
// User went back
storedIndex = Math.max(0, storedIndex - 1);
} else if (nextPath === currentPath) {
// User went forward
storedIndex = Math.min(storedHistory.length - 1, storedIndex + 1);
} else {
// New navigation - truncate forward history and add new entry
storedHistory = storedHistory.slice(0, storedIndex + 1);
storedHistory.push(currentPath);
storedIndex = storedHistory.length - 1;
}
sessionStorage.setItem(HISTORY_KEY, JSON.stringify(storedHistory));
sessionStorage.setItem(INDEX_KEY, storedIndex.toString());
}
updateNavigationState();
};
// Initial state
updateNavigationState();
// Listen for route changes
const unsubscribe = router.subscribe("onResolved", handleNavigation);
// Override the navigation methods to track programmatic navigation
const originalBack = router.history.back.bind(router.history);
const originalForward = router.history.forward.bind(router.history);
router.history.back = () => {
const storedIndex = parseInt(sessionStorage.getItem(INDEX_KEY) || "0");
if (storedIndex > 0) {
isNavigatingProgrammatically = true;
sessionStorage.setItem(INDEX_KEY, (storedIndex - 1).toString());
originalBack();
}
};
router.history.forward = () => {
const storedHistory = JSON.parse(
sessionStorage.getItem(HISTORY_KEY) || "[]",
);
const storedIndex = parseInt(sessionStorage.getItem(INDEX_KEY) || "0");
if (storedIndex < storedHistory.length - 1) {
isNavigatingProgrammatically = true;
sessionStorage.setItem(INDEX_KEY, (storedIndex + 1).toString());
originalForward();
}
};
return () => {
unsubscribe();
// Restore original methods
router.history.back = originalBack;
router.history.forward = originalForward;
};
}, [router]);
const handleGoBack = () => {
router.history.back();
};
const handleGoForward = () => {
router.history.forward();
};
return (
<header
className="flex h-[var(--header-height)] shrink-0 items-center gap-2 backdrop-blur supports-[backdrop-filter]:bg-sidebar/60 sticky top-0 z-50 w-full"
style={{ WebkitAppRegion: "drag" } as React.CSSProperties}
style={dragRegion}
>
<div className="flex w-full items-center gap-1">
{/* macOS traffic light button spacing */}
<div className="w-[78px] flex-shrink-0" />
<div className="flex items-center gap-1 px-4 lg:gap-2 lg:px-6 py-1.5">
<SidebarTrigger
className="-ml-1"
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
/>
<SidebarTrigger className="-ml-1" style={noDragRegion} />
<Separator orientation="vertical" className="h-4" />
{/* Navigation buttons */}
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={handleGoBack}
disabled={!canGoBack}
className="h-7 w-7 p-0"
style={noDragRegion}
title="Go back"
aria-label="Go back"
>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={handleGoForward}
disabled={!canGoForward}
className="h-7 w-7 p-0"
style={noDragRegion}
title="Go forward"
aria-label="Go forward"
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 flex items-center gap-2 pointer-events-none select-none">
<h1 className="text-base font-medium">{currentView || "Amical"}</h1>

View file

@ -0,0 +1,96 @@
"use client";
import * as React from "react";
import { CheckIcon, ChevronsUpDownIcon, XIcon } from "lucide-react";
import { Checkbox } from "@/components/ui/checkbox";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
export function ComboboxMulti({
options,
value,
onChange,
placeholder,
className,
disabled,
}: {
options: { value: string; label: string }[];
value: string[];
onChange: (value: string[]) => void;
placeholder?: string;
className?: string;
disabled?: boolean;
}) {
const [open, setOpen] = React.useState(false);
const selectedOptions = options.filter((option) =>
value.includes(option.value),
);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger disabled={disabled} asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className={cn(
"w-[250px] justify-between flex-wrap min-h-[40px]",
className,
)}
>
<div className="flex flex-wrap gap-1 items-center">
<span className="text-muted-foreground">
{placeholder || "Select..."}
</span>
</div>
<ChevronsUpDownIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[250px] p-0">
<Command>
<CommandInput placeholder={placeholder || "Search..."} />
<CommandList>
<CommandEmpty>No option found.</CommandEmpty>
<CommandGroup>
{options.map((option) => (
<CommandItem
key={option.value}
value={option.value}
onSelect={() => {
if (value.includes(option.value)) {
onChange(value.filter((v) => v !== option.value));
} else {
onChange([...value, option.value]);
}
}}
className="flex items-center gap-2"
>
<Checkbox
checked={value.includes(option.value)}
tabIndex={-1}
className="pointer-events-none"
/>
<span>{option.label}</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}

View file

@ -0,0 +1,82 @@
"use client";
import * as React from "react";
import { CheckIcon, ChevronsUpDownIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
export function Combobox({
options,
value,
onChange,
disabled,
placeholder = "Select option...",
}: {
options: { value: string; label: string }[];
value: string;
onChange: (value: string) => void;
disabled?: boolean;
placeholder?: string;
}) {
const [open, setOpen] = React.useState(false);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild disabled={disabled}>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="min-w-[200px] justify-between"
>
{value
? options.find((option) => option.value === value)?.label
: placeholder}
<ChevronsUpDownIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0">
<Command>
<CommandInput placeholder={placeholder} />
<CommandList>
<CommandEmpty>No option found.</CommandEmpty>
<CommandGroup>
{options.map((option) => (
<CommandItem
key={option.value}
value={option.value}
onSelect={(currentValue) => {
setOpen(false);
onChange(currentValue === value ? "" : currentValue);
}}
>
<CheckIcon
className={cn(
"mr-2 h-4 w-4",
value === option.value ? "opacity-100" : "opacity-0",
)}
/>
{option.label}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}

View file

@ -0,0 +1,102 @@
export const AVAILABLE_LANGUAGES = [
{ value: "auto", label: "Auto detect" },
{ value: "en", label: "🇺🇸 English" },
{ value: "zh", label: "🇨🇳 Chinese" },
{ value: "es", label: "🇪🇸 Spanish" },
{ value: "af", label: "🇿🇦 Afrikaans" },
{ value: "sq", label: "🇦🇱 Albanian" },
{ value: "am", label: "🇪🇹 Amharic" },
{ value: "ar", label: "🇸🇦 Arabic" },
{ value: "hy", label: "🇦🇲 Armenian" },
{ value: "as", label: "🇮🇳 Assamese" },
{ value: "az", label: "🇦🇿 Azerbaijani" },
{ value: "ba", label: "🇷🇺 Bashkir" },
{ value: "eu", label: "🇪🇸 Basque" },
{ value: "be", label: "🇧🇾 Belarusian" },
{ value: "bn", label: "🇧🇩 Bengali" },
{ value: "bs", label: "🇧🇦 Bosnian" },
{ value: "br", label: "🇫🇷 Breton" },
{ value: "bg", label: "🇧🇬 Bulgarian" },
{ value: "ca", label: "🇪🇸 Catalan" },
{ value: "hr", label: "🇭🇷 Croatian" },
{ value: "cs", label: "🇨🇿 Czech" },
{ value: "da", label: "🇩🇰 Danish" },
{ value: "nl", label: "🇳🇱 Dutch" },
{ value: "et", label: "🇪🇪 Estonian" },
{ value: "fo", label: "🇫🇴 Faroese" },
{ value: "fi", label: "🇫🇮 Finnish" },
{ value: "fr", label: "🇫🇷 French" },
{ value: "gl", label: "🇪🇸 Galician" },
{ value: "ka", label: "🇬🇪 Georgian" },
{ value: "de", label: "🇩🇪 German" },
{ value: "el", label: "🇬🇷 Greek" },
{ value: "gu", label: "🇮🇳 Gujarati" },
{ value: "ht", label: "🇭🇹 Haitian Creole" },
{ value: "ha", label: "🇳🇬 Hausa" },
{ value: "haw", label: "🇺🇸 Hawaiian" },
{ value: "he", label: "🇮🇱 Hebrew" },
{ value: "hi", label: "🇮🇳 Hindi" },
{ value: "hu", label: "🇭🇺 Hungarian" },
{ value: "is", label: "🇮🇸 Icelandic" },
{ value: "id", label: "🇮🇩 Indonesian" },
{ value: "it", label: "🇮🇹 Italian" },
{ value: "ja", label: "🇯🇵 Japanese" },
{ value: "jw", label: "🇮🇩 Javanese" },
{ value: "kn", label: "🇮🇳 Kannada" },
{ value: "kk", label: "🇰🇿 Kazakh" },
{ value: "km", label: "🇰🇭 Khmer" },
{ value: "ko", label: "🇰🇷 Korean" },
{ value: "lo", label: "🇱🇦 Lao" },
{ value: "la", label: "🇻🇦 Latin" },
{ value: "lv", label: "🇱🇻 Latvian" },
{ value: "ln", label: "🇨🇩 Lingala" },
{ value: "lt", label: "🇱🇹 Lithuanian" },
{ value: "lb", label: "🇱🇺 Luxembourgish" },
{ value: "mk", label: "🇲🇰 Macedonian" },
{ value: "mg", label: "🇲🇬 Malagasy" },
{ value: "ms", label: "🇲🇾 Malay" },
{ value: "ml", label: "🇮🇳 Malayalam" },
{ value: "mt", label: "🇲🇹 Maltese" },
{ value: "mi", label: "🇳🇿 Maori" },
{ value: "mr", label: "🇮🇳 Marathi" },
{ value: "mn", label: "🇲🇳 Mongolian" },
{ value: "my", label: "🇲🇲 Myanmar (Burmese)" },
{ value: "ne", label: "🇳🇵 Nepali" },
{ value: "no", label: "🇳🇴 Norwegian" },
{ value: "nn", label: "🇳🇴 Nynorsk" },
{ value: "oc", label: "🇫🇷 Occitan" },
{ value: "ps", label: "🇦🇫 Pashto" },
{ value: "fa", label: "🇮🇷 Persian" },
{ value: "pl", label: "🇵🇱 Polish" },
{ value: "pt", label: "🇵🇹 Portuguese" },
{ value: "pa", label: "🇮🇳 Punjabi" },
{ value: "ro", label: "🇷🇴 Romanian" },
{ value: "ru", label: "🇷🇺 Russian" },
{ value: "sa", label: "🇮🇳 Sanskrit" },
{ value: "sr", label: "🇷🇸 Serbian" },
{ value: "sn", label: "🇿🇼 Shona" },
{ value: "sd", label: "🇵🇰 Sindhi" },
{ value: "si", label: "🇱🇰 Sinhala" },
{ value: "sk", label: "🇸🇰 Slovak" },
{ value: "sl", label: "🇸🇮 Slovenian" },
{ value: "so", label: "🇸🇴 Somali" },
{ value: "su", label: "🇮🇩 Sundanese" },
{ value: "sw", label: "🇰🇪 Swahili" },
{ value: "sv", label: "🇸🇪 Swedish" },
{ value: "tl", label: "🇵🇭 Tagalog" },
{ value: "tg", label: "🇹🇯 Tajik" },
{ value: "ta", label: "🇮🇳 Tamil" },
{ value: "tt", label: "🇷🇺 Tatar" },
{ value: "te", label: "🇮🇳 Telugu" },
{ value: "th", label: "🇹🇭 Thai" },
{ value: "bo", label: "🇨🇳 Tibetan" },
{ value: "tr", label: "🇹🇷 Turkish" },
{ value: "tk", label: "🇹🇲 Turkmen" },
{ value: "uk", label: "🇺🇦 Ukrainian" },
{ value: "ur", label: "🇵🇰 Urdu" },
{ value: "uz", label: "🇺🇿 Uzbek" },
{ value: "vi", label: "🇻🇳 Vietnamese" },
{ value: "cy", label: "🏴󠁧󠁢󠁷󠁬󠁳󠁿 Welsh" },
{ value: "yi", label: "🇮🇱 Yiddish" },
{ value: "yo", label: "🇳🇬 Yoruba" },
];

View file

@ -1,4 +1,4 @@
export interface Model {
export interface AvailableWhisperModel {
id: string;
name: string;
type: "whisper" | "tts" | "other";
@ -8,6 +8,16 @@ export interface Model {
downloadUrl: string;
filename: string; // Expected filename after download
checksum?: string; // Optional checksum for validation
features: {
icon: string;
tooltip: string;
}[];
speed: number;
accuracy: number;
setup: "offline" | "cloud";
provider: string;
providerIcon: string;
modelSize: string;
}
// DownloadedModel type is now imported from the database schema
@ -27,81 +37,277 @@ export interface ModelManagerState {
}
// Available Whisper models manifest
export const AVAILABLE_MODELS: Model[] = [
// export const AVAILABLE_MODELS: AvailableWhisperModel[] = [
// {
// id: "whisper-tiny",
// name: "Whisper Tiny",
// type: "whisper",
// size: 77.7 * 1024 * 1024, // ~77.7 MB
// sizeFormatted: "~78 MB",
// description:
// "Fastest model with basic accuracy. Good for real-time transcription.",
// downloadUrl:
// "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-tiny.bin",
// filename: "ggml-tiny.bin",
// checksum: "bd577a113a864445d4c299885e0cb97d4ba92b5f",
// },
// {
// id: "whisper-base",
// name: "Whisper Base",
// type: "whisper",
// size: 148 * 1024 * 1024, // ~148 MB
// sizeFormatted: "~148 MB",
// description: "Balanced speed and accuracy. Recommended for most use cases.",
// downloadUrl:
// "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.bin",
// filename: "ggml-base.bin",
// checksum: "465707469ff3a37a2b9b8d8f89f2f99de7299dac",
// },
// {
// id: "whisper-small",
// name: "Whisper Small",
// type: "whisper",
// size: 488 * 1024 * 1024, // ~488 MB
// sizeFormatted: "~488 MB",
// description:
// "Higher accuracy with moderate speed. Good for quality transcription.",
// downloadUrl:
// "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-small.bin",
// filename: "ggml-small.bin",
// checksum: "55356645c2b361a969dfd0ef2c5a50d530afd8d5",
// },
// {
// id: "whisper-medium",
// name: "Whisper Medium",
// type: "whisper",
// size: 1.53 * 1024 * 1024 * 1024, // ~1.53 GB
// sizeFormatted: "~1.5 GB",
// description: "High accuracy model. Slower but more precise transcription.",
// downloadUrl:
// "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-medium.bin",
// filename: "ggml-medium.bin",
// checksum: "fd9727b6e1217c2f614f9b698455c4ffd82463b4",
// },
// {
// id: "whisper-large-v3",
// name: "Whisper Large v3",
// type: "whisper",
// size: 3.1 * 1024 * 1024 * 1024, // ~3.1 GB
// sizeFormatted: "~3.1 GB",
// description:
// "Highest accuracy model. Best quality but slowest transcription.",
// downloadUrl:
// "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-large-v3.bin",
// filename: "ggml-large-v3.bin",
// checksum: "ad82bf6a9043ceed055076d0fd39f5f186ff8062",
// },
// {
// id: "whisper-large-v3-turbo",
// name: "Whisper Large v3 Turbo",
// type: "whisper",
// size: 1.5 * 1024 * 1024 * 1024, // ~1.5 GB
// sizeFormatted: "~1.5 GB",
// description:
// "Optimized Large v3 variant with only 4 decoder layers, offering significantly faster transcription with accuracy comparable to Large v2/v3.",
// downloadUrl:
// "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-large-v3-turbo.bin",
// filename: "ggml-large-v3-turbo.bin",
// checksum: "4af2b29d7ec73d781377bfd1758ca957a807e941",
// },
// ];
export const AVAILABLE_MODELS: AvailableWhisperModel[] = [
{
id: "whisper-tiny",
name: "Whisper Tiny",
type: "whisper",
size: 77.7 * 1024 * 1024, // ~77.7 MB
sizeFormatted: "~78 MB",
description:
"Fastest model with basic accuracy. Good for real-time transcription.",
description: "Very fast, lightweight model ideal for real-time tasks.",
checksum: "bd577a113a864445d4c299885e0cb97d4ba92b5f",
filename: "ggml-tiny.bin",
downloadUrl:
"https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-tiny.bin",
filename: "ggml-tiny.bin",
checksum: "bd577a113a864445d4c299885e0cb97d4ba92b5f",
size: 77.7 * 1024 * 1024,
sizeFormatted: "~78 MB",
modelSize: "~78 MB",
features: [
{
icon: "rabbit",
tooltip: "Very fast transcription",
},
{
icon: "scale",
tooltip: "Lightweight, efficient model",
},
{
icon: "languages",
tooltip: "Multilingual support",
},
],
speed: 5.0,
accuracy: 2.5,
setup: "offline",
provider: "OpenAI",
providerIcon: "/icons/models/openai_dark.svg",
},
{
id: "whisper-base",
name: "Whisper Base",
type: "whisper",
size: 148 * 1024 * 1024, // ~148 MB
sizeFormatted: "~148 MB",
description: "Balanced speed and accuracy. Recommended for most use cases.",
description: "Balanced speed and accuracy for everyday use.",
checksum: "465707469ff3a37a2b9b8d8f89f2f99de7299dac",
filename: "ggml-base.bin",
downloadUrl:
"https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.bin",
filename: "ggml-base.bin",
checksum: "465707469ff3a37a2b9b8d8f89f2f99de7299dac",
size: 148 * 1024 * 1024,
sizeFormatted: "~148 MB",
modelSize: "~148 MB",
features: [
{
icon: "gauge",
tooltip: "Good balance of speed & accuracy",
},
{
icon: "scale",
tooltip: "Efficient model size",
},
{
icon: "languages",
tooltip: "Multilingual support",
},
],
speed: 4.0,
accuracy: 3.0,
setup: "offline",
provider: "OpenAI",
providerIcon: "/icons/models/openai_dark.svg",
},
{
id: "whisper-small",
name: "Whisper Small",
type: "whisper",
size: 488 * 1024 * 1024, // ~488 MB
sizeFormatted: "~488 MB",
description:
"Higher accuracy with moderate speed. Good for quality transcription.",
"High accuracy with moderate speed, ideal for quality transcription.",
checksum: "55356645c2b361a969dfd0ef2c5a50d530afd8d5",
filename: "ggml-small.bin",
downloadUrl:
"https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-small.bin",
filename: "ggml-small.bin",
checksum: "55356645c2b361a969dfd0ef2c5a50d530afd8d5",
size: 488 * 1024 * 1024,
sizeFormatted: "~488 MB",
modelSize: "~488 MB",
features: [
{
icon: "crosshair",
tooltip: "High transcription accuracy",
},
{
icon: "timer",
tooltip: "Moderate processing speed",
},
{
icon: "languages",
tooltip: "Multilingual support",
},
],
speed: 3.0,
accuracy: 3.8,
setup: "offline",
provider: "OpenAI",
providerIcon: "/icons/models/openai_dark.svg",
},
{
id: "whisper-medium",
name: "Whisper Medium",
type: "whisper",
size: 1.53 * 1024 * 1024 * 1024, // ~1.53 GB
sizeFormatted: "~1.5 GB",
description: "High accuracy model. Slower but more precise transcription.",
description: "Very high accuracy for professional, precise transcription.",
checksum: "fd9727b6e1217c2f614f9b698455c4ffd82463b4",
filename: "ggml-medium.bin",
downloadUrl:
"https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-medium.bin",
filename: "ggml-medium.bin",
checksum: "fd9727b6e1217c2f614f9b698455c4ffd82463b4",
size: 1.53 * 1024 * 1024 * 1024,
sizeFormatted: "~1.5 GB",
modelSize: "~1.5 GB",
features: [
{
icon: "crosshair",
tooltip: "Very high transcription accuracy",
},
{
icon: "languages",
tooltip: "Advanced multilingual support",
},
{
icon: "gauge",
tooltip: "Stable performance for large jobs",
},
],
speed: 2.0,
accuracy: 4.3,
setup: "offline",
provider: "OpenAI",
providerIcon: "/icons/models/openai_dark.svg",
},
{
id: "whisper-large-v3",
name: "Whisper Large v3",
type: "whisper",
size: 3.1 * 1024 * 1024 * 1024, // ~3.1 GB
sizeFormatted: "~3.1 GB",
description:
"Highest accuracy model. Best quality but slowest transcription.",
description: "Highest accuracy and best robustness for complex audio.",
checksum: "ad82bf6a9043ceed055076d0fd39f5f186ff8062",
filename: "ggml-large-v3.bin",
downloadUrl:
"https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-large-v3.bin",
filename: "ggml-large-v3.bin",
checksum: "ad82bf6a9043ceed055076d0fd39f5f186ff8062",
size: 3.1 * 1024 * 1024 * 1024,
sizeFormatted: "~3.1 GB",
modelSize: "~3.1 GB",
features: [
{
icon: "award",
tooltip: "Highest transcription accuracy",
},
{
icon: "languages",
tooltip: "Superior multilingual & accent support",
},
{
icon: "gauge",
tooltip: "Robust, reliable processing for intensive needs",
},
],
speed: 1.5,
accuracy: 4.7,
setup: "offline",
provider: "OpenAI",
providerIcon: "/icons/models/openai_dark.svg",
},
{
id: "whisper-large-v3-turbo",
name: "Whisper Large v3 Turbo",
type: "whisper",
size: 1.5 * 1024 * 1024 * 1024, // ~1.5 GB
sizeFormatted: "~1.5 GB",
description:
"Optimized Large v3 variant with only 4 decoder layers, offering significantly faster transcription with accuracy comparable to Large v2/v3.",
description: "Optimized for fastest performance with high accuracy.",
checksum: "4af2b29d7ec73d781377bfd1758ca957a807e941",
filename: "ggml-large-v3-turbo.bin",
downloadUrl:
"https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-large-v3-turbo.bin",
filename: "ggml-large-v3-turbo.bin",
checksum: "4af2b29d7ec73d781377bfd1758ca957a807e941",
size: 1.5 * 1024 * 1024 * 1024,
sizeFormatted: "~1.5 GB",
modelSize: "~1.5 GB",
features: [
{
icon: "rocket",
tooltip: "Optimized turbo speed",
},
{
icon: "award",
tooltip: "High accuracy across conditions",
},
{
icon: "languages",
tooltip: "Strong multilingual support",
},
],
speed: 3.5,
accuracy: 4.2,
setup: "offline",
provider: "OpenAI",
providerIcon: "/icons/models/openai_dark.svg",
},
];

View file

@ -1,5 +1,19 @@
/**
* Application Settings Management
*
* This module manages app settings with a section-based approach:
* - Settings are organized into top-level sections (ui, transcription, recording, etc.)
* - Each section is treated as an atomic unit - updates replace the entire section
* - No deep merging is performed within sections to avoid complexity
*
* When updating settings:
* - To update a single field, fetch the current section, modify it, and save the complete section
* - The SettingsService handles this pattern correctly for all methods
* - Direct calls to updateAppSettings should pass complete sections
*/
import { eq } from "drizzle-orm";
import { db } from "./config";
import { db } from ".";
import {
appSettings,
type NewAppSettings,
@ -12,9 +26,7 @@ const SETTINGS_ID = 1;
// Default settings
const defaultSettings: AppSettingsData = {
formatterConfig: {
provider: "openrouter",
model: "anthropic/claude-3-haiku",
apiKey: "",
model: "", // Will be set when models are synced
enabled: false,
},
ui: {
@ -40,6 +52,11 @@ const defaultSettings: AppSettingsData = {
pushToTalk: "Fn",
toggleRecording: "",
},
modelProvidersConfig: {
defaultSpeechModel: "",
defaultLanguageModel: "",
defaultEmbeddingModel: "",
},
};
// Get all app settings
@ -58,52 +75,20 @@ export async function getAppSettings(): Promise<AppSettingsData> {
return result[0].data;
}
// Update app settings (merges with existing settings)
// Update app settings (shallow merge at top level only)
// IMPORTANT: When updating a section, always pass the complete section.
// This function does NOT deep merge nested objects.
export async function updateAppSettings(
newSettings: Partial<AppSettingsData>,
): Promise<AppSettingsData> {
const currentSettings = await getAppSettings();
// Simple shallow merge - each top-level section is replaced entirely
const mergedSettings: AppSettingsData = {
...currentSettings,
...newSettings,
};
// Deep merge specific nested objects if they exist in newSettings
if (newSettings.formatterConfig && currentSettings.formatterConfig) {
mergedSettings.formatterConfig = {
...currentSettings.formatterConfig,
...newSettings.formatterConfig,
};
}
if (newSettings.ui && currentSettings.ui) {
mergedSettings.ui = {
...currentSettings.ui,
...newSettings.ui,
};
}
if (newSettings.transcription && currentSettings.transcription) {
mergedSettings.transcription = {
...currentSettings.transcription,
...newSettings.transcription,
};
}
if (newSettings.recording && currentSettings.recording) {
mergedSettings.recording = {
...currentSettings.recording,
...newSettings.recording,
};
}
if (newSettings.shortcuts && currentSettings.shortcuts) {
mergedSettings.shortcuts = {
...currentSettings.shortcuts,
...newSettings.shortcuts,
};
}
const now = new Date();
await db
@ -142,7 +127,9 @@ export async function getSettingsSection<K extends keyof AppSettingsData>(
return settings[section];
}
// Update a specific setting section
//! Update a specific setting section (replaces the entire section)
//! IMPORTANT: This replaces the entire section with newData.
//! Make sure to pass the complete section data, not partial updates.
export async function updateSettingsSection<K extends keyof AppSettingsData>(
section: K,
newData: AppSettingsData[K],

View file

@ -1,144 +0,0 @@
import { eq, desc } from "drizzle-orm";
import * as fs from "fs";
import { db } from "./config";
import {
downloadedModels,
type DownloadedModel,
type NewDownloadedModel,
} from "./schema";
// Create a new downloaded model record
export async function createDownloadedModel(
data: Omit<NewDownloadedModel, "createdAt" | "updatedAt">,
) {
const now = new Date();
const newModel: NewDownloadedModel = {
...data,
createdAt: now,
updatedAt: now,
};
const result = await db.insert(downloadedModels).values(newModel).returning();
return result[0];
}
// Get all downloaded models
export async function getDownloadedModels() {
return await db
.select()
.from(downloadedModels)
.orderBy(desc(downloadedModels.downloadedAt));
}
// Get downloaded model by ID
export async function getDownloadedModelById(id: string) {
const result = await db
.select()
.from(downloadedModels)
.where(eq(downloadedModels.id, id));
return result[0] || null;
}
// Check if model is downloaded
export async function isModelDownloaded(modelId: string) {
const model = await getDownloadedModelById(modelId);
return !!model;
}
// Update downloaded model
export async function updateDownloadedModel(
id: string,
data: Partial<Omit<DownloadedModel, "id" | "createdAt">>,
) {
const updateData = {
...data,
updatedAt: new Date(),
};
const result = await db
.update(downloadedModels)
.set(updateData)
.where(eq(downloadedModels.id, id))
.returning();
return result[0] || null;
}
// Delete downloaded model
export async function deleteDownloadedModel(id: string) {
const result = await db
.delete(downloadedModels)
.where(eq(downloadedModels.id, id))
.returning();
return result[0] || null;
}
// Get downloaded models as a record
export async function getDownloadedModelsRecord(): Promise<
Record<string, DownloadedModel>
> {
const models = await getDownloadedModels();
const record: Record<string, DownloadedModel> = {};
for (const model of models) {
record[model.id] = model;
}
return record;
}
// Validate that all downloaded models still exist on disk
export async function validateDownloadedModels(): Promise<{
valid: DownloadedModel[];
missing: DownloadedModel[];
cleaned: number;
}> {
const models = await getDownloadedModels();
const valid: DownloadedModel[] = [];
const missing: DownloadedModel[] = [];
for (const model of models) {
if (fs.existsSync(model.localPath)) {
valid.push(model);
} else {
missing.push(model);
}
}
// Clean up database records for missing files
let cleaned = 0;
for (const missingModel of missing) {
await deleteDownloadedModel(missingModel.id);
cleaned++;
}
return {
valid,
missing,
cleaned,
};
}
// Check if a specific model file exists on disk
export async function validateModelFile(modelId: string): Promise<boolean> {
const model = await getDownloadedModelById(modelId);
if (!model) return false;
return fs.existsSync(model.localPath);
}
// Get only models that exist on disk (with real-time validation)
export async function getValidDownloadedModels(): Promise<DownloadedModel[]> {
const models = await getDownloadedModels();
const validModels: DownloadedModel[] = [];
for (const model of models) {
if (fs.existsSync(model.localPath)) {
validModels.push(model);
}
}
return validModels;
}

View file

@ -6,7 +6,9 @@ import * as fs from "fs";
import * as schema from "./schema";
// Get the user data directory for storing the database
const dbPath = path.join(app.getPath("userData"), "amical.db");
const dbPath = app.isPackaged
? path.join(app.getPath("userData"), "amical.db")
: path.join(process.cwd(), "amical.db");
export const db = drizzle(`file:${dbPath}`, {
schema: {

View file

@ -1,14 +0,0 @@
import { migrate } from "drizzle-orm/libsql/migrator";
import { db } from "./config";
import { logger } from "../main/logger";
export async function runMigrations() {
try {
// Run migrations
await migrate(db, { migrationsFolder: "./src/db/migrations" });
logger.db.info("Migrations completed successfully");
} catch (error) {
logger.db.error("Error running migrations:", error);
throw error;
}
}

View file

@ -0,0 +1,25 @@
CREATE TABLE `models` (
`id` text NOT NULL,
`provider` text NOT NULL,
`name` text NOT NULL,
`type` text NOT NULL,
`size` text,
`context` text,
`description` text,
`local_path` text,
`size_bytes` integer,
`checksum` text,
`downloaded_at` integer,
`original_model` text,
`speed` real,
`accuracy` real,
`created_at` integer DEFAULT (unixepoch()) NOT NULL,
`updated_at` integer DEFAULT (unixepoch()) NOT NULL,
PRIMARY KEY(`provider`, `id`)
);
--> statement-breakpoint
CREATE INDEX `models_provider_idx` ON `models` (`provider`);--> statement-breakpoint
CREATE INDEX `models_type_idx` ON `models` (`type`);--> statement-breakpoint
DROP TABLE `downloaded_models`;--> statement-breakpoint
ALTER TABLE `vocabulary` ADD `replacement_word` text;--> statement-breakpoint
ALTER TABLE `vocabulary` ADD `is_replacement` integer DEFAULT false;

View file

@ -0,0 +1,381 @@
{
"version": "6",
"dialect": "sqlite",
"id": "ee7c367e-1078-491c-bcfe-5cca9efbd92f",
"prevId": "ab7ec8ad-f088-4400-aa05-c799133e7ada",
"tables": {
"app_settings": {
"name": "app_settings",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"data": {
"name": "data",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"version": {
"name": "version",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 1
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"models": {
"name": "models",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"provider": {
"name": "provider",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"size": {
"name": "size",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"context": {
"name": "context",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"local_path": {
"name": "local_path",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"size_bytes": {
"name": "size_bytes",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"checksum": {
"name": "checksum",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"downloaded_at": {
"name": "downloaded_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"original_model": {
"name": "original_model",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"speed": {
"name": "speed",
"type": "real",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"accuracy": {
"name": "accuracy",
"type": "real",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {
"models_provider_idx": {
"name": "models_provider_idx",
"columns": ["provider"],
"isUnique": false
},
"models_type_idx": {
"name": "models_type_idx",
"columns": ["type"],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {
"models_provider_id_pk": {
"columns": ["provider", "id"],
"name": "models_provider_id_pk"
}
},
"uniqueConstraints": {},
"checkConstraints": {}
},
"transcriptions": {
"name": "transcriptions",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"text": {
"name": "text",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"timestamp": {
"name": "timestamp",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"language": {
"name": "language",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'en'"
},
"audio_file": {
"name": "audio_file",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"confidence": {
"name": "confidence",
"type": "real",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"duration": {
"name": "duration",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"speech_model": {
"name": "speech_model",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"formatting_model": {
"name": "formatting_model",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"meta": {
"name": "meta",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"vocabulary": {
"name": "vocabulary",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"word": {
"name": "word",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"replacement_word": {
"name": "replacement_word",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"is_replacement": {
"name": "is_replacement",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": false
},
"date_added": {
"name": "date_added",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"usage_count": {
"name": "usage_count",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {
"vocabulary_word_unique": {
"name": "vocabulary_word_unique",
"columns": ["word"],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View file

@ -8,6 +8,13 @@
"when": 1750751926568,
"tag": "0000_worried_black_bird",
"breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1757443963743,
"tag": "0001_short_betty_ross",
"breakpoints": true
}
]
}

View file

@ -0,0 +1,270 @@
import { eq, and, or } from "drizzle-orm";
import { db } from ".";
import { models, type Model, type NewModel } from "./schema";
/**
* Database operations for unified models table
*/
/**
* Get all models
*/
export async function getAllModels(): Promise<Model[]> {
return await db.select().from(models);
}
/**
* Get models by provider
*/
export async function getModelsByProvider(provider: string): Promise<Model[]> {
return await db.select().from(models).where(eq(models.provider, provider));
}
/**
* Get models by type
*/
export async function getModelsByType(type: string): Promise<Model[]> {
return await db.select().from(models).where(eq(models.type, type));
}
/**
* Get models by provider and type
*/
export async function getModelsByProviderAndType(
provider: string,
type: string,
): Promise<Model[]> {
return await db
.select()
.from(models)
.where(and(eq(models.provider, provider), eq(models.type, type)));
}
/**
* Get a specific model by provider and ID
*/
export async function getModelById(
provider: string,
id: string,
): Promise<Model | null> {
const result = await db
.select()
.from(models)
.where(and(eq(models.provider, provider), eq(models.id, id)));
return result.length > 0 ? result[0] : null;
}
/**
* Get downloaded Whisper models (where localPath is not null)
*/
export async function getDownloadedWhisperModels(): Promise<Model[]> {
return await db
.select()
.from(models)
.where(
and(eq(models.provider, "local-whisper"), eq(models.type, "speech")),
);
}
/**
* Create or update a model
*/
export async function upsertModel(model: NewModel): Promise<void> {
// Check if model exists
const existing = await getModelById(model.provider, model.id);
if (existing) {
// Update existing model
await db
.update(models)
.set({
...model,
updatedAt: new Date(),
})
.where(and(eq(models.provider, model.provider), eq(models.id, model.id)));
} else {
// Insert new model
await db.insert(models).values(model);
}
}
/**
* Sync models for a provider (replace all models)
*/
export async function syncModelsForProvider(
provider: string,
newModels: NewModel[],
): Promise<void> {
await db.transaction(async (tx) => {
// Delete existing models for this provider
await tx.delete(models).where(eq(models.provider, provider));
// Insert new models
if (newModels.length > 0) {
await tx.insert(models).values(newModels);
}
});
}
/**
* Remove a model
*/
export async function removeModel(provider: string, id: string): Promise<void> {
await db
.delete(models)
.where(and(eq(models.provider, provider), eq(models.id, id)));
}
/**
* Remove all models for a provider
*/
export async function removeModelsForProvider(provider: string): Promise<void> {
await db.delete(models).where(eq(models.provider, provider));
}
/**
* Check if a model exists
*/
export async function modelExists(
provider: string,
id: string,
): Promise<boolean> {
const result = await db
.select({ id: models.id })
.from(models)
.where(and(eq(models.provider, provider), eq(models.id, id)));
return result.length > 0;
}
/**
* Get models by IDs (for batch operations)
*/
export async function getModelsByIds(
modelIds: Array<{ provider: string; id: string }>,
): Promise<Model[]> {
if (modelIds.length === 0) return [];
// Build OR conditions for each provider-id pair
const conditions = modelIds.map(({ provider, id }) =>
and(eq(models.provider, provider), eq(models.id, id)),
);
return await db
.select()
.from(models)
.where(or(...conditions));
}
/**
* Sync Local Whisper models with filesystem
* Scans the models directory and syncs database records with actual files
*/
export async function syncLocalWhisperModels(
modelsDirectory: string,
availableModels: Array<{
id: string;
name: string;
description: string;
size: string;
checksum?: string;
speed: number;
accuracy: number;
filename: string;
}>,
): Promise<{ added: number; updated: number; removed: number }> {
const fs = await import("fs");
const path = await import("path");
let added = 0;
let updated = 0;
let removed = 0;
// Get all existing whisper models from DB
const existingModels = await getModelsByProvider("local-whisper");
const existingModelMap = new Map(existingModels.map((m) => [m.id, m]));
// Scan the models directory for .bin files
const modelFiles = new Set<string>();
if (fs.existsSync(modelsDirectory)) {
const files = fs.readdirSync(modelsDirectory);
for (const file of files) {
if (file.endsWith(".bin")) {
modelFiles.add(file);
}
}
}
// Map available models by ID for easy lookup
// (we already have them indexed by ID, so we don't need this map)
// Process each available model
for (const model of availableModels) {
const filePath = path.join(modelsDirectory, model.filename);
const fileExists = modelFiles.has(model.filename);
const existingRecord = existingModelMap.get(model.id);
if (fileExists) {
// File exists on disk
const stats = fs.statSync(filePath);
if (existingRecord) {
// Update existing record if needed
if (
existingRecord.localPath !== filePath ||
existingRecord.sizeBytes !== stats.size
) {
await upsertModel({
...existingRecord,
localPath: filePath,
sizeBytes: stats.size,
downloadedAt: existingRecord.downloadedAt || new Date(),
});
updated++;
}
} else {
// Add new record for found file
await upsertModel({
id: model.id,
provider: "local-whisper",
name: model.name,
type: "speech",
size: model.size,
description: model.description,
checksum: model.checksum,
speed: model.speed,
accuracy: model.accuracy,
localPath: filePath,
sizeBytes: stats.size,
downloadedAt: new Date(),
context: null,
originalModel: null,
});
added++;
}
// Mark as processed
existingModelMap.delete(model.id);
} else if (existingRecord && existingRecord.localPath) {
// File doesn't exist but we have a record with download info - remove it
await removeModel(existingRecord.provider, existingRecord.id);
removed++;
// Mark as processed
existingModelMap.delete(model.id);
}
}
// Remove any remaining records that don't have corresponding available models
// (these would be orphaned records)
for (const [, model] of existingModelMap) {
await removeModel(model.provider, model.id);
removed++;
}
return { added, updated, removed };
}
// Re-export types for use in other modules
export { Model, NewModel } from "./schema";

View file

@ -1,5 +1,12 @@
import { sql } from "drizzle-orm";
import { sqliteTable, text, integer, real } from "drizzle-orm/sqlite-core";
import {
sqliteTable,
text,
integer,
real,
index,
primaryKey,
} from "drizzle-orm/sqlite-core";
// Transcriptions table
export const transcriptions = sqliteTable("transcriptions", {
@ -27,6 +34,8 @@ export const transcriptions = sqliteTable("transcriptions", {
export const vocabulary = sqliteTable("vocabulary", {
id: integer("id").primaryKey({ autoIncrement: true }),
word: text("word").notNull().unique(),
replacementWord: text("replacement_word"),
isReplacement: integer("is_replacement", { mode: "boolean" }).default(false),
dateAdded: integer("date_added", { mode: "timestamp" })
.notNull()
.default(sql`(unixepoch())`),
@ -39,25 +48,6 @@ export const vocabulary = sqliteTable("vocabulary", {
.default(sql`(unixepoch())`),
});
// Downloaded models table
export const downloadedModels = sqliteTable("downloaded_models", {
id: text("id").primaryKey(), // Model ID (e.g., 'whisper-large-v3')
name: text("name").notNull(),
type: text("type").notNull(), // 'whisper', 'llama', etc.
localPath: text("local_path").notNull(),
downloadedAt: integer("downloaded_at", { mode: "timestamp" })
.notNull()
.default(sql`(unixepoch())`),
size: integer("size").notNull(), // File size in bytes
checksum: text("checksum"),
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.default(sql`(unixepoch())`),
updatedAt: integer("updated_at", { mode: "timestamp" })
.notNull()
.default(sql`(unixepoch())`),
});
// App settings table with typed JSON
export const appSettings = sqliteTable("app_settings", {
id: integer("id").primaryKey(),
@ -71,12 +61,55 @@ export const appSettings = sqliteTable("app_settings", {
.default(sql`(unixepoch())`),
});
// Unified models table for all model types (Whisper, Language, Embedding)
export const models = sqliteTable(
"models",
{
// Identity
id: text("id").notNull(),
provider: text("provider").notNull(), // "local-whisper", "openrouter", "ollama"
// Common fields
name: text("name").notNull(),
type: text("type").notNull(), // "speech", "language", "embedding"
size: text("size"), // Model size string (e.g., "7B", "Large", "~78 MB")
context: text("context"), // Context window (e.g., "32k", "128k")
description: text("description"),
// Local model fields (only for downloaded Whisper models)
localPath: text("local_path"), // Where file is stored on disk
sizeBytes: integer("size_bytes"), // Actual file size in bytes
checksum: text("checksum"), // SHA-1 hash for verification
downloadedAt: integer("downloaded_at", { mode: "timestamp" }),
// Remote model fields (OpenRouter/Ollama)
originalModel: text("original_model", { mode: "json" }), // Original API response
// Model characteristics (for UI display)
speed: real("speed"), // 1-5 rating
accuracy: real("accuracy"), // 1-5 rating
// Timestamps
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.default(sql`(unixepoch())`),
updatedAt: integer("updated_at", { mode: "timestamp" })
.notNull()
.default(sql`(unixepoch())`),
},
(table) => [
// Composite primary key on (provider, id)
primaryKey({ columns: [table.provider, table.id] }),
// Indexes for efficient lookups
index("models_provider_idx").on(table.provider),
index("models_type_idx").on(table.type),
],
);
// Define the shape of our settings JSON
export interface AppSettingsData {
formatterConfig?: {
provider: "openrouter";
model: string;
apiKey: string;
model: string; // Now stores the model ID from synced models
enabled: boolean;
};
ui?: {
@ -111,6 +144,23 @@ export interface AppSettingsData {
toggleRecording?: string;
toggleWindow?: string;
};
modelProvidersConfig?: {
openRouter?: {
apiKey: string;
};
ollama?: {
url: string;
};
defaultSpeechModel?: string; // Model ID for default speech model (Whisper)
defaultLanguageModel?: string; // Model ID for default language model
defaultEmbeddingModel?: string; // Model ID for default embedding model
};
dictation?: {
autoDetectEnabled: boolean;
selectedLanguage: string; // Required when autoDetectEnabled is false, defaults to "en"
};
}
// Export types for TypeScript
@ -118,7 +168,7 @@ export type Transcription = typeof transcriptions.$inferSelect;
export type NewTranscription = typeof transcriptions.$inferInsert;
export type Vocabulary = typeof vocabulary.$inferSelect;
export type NewVocabulary = typeof vocabulary.$inferInsert;
export type DownloadedModel = typeof downloadedModels.$inferSelect;
export type NewDownloadedModel = typeof downloadedModels.$inferInsert;
export type Model = typeof models.$inferSelect;
export type NewModel = typeof models.$inferInsert;
export type AppSettings = typeof appSettings.$inferSelect;
export type NewAppSettings = typeof appSettings.$inferInsert;

View file

@ -1,5 +1,5 @@
import { eq, desc, asc, and, like, count, gte, lte } from "drizzle-orm";
import { db } from "./config";
import { db } from ".";
import {
transcriptions,
type Transcription,

View file

@ -1,5 +1,5 @@
import { eq, desc, asc, like, count, gt, sql } from "drizzle-orm";
import { db } from "./config";
import { db } from ".";
import { vocabulary, type Vocabulary, type NewVocabulary } from "./schema";
// Create a new vocabulary word

View file

@ -1,5 +1,5 @@
import { app, systemPreferences } from "electron";
import { initializeDatabase } from "../../db/config";
import { initializeDatabase } from "../../db";
import { logger } from "../logger";
import { WindowManager } from "./window-manager";
import { setupApplicationMenu } from "../menu";

View file

@ -81,7 +81,10 @@ export class ServiceManager {
private async initializeModelServices(): Promise<void> {
// Initialize Model Manager Service
this.modelManagerService = new ModelManagerService();
if (!this.settingsService) {
throw new Error("Settings service not initialized");
}
this.modelManagerService = new ModelManagerService(this.settingsService);
await this.modelManagerService.initialize();
}
@ -119,7 +122,7 @@ export class ServiceManager {
if (formatterConfig) {
this.transcriptionService.configureFormatter(formatterConfig);
logger.transcription.info("Formatter configured", {
provider: formatterConfig.provider,
model: formatterConfig.model,
enabled: formatterConfig.enabled,
});
}

View file

@ -4,7 +4,6 @@
import { contextBridge, ipcRenderer, IpcRendererEvent } from "electron";
import { exposeElectronTRPC } from "electron-trpc-experimental/preload";
import type { ElectronAPI } from "../types/electron-api";
import type { FormatterConfig } from "../types/formatter";
interface ShortcutData {
shortcut: string;
@ -53,36 +52,7 @@ const api: ElectronAPI = {
// ipcRenderer.removeAllListeners('global-shortcut-event');
// }
// Model Management API
getAvailableModels: () => ipcRenderer.invoke("get-available-models"),
getDownloadedModels: () => ipcRenderer.invoke("get-downloaded-models"),
isModelDownloaded: (modelId: string) =>
ipcRenderer.invoke("is-model-downloaded", modelId),
getDownloadProgress: (modelId: string) =>
ipcRenderer.invoke("get-download-progress", modelId),
getActiveDownloads: () => ipcRenderer.invoke("get-active-downloads"),
downloadModel: (modelId: string) =>
ipcRenderer.invoke("download-model", modelId),
cancelDownload: (modelId: string) =>
ipcRenderer.invoke("cancel-download", modelId),
deleteModel: (modelId: string) => ipcRenderer.invoke("delete-model", modelId),
getModelsDirectory: () => ipcRenderer.invoke("get-models-directory"),
// Local Whisper API
isLocalWhisperAvailable: () =>
ipcRenderer.invoke("is-local-whisper-available"),
getLocalWhisperModels: () => ipcRenderer.invoke("get-local-whisper-models"),
getSelectedModel: () => ipcRenderer.invoke("get-selected-model"),
setSelectedModel: (modelId: string) =>
ipcRenderer.invoke("set-selected-model", modelId),
setWhisperExecutablePath: (path: string) =>
ipcRenderer.invoke("set-whisper-executable-path", path),
// Formatter Configuration API
getFormatterConfig: () => ipcRenderer.invoke("get-formatter-config"),
setFormatterConfig: (config: FormatterConfig) =>
ipcRenderer.invoke("set-formatter-config", config),
// Model Management API (moved to tRPC)
// Transcription Database API (moved to tRPC)
on: (channel: string, callback: (...args: any[]) => void) => {

View file

@ -1,11 +1,14 @@
import * as React from "react";
import {
IconDatabase,
IconFileDescription,
IconFileWord,
IconReport,
IconSettings,
IconMicrophone,
IconBook,
IconBrain,
IconHistory,
IconInfoCircle,
IconBookFilled,
IconKeyboard,
IconAdjustments,
} from "@tabler/icons-react";
import { NavMain } from "@/components/nav-main";
@ -23,38 +26,53 @@ import {
// Custom Discord icon component
const DiscordIcon = ({ className }: { className?: string }) => (
<img
src="assets/discord-icon.svg"
src="/assets/discord-icon.svg"
alt="Discord"
className={`w-4 h-4 ${className || ""}`}
/>
);
const data = {
user: {
name: "shadcn",
email: "m@example.com",
avatar: "/avatars/shadcn.jpg",
},
navMain: [
{
title: "Transcriptions",
url: "#",
icon: IconFileDescription,
title: "Preferences",
url: "/settings/preferences",
icon: IconSettings,
},
{
title: "Dictation",
url: "/settings/dictation",
icon: IconMicrophone,
},
{
title: "Shortcuts",
url: "/settings/shortcuts",
icon: IconKeyboard,
},
{
title: "Vocabulary",
url: "#",
icon: IconFileWord,
url: "/settings/vocabulary",
icon: IconBook,
},
{
title: "Speech Models",
url: "#",
icon: IconDatabase,
title: "AI Models",
url: "/settings/ai-models",
icon: IconBrain,
},
{
title: "Settings",
url: "#",
icon: IconSettings,
title: "History",
url: "/settings/history",
icon: IconHistory,
},
{
title: "Advanced",
url: "/settings/advanced",
icon: IconAdjustments,
},
{
title: "About",
url: "/settings/about",
icon: IconInfoCircle,
},
],
navSecondary: [
@ -71,35 +89,11 @@ const data = {
external: true,
},
],
documents: [
{
name: "Data Library",
url: "#",
icon: IconDatabase,
},
{
name: "Reports",
url: "#",
icon: IconReport,
},
{
name: "Word Assistant",
url: "#",
icon: IconFileWord,
},
],
};
interface AppSidebarProps extends React.ComponentProps<typeof Sidebar> {
onNavigate?: (item: { title: string }) => void;
currentView?: string;
}
export function AppSidebar({
onNavigate,
currentView,
export function SettingsSidebar({
...props
}: AppSidebarProps) {
}: React.ComponentProps<typeof Sidebar>) {
return (
<Sidebar collapsible="offcanvas" {...props}>
<div className="h-[var(--header-height)]"></div>
@ -110,35 +104,23 @@ export function AppSidebar({
asChild
className="data-[slot=sidebar-menu-button]:!p-1.5"
>
<a
href="#"
className="inline-flex items-center gap-2.5 font-semibold"
>
<div className="inline-flex items-center gap-2.5 font-semibold w-full">
<img
src="assets/logo.svg"
src="/assets/logo.svg"
alt="Amical Logo"
className="!size-7"
/>
<span className="font-semibold">Amical</span>
</a>
</div>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarContent>
<NavMain
items={data.navMain}
onNavigate={onNavigate}
currentView={currentView}
/>
<NavSecondary
items={data.navSecondary}
onNavigate={onNavigate}
currentView={currentView}
className="mt-auto"
/>
<NavMain items={data.navMain} />
<NavSecondary items={data.navSecondary} className="mt-auto" />
</SidebarContent>
<SidebarFooter>{/* <NavUser user={data.user} /> */}</SidebarFooter>
<SidebarFooter></SidebarFooter>
</Sidebar>
);
}

View file

@ -1,109 +1,23 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import React from "react";
import { RouterProvider, createRouter } from "@tanstack/react-router";
import { routeTree } from "./routeTree.gen";
import React, { useState, useEffect } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import {
SidebarProvider,
SidebarInset,
SidebarTrigger,
} from "@/components/ui/sidebar";
import { AppSidebar } from "@/components/app-sidebar";
import { TranscriptionsPage } from "./pages/transcriptions";
import { VocabularyPage } from "./pages/vocabulary";
import { ModelsPage } from "./pages/models";
import { SettingsPage } from "./pages/settings";
import { SiteHeader } from "@/components/site-header";
import { api, trpcClient } from "@/trpc/react";
// import { Waveform } from '../components/Waveform'; // Waveform might not be needed if hook is removed
// import { useRecording } from '../hooks/useRecording'; // Remove hook import
const NUM_WAVEFORM_BARS = 10; // This might be unused now
// Create a client
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
refetchOnWindowFocus: false,
},
},
// Create the router instance
const router = createRouter({
routeTree,
defaultPreload: "intent",
});
// Register the router instance for type safety
declare module "@tanstack/react-router" {
interface Register {
router: typeof router;
}
}
// Root App component with routing
const App: React.FC = () => {
const [currentView, setCurrentView] = useState(() => {
// Try to restore the view from localStorage, fallback to default
if (typeof window !== "undefined") {
return localStorage.getItem("amical-current-view") || "Voice Recording";
}
return "Voice Recording";
});
const handleNavigation = (item: any) => {
setCurrentView(item.title);
// Save to localStorage to preserve during HMR
localStorage.setItem("amical-current-view", item.title);
};
const renderContent = () => {
switch (currentView) {
case "Vocabulary":
return <VocabularyPage />;
case "Settings":
return <SettingsPage />;
case "Transcriptions":
return <TranscriptionsPage />;
case "Speech Models":
default:
return <ModelsPage />;
}
};
return (
<api.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
<SidebarProvider
style={
{
"--sidebar-width": "calc(var(--spacing) * 52)",
"--header-height": "calc(var(--spacing) * 12)",
} as React.CSSProperties
}
>
<div className="flex h-screen w-screen flex-col">
{/* Header spans full width with traffic light spacing */}
<SiteHeader currentView={currentView} />
<div className="flex flex-1 min-h-0">
<AppSidebar
variant="inset"
onNavigate={handleNavigation}
currentView={currentView}
/>
<SidebarInset className="mt-0!">
<div className="flex flex-1 flex-col min-h-0">
<div className="@container/main flex flex-1 flex-col min-h-0 overflow-hidden">
<div className="flex-1 overflow-y-auto">
<div
className="mx-auto w-full flex flex-col gap-4 md:gap-6"
style={{
maxWidth: "var(--content-max-width)",
padding: "var(--content-padding)",
}}
>
{renderContent()}
</div>
</div>
</div>
</div>
</SidebarInset>
</div>
</div>
</SidebarProvider>
</QueryClientProvider>
</api.Provider>
);
return <RouterProvider router={router} />;
};
// Export the App component as default for lazy loading
export default App;

View file

@ -5,50 +5,53 @@ import { ThemeProvider } from "@/components/theme-provider";
import { Toaster } from "@/components/ui/sonner";
// Lazy import the main content
const Content = React.lazy(
() =>
import("./content.js") as unknown as Promise<{
default: React.ComponentType<any>;
}>,
);
const Content = React.lazy(() => import("./content"));
// Extend Console interface to include original methods
declare global {
interface Console {
original: {
log: (...args: any[]) => void;
info: (...args: any[]) => void;
warn: (...args: any[]) => void;
error: (...args: any[]) => void;
debug: (...args: any[]) => void;
log: (...data: unknown[]) => void;
info: (...data: unknown[]) => void;
warn: (...data: unknown[]) => void;
error: (...data: unknown[]) => void;
debug: (...data: unknown[]) => void;
};
}
}
// Main window scoped logger setup
const mainWindowLogger = window.electronAPI.log.scope("mainWindow");
// Main window scoped logger setup with guards
const mainWindowLogger = window.electronAPI?.log?.scope?.("mainWindow");
// Store original console methods with proper binding
const originalConsole = {
log: console.log.bind(console),
info: console.info.bind(console),
warn: console.warn.bind(console),
error: console.error.bind(console),
debug: console.debug.bind(console),
};
// Proxy console methods to use BOTH original console AND main window logger
const originalConsole = { ...console };
console.log = (...args: any[]) => {
console.log = (...args: unknown[]) => {
originalConsole.log(...args); // Show in dev console
mainWindowLogger.info(...args); // Send via IPC
mainWindowLogger?.info?.(...args); // Send via IPC if available
};
console.info = (...args: any[]) => {
console.info = (...args: unknown[]) => {
originalConsole.info(...args);
mainWindowLogger.info(...args);
mainWindowLogger?.info?.(...args);
};
console.warn = (...args: any[]) => {
console.warn = (...args: unknown[]) => {
originalConsole.warn(...args);
mainWindowLogger.warn(...args);
mainWindowLogger?.warn?.(...args);
};
console.error = (...args: any[]) => {
console.error = (...args: unknown[]) => {
originalConsole.error(...args);
mainWindowLogger.error(...args);
mainWindowLogger?.error?.(...args);
};
console.debug = (...args: any[]) => {
console.debug = (...args: unknown[]) => {
originalConsole.debug(...args);
mainWindowLogger.debug(...args);
mainWindowLogger?.debug?.(...args);
};
// Keep original methods available if needed

View file

@ -1,406 +0,0 @@
import React, { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Label } from "@/components/ui/label";
import { Trash2, Download, Square, Loader2 } from "lucide-react";
import { toast } from "sonner";
import {
AlertDialog,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
AlertDialogAction,
AlertDialogCancel,
} from "@/components/ui/alert-dialog";
import { DownloadProgress } from "@/constants/models";
import { api } from "@/trpc/react";
export const ModelsManager: React.FC = () => {
const [downloadProgress, setDownloadProgress] = useState<
Record<string, DownloadProgress>
>({});
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [modelToDelete, setModelToDelete] = useState<string | null>(null);
// tRPC queries
const availableModelsQuery = api.models.getAvailableModels.useQuery();
const downloadedModelsQuery = api.models.getDownloadedModels.useQuery();
const activeDownloadsQuery = api.models.getActiveDownloads.useQuery();
const isTranscriptionAvailableQuery =
api.models.isTranscriptionAvailable.useQuery();
const selectedModelQuery = api.models.getSelectedModel.useQuery();
const utils = api.useUtils();
// tRPC mutations
const downloadModelMutation = api.models.downloadModel.useMutation({
onSuccess: () => {
utils.models.getDownloadedModels.invalidate();
utils.models.getActiveDownloads.invalidate();
},
onError: (error) => {
console.error("Failed to start download:", error);
if (error instanceof Error && error.message.includes("AbortError")) {
console.log("Download was manually aborted, not showing error");
return;
}
toast.error("Failed to start download");
},
});
const cancelDownloadMutation = api.models.cancelDownload.useMutation({
onSuccess: () => {
utils.models.getActiveDownloads.invalidate();
},
onError: (error) => {
console.error("Failed to cancel download:", error);
toast.error("Failed to cancel download");
},
});
const deleteModelMutation = api.models.deleteModel.useMutation({
onSuccess: () => {
utils.models.getDownloadedModels.invalidate();
setShowDeleteDialog(false);
setModelToDelete(null);
},
onError: (error) => {
console.error("Failed to delete model:", error);
toast.error("Failed to delete model");
setShowDeleteDialog(false);
setModelToDelete(null);
},
});
const setSelectedModelMutation = api.models.setSelectedModel.useMutation({
onSuccess: () => {
utils.models.getSelectedModel.invalidate();
},
onError: (error) => {
console.error("Failed to select model:", error);
toast.error("Failed to select model");
},
});
// Initialize active downloads progress on load
useEffect(() => {
if (activeDownloadsQuery.data) {
const progressMap: Record<string, DownloadProgress> = {};
activeDownloadsQuery.data.forEach((download) => {
progressMap[download.modelId] = download;
});
setDownloadProgress(progressMap);
}
}, [activeDownloadsQuery.data]);
// Set up tRPC subscriptions for real-time download updates
api.models.onDownloadProgress.useSubscription(undefined, {
onData: ({ modelId, progress }) => {
setDownloadProgress((prev) => ({ ...prev, [modelId]: progress }));
},
onError: (error) => {
console.error("Download progress subscription error:", error);
},
});
api.models.onDownloadComplete.useSubscription(undefined, {
onData: ({ modelId, downloadedModel }) => {
setDownloadProgress((prev) => {
const newProgress = { ...prev };
delete newProgress[modelId];
return newProgress;
});
utils.models.getDownloadedModels.invalidate();
utils.models.getActiveDownloads.invalidate();
},
onError: (error) => {
console.error("Download complete subscription error:", error);
},
});
api.models.onDownloadError.useSubscription(undefined, {
onData: ({ modelId, error }) => {
setDownloadProgress((prev) => {
const newProgress = { ...prev };
delete newProgress[modelId];
return newProgress;
});
toast.error(`Download failed: ${error}`);
utils.models.getActiveDownloads.invalidate();
},
onError: (error) => {
console.error("Download error subscription error:", error);
},
});
api.models.onDownloadCancelled.useSubscription(undefined, {
onData: ({ modelId }) => {
setDownloadProgress((prev) => {
const newProgress = { ...prev };
delete newProgress[modelId];
return newProgress;
});
utils.models.getActiveDownloads.invalidate();
},
onError: (error) => {
console.error("Download cancelled subscription error:", error);
},
});
api.models.onModelDeleted.useSubscription(undefined, {
onData: ({ modelId }) => {
utils.models.getDownloadedModels.invalidate();
},
onError: (error) => {
console.error("Model deleted subscription error:", error);
},
});
const handleDownload = async (modelId: string, event?: React.MouseEvent) => {
if (event) {
event.preventDefault();
event.stopPropagation();
}
try {
await downloadModelMutation.mutateAsync({ modelId });
console.log("Download started for:", modelId);
} catch (err) {
console.error("Failed to start download:", err);
// Error is already handled by the mutation's onError
}
};
const handleCancelDownload = async (
modelId: string,
event?: React.MouseEvent,
) => {
if (event) {
event.preventDefault();
event.stopPropagation();
}
try {
await cancelDownloadMutation.mutateAsync({ modelId });
console.log("Cancel download successful for:", modelId);
} catch (err) {
console.error("Failed to cancel download:", err);
// Error is already handled by the mutation's onError
}
};
const handleDeleteClick = (modelId: string) => {
setModelToDelete(modelId);
setShowDeleteDialog(true);
};
const handleDeleteConfirm = async () => {
if (!modelToDelete) return;
try {
await deleteModelMutation.mutateAsync({ modelId: modelToDelete });
} catch (err) {
console.error("Failed to delete model:", err);
// Error is already handled by the mutation's onError
}
};
const handleDeleteCancel = () => {
setShowDeleteDialog(false);
setModelToDelete(null);
};
const handleSelectModel = async (modelId: string) => {
try {
await setSelectedModelMutation.mutateAsync({ modelId });
} catch (err) {
console.error("Failed to select model:", err);
// Error is already handled by the mutation's onError
}
};
const formatBytes = (bytes: number) => {
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i];
};
// Loading state
const loading =
availableModelsQuery.isLoading ||
downloadedModelsQuery.isLoading ||
isTranscriptionAvailableQuery.isLoading ||
selectedModelQuery.isLoading;
// Data from queries
const availableModels = availableModelsQuery.data || [];
const downloadedModels = downloadedModelsQuery.data || {};
const isTranscriptionAvailable = isTranscriptionAvailableQuery.data || false;
const selectedModel = selectedModelQuery.data;
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<Loader2 className="w-8 h-8 animate-spin" />
<span className="ml-2">Loading models...</span>
</div>
);
}
return (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Whisper Speech Models</CardTitle>
<CardDescription>
Select and manage Whisper models for speech recognition
</CardDescription>
</CardHeader>
<CardContent>
<RadioGroup
value={selectedModel || ""}
onValueChange={handleSelectModel}
className="space-y-4"
>
{availableModels.map((model) => {
const isDownloaded = !!downloadedModels[model.id];
const progress = downloadProgress[model.id];
const isDownloading = progress?.status === "downloading";
return (
<div
key={model.id}
className="flex items-center justify-between py-3 border-b last:border-b-0"
>
<div className="flex items-center space-x-3">
<RadioGroupItem
value={model.id}
id={model.id}
disabled={!isDownloaded || !isTranscriptionAvailable}
/>
<div className="flex-1">
<Label
htmlFor={model.id}
className="text-base font-medium cursor-pointer"
>
{model.name}
</Label>
<div className="text-sm text-muted-foreground mt-1">
{model.description}
</div>
</div>
</div>
<div className="flex flex-col items-center space-y-1">
{!isDownloaded && !isDownloading && (
<button
onClick={(e) => handleDownload(model.id, e)}
className="w-10 h-10 rounded-full bg-primary hover:bg-primary/90 flex items-center justify-center text-primary-foreground transition-colors"
title="Click to download"
>
<Download className="w-5 h-5" />
</button>
)}
{!isDownloaded && isDownloading && (
<div className="relative">
<button
onClick={(e) => handleCancelDownload(model.id, e)}
className="w-10 h-10 rounded-full bg-orange-500 hover:bg-orange-600 flex items-center justify-center text-white transition-colors"
title="Click to cancel download"
>
<Square className="w-4 h-4" />
</button>
{/* Circular Progress Ring */}
{progress && (
<svg
className="absolute inset-0 w-10 h-10 -rotate-90 pointer-events-none"
viewBox="0 0 36 36"
>
<circle
cx="18"
cy="18"
r="15.9155"
fill="none"
stroke="currentColor"
strokeWidth="3"
strokeDasharray="100 100"
className="text-muted-foreground/30"
/>
<circle
cx="18"
cy="18"
r="15.9155"
fill="none"
stroke="currentColor"
strokeWidth="3"
strokeDasharray={`${Math.max(0, Math.min(100, progress.progress))} 100`}
strokeLinecap="round"
className="text-white transition-all duration-300"
/>
</svg>
)}
</div>
)}
{isDownloaded && (
<button
onClick={() => handleDeleteClick(model.id)}
className="w-10 h-10 rounded-full bg-red-500 hover:bg-red-600 flex items-center justify-center text-white transition-colors"
title="Click to delete model"
>
<Trash2 className="w-5 h-5" />
</button>
)}
<div className="text-xs text-muted-foreground text-center">
{model.sizeFormatted}
</div>
</div>
</div>
);
})}
</RadioGroup>
</CardContent>
</Card>
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Model</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete this model? This action cannot be
undone and you will need to download the model again if you want
to use it.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={handleDeleteCancel}>
Cancel
</AlertDialogCancel>
<AlertDialogAction
onClick={handleDeleteConfirm}
className="bg-red-500 hover:bg-red-600"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
};

View file

@ -1,5 +0,0 @@
import { ModelsManager } from "./components/ModelsManager";
export function ModelsPage() {
return <ModelsManager />;
}

View file

@ -0,0 +1,181 @@
import React, { useState } from "react";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { RefreshCw, BookOpen } from "lucide-react";
import { toast } from "sonner";
import { api } from "@/trpc/react";
const CHANGELOG_URL = "https://github.com/amicalhq/amical/releases";
const GITHUB_URL = "https://github.com/amicalhq/amical";
const DISCORD_URL = "https://amical.ai/community";
const CONTACT_EMAIL = "contact@amical.ai";
export default function AboutSettingsPage() {
const [checking, setChecking] = useState(false);
const { data: version } = api.settings.getAppVersion.useQuery();
function handleCheckUpdates() {
setChecking(true);
setTimeout(() => {
setChecking(false);
toast.success("Version is up to date");
}, 2000);
}
return (
<div className="container mx-auto p-6 max-w-5xl">
{/* Header Section */}
<div className="mb-8">
<h1 className="text-xl font-bold">About</h1>
<p className="text-muted-foreground mt-1 text-sm">
Version information, resources, and support links
</p>
</div>
<div className="space-y-6">
<Card>
<CardContent className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div>
<div className="text-lg font-semibold">Current Version</div>
<Badge variant="secondary" className="mt-1">
v{version || "..."}
</Badge>
</div>
{/* <Button
variant="outline"
className="mt-4 md:mt-0 flex items-center gap-2"
onClick={handleCheckUpdates}
disabled={checking}
>
<RefreshCw
className={"w-4 h-4 " + (checking ? "animate-spin" : "")}
/>
{checking ? "Checking..." : "Check for Updates"}
</Button> */}
</CardContent>
</Card>
<Card>
<CardContent className="space-y-4">
<div className="space-y-1">
<div className="text-lg font-semibold text-foreground">
Resources
</div>
<p className="text-xs text-muted-foreground">
Get help, report issues, and stay updated with the latest
changes
</p>
</div>
<div className="divide-y">
<ExternalLink href={CHANGELOG_URL}>
<div className="flex items-center justify-between py-4 group cursor-pointer">
<div>
<div className="flex items-center gap-2 font-semibold text-base group-hover:underline">
<BookOpen className="w-5 h-5 text-muted-foreground" />
Change Log
</div>
<div className="text-muted-foreground text-xs">
View release notes and updates
</div>
</div>
</div>
</ExternalLink>
<ExternalLink href={GITHUB_URL}>
<div className="flex items-center justify-between py-4 group cursor-pointer">
<div>
<div className="flex items-center gap-2 font-semibold text-base group-hover:underline">
{/* GitHub icon as image */}
<img
src="/icons/integrations/github.svg"
alt="GitHub"
className="w-5 h-5 inline-block align-middle"
/>
GitHub Repository
</div>
<div className="text-muted-foreground text-xs">
Source code and issue tracking
</div>
</div>
</div>
</ExternalLink>
<ExternalLink href={DISCORD_URL}>
<div className="flex items-center justify-between py-4 group cursor-pointer">
<div>
<div className="flex items-center gap-2 font-semibold text-base group-hover:underline">
{/* Discord icon as image */}
<img
src="/icons/integrations/discord.svg"
alt="Discord"
className="w-5 h-5 inline-block align-middle"
/>
Discord Community
</div>
<div className="text-muted-foreground text-xs">
Join our community for support and discussions
</div>
</div>
</div>
</ExternalLink>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="space-y-4">
<div className="space-y-1">
<div className="text-lg font-semibold text-foreground">
Contact
</div>
<p className="text-xs text-muted-foreground">
Get in touch with our team for support and inquiries
</p>
</div>
<ExternalLink href={`mailto:${CONTACT_EMAIL}`}>
<div className="flex items-center justify-between group cursor-pointer">
<div>
<div className="font-semibold text-base group-hover:underline">
{CONTACT_EMAIL}
</div>
<div className="text-muted-foreground text-xs">
Send us an email
</div>
</div>
</div>
</ExternalLink>
</CardContent>
</Card>
</div>
</div>
);
}
const ExternalLink = ({
href,
children,
}: {
href: string;
children: React.ReactNode;
}) => {
const handleClick = async (e: React.MouseEvent<HTMLAnchorElement>) => {
e.preventDefault();
if (window.electronAPI?.openExternal) {
await window.electronAPI.openExternal(href);
}
};
return (
<a
href={href}
onClick={handleClick}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
handleClick(e as any);
}
}}
style={{ cursor: "pointer" }}
>
{children}
</a>
);
};

View file

@ -0,0 +1,115 @@
import React, { useState, useEffect } from "react";
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { api } from "@/trpc/react";
import { toast } from "sonner";
export default function AdvancedSettingsPage() {
const [preloadWhisperModel, setPreloadWhisperModel] = useState(true);
// tRPC queries and mutations
const settingsQuery = api.settings.getSettings.useQuery();
const utils = api.useUtils();
const updateTranscriptionSettingsMutation =
api.settings.updateTranscriptionSettings.useMutation({
onSuccess: () => {
utils.settings.getSettings.invalidate();
toast.success("Settings updated");
},
onError: (error) => {
console.error("Failed to update transcription settings:", error);
toast.error("Failed to update settings. Please try again.");
},
});
// Load settings when query data is available
useEffect(() => {
if (settingsQuery.data?.transcription) {
setPreloadWhisperModel(
settingsQuery.data.transcription.preloadWhisperModel !== false,
);
}
}, [settingsQuery.data]);
const handlePreloadWhisperModelChange = (checked: boolean) => {
setPreloadWhisperModel(checked);
updateTranscriptionSettingsMutation.mutate({
preloadWhisperModel: checked,
});
};
return (
<div className="container mx-auto p-6 max-w-5xl">
<div className="mb-8">
<h1 className="text-xl font-bold">Advanced</h1>
<p className="text-muted-foreground mt-1 text-sm">
Advanced configuration options and experimental features
</p>
</div>
<Card>
<CardHeader>
<CardTitle>Advanced Settings</CardTitle>
<CardDescription>Advanced configuration options</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div>
<Label htmlFor="preload-whisper">Preload Whisper Model</Label>
<p className="text-sm text-muted-foreground">
Load AI model at startup for faster transcription
</p>
</div>
<Switch
id="preload-whisper"
checked={preloadWhisperModel}
onCheckedChange={handlePreloadWhisperModelChange}
/>
</div>
<div className="flex items-center justify-between">
<div>
<Label htmlFor="debug-mode">Debug Mode</Label>
<p className="text-sm text-muted-foreground">
Enable detailed logging
</p>
</div>
<Switch id="debug-mode" />
</div>
<div className="flex items-center justify-between">
<div>
<Label htmlFor="auto-update">Auto Updates</Label>
<p className="text-sm text-muted-foreground">
Automatically check for updates
</p>
</div>
<Switch id="auto-update" defaultChecked />
</div>
<div className="space-y-2">
<Label htmlFor="data-location">Data Location</Label>
<div className="flex space-x-2">
<input
type="text"
id="data-location"
className="flex-1 border rounded px-3 py-2"
value="~/Documents/Amical"
readOnly
/>
<Button variant="outline">Change</Button>
</div>
</div>
</CardContent>
</Card>
</div>
);
}

View file

@ -0,0 +1,56 @@
"use client";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import type { Model } from "@/db/schema";
interface ChangeDefaultModelDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
selectedModel: Model | undefined;
onConfirm: () => void;
modelType?: "language" | "embedding";
}
export default function ChangeDefaultModelDialog({
open,
onOpenChange,
selectedModel,
onConfirm,
modelType = "language",
}: ChangeDefaultModelDialogProps) {
const handleConfirm = () => {
onConfirm();
onOpenChange(false);
};
const handleCancel = () => {
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Change Default Model</DialogTitle>
<DialogDescription>
Are you sure you want to set "{selectedModel?.name}" as your default{" "}
{modelType} model?
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={handleCancel}>
Cancel
</Button>
<Button onClick={handleConfirm}>Change Default</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,175 @@
"use client";
import { useState, useMemo } from "react";
import { Label } from "@/components/ui/label";
import { Combobox } from "@/components/ui/combobox";
import { api } from "@/trpc/react";
import { toast } from "sonner";
import ChangeDefaultModelDialog from "./change-default-model-dialog";
interface DefaultModelComboboxProps {
modelType: "speech" | "language" | "embedding";
title?: string;
}
export default function DefaultModelCombobox({
modelType,
title = "Default Model",
}: DefaultModelComboboxProps) {
// State for embedding confirmation dialog
const [changeDefaultDialogOpen, setChangeDefaultDialogOpen] = useState(false);
const [pendingModelId, setPendingModelId] = useState<string>("");
// tRPC queries and mutations
const utils = api.useUtils();
// Unified queries
const modelsQuery = api.models.getModels.useQuery({
type: modelType,
downloadedOnly: modelType === "speech",
});
const defaultModelQuery = api.models.getDefaultModel.useQuery({
type: modelType,
});
// Subscribe to model selection changes
api.models.onSelectionChanged.useSubscription(undefined, {
onData: ({ modelType: changedType }) => {
// Only invalidate if the change is for our model type
if (changedType === modelType) {
utils.models.getDefaultModel.invalidate({ type: modelType });
utils.models.getModels.invalidate({ type: modelType });
}
},
onError: (error) => {
console.error("Selection changed subscription error:", error);
},
});
api.models.onDownloadComplete.useSubscription(undefined, {
onData: () => {
utils.models.getModels.invalidate({ type: modelType });
},
onError: (error) => {
console.error("Selection changed subscription error:", error);
},
});
api.models.onModelDeleted.useSubscription(undefined, {
onData: () => {
utils.models.getModels.invalidate({ type: modelType });
},
onError: (error) => {
console.error("Selection changed subscription error:", error);
},
});
// Unified mutation
const setDefaultModelMutation = api.models.setDefaultModel.useMutation({
onSuccess: () => {
utils.models.getDefaultModel.invalidate({ type: modelType });
toast.success(`Default ${modelType} model updated!`);
},
onError: (error) => {
console.error(`Failed to set default ${modelType} model:`, error);
toast.error(
`Failed to set default ${modelType} model. Please try again.`,
);
},
});
// Transform models for display
const modelOptions = useMemo(() => {
if (!modelsQuery.data) return [];
if (modelType === "speech") {
// Speech models from local whisper
return modelsQuery.data.map((m) => ({
value: m.id,
label: m.name,
}));
} else {
// Provider models for language/embedding
return modelsQuery.data.map((m) => ({
value: m.id,
label: m.name,
}));
}
}, [modelsQuery.data, modelType]);
const handleModelChange = (modelId: string) => {
if (!modelId || modelId === defaultModelQuery.data) return;
// Only show confirmation dialog for embedding models
if (modelType === "embedding") {
setPendingModelId(modelId);
setChangeDefaultDialogOpen(true);
} else {
// For speech and language models, update immediately
setDefaultModelMutation.mutate({ type: modelType, modelId });
}
};
const confirmChangeDefault = () => {
if (pendingModelId) {
setDefaultModelMutation.mutate({
type: modelType,
modelId: pendingModelId,
});
setPendingModelId("");
}
};
// Find the selected model for the dialog
const selectedModel = useMemo(() => {
if (!pendingModelId || !modelsQuery.data) return undefined;
return modelsQuery.data.find((m) => m.id === pendingModelId);
}, [pendingModelId, modelsQuery.data]);
// Loading state
if (modelsQuery.isLoading || defaultModelQuery.isLoading) {
return (
<div>
<Label className="text-lg font-semibold">{title}</Label>
<div className="mt-2 max-w-xs">
<Combobox
options={[]}
value=""
onChange={() => {}}
placeholder="Loading..."
disabled
/>
</div>
</div>
);
}
return (
<>
<div>
<Label className="text-lg font-semibold">{title}</Label>
<div className="mt-2 max-w-xs">
<Combobox
options={modelOptions}
value={defaultModelQuery.data || ""}
onChange={handleModelChange}
placeholder="Select a model..."
/>
</div>
</div>
{modelType === "embedding" && (
<ChangeDefaultModelDialog
open={changeDefaultDialogOpen}
onOpenChange={(open) => {
setChangeDefaultDialogOpen(open);
if (!open) setPendingModelId("");
}}
selectedModel={selectedModel}
onConfirm={confirmChangeDefault}
modelType="embedding"
/>
)}
</>
);
}

View file

@ -0,0 +1,334 @@
"use client";
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import {
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Loader2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { api } from "@/trpc/react";
import { toast } from "sonner";
import SyncModelsDialog from "./sync-models-dialog";
interface ProviderAccordionProps {
provider: "OpenRouter" | "Ollama";
modelType: "language" | "embedding";
}
export default function ProviderAccordion({
provider,
modelType,
}: ProviderAccordionProps) {
// Local state
const [status, setStatus] = useState<"connected" | "disconnected">(
"disconnected",
);
const [inputValue, setInputValue] = useState("");
const [isValidating, setIsValidating] = useState(false);
const [validationError, setValidationError] = useState("");
const [syncDialogOpen, setSyncDialogOpen] = useState(false);
const [removeProviderDialogOpen, setRemoveProviderDialogOpen] =
useState(false);
// tRPC queries and mutations
const utils = api.useUtils();
const modelProvidersConfigQuery =
api.settings.getModelProvidersConfig.useQuery();
const setOpenRouterConfigMutation =
api.settings.setOpenRouterConfig.useMutation({
onSuccess: () => {
toast.success("OpenRouter configuration saved successfully!");
utils.settings.getModelProvidersConfig.invalidate();
},
onError: (error) => {
console.error("Failed to save OpenRouter config:", error);
toast.error(
"Failed to save OpenRouter configuration. Please try again.",
);
},
});
const setOllamaConfigMutation = api.settings.setOllamaConfig.useMutation({
onSuccess: () => {
toast.success("Ollama configuration saved successfully!");
utils.settings.getModelProvidersConfig.invalidate();
},
onError: (error) => {
console.error("Failed to save Ollama config:", error);
toast.error("Failed to save Ollama configuration. Please try again.");
},
});
const validateOpenRouterMutation =
api.models.validateOpenRouterConnection.useMutation({
onSuccess: (result) => {
setIsValidating(false);
if (result.success) {
setOpenRouterConfigMutation.mutate({ apiKey: inputValue.trim() });
setValidationError("");
toast.success("OpenRouter connection validated successfully!");
} else {
setValidationError(result.error || "Validation failed");
toast.error(`OpenRouter validation failed: ${result.error}`);
}
},
onError: (error) => {
setIsValidating(false);
setValidationError(error.message);
toast.error(`OpenRouter validation error: ${error.message}`);
},
});
const validateOllamaMutation =
api.models.validateOllamaConnection.useMutation({
onSuccess: (result) => {
setIsValidating(false);
if (result.success) {
setOllamaConfigMutation.mutate({ url: inputValue.trim() });
setValidationError("");
toast.success("Ollama connection validated successfully!");
} else {
setValidationError(result.error || "Validation failed");
toast.error(`Ollama validation failed: ${result.error}`);
}
},
onError: (error) => {
setIsValidating(false);
setValidationError(error.message);
toast.error(`Ollama validation error: ${error.message}`);
},
});
const removeOpenRouterProviderMutation =
api.models.removeOpenRouterProvider.useMutation({
onSuccess: () => {
utils.settings.getModelProvidersConfig.invalidate();
utils.models.getSyncedProviderModels.invalidate();
utils.models.getDefaultLanguageModel.invalidate();
utils.models.getDefaultEmbeddingModel.invalidate();
setStatus("disconnected");
setInputValue("");
toast.success("OpenRouter provider removed successfully!");
},
onError: (error) => {
console.error("Failed to remove OpenRouter provider:", error);
toast.error("Failed to remove OpenRouter provider. Please try again.");
},
});
const removeOllamaProviderMutation =
api.models.removeOllamaProvider.useMutation({
onSuccess: () => {
utils.settings.getModelProvidersConfig.invalidate();
utils.models.getSyncedProviderModels.invalidate();
utils.models.getDefaultLanguageModel.invalidate();
utils.models.getDefaultEmbeddingModel.invalidate();
setStatus("disconnected");
setInputValue("");
toast.success("Ollama provider removed successfully!");
},
onError: (error) => {
console.error("Failed to remove Ollama provider:", error);
toast.error("Failed to remove Ollama provider. Please try again.");
},
});
// Load configuration when query data is available
useEffect(() => {
if (modelProvidersConfigQuery.data) {
const config = modelProvidersConfigQuery.data;
if (provider === "OpenRouter") {
if (config.openRouter?.apiKey) {
setInputValue(config.openRouter.apiKey);
setStatus("connected");
} else {
setInputValue("");
setStatus("disconnected");
}
} else if (provider === "Ollama") {
if (config.ollama?.url && config.ollama.url !== "") {
setInputValue(config.ollama.url);
setStatus("connected");
} else {
setInputValue("");
setStatus("disconnected");
}
}
}
}, [modelProvidersConfigQuery.data, provider]);
// Connect functions with validation
const handleConnect = () => {
if (!inputValue.trim()) return;
setIsValidating(true);
setValidationError("");
if (provider === "OpenRouter") {
validateOpenRouterMutation.mutate({ apiKey: inputValue.trim() });
} else {
validateOllamaMutation.mutate({ url: inputValue.trim() });
}
};
// Open sync dialog
const openSyncDialog = () => {
setSyncDialogOpen(true);
};
// Remove provider functions
const openRemoveProviderDialog = () => {
setRemoveProviderDialogOpen(true);
};
const confirmRemoveProvider = () => {
if (provider === "OpenRouter") {
removeOpenRouterProviderMutation.mutate();
} else {
removeOllamaProviderMutation.mutate();
}
setRemoveProviderDialogOpen(false);
};
const cancelRemoveProvider = () => {
setRemoveProviderDialogOpen(false);
};
function statusIndicator(status: "connected" | "disconnected") {
return (
<Badge
variant="secondary"
className={cn(
"text-xs flex items-center gap-1",
status === "connected"
? "text-green-500 border-green-500"
: "text-red-500 border-red-500",
)}
>
<span
className={cn(
"w-2 h-2 rounded-full inline-block animate-pulse mr-1",
status === "connected" ? "bg-green-500" : "bg-red-500",
)}
/>
{status === "connected" ? "Connected" : "Disconnected"}
</Badge>
);
}
const getPlaceholder = () => {
if (provider === "OpenRouter") {
return "API Key";
} else {
return "Ollama URL (e.g., http://localhost:11434)";
}
};
const getInputType = () => {
return provider === "OpenRouter" ? "password" : "text";
};
return (
<>
<AccordionItem value={provider.toLowerCase()}>
<AccordionTrigger className="no-underline hover:no-underline group-hover:no-underline">
<div className="flex w-full items-center justify-between">
<span className="hover:underline">{provider}</span>
{statusIndicator(status)}
</div>
</AccordionTrigger>
<AccordionContent className="p-1">
<div className="flex flex-col md:flex-row md:items-center gap-4 mb-2">
<Input
type={getInputType()}
placeholder={getPlaceholder()}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
className="max-w-xs"
disabled={status === "connected"}
/>
{status === "disconnected" ? (
<Button
variant="outline"
onClick={handleConnect}
disabled={!inputValue.trim() || isValidating}
>
{isValidating ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Validating...
</>
) : (
"Connect"
)}
</Button>
) : (
<div className="flex gap-2">
<Button variant="outline" onClick={openSyncDialog}>
Sync models
</Button>
<Button
variant="outline"
onClick={openRemoveProviderDialog}
className="text-destructive hover:text-destructive"
>
Remove Provider
</Button>
</div>
)}
</div>
{validationError && (
<p className="text-xs text-destructive mt-2">{validationError}</p>
)}
</AccordionContent>
</AccordionItem>
{/* Sync Models Dialog */}
<SyncModelsDialog
open={syncDialogOpen}
onOpenChange={setSyncDialogOpen}
provider={provider}
modelType={modelType}
/>
{/* Remove Provider Confirmation Dialog */}
<Dialog
open={removeProviderDialogOpen}
onOpenChange={setRemoveProviderDialogOpen}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Remove Provider Connection</DialogTitle>
<DialogDescription>
Are you sure you want to remove your {provider} connection? This
will disconnect and remove all synced models from this provider.
This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={cancelRemoveProvider}>
Cancel
</Button>
<Button variant="destructive" onClick={confirmRemoveProvider}>
Remove Provider
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View file

@ -0,0 +1,314 @@
"use client";
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
import { Loader2 } from "lucide-react";
import { api } from "@/trpc/react";
import { toast } from "sonner";
import type { Model } from "@/db/schema";
interface SyncModelsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
provider: "OpenRouter" | "Ollama";
modelType?: "language" | "embedding";
}
export default function SyncModelsDialog({
open,
onOpenChange,
provider,
modelType = "language",
}: SyncModelsDialogProps) {
// Local state
const [selectedModels, setSelectedModels] = useState<string[]>([]);
const [searchTerm, setSearchTerm] = useState("");
const [credentials, setCredentials] = useState<{
openRouterApiKey?: string;
ollamaUrl?: string;
}>({});
// tRPC queries and mutations
const utils = api.useUtils();
const modelProvidersConfigQuery =
api.settings.getModelProvidersConfig.useQuery();
const syncedModelsQuery = api.models.getSyncedProviderModels.useQuery();
const defaultLanguageModelQuery =
api.models.getDefaultLanguageModel.useQuery();
const defaultEmbeddingModelQuery =
api.models.getDefaultEmbeddingModel.useQuery();
const fetchOpenRouterModelsQuery = api.models.fetchOpenRouterModels.useQuery(
{ apiKey: credentials.openRouterApiKey ?? "" },
{
enabled: false, // We'll trigger manually
},
);
const fetchOllamaModelsQuery = api.models.fetchOllamaModels.useQuery(
{ url: credentials.ollamaUrl ?? "" },
{
enabled: false, // We'll trigger manually
},
);
const syncProviderModelsMutation =
api.models.syncProviderModelsToDatabase.useMutation({
onSuccess: () => {
// Invalidate all related queries to refresh parent components
utils.models.getSyncedProviderModels.invalidate();
utils.models.getDefaultLanguageModel.invalidate();
utils.models.getDefaultEmbeddingModel.invalidate();
toast.success("Models synced to database successfully!");
},
onError: (error: any) => {
console.error("Failed to sync models to database:", error);
toast.error("Failed to sync models to database. Please try again.");
},
});
const setDefaultLanguageModelMutation =
api.models.setDefaultLanguageModel.useMutation({
onSuccess: () => {
utils.models.getDefaultLanguageModel.invalidate();
},
});
const setDefaultEmbeddingModelMutation =
api.models.setDefaultEmbeddingModel.useMutation({
onSuccess: () => {
utils.models.getDefaultEmbeddingModel.invalidate();
},
});
// Extract credentials when provider config is available
useEffect(() => {
if (modelProvidersConfigQuery.data) {
const config = modelProvidersConfigQuery.data;
setCredentials({
openRouterApiKey: config.openRouter?.apiKey,
ollamaUrl: config.ollama?.url,
});
}
}, [modelProvidersConfigQuery.data]);
// Pre-select already synced models and start fetching when dialog opens
useEffect(() => {
if (open && syncedModelsQuery.data) {
const syncedModelIds = syncedModelsQuery.data
.filter((m) => m.provider === provider)
.map((m) => m.id);
setSelectedModels(syncedModelIds);
setSearchTerm("");
// Start fetching models if we have credentials
if (provider === "OpenRouter" && credentials.openRouterApiKey) {
fetchOpenRouterModelsQuery.refetch();
} else if (provider === "Ollama" && credentials.ollamaUrl) {
fetchOllamaModelsQuery.refetch();
}
}
}, [open, syncedModelsQuery.data, provider, credentials]);
// Get the appropriate query based on provider
const activeQuery =
provider === "OpenRouter"
? fetchOpenRouterModelsQuery
: fetchOllamaModelsQuery;
const availableModels = activeQuery.data || [];
const isFetching = activeQuery.isLoading || activeQuery.isFetching;
const fetchError = activeQuery.error?.message || "";
// Filter models based on search
const filteredModels = availableModels.filter(
(model) =>
model.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
model.id.toLowerCase().includes(searchTerm.toLowerCase()),
);
// Handle model selection
const toggleModel = (modelId: string, checked: boolean) => {
if (checked) {
setSelectedModels((prev) => [...prev, modelId]);
} else {
setSelectedModels((prev) => prev.filter((id) => id !== modelId));
}
};
// Handle sync
const handleSync = async () => {
const modelsToSync = availableModels.filter((model) =>
selectedModels.includes(model.id),
);
// Sync to database
await syncProviderModelsMutation.mutateAsync({
provider,
models: modelsToSync,
});
// Set first model as default if no default is set and this is a language model
if (modelType === "language" && modelsToSync.length > 0) {
if (!defaultLanguageModelQuery.data) {
setDefaultLanguageModelMutation.mutate({ modelId: modelsToSync[0].id });
}
} else if (modelType === "embedding" && modelsToSync.length > 0) {
// For embedding models, only set default if no default is set and this is Ollama provider
// (embedding models only work with Ollama)
if (provider === "Ollama" && !defaultEmbeddingModelQuery.data) {
setDefaultEmbeddingModelMutation.mutate({
modelId: modelsToSync[0].id,
});
}
}
handleCancel();
};
// Handle cancel
const handleCancel = () => {
onOpenChange(false);
setSelectedModels([]);
setSearchTerm("");
};
// Determine display limits and grid layout
const displayLimit = provider === "OpenRouter" ? 10 : undefined;
const gridCols =
provider === "OpenRouter"
? "grid-cols-1 md:grid-cols-2 lg:grid-cols-3"
: "grid-cols-1 md:grid-cols-2";
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="min-w-4xl">
<DialogHeader>
<DialogTitle>
Select {provider} {modelType === "embedding" ? "Embedding " : ""}
Models
</DialogTitle>
<DialogDescription>
Choose which {modelType === "embedding" ? "embedding " : ""}models
you want to sync from {provider}.
</DialogDescription>
</DialogHeader>
<div
className={
provider === "Ollama"
? "overflow-y-auto"
: "max-h-96 overflow-y-auto"
}
>
{isFetching ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin mr-2" />
<span>
Fetching {provider === "Ollama" ? "available " : ""}models...
</span>
</div>
) : fetchError ? (
<div className="text-center py-8">
<p
className={
provider === "Ollama"
? "text-red-500 mb-2"
: "text-destructive"
}
>
Failed to fetch models
{provider === "Ollama" ? "" : `: ${fetchError}`}
</p>
{provider === "Ollama" && (
<p className="text-sm text-muted-foreground">{fetchError}</p>
)}
</div>
) : (
<>
<div className="flex items-center gap-2 mb-4">
<Input
placeholder="Search models..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="max-w-xs"
/>
<Button variant="outline" onClick={() => setSearchTerm("")}>
Clear
</Button>
</div>
<div className={`grid ${gridCols} gap-3`}>
{(displayLimit
? filteredModels.slice(0, displayLimit)
: filteredModels
).map((model) => (
<div
key={model.id}
className="flex items-start space-x-3 p-4 border rounded-lg hover:bg-muted/30 transition-colors"
>
<Checkbox
id={model.id}
checked={selectedModels.includes(model.id)}
onCheckedChange={(checked) =>
toggleModel(model.id, !!checked)
}
className="mt-1"
/>
<div className="grid gap-1.5 leading-none flex-1">
<label
htmlFor={model.id}
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer"
>
{model.name}
</label>
<div className="flex gap-2 text-xs text-muted-foreground">
{model.size && <span>Size: {model.size}</span>}
<span>Context: {model.context}</span>
</div>
{/* {model.description && (
<p className="text-xs text-muted-foreground mt-1">
{model.description}
</p>
)} */}
</div>
</div>
))}
</div>
</>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={handleCancel}>
Cancel
</Button>
<Button
onClick={handleSync}
disabled={
selectedModels.length === 0 ||
syncProviderModelsMutation.isPending
}
>
{syncProviderModelsMutation.isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Syncing...
</>
) : (
`Sync ${selectedModels.length} model${selectedModels.length !== 1 ? "s" : ""}`
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,321 @@
"use client";
import { useState, useEffect } from "react";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Check, Trash2 } from "lucide-react";
import { cn } from "@/lib/utils";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { api } from "@/trpc/react";
import { toast } from "sonner";
import ChangeDefaultModelDialog from "./change-default-model-dialog";
import type { Model } from "@/db/schema";
interface SyncedModelsListProps {
modelType: "language" | "embedding";
title?: string;
}
export default function SyncedModelsList({
modelType,
title = "Synced Models",
}: SyncedModelsListProps) {
// Local state
const [syncedModels, setSyncedModels] = useState<Model[]>([]);
const [defaultModel, setDefaultModel] = useState("");
// Dialog states
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [modelToDelete, setModelToDelete] = useState<string>("");
const [changeDefaultDialogOpen, setChangeDefaultDialogOpen] = useState(false);
const [newDefaultModel, setNewDefaultModel] = useState<string>("");
const [errorDialogOpen, setErrorDialogOpen] = useState(false);
const [errorMessage, setErrorMessage] = useState<string>("");
// tRPC queries and mutations
const utils = api.useUtils();
const syncedModelsQuery = api.models.getSyncedProviderModels.useQuery();
const defaultLanguageModelQuery =
api.models.getDefaultLanguageModel.useQuery();
const defaultEmbeddingModelQuery =
api.models.getDefaultEmbeddingModel.useQuery();
const removeProviderModelMutation =
api.models.removeProviderModel.useMutation({
onSuccess: () => {
utils.models.getSyncedProviderModels.invalidate();
toast.success("Model removed successfully!");
},
onError: (error) => {
console.error("Failed to remove model:", error);
toast.error("Failed to remove model. Please try again.");
},
});
const setDefaultLanguageModelMutation =
api.models.setDefaultLanguageModel.useMutation({
onSuccess: () => {
utils.models.getDefaultLanguageModel.invalidate();
toast.success("Default language model updated!");
},
onError: (error) => {
console.error("Failed to set default language model:", error);
toast.error("Failed to set default language model. Please try again.");
},
});
const setDefaultEmbeddingModelMutation =
api.models.setDefaultEmbeddingModel.useMutation({
onSuccess: () => {
utils.models.getDefaultEmbeddingModel.invalidate();
toast.success("Default embedding model updated!");
},
onError: (error) => {
console.error("Failed to set default embedding model:", error);
toast.error("Failed to set default embedding model. Please try again.");
},
});
// Load synced models
useEffect(() => {
if (syncedModelsQuery.data) {
let filteredModels = syncedModelsQuery.data;
// For embedding models, only show Ollama models
if (modelType === "embedding") {
filteredModels = syncedModelsQuery.data.filter(
(model) => model.provider.toLowerCase() === "ollama",
);
}
setSyncedModels(filteredModels);
}
}, [syncedModelsQuery.data, modelType]);
// Load default model based on type
useEffect(() => {
if (
modelType === "language" &&
defaultLanguageModelQuery.data !== undefined
) {
setDefaultModel(defaultLanguageModelQuery.data || "");
} else if (
modelType === "embedding" &&
defaultEmbeddingModelQuery.data !== undefined
) {
setDefaultModel(defaultEmbeddingModelQuery.data || "");
}
}, [
modelType,
defaultLanguageModelQuery.data,
defaultEmbeddingModelQuery.data,
]);
// Delete confirmation functions
const openDeleteDialog = (modelId: string) => {
// Check if trying to remove the default model
if (modelId === defaultModel) {
setErrorMessage(
"Please select another model as default before removing this model.",
);
setErrorDialogOpen(true);
return;
}
setModelToDelete(modelId);
setDeleteDialogOpen(true);
};
const confirmDelete = () => {
if (modelToDelete) {
handleRemoveModel(modelToDelete);
setDeleteDialogOpen(false);
setModelToDelete("");
}
};
const cancelDelete = () => {
setDeleteDialogOpen(false);
setModelToDelete("");
};
// Change default model functions
const openChangeDefaultDialog = (modelId: string) => {
setNewDefaultModel(modelId);
setChangeDefaultDialogOpen(true);
};
const confirmChangeDefault = () => {
if (modelType === "language") {
setDefaultLanguageModelMutation.mutate({ modelId: newDefaultModel });
} else {
setDefaultEmbeddingModelMutation.mutate({ modelId: newDefaultModel });
}
setNewDefaultModel("");
};
const handleRemoveModel = (modelId: string) => {
removeProviderModelMutation.mutate({ modelId });
// Clear default if removing the default model
if (defaultModel === modelId) {
if (modelType === "language") {
setDefaultLanguageModelMutation.mutate({ modelId: null });
} else {
setDefaultEmbeddingModelMutation.mutate({ modelId: null });
}
}
};
return (
<>
{/* Model Table */}
<div>
<Label className="text-lg font-semibold mb-2 block">{title}</Label>
{syncedModels.length === 0 ? (
<div className="border rounded-md p-8 text-center text-muted-foreground">
<p>No models synced yet.</p>
<p className="text-sm mt-1">
Connect to a provider and sync models to see them here.
</p>
</div>
) : (
<div className="divide-y border rounded-md bg-muted/30">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Provider</TableHead>
<TableHead>Size</TableHead>
<TableHead>Context</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{syncedModels.map((model) => (
<TableRow key={model.id}>
<TableCell className="font-medium">{model.name}</TableCell>
<TableCell>{model.provider}</TableCell>
<TableCell>{model.size || "Unknown"}</TableCell>
<TableCell>{model.context}</TableCell>
<TableCell>
<div className="flex gap-2">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="ghost"
onClick={() =>
openChangeDefaultDialog(model.id)
}
>
<Check
className={cn(
"w-4 h-4",
defaultModel === model.id
? "text-green-500"
: "text-muted-foreground",
)}
/>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>
{defaultModel === model.id
? "Current default model"
: "Set as default model"}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="ghost"
onClick={() => openDeleteDialog(model.id)}
>
<Trash2 className="w-4 h-4 text-destructive" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Remove model</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</div>
{/* Delete Confirmation Dialog */}
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Confirm Deletion</DialogTitle>
<DialogDescription>
Are you sure you want to remove "
{syncedModels.find((m) => m.id === modelToDelete)?.name}" from
your synced models? This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={cancelDelete}>
Cancel
</Button>
<Button variant="destructive" onClick={confirmDelete}>
Remove Model
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<ChangeDefaultModelDialog
open={changeDefaultDialogOpen}
onOpenChange={setChangeDefaultDialogOpen}
selectedModel={syncedModels.find((m) => m.id === newDefaultModel)}
onConfirm={confirmChangeDefault}
modelType={modelType}
/>
{/* Error Dialog */}
<Dialog open={errorDialogOpen} onOpenChange={setErrorDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Cannot Remove Model</DialogTitle>
<DialogDescription>{errorMessage}</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button onClick={() => setErrorDialogOpen(false)}>OK</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View file

@ -0,0 +1,50 @@
"use client";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import SpeechTab from "./tabs/SpeechTab";
import LanguageTab from "./tabs/LanguageTab";
import EmbeddingTab from "./tabs/EmbeddingTab";
import { useNavigate } from "@tanstack/react-router";
import { Route } from "../../../routes/settings/ai-models";
export default function AIModelsSettingsPage() {
const navigate = useNavigate();
const { tab } = Route.useSearch();
return (
<div className="container mx-auto p-6 max-w-5xl">
<h1 className="text-xl font-bold mb-6">AI Models</h1>
<Tabs
value={tab}
onValueChange={(newTab) => {
navigate({
to: "/settings/ai-models",
search: { tab: newTab as "speech" | "language" | "embedding" },
});
}}
className="w-full"
>
<TabsList className="mb-6">
<TabsTrigger value="speech" className="text-base">
Speech
</TabsTrigger>
<TabsTrigger value="language" className="text-base">
Language
</TabsTrigger>
<TabsTrigger value="embedding" className="text-base">
Embedding
</TabsTrigger>
</TabsList>
<TabsContent value="speech">
<SpeechTab />
</TabsContent>
<TabsContent value="language">
<LanguageTab />
</TabsContent>
<TabsContent value="embedding">
<EmbeddingTab />
</TabsContent>
</Tabs>
</div>
);
}

View file

@ -0,0 +1,30 @@
"use client";
import { Card, CardContent } from "@/components/ui/card";
import { Accordion } from "@/components/ui/accordion";
import SyncedModelsList from "../components/synced-models-list";
import DefaultModelCombobox from "../components/default-model-combobox";
import ProviderAccordion from "../components/provider-accordion";
// Note: OpenRouter doesn't provide embedding models, only Ollama for now
export default function EmbeddingTab() {
return (
<Card>
<CardContent className="space-y-6 p-6">
{/* Default model picker */}
<DefaultModelCombobox
modelType="embedding"
title="Default Embedding Model"
/>
{/* Providers Accordions */}
<Accordion type="multiple" className="w-full">
<ProviderAccordion provider="Ollama" modelType="embedding" />
</Accordion>
{/* Synced Models List */}
<SyncedModelsList modelType="embedding" title="Synced Models" />
</CardContent>
</Card>
);
}

View file

@ -0,0 +1,29 @@
"use client";
import { Card, CardContent } from "@/components/ui/card";
import { Accordion } from "@/components/ui/accordion";
import SyncedModelsList from "../components/synced-models-list";
import DefaultModelCombobox from "../components/default-model-combobox";
import ProviderAccordion from "../components/provider-accordion";
export default function LanguageTab() {
return (
<Card>
<CardContent className="space-y-6 p-6">
{/* Default model picker */}
<DefaultModelCombobox
modelType="language"
title="Default Language Model"
/>
{/* Providers Accordions */}
<Accordion type="multiple" className="w-full">
<ProviderAccordion provider="OpenRouter" modelType="language" />
<ProviderAccordion provider="Ollama" modelType="language" />
</Accordion>
{/* Synced Models List */}
<SyncedModelsList modelType="language" title="Synced Models" />
</CardContent>
</Card>
);
}

View file

@ -0,0 +1,531 @@
"use client";
import { ComponentProps, useEffect, useState } from "react";
import { Card, CardContent } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import DefaultModelCombobox from "../components/default-model-combobox";
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Download, Zap, Circle, Square, Loader2, Trash2 } from "lucide-react";
import { DynamicIcon } from "lucide-react/dynamic";
import {
TooltipContent,
Tooltip,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { toast } from "sonner";
import {
AlertDialog,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogAction,
AlertDialogCancel,
} from "@/components/ui/alert-dialog";
import { DownloadProgress } from "@/constants/models";
import { api } from "@/trpc/react";
const SpeedRating = ({ rating }: { rating: number }) => {
const fullIcons = Math.floor(rating);
const hasHalf = rating % 1 !== 0;
return (
<div className="flex items-center gap-1">
{Array.from({ length: 5 }, (_, i) => {
if (i < fullIcons) {
return (
<Zap key={i} className="w-4 h-4 fill-yellow-400 text-yellow-400" />
);
} else if (i === fullIcons && hasHalf) {
return (
<div key={i} className="relative w-4 h-4">
<Zap className="w-4 h-4 text-gray-300" />
<div className="absolute inset-0 overflow-hidden w-1/2">
<Zap className="w-4 h-4 fill-yellow-400 text-yellow-400" />
</div>
</div>
);
} else {
return <Zap key={i} className="w-4 h-4 text-gray-300" />;
}
})}
<span className="text-sm text-muted-foreground ml-1">{rating}</span>
</div>
);
};
const AccuracyRating = ({ rating }: { rating: number }) => {
const fullIcons = Math.floor(rating);
const hasHalf = rating % 1 !== 0;
return (
<div className="flex items-center gap-1">
{Array.from({ length: 5 }, (_, i) => {
if (i < fullIcons) {
return (
<Circle key={i} className="w-4 h-4 fill-green-500 text-green-500" />
);
} else if (i === fullIcons && hasHalf) {
return (
<div key={i} className="relative w-4 h-4">
<Circle className="w-4 h-4 text-gray-300" />
<div className="absolute inset-0 overflow-hidden w-1/2">
<Circle className="w-4 h-4 fill-green-500 text-green-500" />
</div>
</div>
);
} else {
return <Circle key={i} className="w-4 h-4 text-gray-300" />;
}
})}
<span className="text-sm text-muted-foreground ml-1">{rating}</span>
</div>
);
};
export default function SpeechTab() {
const [downloadProgress, setDownloadProgress] = useState<
Record<string, DownloadProgress>
>({});
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [modelToDelete, setModelToDelete] = useState<string | null>(null);
// tRPC queries
const availableModelsQuery = api.models.getAvailableModels.useQuery();
const downloadedModelsQuery = api.models.getDownloadedModels.useQuery();
const activeDownloadsQuery = api.models.getActiveDownloads.useQuery();
const isTranscriptionAvailableQuery =
api.models.isTranscriptionAvailable.useQuery();
const selectedModelQuery = api.models.getSelectedModel.useQuery();
const utils = api.useUtils();
// tRPC mutations
const downloadModelMutation = api.models.downloadModel.useMutation({
onSuccess: () => {
utils.models.getDownloadedModels.invalidate();
utils.models.getActiveDownloads.invalidate();
},
onError: (error) => {
console.error("Failed to start download:", error);
toast.error("Failed to start download");
},
});
const cancelDownloadMutation = api.models.cancelDownload.useMutation({
onSuccess: () => {
utils.models.getActiveDownloads.invalidate();
},
onError: (error) => {
console.error("Failed to cancel download:", error);
toast.error("Failed to cancel download");
},
});
const deleteModelMutation = api.models.deleteModel.useMutation({
onSuccess: () => {
utils.models.getDownloadedModels.invalidate();
setShowDeleteDialog(false);
setModelToDelete(null);
},
onError: (error) => {
console.error("Failed to delete model:", error);
toast.error("Failed to delete model");
setShowDeleteDialog(false);
setModelToDelete(null);
},
});
const setSelectedModelMutation = api.models.setSelectedModel.useMutation({
onSuccess: () => {
utils.models.getSelectedModel.invalidate();
},
onError: (error) => {
console.error("Failed to select model:", error);
toast.error("Failed to select model");
},
});
// Initialize active downloads progress on load
useEffect(() => {
if (activeDownloadsQuery.data) {
const progressMap: Record<string, DownloadProgress> = {};
activeDownloadsQuery.data.forEach((download) => {
progressMap[download.modelId] = download;
});
setDownloadProgress(progressMap);
}
}, [activeDownloadsQuery.data]);
// Set up tRPC subscriptions for real-time download updates
api.models.onDownloadProgress.useSubscription(undefined, {
onData: ({ modelId, progress }) => {
setDownloadProgress((prev) => ({ ...prev, [modelId]: progress }));
},
onError: (error) => {
console.error("Download progress subscription error:", error);
},
});
api.models.onDownloadComplete.useSubscription(undefined, {
onData: ({ modelId }) => {
setDownloadProgress((prev) => {
const newProgress = { ...prev };
delete newProgress[modelId];
return newProgress;
});
utils.models.getDownloadedModels.invalidate();
utils.models.getActiveDownloads.invalidate();
// Also invalidate selected model in case of auto-selection
utils.models.getSelectedModel.invalidate();
},
onError: (error) => {
console.error("Download complete subscription error:", error);
},
});
api.models.onDownloadError.useSubscription(undefined, {
onData: ({ modelId, error }) => {
setDownloadProgress((prev) => {
const newProgress = { ...prev };
delete newProgress[modelId];
return newProgress;
});
toast.error(`Download failed: ${error}`);
utils.models.getActiveDownloads.invalidate();
},
onError: (error) => {
console.error("Download error subscription error:", error);
},
});
api.models.onDownloadCancelled.useSubscription(undefined, {
onData: ({ modelId }) => {
setDownloadProgress((prev) => {
const newProgress = { ...prev };
delete newProgress[modelId];
return newProgress;
});
utils.models.getActiveDownloads.invalidate();
},
onError: (error) => {
console.error("Download cancelled subscription error:", error);
},
});
api.models.onModelDeleted.useSubscription(undefined, {
onData: () => {
utils.models.getDownloadedModels.invalidate();
},
onError: (error) => {
console.error("Model deleted subscription error:", error);
},
});
const handleDownload = async (modelId: string, event?: React.MouseEvent) => {
if (event) {
event.preventDefault();
event.stopPropagation();
}
try {
await downloadModelMutation.mutateAsync({ modelId });
console.log("Download started for:", modelId);
} catch (err) {
console.error("Failed to start download:", err);
// Error is already handled by the mutation's onError
}
};
const handleCancelDownload = async (
modelId: string,
event?: React.MouseEvent,
) => {
if (event) {
event.preventDefault();
event.stopPropagation();
}
try {
await cancelDownloadMutation.mutateAsync({ modelId });
console.log("Cancel download successful for:", modelId);
} catch (err) {
console.error("Failed to cancel download:", err);
// Error is already handled by the mutation's onError
}
};
const handleDeleteClick = (modelId: string) => {
setModelToDelete(modelId);
setShowDeleteDialog(true);
};
const handleDeleteConfirm = async () => {
if (!modelToDelete) return;
try {
await deleteModelMutation.mutateAsync({ modelId: modelToDelete });
} catch (err) {
console.error("Failed to delete model:", err);
// Error is already handled by the mutation's onError
}
};
const handleDeleteCancel = () => {
setShowDeleteDialog(false);
setModelToDelete(null);
};
const handleSelectModel = async (modelId: string) => {
try {
await setSelectedModelMutation.mutateAsync({ modelId });
} catch (err) {
console.error("Failed to select model:", err);
// Error is already handled by the mutation's onError
}
};
// Loading state
const loading =
availableModelsQuery.isLoading ||
downloadedModelsQuery.isLoading ||
isTranscriptionAvailableQuery.isLoading ||
selectedModelQuery.isLoading;
// Data from queries
const availableModels = availableModelsQuery.data || [];
const downloadedModels = downloadedModelsQuery.data || {};
const isTranscriptionAvailable = isTranscriptionAvailableQuery.data || false;
const selectedModel = selectedModelQuery.data;
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<Loader2 className="w-8 h-8 animate-spin" />
<span className="ml-2">Loading models...</span>
</div>
);
}
return (
<>
<Card>
<CardContent className="space-y-6">
{/* Default model picker using unified component */}
<DefaultModelCombobox
modelType="speech"
title="Default Speech Model"
/>
<div>
<Label className="text-lg font-semibold mb-2 block">
Available Models
</Label>
<div className="divide-y border rounded-md bg-muted/30">
<TooltipProvider>
<RadioGroup
value={selectedModel || ""}
onValueChange={handleSelectModel}
>
<Table>
<TableHeader>
<TableRow>
<TableHead>Model</TableHead>
<TableHead>Features</TableHead>
<TableHead>Speed</TableHead>
<TableHead>Accuracy</TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{availableModels.map((model) => {
const isDownloaded = !!downloadedModels[model.id];
const progress = downloadProgress[model.id];
const isDownloading =
progress?.status === "downloading";
return (
<TableRow
key={model.id}
className="hover:bg-muted/50"
>
<TableCell>
<div className="flex items-center space-x-3">
<RadioGroupItem
value={model.id}
id={model.id}
disabled={
!isDownloaded || !isTranscriptionAvailable
}
/>
<div>
<Label
htmlFor={model.id}
className="font-semibold cursor-pointer"
>
{model.name}
</Label>
<div className="flex items-center gap-2 text-sm text-muted-foreground mt-1">
<Avatar className="w-4 h-4">
<AvatarImage
src={model.providerIcon}
alt={`${model.provider} icon`}
/>
<AvatarFallback className="text-xs">
{model.provider.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<span>{model.provider}</span>
</div>
</div>
</div>
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-2">
{model.features.map((feature, featureIndex) => (
<Tooltip key={featureIndex}>
<TooltipTrigger asChild>
<div className="p-2 rounded-md bg-muted hover:bg-muted/80 cursor-help transition-colors">
{
<DynamicIcon
name={
feature.icon as ComponentProps<
typeof DynamicIcon
>["name"]
}
className="w-4 h-4"
/>
}
</div>
</TooltipTrigger>
<TooltipContent>
<p>{feature.tooltip}</p>
</TooltipContent>
</Tooltip>
))}
</div>
</TableCell>
<TableCell>
<SpeedRating rating={model.speed} />
</TableCell>
<TableCell>
<AccuracyRating rating={model.accuracy} />
</TableCell>
<TableCell>
<div className="flex flex-col items-center space-y-1">
{!isDownloaded && !isDownloading && (
<button
onClick={(e) => handleDownload(model.id, e)}
className="w-8 h-8 rounded-full bg-muted hover:bg-muted/80 flex items-center justify-center text-primary-foreground transition-colors"
title="Click to download"
>
<Download className="w-4 h-4 text-muted-foreground" />
</button>
)}
{!isDownloaded && isDownloading && (
<div className="relative">
<button
onClick={(e) =>
handleCancelDownload(model.id, e)
}
className="w-8 h-8 rounded-full bg-orange-500 hover:bg-orange-600 flex items-center justify-center text-white transition-colors"
title="Click to cancel download"
>
<Square className="w-4 h-4" />
</button>
{/* Circular Progress Ring */}
{progress && (
<svg
className="absolute inset-0 w-8 h-8 -rotate-90 pointer-events-none"
viewBox="0 0 36 36"
>
<circle
cx="18"
cy="18"
r="15.9155"
fill="none"
stroke="currentColor"
strokeWidth="3"
strokeDasharray="100 100"
className="text-muted-foreground/30"
/>
<circle
cx="18"
cy="18"
r="15.9155"
fill="none"
stroke="currentColor"
strokeWidth="3"
strokeDasharray={`${Math.max(0, Math.min(100, progress.progress))} 100`}
strokeLinecap="round"
className="text-white transition-all duration-300"
/>
</svg>
)}
</div>
)}
{isDownloaded && (
<button
onClick={() => handleDeleteClick(model.id)}
className="w-8 h-8 rounded-full bg-red-500 hover:bg-red-600 flex items-center justify-center text-white transition-colors"
title="Click to delete model"
>
<Trash2 className="w-4 h-4" />
</button>
)}
<div className="text-xs text-muted-foreground text-center">
{model.sizeFormatted}
</div>
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</RadioGroup>
</TooltipProvider>
</div>
</div>
</CardContent>
</Card>
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Model</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete this model? This action cannot be
undone and you will need to download the model again if you want
to use it.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={handleDeleteCancel}>
Cancel
</AlertDialogCancel>
<AlertDialogAction
onClick={handleDeleteConfirm}
className="bg-red-500 hover:bg-red-600"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}

View file

@ -0,0 +1,561 @@
"use client";
import type React from "react";
import { useState, useEffect } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Zap,
Circle,
Cloud,
Download,
Languages,
Clock,
Shield,
Users,
Volume2,
FileText,
Filter,
Headphones,
MessageSquare,
Sparkles,
Brain,
Gauge,
Settings,
X,
} from "lucide-react";
interface SpeechModel {
name: string;
features: Array<{
icon: React.ReactNode;
tooltip: string;
}>;
speed: number; // out of 5
accuracy: number; // out of 5
setup: "cloud" | "offline";
provider: string;
modelSize?: string; // for offline models
}
const models: SpeechModel[] = [
{
name: "OpenAI Whisper",
features: [
{
icon: <Languages className="w-4 h-4" />,
tooltip: "99+ languages with automatic detection",
},
{
icon: <FileText className="w-4 h-4" />,
tooltip: "Automatic punctuation and capitalization",
},
{
icon: <MessageSquare className="w-4 h-4" />,
tooltip: "Built-in translation to English",
},
{
icon: <Volume2 className="w-4 h-4" />,
tooltip: "Robust to background noise and accents",
},
{
icon: <Clock className="w-4 h-4" />,
tooltip: "Timestamp generation for segments",
},
],
speed: 3.0,
accuracy: 4.5,
setup: "offline",
provider: "OpenAI",
modelSize: "769 MB",
},
{
name: "Google Speech-to-Text",
features: [
{
icon: <Languages className="w-4 h-4" />,
tooltip: "125+ languages and variants",
},
{
icon: <Gauge className="w-4 h-4" />,
tooltip: "Real-time streaming recognition",
},
{
icon: <FileText className="w-4 h-4" />,
tooltip: "Automatic punctuation and formatting",
},
{ icon: <Filter className="w-4 h-4" />, tooltip: "Profanity filtering" },
{
icon: <Settings className="w-4 h-4" />,
tooltip: "Custom vocabulary and models",
},
{
icon: <Headphones className="w-4 h-4" />,
tooltip: "Enhanced phone call model",
},
],
speed: 4.5,
accuracy: 4.0,
setup: "cloud",
provider: "Google",
},
{
name: "Azure Speech Services",
features: [
{
icon: <Languages className="w-4 h-4" />,
tooltip: "100+ languages and dialects",
},
{
icon: <Settings className="w-4 h-4" />,
tooltip: "Custom Speech model training",
},
{
icon: <Gauge className="w-4 h-4" />,
tooltip: "Real-time and batch processing",
},
{
icon: <Users className="w-4 h-4" />,
tooltip: "Speaker recognition and verification",
},
{
icon: <Brain className="w-4 h-4" />,
tooltip: "Intent recognition integration",
},
{
icon: <Shield className="w-4 h-4" />,
tooltip: "Enterprise-grade security",
},
],
speed: 4.0,
accuracy: 4.0,
setup: "cloud",
provider: "Microsoft",
},
{
name: "Amazon Transcribe",
features: [
{
icon: <Languages className="w-4 h-4" />,
tooltip: "31 languages supported",
},
{
icon: <Users className="w-4 h-4" />,
tooltip: "Speaker identification (diarization)",
},
{
icon: <Settings className="w-4 h-4" />,
tooltip: "Custom vocabulary and models",
},
{
icon: <Headphones className="w-4 h-4" />,
tooltip: "Call analytics specialization",
},
{
icon: <Shield className="w-4 h-4" />,
tooltip: "Content redaction (PII removal)",
},
{
icon: <Sparkles className="w-4 h-4" />,
tooltip: "Medical and legal transcription",
},
],
speed: 4.0,
accuracy: 3.5,
setup: "cloud",
provider: "Amazon",
},
{
name: "AssemblyAI",
features: [
{
icon: <Users className="w-4 h-4" />,
tooltip: "Advanced speaker diarization",
},
{
icon: <MessageSquare className="w-4 h-4" />,
tooltip: "Sentiment analysis and emotion detection",
},
{
icon: <Sparkles className="w-4 h-4" />,
tooltip: "Topic detection and summarization",
},
{
icon: <Filter className="w-4 h-4" />,
tooltip: "Content safety and moderation",
},
{
icon: <FileText className="w-4 h-4" />,
tooltip: "Auto-chapters and key phrases",
},
{ icon: <Gauge className="w-4 h-4" />, tooltip: "Real-time streaming" },
],
speed: 4.5,
accuracy: 4.5,
setup: "cloud",
provider: "AssemblyAI",
},
{
name: "Deepgram",
features: [
{
icon: <Gauge className="w-4 h-4" />,
tooltip: "Ultra-fast real-time processing",
},
{
icon: <Languages className="w-4 h-4" />,
tooltip: "30+ languages with custom models",
},
{
icon: <Sparkles className="w-4 h-4" />,
tooltip: "Keyword and topic detection",
},
{ icon: <Users className="w-4 h-4" />, tooltip: "Speaker diarization" },
{
icon: <Settings className="w-4 h-4" />,
tooltip: "Custom model training",
},
{
icon: <Volume2 className="w-4 h-4" />,
tooltip: "Enhanced audio preprocessing",
},
],
speed: 5.0,
accuracy: 4.0,
setup: "cloud",
provider: "Deepgram",
},
{
name: "Wav2Vec2",
features: [
{ icon: <Shield className="w-4 h-4" />, tooltip: "Open source and free" },
{
icon: <Brain className="w-4 h-4" />,
tooltip: "Self-supervised learning approach",
},
{
icon: <Languages className="w-4 h-4" />,
tooltip: "Multilingual model variants",
},
{
icon: <Settings className="w-4 h-4" />,
tooltip: "Fine-tunable for custom domains",
},
{
icon: <Volume2 className="w-4 h-4" />,
tooltip: "Robust to noisy audio",
},
],
speed: 2.5,
accuracy: 3.5,
setup: "offline",
provider: "Meta",
modelSize: "360 MB",
},
{
name: "Vosk",
features: [
{
icon: <Shield className="w-4 h-4" />,
tooltip: "Open source and lightweight",
},
{
icon: <Gauge className="w-4 h-4" />,
tooltip: "Real-time processing capability",
},
{
icon: <Languages className="w-4 h-4" />,
tooltip: "20+ language models available",
},
{
icon: <Settings className="w-4 h-4" />,
tooltip: "Embedded and mobile friendly",
},
{
icon: <Clock className="w-4 h-4" />,
tooltip: "Partial results and timestamps",
},
],
speed: 3.0,
accuracy: 3.0,
setup: "offline",
provider: "Alpha Cephei",
modelSize: "50 MB",
},
];
const SpeedRating = ({ rating }: { rating: number }) => {
const fullIcons = Math.floor(rating);
const hasHalf = rating % 1 !== 0;
return (
<div className="flex items-center gap-1">
{Array.from({ length: 5 }, (_, i) => {
if (i < fullIcons) {
return (
<Zap key={i} className="w-4 h-4 fill-yellow-400 text-yellow-400" />
);
} else if (i === fullIcons && hasHalf) {
return (
<div key={i} className="relative w-4 h-4">
<Zap className="w-4 h-4 text-gray-300" />
<div className="absolute inset-0 overflow-hidden w-1/2">
<Zap className="w-4 h-4 fill-yellow-400 text-yellow-400" />
</div>
</div>
);
} else {
return <Zap key={i} className="w-4 h-4 text-gray-300" />;
}
})}
<span className="text-sm text-muted-foreground ml-1">{rating}</span>
</div>
);
};
const AccuracyRating = ({ rating }: { rating: number }) => {
const fullIcons = Math.floor(rating);
const hasHalf = rating % 1 !== 0;
return (
<div className="flex items-center gap-1">
{Array.from({ length: 5 }, (_, i) => {
if (i < fullIcons) {
return (
<Circle key={i} className="w-4 h-4 fill-green-500 text-green-500" />
);
} else if (i === fullIcons && hasHalf) {
return (
<div key={i} className="relative w-4 h-4">
<Circle className="w-4 h-4 text-gray-300" />
<div className="absolute inset-0 overflow-hidden w-1/2">
<Circle className="w-4 h-4 fill-green-500 text-green-500" />
</div>
</div>
);
} else {
return <Circle key={i} className="w-4 h-4 text-gray-300" />;
}
})}
<span className="text-sm text-muted-foreground ml-1">{rating}</span>
</div>
);
};
const CloudBadge = () => {
return (
<Badge className="bg-blue-100 text-blue-800 hover:bg-blue-200 flex items-center gap-1 min-w-[80px] justify-center">
<Cloud className="w-3 h-3" />
Cloud
</Badge>
);
};
const OfflineBadge = () => {
return (
<Badge className="bg-green-100 text-green-800 hover:bg-green-200 flex items-center gap-1 min-w-[80px] justify-center">
<Download className="w-3 h-3" />
Offline
</Badge>
);
};
interface DownloadButtonProps {
modelName: string;
modelSize: string;
}
const DownloadButton = ({ modelName, modelSize }: DownloadButtonProps) => {
const [downloadState, setDownloadState] = useState<
"idle" | "downloading" | "completed"
>("idle");
const [progress, setProgress] = useState(0);
const startDownload = () => {
setDownloadState("downloading");
setProgress(0);
};
const stopDownload = () => {
setDownloadState("idle");
setProgress(0);
};
useEffect(() => {
let interval: NodeJS.Timeout | undefined;
if (downloadState === "downloading") {
interval = setInterval(() => {
setProgress((prev) => {
if (prev >= 100) {
setDownloadState("completed");
return 100;
}
const increment = Math.random() * 15 + 5;
return Math.min(prev + increment, 100); // Clamp to 100
});
}, 200);
}
return () => {
if (interval) {
clearInterval(interval);
}
};
}, [downloadState]);
if (downloadState === "completed") {
return (
<div className="flex flex-col items-center gap-1">
<OfflineBadge />
</div>
);
}
if (downloadState === "downloading") {
return (
<div className="flex flex-col items-center gap-1">
<div className="relative">
<Button
size="sm"
variant="outline"
className="w-10 h-10 rounded-full p-0 relative overflow-hidden bg-transparent"
onClick={stopDownload}
>
<div
className="absolute inset-0 border-2 border-blue-500 rounded-full"
style={{
background: `conic-gradient(#3b82f6 ${progress * 3.6}deg, transparent ${progress * 3.6}deg)`,
mask: "radial-gradient(circle at center, transparent 60%, black 60%)",
WebkitMask:
"radial-gradient(circle at center, transparent 60%, black 60%)",
}}
/>
<X className="w-4 h-4 text-red-500" />
</Button>
</div>
<div className="text-xs text-center text-muted-foreground">
<div>{Math.round(progress)}%</div>
<div className="text-[10px]">{modelSize}</div>
</div>
</div>
);
}
return (
<div className="flex flex-col items-center gap-1">
<Button
size="sm"
variant="outline"
className="w-10 h-10 rounded-full p-0 hover:bg-blue-50 hover:border-blue-300 bg-transparent"
onClick={startDownload}
>
<Download className="w-4 h-4 text-blue-600" />
</Button>
<div className="text-xs text-center text-muted-foreground">
<div className="text-[10px]">{modelSize}</div>
</div>
</div>
);
};
const SetupCell = ({ model }: { model: SpeechModel }) => {
if (model.setup === "cloud") {
return <CloudBadge />;
}
return (
<DownloadButton
modelName={model.name}
modelSize={model.modelSize || "Unknown"}
/>
);
};
export default function Component() {
return (
<div className="w-full max-w-7xl mx-auto p-4">
<Card>
<CardHeader>
<CardTitle className="text-2xl font-bold">
Speech LLM Models Comparison
</CardTitle>
<p className="text-muted-foreground">
Compare features, performance, and setup requirements of popular
speech-to-text models
</p>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<TooltipProvider>
<Table>
<TableHeader>
<TableRow>
<TableHead className="min-w-[200px]">Model</TableHead>
<TableHead className="min-w-[250px]">Features</TableHead>
<TableHead className="min-w-[120px]">Speed</TableHead>
<TableHead className="min-w-[120px]">Accuracy</TableHead>
<TableHead className="min-w-[120px]">Setup</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{models.map((model, index) => (
<TableRow key={index} className="hover:bg-muted/50">
<TableCell>
<div>
<div className="font-semibold">{model.name}</div>
<div className="text-sm text-muted-foreground">
{model.provider}
</div>
</div>
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-2">
{model.features.map((feature, featureIndex) => (
<Tooltip key={featureIndex}>
<TooltipTrigger asChild>
<div className="p-2 rounded-md bg-muted hover:bg-muted/80 cursor-help transition-colors">
{feature.icon}
</div>
</TooltipTrigger>
<TooltipContent>
<p>{feature.tooltip}</p>
</TooltipContent>
</Tooltip>
))}
</div>
</TableCell>
<TableCell>
<SpeedRating rating={model.speed} />
</TableCell>
<TableCell>
<AccuracyRating rating={model.accuracy} />
</TableCell>
<TableCell>
<SetupCell model={model} />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TooltipProvider>
</div>
</CardContent>
</Card>
</div>
);
}

View file

@ -1,107 +0,0 @@
import React, { useState, useEffect } from "react";
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { api } from "@/trpc/react";
import { toast } from "sonner";
export function AdvancedSettings() {
const [preloadWhisperModel, setPreloadWhisperModel] = useState(true);
// tRPC queries and mutations
const settingsQuery = api.settings.getSettings.useQuery();
const utils = api.useUtils();
const updateTranscriptionSettingsMutation =
api.settings.updateTranscriptionSettings.useMutation({
onSuccess: () => {
utils.settings.getSettings.invalidate();
toast.success("Settings updated");
},
onError: (error) => {
console.error("Failed to update transcription settings:", error);
toast.error("Failed to update settings. Please try again.");
},
});
// Load settings when query data is available
useEffect(() => {
if (settingsQuery.data?.transcription) {
setPreloadWhisperModel(
settingsQuery.data.transcription.preloadWhisperModel !== false,
);
}
}, [settingsQuery.data]);
const handlePreloadWhisperModelChange = (checked: boolean) => {
setPreloadWhisperModel(checked);
updateTranscriptionSettingsMutation.mutate({
preloadWhisperModel: checked,
});
};
return (
<Card>
<CardHeader>
<CardTitle>Advanced Settings</CardTitle>
<CardDescription>Advanced configuration options</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div>
<Label htmlFor="preload-whisper">Preload Whisper Model</Label>
<p className="text-sm text-muted-foreground">
Load AI model at startup for faster transcription
</p>
</div>
<Switch
id="preload-whisper"
checked={preloadWhisperModel}
onCheckedChange={handlePreloadWhisperModelChange}
/>
</div>
<div className="flex items-center justify-between">
<div>
<Label htmlFor="debug-mode">Debug Mode</Label>
<p className="text-sm text-muted-foreground">
Enable detailed logging
</p>
</div>
<Switch id="debug-mode" />
</div>
<div className="flex items-center justify-between">
<div>
<Label htmlFor="auto-update">Auto Updates</Label>
<p className="text-sm text-muted-foreground">
Automatically check for updates
</p>
</div>
<Switch id="auto-update" defaultChecked />
</div>
<div className="space-y-2">
<Label htmlFor="data-location">Data Location</Label>
<div className="flex space-x-2">
<input
type="text"
id="data-location"
className="flex-1 border rounded px-3 py-2"
value="~/Documents/Amical"
readOnly
/>
<Button variant="outline">Change</Button>
</div>
</div>
</CardContent>
</Card>
);
}

View file

@ -1,182 +0,0 @@
import React, { useState, useEffect } from "react";
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Input } from "@/components/ui/input";
import { FormatterConfig } from "@/types/formatter";
import { api } from "@/trpc/react";
import { toast } from "sonner";
// OpenRouter models list
const OPENROUTER_MODELS = [
{ value: "google/gemini-2.0-flash-001", label: "Gemini 2.0 Flash" },
{ value: "anthropic/claude-3.5-sonnet", label: "Claude 3.5 Sonnet" },
{ value: "anthropic/claude-3-haiku", label: "Claude 3 Haiku" },
{ value: "openai/gpt-4o", label: "GPT-4o" },
{ value: "openai/gpt-4o-mini", label: "GPT-4o mini" },
{ value: "openai/gpt-4-turbo", label: "GPT-4 Turbo" },
{ value: "meta-llama/llama-3.1-8b-instruct", label: "Llama 3.1 8B" },
{ value: "meta-llama/llama-3.1-70b-instruct", label: "Llama 3.1 70B" },
{ value: "google/gemini-pro-1.5", label: "Gemini Pro 1.5" },
];
export function FormatterSettings() {
const [formatterProvider, setFormatterProvider] =
useState<"openrouter">("openrouter");
const [openrouterModel, setOpenrouterModel] = useState("");
const [openrouterApiKey, setOpenrouterApiKey] = useState("");
const [formatterEnabled, setFormatterEnabled] = useState(false);
// tRPC queries and mutations
const formatterConfigQuery = api.settings.getFormatterConfig.useQuery();
const utils = api.useUtils();
const setFormatterConfigMutation =
api.settings.setFormatterConfig.useMutation({
onSuccess: () => {
toast.success("Configuration saved successfully!");
utils.settings.getFormatterConfig.invalidate();
},
onError: (error) => {
console.error("Failed to save formatter config:", error);
toast.error("Failed to save configuration. Please try again.");
},
});
// Load configuration when query data is available
useEffect(() => {
if (formatterConfigQuery.data) {
const config = formatterConfigQuery.data;
setFormatterProvider(config.provider);
setOpenrouterModel(config.model);
setOpenrouterApiKey(config.apiKey);
setFormatterEnabled(config.enabled);
}
}, [formatterConfigQuery.data]);
const saveFormatterConfig = async () => {
const config: FormatterConfig = {
provider: formatterProvider,
model: openrouterModel,
apiKey: openrouterApiKey,
enabled: formatterEnabled,
};
setFormatterConfigMutation.mutate(config);
};
return (
<Card>
<CardHeader>
<CardTitle>Text Formatting Configuration</CardTitle>
<CardDescription>
Configure AI-powered post-processing of transcriptions
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="formatter-provider">Provider</Label>
<Select
value={formatterProvider}
onValueChange={(value: "openrouter") => setFormatterProvider(value)}
>
<SelectTrigger>
<SelectValue placeholder="Select a provider" />
</SelectTrigger>
<SelectContent>
<SelectItem value="openrouter">OpenRouter</SelectItem>
</SelectContent>
</Select>
</div>
{formatterProvider === "openrouter" && (
<>
<div className="space-y-2">
<Label htmlFor="openrouter-model">Model</Label>
<Select
value={openrouterModel}
onValueChange={setOpenrouterModel}
>
<SelectTrigger>
<SelectValue placeholder="Select a model" />
</SelectTrigger>
<SelectContent>
{OPENROUTER_MODELS.map((model) => (
<SelectItem key={model.value} value={model.value}>
{model.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="openrouter-api-key">API Key</Label>
<Input
id="openrouter-api-key"
type="password"
placeholder="Enter your OpenRouter API key"
value={openrouterApiKey}
onChange={(e) => setOpenrouterApiKey(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
Get your API key from{" "}
<a
href="https://openrouter.ai"
target="_blank"
rel="noopener noreferrer"
className="underline"
>
openrouter.ai
</a>
</p>
</div>
</>
)}
<div className="flex items-center justify-between">
<div>
<Label htmlFor="enable-formatter">Enable Formatter</Label>
<p className="text-sm text-muted-foreground">
Apply AI formatting to transcriptions
</p>
</div>
<Switch
id="enable-formatter"
checked={formatterEnabled}
onCheckedChange={setFormatterEnabled}
/>
</div>
<div className="pt-4">
<Button
onClick={saveFormatterConfig}
disabled={
setFormatterConfigMutation.isPending ||
!openrouterModel ||
!openrouterApiKey
}
>
{setFormatterConfigMutation.isPending
? "Saving..."
: "Save Configuration"}
</Button>
</div>
</CardContent>
</Card>
);
}

View file

@ -1,53 +0,0 @@
import React from "react";
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
} from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { ThemeToggle } from "@/components/theme-toggle";
export function GeneralSettings() {
return (
<Card>
<CardHeader>
<CardTitle>General Settings</CardTitle>
<CardDescription>Configure your general preferences</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div>
<Label htmlFor="launch-login">Launch at Login</Label>
<p className="text-sm text-muted-foreground">
Start Amical when you log in
</p>
</div>
<Switch id="launch-login" />
</div>
<div className="flex items-center justify-between">
<div>
<Label htmlFor="minimize-tray">Minimize to Tray</Label>
<p className="text-sm text-muted-foreground">
Keep running in system tray when closed
</p>
</div>
<Switch id="minimize-tray" />
</div>
<div className="flex items-center justify-between">
<div>
<Label htmlFor="theme-toggle">Theme</Label>
<p className="text-sm text-muted-foreground">
Choose your preferred theme
</p>
</div>
<ThemeToggle />
</div>
</CardContent>
</Card>
);
}

View file

@ -1,115 +0,0 @@
import React from "react";
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
} from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { api } from "@/trpc/react";
import { useAudioDevices } from "@/hooks/useAudioDevices";
import { toast } from "sonner";
import { Mic, MicOff } from "lucide-react";
export function MicrophoneSettings() {
const { data: settings, refetch: refetchSettings } =
api.settings.getSettings.useQuery();
const setPreferredMicrophone =
api.settings.setPreferredMicrophone.useMutation();
const { devices: audioDevices } = useAudioDevices();
const currentMicrophoneName = settings?.recording?.preferredMicrophoneName;
const handleMicrophoneChange = async (deviceName: string) => {
try {
// If "System Default" is selected, store null to follow system default
const actualDeviceName = deviceName.startsWith("System Default")
? null
: deviceName;
await setPreferredMicrophone.mutateAsync({
deviceName: actualDeviceName,
});
// Refetch settings to update UI
await refetchSettings();
toast.success(
actualDeviceName
? `Microphone changed to ${deviceName}`
: "Using system default microphone",
);
} catch (error) {
console.error("Failed to set preferred microphone:", error);
toast.error("Failed to change microphone");
}
};
// Find the current selection value
const currentSelectionValue =
currentMicrophoneName &&
audioDevices.some((device) => device.label === currentMicrophoneName)
? currentMicrophoneName
: audioDevices.find((d) => d.isDefault)?.label || "";
return (
<Card>
<CardHeader>
<CardTitle>Microphone Settings</CardTitle>
<CardDescription>Configure your microphone preferences</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="microphone-select">Microphone</Label>
<Select
value={currentSelectionValue}
onValueChange={handleMicrophoneChange}
>
<SelectTrigger id="microphone-select" className="w-full">
<SelectValue placeholder="Select a microphone">
<div className="flex items-center gap-2">
{audioDevices.length > 0 ? (
<Mic className="h-4 w-4" />
) : (
<MicOff className="h-4 w-4" />
)}
<span>{currentSelectionValue || "Select a microphone"}</span>
</div>
</SelectValue>
</SelectTrigger>
<SelectContent>
{audioDevices.length === 0 ? (
<SelectItem value="no-devices" disabled>
No microphones available
</SelectItem>
) : (
audioDevices.map((device) => (
<SelectItem key={device.deviceId} value={device.label}>
<div className="flex items-center gap-2">
<Mic className="h-4 w-4" />
<span>{device.label}</span>
</div>
</SelectItem>
))
)}
</SelectContent>
</Select>
{audioDevices.length === 0 && (
<p className="text-sm text-muted-foreground">
No microphones detected. Please check your audio devices.
</p>
)}
</div>
</CardContent>
</Card>
);
}

View file

@ -1,43 +0,0 @@
import React from "react";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { GeneralSettings } from "./GeneralSettings";
import { MicrophoneSettings } from "./MicrophoneSettings";
import { ShortcutsSettings } from "./ShortcutsSettings";
import { FormatterSettings } from "./FormatterSettings";
import { AdvancedSettings } from "./AdvancedSettings";
export function SettingsManager() {
return (
<div className="space-y-6">
<Tabs defaultValue="general" className="w-full">
<TabsList>
<TabsTrigger value="general">General</TabsTrigger>
<TabsTrigger value="microphone">Microphone</TabsTrigger>
<TabsTrigger value="shortcuts">Shortcuts</TabsTrigger>
<TabsTrigger value="formatter">Formatter</TabsTrigger>
<TabsTrigger value="advanced">Advanced</TabsTrigger>
</TabsList>
<TabsContent value="general" className="space-y-6">
<GeneralSettings />
</TabsContent>
<TabsContent value="microphone" className="space-y-6">
<MicrophoneSettings />
</TabsContent>
<TabsContent value="shortcuts" className="space-y-6">
<ShortcutsSettings />
</TabsContent>
<TabsContent value="formatter" className="space-y-6">
<FormatterSettings />
</TabsContent>
<TabsContent value="advanced" className="space-y-6">
<AdvancedSettings />
</TabsContent>
</Tabs>
</div>
);
}

View file

@ -1,104 +0,0 @@
import React, { useState, useEffect } from "react";
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
} from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { ShortcutInput } from "@/components/shortcut-input";
import { api } from "@/trpc/react";
import { toast } from "sonner";
export function ShortcutsSettings() {
const [pushToTalkShortcut, setPushToTalkShortcut] = useState("");
const [toggleRecordingShortcut, setToggleRecordingShortcut] = useState("");
const [recordingShortcut, setRecordingShortcut] = useState<
"pushToTalk" | "toggleRecording" | null
>(null);
// tRPC queries and mutations
const shortcutsQuery = api.settings.getShortcuts.useQuery();
const utils = api.useUtils();
const setShortcutMutation = api.settings.setShortcut.useMutation({
onSuccess: () => {
utils.settings.getShortcuts.invalidate();
},
onError: (error) => {
console.error("Failed to save shortcut:", error);
toast.error("Failed to save shortcut. Please try again.");
},
});
// Load shortcuts when query data is available
useEffect(() => {
if (shortcutsQuery.data) {
setPushToTalkShortcut(shortcutsQuery.data.pushToTalk);
setToggleRecordingShortcut(shortcutsQuery.data.toggleRecording);
}
}, [shortcutsQuery.data]);
const handlePushToTalkChange = (shortcut: string) => {
setPushToTalkShortcut(shortcut);
setShortcutMutation.mutate({
type: "pushToTalk",
shortcut: shortcut,
});
toast.success("Push to Talk shortcut updated");
};
const handleToggleRecordingChange = (shortcut: string) => {
setToggleRecordingShortcut(shortcut);
setShortcutMutation.mutate({
type: "toggleRecording",
shortcut: shortcut,
});
toast.success("Toggle Recording shortcut updated");
};
return (
<Card>
<CardHeader>
<CardTitle>Keyboard Shortcuts</CardTitle>
<CardDescription>Customize your keyboard shortcuts</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div>
<Label>Push to Talk</Label>
<p className="text-sm text-muted-foreground">
Hold to dictate while key is pressed
</p>
</div>
<ShortcutInput
value={pushToTalkShortcut}
onChange={handlePushToTalkChange}
isRecordingShortcut={recordingShortcut === "pushToTalk"}
onRecordingShortcutChange={(recording) =>
setRecordingShortcut(recording ? "pushToTalk" : null)
}
/>
</div>
<div className="flex items-center justify-between">
<div>
<Label>Toggle Recording</Label>
<p className="text-sm text-muted-foreground">
Start/stop dictation
</p>
</div>
<ShortcutInput
value={toggleRecordingShortcut}
onChange={handleToggleRecordingChange}
isRecordingShortcut={recordingShortcut === "toggleRecording"}
onRecordingShortcutChange={(recording) =>
setRecordingShortcut(recording ? "toggleRecording" : null)
}
/>
</div>
</CardContent>
</Card>
);
}

View file

@ -0,0 +1,139 @@
import { useState, useEffect } from "react";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { Plus } from "lucide-react";
import { Link } from "@tanstack/react-router";
import { cn } from "@/lib/utils";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { api } from "@/trpc/react";
import { toast } from "sonner";
import DefaultModelCombobox from "@/renderer/main/pages/settings/ai-models/components/default-model-combobox";
export function FormattingSettings() {
const [formattingEnabled, setFormattingEnabled] = useState(false);
// tRPC queries and mutations
const formatterConfigQuery = api.settings.getFormatterConfig.useQuery();
const modelsQuery = api.models.getModels.useQuery({
type: "language",
});
const defaultLanguageModelQuery = api.models.getDefaultModel.useQuery({
type: "language",
});
const utils = api.useUtils();
const setFormatterConfigMutation =
api.settings.setFormatterConfig.useMutation({
onSuccess: () => {
utils.settings.getFormatterConfig.invalidate();
},
onError: (error) => {
console.error("Failed to save formatting settings:", error);
toast.error("Failed to save formatting settings. Please try again.");
},
});
// Load formatter config from database
useEffect(() => {
if (formatterConfigQuery.data) {
const config = formatterConfigQuery.data;
setFormattingEnabled(config.enabled);
}
}, [formatterConfigQuery.data]);
const handleFormattingEnabledChange = (enabled: boolean) => {
setFormattingEnabled(enabled);
// Save with the current default language model
const model = defaultLanguageModelQuery.data || "";
saveFormatterConfig(model, enabled);
};
const saveFormatterConfig = (model: string, enabled: boolean) => {
setFormatterConfigMutation.mutate({
model,
enabled,
});
};
const hasModels = (modelsQuery.data?.length ?? 0) > 0;
return (
<div className="">
<div className="flex items-center justify-between mb-2">
<div>
<Label className="text-base font-semibold text-foreground">
Formatting
</Label>
<p className="text-xs text-muted-foreground mb-2">
Enable formatting and select the AI model for formatting output.
</p>
</div>
<Tooltip delayDuration={100}>
<TooltipTrigger asChild>
<div>
<Switch
checked={formattingEnabled}
onCheckedChange={handleFormattingEnabledChange}
disabled={!hasModels}
/>
</div>
</TooltipTrigger>
{!hasModels && (
<TooltipContent className="max-w-sm text-center">
Please sync AI models first to enable formatting functionality.
</TooltipContent>
)}
</Tooltip>
</div>
{formattingEnabled && (
<div className="mt-6 border-border border rounded-md p-4">
<div className="space-y-4">
<div>
<Label className="text-sm font-medium text-foreground mb-2 block">
Formatting Model
</Label>
<p className="text-xs text-muted-foreground mb-4">
Select the language model to use for formatting transcriptions.
</p>
</div>
{!hasModels ? (
<div className="flex flex-col items-center gap-2">
<span className="text-destructive text-sm">
No models available. Please sync models first.
</span>
<Link to="/settings/ai-models" search={{ tab: "language" }}>
<Button variant="outline" size={"sm"}>
<Plus className="w-4 h-4 mr-1" />
Sync models
</Button>
</Link>
</div>
) : (
<div className="space-y-3">
<DefaultModelCombobox
modelType="language"
title="Default Language Model"
/>
<Link
to="/settings/ai-models"
search={{ tab: "language" }}
className="inline-block"
>
<Button variant="link" className="text-xs px-0">
<Plus className="w-4 h-4" />
Manage language models
</Button>
</Link>
</div>
)}
</div>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,133 @@
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { cn } from "@/lib/utils";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { AVAILABLE_LANGUAGES } from "@/constants/languages";
import { useState, useEffect } from "react";
import { Combobox } from "@/components/ui/combobox";
import { api } from "@/trpc/react";
export function LanguageSettings() {
// Get dictation settings from tRPC
const { data: dictationSettings, isLoading } =
api.settings.getDictationSettings.useQuery();
// Mutation for updating settings
const updateDictationSettings = api.settings.setDictationSettings.useMutation(
{
onSuccess: () => {
// Refetch to ensure UI is in sync
utils.settings.getDictationSettings.invalidate();
},
},
);
const utils = api.useUtils();
// Local state for immediate UI updates
const [selectedLanguage, setSelectedLanguage] = useState<string>("en");
const [autoDetect, setAutoDetect] = useState(true);
// Sync local state with server data
useEffect(() => {
if (dictationSettings) {
setSelectedLanguage(dictationSettings.selectedLanguage);
setAutoDetect(dictationSettings.autoDetectEnabled);
}
}, [dictationSettings]);
// Handle auto-detect toggle
const handleAutoDetectChange = async (enabled: boolean) => {
setAutoDetect(enabled);
const newSettings = {
autoDetectEnabled: enabled,
selectedLanguage: enabled ? selectedLanguage : selectedLanguage || "en",
};
try {
await updateDictationSettings.mutateAsync(newSettings);
} catch (error) {
// Revert local state on error
setAutoDetect(!enabled);
console.error("Failed to update auto-detect setting:", error);
}
};
// Handle language selection
const handleLanguageChange = async (language: string) => {
setSelectedLanguage(language);
const newSettings = {
autoDetectEnabled: autoDetect,
selectedLanguage: language,
};
try {
await updateDictationSettings.mutateAsync(newSettings);
} catch (error) {
// Revert local state on error
setSelectedLanguage(dictationSettings?.selectedLanguage || "en");
console.error("Failed to update language setting:", error);
}
};
return (
<div className="">
<div className="flex items-center justify-between mb-2">
<div>
<Label className="text-base font-semibold text-foreground">
Auto detect language
</Label>
<p className="text-xs text-muted-foreground mb-2">
Automatically detect spoken language. Turn off to select specific
languages.
</p>
</div>
<Switch
checked={autoDetect}
onCheckedChange={handleAutoDetectChange}
disabled={isLoading || updateDictationSettings.isPending}
/>
</div>
<div className="flex justify-between items-start mt-6 border-border border rounded-md p-4">
<div
className={cn(
"flex items-start gap-2 flex-col",
autoDetect && "opacity-50 pointer-events-none",
)}
>
<Label className="text-sm font-medium text-foreground">
Languages
</Label>
</div>
<Tooltip delayDuration={100}>
<TooltipTrigger asChild>
<div>
<Combobox
options={AVAILABLE_LANGUAGES.filter((l) => l.value !== "auto")}
value={selectedLanguage}
onChange={handleLanguageChange}
placeholder="Select languages..."
disabled={
autoDetect || isLoading || updateDictationSettings.isPending
}
/>
</div>
</TooltipTrigger>
{autoDetect && (
<TooltipContent className="max-w-sm text-center">
Disable auto detection to select languages. Selecting specific
languages may increase accuracy.
</TooltipContent>
)}
</Tooltip>
</div>
</div>
);
}

View file

@ -0,0 +1,98 @@
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { api } from "@/trpc/react";
import { useAudioDevices } from "@/hooks/useAudioDevices";
import { toast } from "sonner";
import { Mic } from "lucide-react";
export function MicrophoneSettings() {
const { data: settings, refetch: refetchSettings } =
api.settings.getSettings.useQuery();
const setPreferredMicrophone =
api.settings.setPreferredMicrophone.useMutation();
const { devices: audioDevices } = useAudioDevices();
const currentMicrophoneName = settings?.recording?.preferredMicrophoneName;
const handleMicrophoneChange = async (deviceName: string) => {
try {
// If "System Default" is selected, store null to follow system default
const actualDeviceName = deviceName.startsWith("System Default")
? null
: deviceName;
await setPreferredMicrophone.mutateAsync({
deviceName: actualDeviceName,
});
// Refetch settings to update UI
await refetchSettings();
toast.success(
actualDeviceName
? `Microphone changed to ${deviceName}`
: "Using system default microphone",
);
} catch (error) {
console.error("Failed to set preferred microphone:", error);
toast.error("Failed to change microphone");
}
};
// Find the current selection value
const currentSelectionValue =
currentMicrophoneName &&
audioDevices.some((device) => device.label === currentMicrophoneName)
? currentMicrophoneName
: audioDevices.find((d) => d.isDefault)?.label || "";
return (
<div className="flex items-center justify-between">
<div>
<Label className="text-base font-semibold text-foreground">
Microphone
</Label>
<p className="text-xs text-muted-foreground mb-2">
Choose your preferred microphone for dictation.
</p>
</div>
<div className="min-w-[200px]">
<Select
value={currentSelectionValue}
onValueChange={handleMicrophoneChange}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select a microphone" />
</SelectTrigger>
<SelectContent>
{audioDevices.length === 0 ? (
<SelectItem value="no-devices" disabled>
No microphones available
</SelectItem>
) : (
audioDevices.map((device) => (
<SelectItem key={device.deviceId} value={device.label}>
<div className="flex items-center gap-2">
<Mic className="h-4 w-4" />
<span>{device.label}</span>
</div>
</SelectItem>
))
)}
</SelectContent>
</Select>
{audioDevices.length === 0 && (
<p className="text-sm text-muted-foreground mt-1">
No microphones detected. Please check your audio devices.
</p>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,3 @@
export { LanguageSettings } from "./LanguageSettings";
export { MicrophoneSettings } from "./MicrophoneSettings";
export { FormattingSettings } from "./FormattingSettings";

View file

@ -0,0 +1,36 @@
import { useState } from "react";
import { Card, CardContent } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import {
LanguageSettings,
MicrophoneSettings,
FormattingSettings,
} from "./components";
export default function DictationSettingsPage() {
return (
<div className="container mx-auto p-6 max-w-5xl">
<div className="mb-8">
<h1 className="text-xl font-bold">Dictation</h1>
<p className="text-muted-foreground mt-1 text-sm">
Configure dictation, language, microphone, and AI model settings
</p>
</div>
<Card>
<CardContent className="space-y-4">
<LanguageSettings />
<Separator />
<MicrophoneSettings />
<Separator />
{/* <SpeechToTextSettings
speechModels={speechModels}
speechModel={speechModel}
onSpeechModelChange={setSpeechModel}
/>
<Separator /> */}
<FormattingSettings />
</CardContent>
</Card>
</div>
);
}

View file

@ -0,0 +1,505 @@
import { useState } from "react";
import type { Transcription } from "@/db/schema";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Table, TableBody, TableRow, TableCell } from "@/components/ui/table";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Copy,
Play,
Pause,
Download,
Trash2,
MicOff,
Search,
} from "lucide-react";
import { Input } from "@/components/ui/input";
import { toast } from "sonner";
import { api } from "@/trpc/react";
import { useAudioPlayer } from "@/hooks/useAudioPlayer";
import { format } from "date-fns";
// Helper to get formatted title
function getTitle(text: string) {
if (!text || text.trim() === "") {
return `no words detected`;
}
return text;
}
function formatDate(timestamp: Date) {
return format(timestamp, "MMM d, h:mm a");
}
function getDateGroup(timestamp: Date) {
const today = new Date();
const itemDate = new Date(timestamp);
// Reset time to compare only dates
const todayDate = new Date(
today.getFullYear(),
today.getMonth(),
today.getDate(),
);
const itemDateOnly = new Date(
itemDate.getFullYear(),
itemDate.getMonth(),
itemDate.getDate(),
);
const diffTime = todayDate.getTime() - itemDateOnly.getTime();
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
if (diffDays === 0) return "today";
if (diffDays === 1) return "yesterday";
return "earlier";
}
function groupHistoryByDate(history: Transcription[]) {
const grouped = {
today: [] as Transcription[],
yesterday: [] as Transcription[],
earlier: [] as Transcription[],
};
history.forEach((item) => {
const group = getDateGroup(item.timestamp);
grouped[group as keyof typeof grouped].push(item);
});
return grouped;
}
interface HistoryTableCardProps {
items: Transcription[];
onCopy: (text: string) => void;
onPlay: (transcriptionId: number) => void;
onDownload: (transcriptionId: number) => void;
onDelete: (id: number) => void;
hovered: number | null;
setHovered: (id: number | null) => void;
currentPlayingId: number | null;
isPlaying: boolean;
}
function HistoryTableCard({
items,
onCopy,
onPlay,
onDownload,
onDelete,
setHovered,
currentPlayingId,
isPlaying,
}: HistoryTableCardProps) {
const [selectedText, setSelectedText] = useState<string | null>(null);
const [isDialogOpen, setIsDialogOpen] = useState(false);
const handleReadMore = (text: string) => {
setSelectedText(text);
setIsDialogOpen(true);
};
return (
<>
<Card className="p-0">
<CardContent className="p-0">
<Table>
<TableBody>
{items.map((item) => (
<TableRow
key={item.id}
onMouseEnter={() => setHovered(item.id)}
onMouseLeave={() => setHovered(null)}
className="group hover:bg-muted/40 transition px-4"
>
<TableCell className="align-top text-xs text-muted-foreground pt-4.5 px-4">
{formatDate(item.timestamp)}
</TableCell>
<TableCell className="align-top py-4 px-4">
<div className="text-foreground max-w-[500px]">
<div
className={`line-clamp-3 whitespace-pre-line ${!item.text.trim() ? "font-mono text-muted-foreground" : ""}`}
>
{getTitle(item.text)}
</div>
{item.text.split("\n").length > 3 ||
item.text.length > 200 ? (
<Button
variant="link"
size="sm"
className="p-0 h-auto text-xs text-muted-foreground hover:text-foreground mt-1"
onClick={() => handleReadMore(item.text)}
>
Read more
</Button>
) : null}
</div>
</TableCell>
<TableCell className="w-32 align-top text-right">
<div className="flex gap-2 justify-end opacity-0 group-hover:opacity-100 transition-opacity">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="ghost"
onClick={() => onCopy(item.text)}
>
<Copy className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Copy</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
{item.audioFile && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="ghost"
onClick={() => onPlay(item.id)}
>
{currentPlayingId === item.id && isPlaying ? (
<Pause className="w-4 h-4" />
) : (
<Play className="w-4 h-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>
{currentPlayingId === item.id && isPlaying
? "Pause audio"
: "Play audio"}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{item.audioFile && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="ghost"
onClick={() => onDownload(item.id)}
>
<Download className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Download Audio</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="ghost"
onClick={() => onDelete(item.id)}
>
<Trash2 className="w-4 h-4 text-destructive" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Delete</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto bg-secondary">
<DialogHeader>
<DialogTitle>Transcription Details</DialogTitle>
</DialogHeader>
<div className="whitespace-pre-line text-sm leading-relaxed">
{selectedText}
</div>
</DialogContent>
</Dialog>
</>
);
}
export default function HistorySettingsPage() {
const [searchTerm, setSearchTerm] = useState("");
const [hovered, setHovered] = useState<number | null>(null);
const audioPlayer = useAudioPlayer();
// tRPC React Query hooks
const transcriptionsQuery = api.transcriptions.getTranscriptions.useQuery(
{
limit: 100, // Get more records for history view
offset: 0,
sortBy: "timestamp",
sortOrder: "desc",
search: searchTerm || undefined,
},
{
refetchInterval: 5000, // Poll every 5 seconds for updates
},
);
const utils = api.useUtils();
const deleteTranscriptionMutation =
api.transcriptions.deleteTranscription.useMutation({
onSuccess: () => {
// Invalidate and refetch transcriptions data
utils.transcriptions.getTranscriptions.invalidate();
toast.success("Transcription deleted");
},
onError: (error) => {
console.error("Error deleting transcription:", error);
toast.error("Failed to delete transcription");
},
});
const downloadAudioMutation =
api.transcriptions.downloadAudioFile.useMutation({
onSuccess: () => {
toast.success("Audio file downloaded");
},
onError: (error) => {
console.error("Error downloading audio:", error);
toast.error("Failed to download audio file");
},
});
// Using mutation for fetching audio data instead of query to:
// - Prevent caching of large binary audio files in memory
// - Avoid automatic refetching behaviors (window focus, network reconnect)
// - Clearly indicate this is a user-triggered action (play button click)
// - Track loading state per transcription ID efficiently
const getAudioFileMutation = api.transcriptions.getAudioFile.useMutation({
onSuccess: (data, variables) => {
if (data?.data) {
// Decode base64 to ArrayBuffer
const base64 = data.data;
const binaryString = atob(base64);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
// Pass the MIME type from the server response
audioPlayer.toggle(
bytes.buffer,
variables.transcriptionId,
data.mimeType,
);
}
},
onError: (error) => {
console.error("Error fetching audio file:", error);
toast.error("Failed to load audio file");
},
});
const transcriptions = transcriptionsQuery.data || [];
const loading = transcriptionsQuery.isLoading;
function handleCopy(text: string) {
navigator.clipboard.writeText(text);
toast.success("Copied to clipboard");
}
const handlePlayAudio = (transcriptionId: number) => {
if (
audioPlayer.currentPlayingId === transcriptionId &&
audioPlayer.isPlaying
) {
audioPlayer.stop();
} else {
getAudioFileMutation.mutate({ transcriptionId });
}
};
function handleDownload(transcriptionId: number) {
downloadAudioMutation.mutate({ transcriptionId });
}
function handleDelete(id: number) {
deleteTranscriptionMutation.mutate({ id });
}
const groupedHistory = groupHistoryByDate(transcriptions);
// Loading state
if (loading) {
return (
<div className="container mx-auto p-6 max-w-5xl">
<div className="mb-8">
<h1 className="text-xl font-bold">History</h1>
<p className="text-muted-foreground mt-1 text-sm">
Your recent transcription history
</p>
</div>
<Card>
<CardContent className="py-12">
<div className="flex flex-col items-center space-y-2 text-center">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-gray-300 border-t-blue-600"></div>
<p className="text-sm text-muted-foreground">
Loading transcription history...
</p>
</div>
</CardContent>
</Card>
</div>
);
}
return (
<div className="container mx-auto p-6 max-w-5xl">
{/* Header Section */}
<div className="mb-8">
<h1 className="text-xl font-bold">History</h1>
<p className="text-muted-foreground mt-1 text-sm">
Your recent transcription history
</p>
</div>
<div className="space-y-6">
{/* Search Bar */}
<div className="flex items-center space-x-4">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground h-4 w-4" />
<Input
placeholder="Search transcriptions..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10"
/>
</div>
</div>
{transcriptions.length === 0 ? (
<Card className="p-0">
<CardContent className="p-0">
<div className="flex flex-col items-center justify-center py-16 text-center text-muted-foreground gap-2">
<MicOff className="w-10 h-10 mb-2" />
<div className="text-base font-semibold">
{searchTerm
? "No transcriptions found"
: "No transcription history yet"}
</div>
<div className="text-xs">
{searchTerm
? "Try adjusting your search terms."
: "Your recent transcriptions will appear here."}
</div>
</div>
</CardContent>
</Card>
) : (
<>
{/* Today's Entries */}
{groupedHistory.today.length > 0 && (
<>
<div className="text-sm font-medium text-muted-foreground">
Today
</div>
<HistoryTableCard
items={groupedHistory.today}
onCopy={handleCopy}
onPlay={handlePlayAudio}
onDownload={handleDownload}
onDelete={handleDelete}
hovered={hovered}
setHovered={setHovered}
currentPlayingId={audioPlayer.currentPlayingId}
isPlaying={audioPlayer.isPlaying}
/>
</>
)}
{/* Yesterday's Entries */}
{groupedHistory.yesterday.length > 0 && (
<>
<div className="text-sm font-medium text-muted-foreground">
Yesterday
</div>
<HistoryTableCard
items={groupedHistory.yesterday}
onCopy={handleCopy}
onPlay={handlePlayAudio}
onDownload={handleDownload}
onDelete={handleDelete}
hovered={hovered}
setHovered={setHovered}
currentPlayingId={audioPlayer.currentPlayingId}
isPlaying={audioPlayer.isPlaying}
/>
</>
)}
{/* Earlier Entries */}
{groupedHistory.earlier.length > 0 && (
<>
<div className="text-sm font-medium text-muted-foreground">
Earlier
</div>
<HistoryTableCard
items={groupedHistory.earlier}
onCopy={handleCopy}
onPlay={handlePlayAudio}
onDownload={handleDownload}
onDelete={handleDelete}
hovered={hovered}
setHovered={setHovered}
currentPlayingId={audioPlayer.currentPlayingId}
isPlaying={audioPlayer.isPlaying}
/>
</>
)}
{/* Show message when no entries in any group after filtering */}
{groupedHistory.today.length === 0 &&
groupedHistory.yesterday.length === 0 &&
groupedHistory.earlier.length === 0 && (
<Card className="p-0">
<CardContent className="p-0">
<div className="flex flex-col items-center justify-center py-16 text-center text-muted-foreground gap-2">
<MicOff className="w-10 h-10 mb-2" />
<div className="text-lg font-semibold">
No transcriptions found
</div>
<div className="text-sm">
Try adjusting your search terms.
</div>
</div>
</CardContent>
</Card>
)}
</>
)}
</div>
</div>
);
}

View file

@ -1,5 +0,0 @@
import { SettingsManager } from "./components/SettingsManager";
export function SettingsPage() {
return <SettingsManager />;
}

View file

@ -0,0 +1,80 @@
import { useState } from "react";
import { Card, CardContent } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Separator } from "@/components/ui/separator";
import { ThemeToggle } from "@/components/theme-toggle";
export default function PreferencesSettingsPage() {
const [launchAtLogin, setLaunchAtLogin] = useState(false);
const [minimizeToTray, setMinimizeToTray] = useState(true);
return (
<div className="container mx-auto p-6 max-w-5xl">
{/* Header Section */}
<div className="mb-8">
<h1 className="text-xl font-bold">Preferences</h1>
<p className="text-muted-foreground mt-1 text-sm">
Customize your application behavior and appearance
</p>
</div>
<div className="space-y-6">
<Card>
<CardContent className="space-y-4">
{/* Launch at Login Section */}
<div className="flex items-center justify-between">
<div className="space-y-1">
<Label className="text-base font-medium text-foreground">
Launch at login
</Label>
<p className="text-xs text-muted-foreground">
Automatically start the application when you log in
</p>
</div>
<Switch
checked={launchAtLogin}
onCheckedChange={setLaunchAtLogin}
/>
</div>
<Separator />
{/* Minimize to Tray Section */}
<div className="flex items-center justify-between">
<div className="space-y-1">
<Label className="text-base font-medium text-foreground">
Minimize to tray
</Label>
<p className="text-xs text-muted-foreground">
Keep the application running in the system tray when minimized
</p>
</div>
<Switch
checked={minimizeToTray}
onCheckedChange={setMinimizeToTray}
/>
</div>
<Separator />
{/* Theme Section */}
<div className="flex items-center justify-between">
<div className="space-y-1">
<Label className="text-base font-medium text-foreground">
Theme
</Label>
<p className="text-xs text-muted-foreground">
Choose your preferred color scheme
</p>
</div>
<ThemeToggle />
</div>
</CardContent>
</Card>
{/* add future preferences here in a card */}
</div>
</div>
);
}

View file

@ -0,0 +1,122 @@
import React, { useState, useEffect } from "react";
import { Card, CardContent } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { ShortcutInput } from "@/components/shortcut-input";
import { Separator } from "@/components/ui/separator";
import { api } from "@/trpc/react";
import { toast } from "sonner";
export function ShortcutsSettingsPage() {
const [pushToTalkShortcut, setPushToTalkShortcut] = useState("");
const [toggleRecordingShortcut, setToggleRecordingShortcut] = useState("");
const [recordingShortcut, setRecordingShortcut] = useState<
"pushToTalk" | "toggleRecording" | null
>(null);
// tRPC queries and mutations
const shortcutsQuery = api.settings.getShortcuts.useQuery();
const utils = api.useUtils();
const setShortcutMutation = api.settings.setShortcut.useMutation({
onSuccess: () => {
utils.settings.getShortcuts.invalidate();
},
onError: (error) => {
console.error("Failed to save shortcut:", error);
toast.error("Failed to save shortcut. Please try again.");
},
});
// Load shortcuts when query data is available
useEffect(() => {
if (shortcutsQuery.data) {
setPushToTalkShortcut(shortcutsQuery.data.pushToTalk);
setToggleRecordingShortcut(shortcutsQuery.data.toggleRecording);
}
}, [shortcutsQuery.data]);
const handlePushToTalkChange = (shortcut: string) => {
setPushToTalkShortcut(shortcut);
setShortcutMutation.mutate({
type: "pushToTalk",
shortcut: shortcut,
});
toast.success("Push to Talk shortcut updated");
};
const handleToggleRecordingChange = (shortcut: string) => {
setToggleRecordingShortcut(shortcut);
setShortcutMutation.mutate({
type: "toggleRecording",
shortcut: shortcut,
});
toast.success("Toggle Recording shortcut updated");
};
return (
<div className="container mx-auto p-6 max-w-5xl">
<div className="mb-8">
<h1 className="text-xl font-bold">Shortcuts</h1>
<p className="text-muted-foreground mt-1 text-sm">
Configure keyboard shortcuts for dictation and hands-free modes
</p>
</div>
<div className="space-y-6">
<Card>
<CardContent className="space-y-8">
<div>
<div className="flex flex-col md:flex-row md:justify-between gap-4">
<div>
<Label className="text-base font-semibold text-foreground">
Push to talk
</Label>
<p className="text-xs text-muted-foreground mt-1 max-w-md">
Hold to dictate while key is pressed
</p>
</div>
<div className="flex flex-col gap-2 items-end min-w-[260px]">
<ShortcutInput
value={pushToTalkShortcut}
onChange={handlePushToTalkChange}
isRecordingShortcut={recordingShortcut === "pushToTalk"}
onRecordingShortcutChange={(recording) =>
setRecordingShortcut(recording ? "pushToTalk" : null)
}
/>
</div>
</div>
<Separator className="my-4" />
</div>
<div>
<div className="flex flex-col md:flex-row md:justify-between gap-4">
<div>
<Label className="text-base font-semibold text-foreground">
Hands-free mode
</Label>
<p className="text-xs text-muted-foreground mt-1 max-w-md">
Start/stop dictation by pressing once to start and pressing
again to stop
</p>
</div>
<div className="flex flex-col gap-2 items-end min-w-[260px]">
<ShortcutInput
value={toggleRecordingShortcut}
onChange={handleToggleRecordingChange}
isRecordingShortcut={
recordingShortcut === "toggleRecording"
}
onRecordingShortcutChange={(recording) =>
setRecordingShortcut(recording ? "toggleRecording" : null)
}
/>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
</div>
);
}

View file

@ -0,0 +1,429 @@
import { useState } from "react";
import { Plus, Edit, Trash2, Info, MoveRight } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { api } from "@/trpc/react";
import { toast } from "sonner";
type VocabularyItem = {
id: number;
word: string;
replacementWord?: string | null;
isReplacement: boolean | null;
dateAdded: Date;
usageCount: number | null;
createdAt: Date;
updatedAt: Date;
};
// Add/Edit Dialog Component
interface VocabularyDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
mode: "add" | "edit";
formData: {
word: string;
replacementWord: string;
isReplacement: boolean;
};
onFormDataChange: (data: {
word: string;
replacementWord: string;
isReplacement: boolean;
}) => void;
onSubmit: () => void;
isLoading?: boolean;
}
function VocabularyDialog({
open,
onOpenChange,
mode,
formData,
onFormDataChange,
onSubmit,
isLoading = false,
}: VocabularyDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{mode === "add" ? "Add to vocabulary" : "Edit vocabulary"}
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Label htmlFor="replacement-toggle">Make it a replacement</Label>
<Info className="w-4 h-4 text-muted-foreground" />
</div>
<Switch
id="replacement-toggle"
checked={formData.isReplacement}
onCheckedChange={(checked) =>
onFormDataChange({ ...formData, isReplacement: checked })
}
/>
</div>
{formData.isReplacement ? (
<div className="space-y-3">
<div className="flex items-center gap-2">
<Input
placeholder="Misspelling"
value={formData.word}
onChange={(e) =>
onFormDataChange({ ...formData, word: e.target.value })
}
/>
<span className="text-muted-foreground"></span>
<Input
placeholder="Correct spelling"
value={formData.replacementWord}
onChange={(e) =>
onFormDataChange({
...formData,
replacementWord: e.target.value,
})
}
/>
</div>
</div>
) : (
<Input
placeholder="Add a new word"
value={formData.word}
onChange={(e) =>
onFormDataChange({ ...formData, word: e.target.value })
}
/>
)}
<DialogFooter className="flex justify-end gap-2 pt-4">
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
onClick={onSubmit}
disabled={
!formData.word.trim() ||
(formData.isReplacement && !formData.replacementWord.trim()) ||
isLoading
}
>
{isLoading
? "Saving..."
: mode === "add"
? "Add word"
: "Save changes"}
</Button>
</DialogFooter>
</div>
</DialogContent>
</Dialog>
);
}
// Delete Confirmation Dialog Component
interface DeleteDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
deletingItem: VocabularyItem | null;
onConfirm: () => void;
isLoading?: boolean;
}
function DeleteDialog({
open,
onOpenChange,
deletingItem,
onConfirm,
isLoading = false,
}: DeleteDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete vocabulary item</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
Are you sure you want to delete "
{deletingItem?.isReplacement
? `${deletingItem?.word}${deletingItem?.replacementWord}`
: deletingItem?.word}
"? This action cannot be undone.
</p>
</div>
<DialogFooter className="flex justify-end gap-2 pt-4">
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
variant="destructive"
onClick={onConfirm}
disabled={isLoading}
>
{isLoading ? "Deleting..." : "Delete"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export default function VocabularySettingsPage() {
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [editingItem, setEditingItem] = useState<VocabularyItem | null>(null);
const [deletingItem, setDeletingItem] = useState<VocabularyItem | null>(null);
const [formData, setFormData] = useState({
word: "",
replacementWord: "",
isReplacement: false,
});
const vocabularyQuery = api.vocabulary.getVocabulary.useQuery({
limit: 100,
offset: 0,
sortBy: "dateAdded",
sortOrder: "desc",
});
const vocabularyItems = vocabularyQuery.data || [];
const vocabularyLoading = vocabularyQuery.isLoading;
// tRPC mutations
const utils = api.useUtils();
const createVocabularyMutation =
api.vocabulary.createVocabularyWord.useMutation({
onSuccess: () => {
utils.vocabulary.getVocabulary.invalidate();
toast.success("Word added to vocabulary");
},
onError: (error) => {
toast.error(`Failed to add word: ${error.message}`);
},
});
const updateVocabularyMutation = api.vocabulary.updateVocabulary.useMutation({
onSuccess: () => {
utils.vocabulary.getVocabulary.invalidate();
toast.success("Vocabulary updated");
},
onError: (error) => {
toast.error(`Failed to update word: ${error.message}`);
},
});
const deleteVocabularyMutation = api.vocabulary.deleteVocabulary.useMutation({
onSuccess: () => {
utils.vocabulary.getVocabulary.invalidate();
toast.success("Word deleted from vocabulary");
},
onError: (error) => {
toast.error(`Failed to delete word: ${error.message}`);
},
});
const handleAddWord = async () => {
try {
await createVocabularyMutation.mutateAsync({
word: formData.word,
isReplacement: formData.isReplacement,
replacementWord: formData.isReplacement
? formData.replacementWord
: undefined,
});
setFormData({ word: "", replacementWord: "", isReplacement: false });
setIsAddDialogOpen(false);
} catch {
// Error is handled by the mutation's onError callback
// Keep dialog open so user can retry
}
};
const handleEditWord = async () => {
if (!editingItem) return;
try {
await updateVocabularyMutation.mutateAsync({
id: editingItem.id,
data: {
word: formData.word,
isReplacement: formData.isReplacement,
replacementWord: formData.isReplacement
? formData.replacementWord
: undefined,
},
});
setFormData({ word: "", replacementWord: "", isReplacement: false });
setEditingItem(null);
setIsEditDialogOpen(false);
} catch {
// Error is handled by the mutation's onError callback
// Keep dialog open so user can retry
}
};
const handleDeleteWord = async () => {
if (!deletingItem) return;
try {
await deleteVocabularyMutation.mutateAsync({
id: deletingItem.id,
});
setDeletingItem(null);
setIsDeleteDialogOpen(false);
} catch {
// Error is handled by the mutation's onError callback
// Keep dialog open so user can retry
}
};
const openEditDialog = (item: VocabularyItem) => {
setEditingItem(item);
setFormData({
word: item.word,
replacementWord: item.replacementWord || "",
isReplacement: item.isReplacement || false,
});
setIsEditDialogOpen(true);
};
const openDeleteDialog = (item: VocabularyItem) => {
setDeletingItem(item);
setIsDeleteDialogOpen(true);
};
const resetForm = () => {
setFormData({ word: "", replacementWord: "", isReplacement: false });
setEditingItem(null);
};
return (
<div className="container mx-auto p-6 max-w-5xl">
{/* Header Section */}
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="text-xl font-bold">Vocabulary</h1>
<p className="text-muted-foreground mt-1 text-sm">
Manage your custom vocabulary and word replacements for dictation.
</p>
</div>
<Dialog open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen}>
<DialogTrigger asChild>
<Button
onClick={() => resetForm()}
className="flex items-center gap-2"
>
<Plus className="w-4 h-4" />
Add Word
</Button>
</DialogTrigger>
</Dialog>
</div>
{/* Vocabulary List */}
<Card className="p-0 overflow-clip">
<CardContent className="p-0">
{vocabularyLoading ? (
<div className="p-8 text-center text-muted-foreground">
Loading vocabulary...
</div>
) : vocabularyItems.length === 0 ? (
<div className="p-8 text-center text-muted-foreground">
No vocabulary words found. Add your first word to get started.
</div>
) : (
<div className="space-y-0">
{vocabularyItems.map((item, index) => (
<div
className="hover:bg-muted/50 transition-colors"
key={item.id}
>
<div className="flex items-center justify-between py-3 px-4 group">
<span className="text-sm flex items-center gap-1">
{item.isReplacement ? (
<>
<span>{item.word}</span>
<MoveRight className="w-4 h-4 mx-2" />
<span>{item.replacementWord}</span>
</>
) : (
item.word
)}
</span>
<div className="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<Button
variant="ghost"
size="sm"
onClick={() => openEditDialog(item)}
>
<Edit className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => openDeleteDialog(item)}
>
<Trash2 className="w-4 h-4 text-destructive" />
</Button>
</div>
</div>
{index < vocabularyItems.length - 1 && (
<div className="border-t border-border" />
)}
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* Dialog Components */}
<VocabularyDialog
open={isAddDialogOpen}
onOpenChange={setIsAddDialogOpen}
mode="add"
formData={formData}
onFormDataChange={setFormData}
onSubmit={handleAddWord}
isLoading={createVocabularyMutation.isPending}
/>
<VocabularyDialog
open={isEditDialogOpen}
onOpenChange={setIsEditDialogOpen}
mode="edit"
formData={formData}
onFormDataChange={setFormData}
onSubmit={handleEditWord}
isLoading={updateVocabularyMutation.isPending}
/>
<DeleteDialog
open={isDeleteDialogOpen}
onOpenChange={setIsDeleteDialogOpen}
deletingItem={deletingItem}
onConfirm={handleDeleteWord}
isLoading={deleteVocabularyMutation.isPending}
/>
</div>
);
}

View file

@ -1,376 +0,0 @@
import React, { useState } from "react";
import type { Transcription } from "@/db/schema";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { api } from "@/trpc/react";
import { useAudioPlayer } from "@/hooks/useAudioPlayer";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import {
Copy,
Play,
Pause,
Trash2,
FileText,
Search,
MoreHorizontal,
FileAudio,
} from "lucide-react";
import { format } from "date-fns";
import { Input } from "@/components/ui/input";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
export const TranscriptionsList: React.FC = () => {
const [searchTerm, setSearchTerm] = useState("");
const [openDropdownId, setOpenDropdownId] = useState<number | null>(null);
const audioPlayer = useAudioPlayer();
// Get shortcuts data
const shortcutsQuery = api.settings.getShortcuts.useQuery();
const pushToTalkShortcut = shortcutsQuery.data?.pushToTalk || "";
// tRPC React Query hooks
const transcriptionsQuery = api.transcriptions.getTranscriptions.useQuery(
{
limit: 50,
offset: 0,
sortBy: "timestamp",
sortOrder: "desc",
search: searchTerm || undefined,
},
{
refetchInterval: 2000, // Poll every 2 seconds, auto-pauses when out of focus
},
);
const transcriptionsCountQuery =
api.transcriptions.getTranscriptionsCount.useQuery(
{
search: searchTerm || undefined,
},
{
refetchInterval: 2000, // Poll every 2 seconds, auto-pauses when out of focus
},
);
const utils = api.useUtils();
const deleteTranscriptionMutation =
api.transcriptions.deleteTranscription.useMutation({
onSuccess: () => {
// Invalidate and refetch transcriptions data
utils.transcriptions.getTranscriptions.invalidate();
utils.transcriptions.getTranscriptionsCount.invalidate();
},
onError: (error) => {
console.error("Error deleting transcription:", error);
},
});
const downloadAudioMutation =
api.transcriptions.downloadAudioFile.useMutation({
onError: (error) => {
console.error("Error downloading audio:", error);
},
});
// Using mutation for fetching audio data instead of query to:
// - Prevent caching of large binary audio files in memory
// - Avoid automatic refetching behaviors (window focus, network reconnect)
// - Clearly indicate this is a user-triggered action (play button click)
// - Track loading state per transcription ID efficiently
const getAudioFileMutation = api.transcriptions.getAudioFile.useMutation({
onSuccess: (data, variables) => {
if (data?.data) {
// Decode base64 to ArrayBuffer
const base64 = data.data;
const binaryString = atob(base64);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
// Pass the MIME type from the server response
audioPlayer.toggle(
bytes.buffer,
variables.transcriptionId,
data.mimeType,
);
}
},
onError: (error) => {
console.error("Error fetching audio file:", error);
},
});
const transcriptions = transcriptionsQuery.data || [];
const totalCount = transcriptionsCountQuery.data || 0;
const loading =
transcriptionsQuery.isLoading || transcriptionsCountQuery.isLoading;
const copyToClipboard = async (text: string) => {
try {
await navigator.clipboard.writeText(text);
console.log("Copied to clipboard");
} catch (err) {
console.error("Failed to copy text: ", err);
}
};
const handleDelete = async (id: number) => {
deleteTranscriptionMutation.mutate({ id });
};
const handlePlayAudio = (transcriptionId: number) => {
if (
audioPlayer.currentPlayingId === transcriptionId &&
audioPlayer.isPlaying
) {
audioPlayer.stop();
} else {
getAudioFileMutation.mutate({ transcriptionId });
}
};
const handleDownloadAudio = async (transcriptionId: number) => {
console.log("Downloading audio:", transcriptionId);
// Close dropdown first
setOpenDropdownId(null);
// Small delay to ensure dropdown closes before system dialog opens
setTimeout(async () => {
try {
await downloadAudioMutation.mutateAsync({ transcriptionId });
} catch (error) {
console.error("Failed to download audio:", error);
}
}, 0);
};
const getTitle = (text: string) => {
if (!text || text.trim() === "") {
return `no words detected`;
}
return text;
};
const getWordCount = (text: string) => {
const trimmedText = text.trim();
if (!trimmedText) return 0;
return trimmedText.split(/\s+/).length;
};
const renderLoadingState = () => (
<Card>
<CardContent className="py-12">
<div className="flex flex-col items-center space-y-2 text-center">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-gray-300 border-t-blue-600"></div>
<p className="text-sm text-muted-foreground">
Loading transcriptions...
</p>
</div>
</CardContent>
</Card>
);
const renderEmptyState = () => (
<Card>
<CardContent className="py-12">
<div className="flex flex-col items-center space-y-2 text-center">
<FileText className="h-12 w-12 text-muted-foreground/50" />
<h3 className="text-lg font-medium">No transcriptions found</h3>
<p className="text-sm text-muted-foreground max-w-sm">
{searchTerm
? "Try adjusting your search terms."
: `Click on the widget to start dictation or press and hold the PTT shortcut key${pushToTalkShortcut ? ` (${pushToTalkShortcut})` : ""}.`}
</p>
</div>
</CardContent>
</Card>
);
const renderTranscriptionCard = (transcription: Transcription) => (
<Card
key={transcription.id}
className="hover:shadow-md transition-shadow overflow-hidden"
>
<CardContent className="p-4">
<div className="flex items-center gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3">
<h3
className={`font-medium truncate ${!transcription.text.trim() ? "font-mono text-muted-foreground" : ""}`}
>
{getTitle(transcription.text)}
</h3>
</div>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
<div className="flex items-center gap-2 text-sm text-muted-foreground flex-shrink-0">
<Badge variant="secondary" className="text-xs">
{getWordCount(transcription.text)} words
</Badge>
<span className="hidden sm:inline">
{format(new Date(transcription.timestamp), "MMM d")}
</span>
<span>{format(new Date(transcription.timestamp), "h:mm a")}</span>
<Badge variant="outline" className="text-xs">
{transcription.language?.toUpperCase() || "EN"}
</Badge>
</div>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={() => copyToClipboard(transcription.text)}
>
<Copy className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Copy transcription</TooltipContent>
</Tooltip>
</TooltipProvider>
{transcription.audioFile && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={() => handlePlayAudio(transcription.id)}
disabled={
getAudioFileMutation.isPending &&
getAudioFileMutation.variables?.transcriptionId ===
transcription.id
}
>
{audioPlayer.currentPlayingId === transcription.id &&
audioPlayer.isPlaying ? (
<Pause className="h-4 w-4" />
) : (
<Play className="h-4 w-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
{audioPlayer.currentPlayingId === transcription.id &&
audioPlayer.isPlaying
? "Pause audio"
: "Play audio"}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
<DropdownMenu
open={openDropdownId === transcription.id}
onOpenChange={(open) =>
setOpenDropdownId(open ? transcription.id : null)
}
>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
{transcription.audioFile && (
<>
<DropdownMenuItem
onClick={() => handleDownloadAudio(transcription.id)}
disabled={downloadAudioMutation.isPending}
>
<FileAudio className="h-4 w-4 mr-2" />
Download Audio
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem
onClick={() => handleDelete(transcription.id)}
className="text-destructive"
disabled={deleteTranscriptionMutation.isPending}
>
<Trash2 className="h-4 w-4 mr-2" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</CardContent>
</Card>
);
const renderTranscriptionsList = () => (
<div className="grid gap-3">
{transcriptions.map(renderTranscriptionCard)}
</div>
);
const renderFooter = () => {
if (loading || transcriptions.length === 0) return null;
return (
<div className="flex items-center justify-between text-sm text-muted-foreground">
<span>
Showing {transcriptions.length} of {totalCount} transcription
{totalCount !== 1 ? "s" : ""}
</span>
<span>
Total:{" "}
{transcriptions.reduce((acc, t) => acc + getWordCount(t.text), 0)}{" "}
words
</span>
</div>
);
};
const renderContent = () => {
if (loading) return renderLoadingState();
if (transcriptions.length === 0) return renderEmptyState();
return renderTranscriptionsList();
};
return (
<div className="space-y-6">
{/* Search and Filter Bar */}
<div className="flex items-center space-x-4">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground h-4 w-4" />
<Input
placeholder="Search transcriptions..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10"
/>
</div>
</div>
{/* Transcriptions Content */}
{renderContent()}
{/* Footer Stats */}
{renderFooter()}
</div>
);
};

View file

@ -1,5 +0,0 @@
import { TranscriptionsList } from "./components/TranscriptionsList";
export function TranscriptionsPage() {
return <TranscriptionsList />;
}

View file

@ -1,221 +0,0 @@
import * as React from "react";
import type { Vocabulary } from "@/db/schema";
import { format } from "date-fns";
import { Plus, Trash2, Edit, Book } from "lucide-react";
import { api } from "@/trpc/react";
import { Button } from "@/components/ui/button";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
export function VocabularyManager() {
const [isAddDialogOpen, setIsAddDialogOpen] = React.useState(false);
const [newWord, setNewWord] = React.useState({
word: "",
});
// tRPC React Query hooks
const vocabularyQuery = api.vocabulary.getVocabulary.useQuery({
limit: 100,
offset: 0,
sortBy: "dateAdded",
sortOrder: "desc",
});
const vocabularyCountQuery = api.vocabulary.getVocabularyCount.useQuery({});
const utils = api.useUtils();
const createVocabularyMutation =
api.vocabulary.createVocabularyWord.useMutation({
onSuccess: () => {
// Invalidate and refetch vocabulary data
utils.vocabulary.getVocabulary.invalidate();
utils.vocabulary.getVocabularyCount.invalidate();
setNewWord({ word: "" });
setIsAddDialogOpen(false);
},
onError: (error) => {
console.error("Error adding word:", error);
},
});
const deleteVocabularyMutation = api.vocabulary.deleteVocabulary.useMutation({
onSuccess: () => {
// Invalidate and refetch vocabulary data
utils.vocabulary.getVocabulary.invalidate();
utils.vocabulary.getVocabularyCount.invalidate();
},
onError: (error) => {
console.error("Error deleting word:", error);
},
});
const handleAddWord = async () => {
if (newWord.word.trim()) {
createVocabularyMutation.mutate({
word: newWord.word.trim().toLowerCase(),
});
}
};
const handleDeleteWord = async (id: number) => {
deleteVocabularyMutation.mutate({ id });
};
const vocabulary = vocabularyQuery.data || [];
const totalCount = vocabularyCountQuery.data || 0;
const loading = vocabularyQuery.isLoading || vocabularyCountQuery.isLoading;
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div></div>
<Dialog open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen}>
<DialogTrigger asChild>
<Button className="h-10">
<Plus className="mr-2 h-4 w-4" />
Add Word
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Add Custom Word</DialogTitle>
</DialogHeader>
<div className="space-y-4 pt-4">
<div className="space-y-2">
<Label htmlFor="word">Word</Label>
<Input
id="word"
placeholder="Enter the word"
value={newWord.word}
onChange={(e) =>
setNewWord({ ...newWord, word: e.target.value })
}
/>
</div>
<div className="flex justify-end space-x-2 pt-4">
<Button
variant="outline"
onClick={() => setIsAddDialogOpen(false)}
>
Cancel
</Button>
<Button
onClick={handleAddWord}
disabled={
createVocabularyMutation.isPending || !newWord.word.trim()
}
>
{createVocabularyMutation.isPending
? "Adding..."
: "Add Word"}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
<div className="rounded-lg border bg-card">
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="w-[300px] font-semibold">Word</TableHead>
<TableHead className="w-[200px] font-semibold">
Date Added
</TableHead>
<TableHead className="w-[100px] text-right font-semibold">
Actions
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={3} className="text-center py-12">
<div className="flex flex-col items-center space-y-2">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-gray-300 border-t-blue-600"></div>
<p className="text-sm text-muted-foreground">
Loading vocabulary...
</p>
</div>
</TableCell>
</TableRow>
) : vocabulary.length === 0 ? (
<TableRow>
<TableCell
colSpan={3}
className="text-center py-12 text-muted-foreground"
>
<div className="flex flex-col items-center space-y-2">
<Book className="h-8 w-8 text-muted-foreground/50" />
<p className="text-sm">No custom vocabulary words yet.</p>
<p className="text-xs">
Add your first word to get started.
</p>
</div>
</TableCell>
</TableRow>
) : (
vocabulary.map((item) => (
<TableRow key={item.id} className="hover:bg-muted/50">
<TableCell className="font-medium py-4">
{item.word}
</TableCell>
<TableCell className="text-muted-foreground py-4 text-sm">
{format(new Date(item.dateAdded), "MMM d, yyyy")}
</TableCell>
<TableCell className="py-4">
<div className="flex justify-end space-x-1">
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
<Edit className="h-4 w-4" />
<span className="sr-only">Edit word</span>
</Button>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 text-destructive hover:text-destructive"
onClick={() => handleDeleteWord(item.id)}
disabled={deleteVocabularyMutation.isPending}
>
<Trash2 className="h-4 w-4" />
<span className="sr-only">Delete word</span>
</Button>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{!loading && vocabulary.length > 0 && (
<div className="flex items-center justify-between text-sm text-muted-foreground">
<span>
Showing {vocabulary.length} of {totalCount} word
{totalCount !== 1 ? "s" : ""}
</span>
<span>
Total: {totalCount} custom word{totalCount !== 1 ? "s" : ""}
</span>
</div>
)}
</div>
);
}

View file

@ -1,5 +0,0 @@
import { VocabularyManager } from "./components/VocabularyManager";
export function VocabularyPage() {
return <VocabularyManager />;
}

View file

@ -0,0 +1,281 @@
/* eslint-disable */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// This file was automatically generated by TanStack Router.
// You should NOT make any changes in this file as it will be overwritten.
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root'
import { Route as SettingsRouteRouteImport } from './routes/settings/route'
import { Route as IndexRouteImport } from './routes/index'
import { Route as SettingsIndexRouteImport } from './routes/settings/index'
import { Route as SettingsVocabularyRouteImport } from './routes/settings/vocabulary'
import { Route as SettingsShortcutsRouteImport } from './routes/settings/shortcuts'
import { Route as SettingsPreferencesRouteImport } from './routes/settings/preferences'
import { Route as SettingsHistoryRouteImport } from './routes/settings/history'
import { Route as SettingsDictationRouteImport } from './routes/settings/dictation'
import { Route as SettingsAiModelsRouteImport } from './routes/settings/ai-models'
import { Route as SettingsAdvancedRouteImport } from './routes/settings/advanced'
import { Route as SettingsAboutRouteImport } from './routes/settings/about'
const SettingsRouteRoute = SettingsRouteRouteImport.update({
id: '/settings',
path: '/settings',
getParentRoute: () => rootRouteImport,
} as any)
const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
const SettingsIndexRoute = SettingsIndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => SettingsRouteRoute,
} as any)
const SettingsVocabularyRoute = SettingsVocabularyRouteImport.update({
id: '/vocabulary',
path: '/vocabulary',
getParentRoute: () => SettingsRouteRoute,
} as any)
const SettingsShortcutsRoute = SettingsShortcutsRouteImport.update({
id: '/shortcuts',
path: '/shortcuts',
getParentRoute: () => SettingsRouteRoute,
} as any)
const SettingsPreferencesRoute = SettingsPreferencesRouteImport.update({
id: '/preferences',
path: '/preferences',
getParentRoute: () => SettingsRouteRoute,
} as any)
const SettingsHistoryRoute = SettingsHistoryRouteImport.update({
id: '/history',
path: '/history',
getParentRoute: () => SettingsRouteRoute,
} as any)
const SettingsDictationRoute = SettingsDictationRouteImport.update({
id: '/dictation',
path: '/dictation',
getParentRoute: () => SettingsRouteRoute,
} as any)
const SettingsAiModelsRoute = SettingsAiModelsRouteImport.update({
id: '/ai-models',
path: '/ai-models',
getParentRoute: () => SettingsRouteRoute,
} as any)
const SettingsAdvancedRoute = SettingsAdvancedRouteImport.update({
id: '/advanced',
path: '/advanced',
getParentRoute: () => SettingsRouteRoute,
} as any)
const SettingsAboutRoute = SettingsAboutRouteImport.update({
id: '/about',
path: '/about',
getParentRoute: () => SettingsRouteRoute,
} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/settings': typeof SettingsRouteRouteWithChildren
'/settings/about': typeof SettingsAboutRoute
'/settings/advanced': typeof SettingsAdvancedRoute
'/settings/ai-models': typeof SettingsAiModelsRoute
'/settings/dictation': typeof SettingsDictationRoute
'/settings/history': typeof SettingsHistoryRoute
'/settings/preferences': typeof SettingsPreferencesRoute
'/settings/shortcuts': typeof SettingsShortcutsRoute
'/settings/vocabulary': typeof SettingsVocabularyRoute
'/settings/': typeof SettingsIndexRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/settings/about': typeof SettingsAboutRoute
'/settings/advanced': typeof SettingsAdvancedRoute
'/settings/ai-models': typeof SettingsAiModelsRoute
'/settings/dictation': typeof SettingsDictationRoute
'/settings/history': typeof SettingsHistoryRoute
'/settings/preferences': typeof SettingsPreferencesRoute
'/settings/shortcuts': typeof SettingsShortcutsRoute
'/settings/vocabulary': typeof SettingsVocabularyRoute
'/settings': typeof SettingsIndexRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/settings': typeof SettingsRouteRouteWithChildren
'/settings/about': typeof SettingsAboutRoute
'/settings/advanced': typeof SettingsAdvancedRoute
'/settings/ai-models': typeof SettingsAiModelsRoute
'/settings/dictation': typeof SettingsDictationRoute
'/settings/history': typeof SettingsHistoryRoute
'/settings/preferences': typeof SettingsPreferencesRoute
'/settings/shortcuts': typeof SettingsShortcutsRoute
'/settings/vocabulary': typeof SettingsVocabularyRoute
'/settings/': typeof SettingsIndexRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths:
| '/'
| '/settings'
| '/settings/about'
| '/settings/advanced'
| '/settings/ai-models'
| '/settings/dictation'
| '/settings/history'
| '/settings/preferences'
| '/settings/shortcuts'
| '/settings/vocabulary'
| '/settings/'
fileRoutesByTo: FileRoutesByTo
to:
| '/'
| '/settings/about'
| '/settings/advanced'
| '/settings/ai-models'
| '/settings/dictation'
| '/settings/history'
| '/settings/preferences'
| '/settings/shortcuts'
| '/settings/vocabulary'
| '/settings'
id:
| '__root__'
| '/'
| '/settings'
| '/settings/about'
| '/settings/advanced'
| '/settings/ai-models'
| '/settings/dictation'
| '/settings/history'
| '/settings/preferences'
| '/settings/shortcuts'
| '/settings/vocabulary'
| '/settings/'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
SettingsRouteRoute: typeof SettingsRouteRouteWithChildren
}
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/settings': {
id: '/settings'
path: '/settings'
fullPath: '/settings'
preLoaderRoute: typeof SettingsRouteRouteImport
parentRoute: typeof rootRouteImport
}
'/': {
id: '/'
path: '/'
fullPath: '/'
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
'/settings/': {
id: '/settings/'
path: '/'
fullPath: '/settings/'
preLoaderRoute: typeof SettingsIndexRouteImport
parentRoute: typeof SettingsRouteRoute
}
'/settings/vocabulary': {
id: '/settings/vocabulary'
path: '/vocabulary'
fullPath: '/settings/vocabulary'
preLoaderRoute: typeof SettingsVocabularyRouteImport
parentRoute: typeof SettingsRouteRoute
}
'/settings/shortcuts': {
id: '/settings/shortcuts'
path: '/shortcuts'
fullPath: '/settings/shortcuts'
preLoaderRoute: typeof SettingsShortcutsRouteImport
parentRoute: typeof SettingsRouteRoute
}
'/settings/preferences': {
id: '/settings/preferences'
path: '/preferences'
fullPath: '/settings/preferences'
preLoaderRoute: typeof SettingsPreferencesRouteImport
parentRoute: typeof SettingsRouteRoute
}
'/settings/history': {
id: '/settings/history'
path: '/history'
fullPath: '/settings/history'
preLoaderRoute: typeof SettingsHistoryRouteImport
parentRoute: typeof SettingsRouteRoute
}
'/settings/dictation': {
id: '/settings/dictation'
path: '/dictation'
fullPath: '/settings/dictation'
preLoaderRoute: typeof SettingsDictationRouteImport
parentRoute: typeof SettingsRouteRoute
}
'/settings/ai-models': {
id: '/settings/ai-models'
path: '/ai-models'
fullPath: '/settings/ai-models'
preLoaderRoute: typeof SettingsAiModelsRouteImport
parentRoute: typeof SettingsRouteRoute
}
'/settings/advanced': {
id: '/settings/advanced'
path: '/advanced'
fullPath: '/settings/advanced'
preLoaderRoute: typeof SettingsAdvancedRouteImport
parentRoute: typeof SettingsRouteRoute
}
'/settings/about': {
id: '/settings/about'
path: '/about'
fullPath: '/settings/about'
preLoaderRoute: typeof SettingsAboutRouteImport
parentRoute: typeof SettingsRouteRoute
}
}
}
interface SettingsRouteRouteChildren {
SettingsAboutRoute: typeof SettingsAboutRoute
SettingsAdvancedRoute: typeof SettingsAdvancedRoute
SettingsAiModelsRoute: typeof SettingsAiModelsRoute
SettingsDictationRoute: typeof SettingsDictationRoute
SettingsHistoryRoute: typeof SettingsHistoryRoute
SettingsPreferencesRoute: typeof SettingsPreferencesRoute
SettingsShortcutsRoute: typeof SettingsShortcutsRoute
SettingsVocabularyRoute: typeof SettingsVocabularyRoute
SettingsIndexRoute: typeof SettingsIndexRoute
}
const SettingsRouteRouteChildren: SettingsRouteRouteChildren = {
SettingsAboutRoute: SettingsAboutRoute,
SettingsAdvancedRoute: SettingsAdvancedRoute,
SettingsAiModelsRoute: SettingsAiModelsRoute,
SettingsDictationRoute: SettingsDictationRoute,
SettingsHistoryRoute: SettingsHistoryRoute,
SettingsPreferencesRoute: SettingsPreferencesRoute,
SettingsShortcutsRoute: SettingsShortcutsRoute,
SettingsVocabularyRoute: SettingsVocabularyRoute,
SettingsIndexRoute: SettingsIndexRoute,
}
const SettingsRouteRouteWithChildren = SettingsRouteRoute._addFileChildren(
SettingsRouteRouteChildren,
)
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
SettingsRouteRoute: SettingsRouteRouteWithChildren,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>()

View file

@ -0,0 +1,31 @@
import { createRootRoute, Outlet } from "@tanstack/react-router";
import { TanStackRouterDevtools } from "@tanstack/router-devtools";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { api, trpcClient } from "@/trpc/react";
// Create a client
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
refetchOnWindowFocus: false,
},
},
});
export const Route = createRootRoute({
component: RootComponent,
});
function RootComponent() {
return (
<api.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
<Outlet />
{process.env.NODE_ENV === "development" && (
<TanStackRouterDevtools position="bottom-right" />
)}
</QueryClientProvider>
</api.Provider>
);
}

View file

@ -0,0 +1,9 @@
import { createFileRoute, redirect } from "@tanstack/react-router";
export const Route = createFileRoute("/")({
beforeLoad: () => {
throw redirect({
to: "/settings/history",
});
},
});

View file

@ -0,0 +1,6 @@
import { createFileRoute } from "@tanstack/react-router";
import AboutSettingsPage from "../../pages/settings/about";
export const Route = createFileRoute("/settings/about")({
component: AboutSettingsPage,
});

View file

@ -0,0 +1,6 @@
import { createFileRoute } from "@tanstack/react-router";
import AdvancedSettingsPage from "../../pages/settings/advanced";
export const Route = createFileRoute("/settings/advanced")({
component: AdvancedSettingsPage,
});

View file

@ -0,0 +1,12 @@
import { createFileRoute } from "@tanstack/react-router";
import { z } from "zod";
import AIModelsSettingsPage from "../../pages/settings/ai-models";
const aiModelsSearchSchema = z.object({
tab: z.enum(["speech", "language", "embedding"]).optional().default("speech"),
});
export const Route = createFileRoute("/settings/ai-models")({
validateSearch: aiModelsSearchSchema,
component: AIModelsSettingsPage,
});

View file

@ -0,0 +1,6 @@
import { createFileRoute } from "@tanstack/react-router";
import DictationSettingsPage from "../../pages/settings/dictation";
export const Route = createFileRoute("/settings/dictation")({
component: DictationSettingsPage,
});

View file

@ -0,0 +1,6 @@
import { createFileRoute } from "@tanstack/react-router";
import HistorySettingsPage from "../../pages/settings/history";
export const Route = createFileRoute("/settings/history")({
component: HistorySettingsPage,
});

View file

@ -0,0 +1,9 @@
import { createFileRoute, redirect } from "@tanstack/react-router";
export const Route = createFileRoute("/settings/")({
beforeLoad: () => {
throw redirect({
to: "/settings/preferences",
});
},
});

View file

@ -0,0 +1,6 @@
import { createFileRoute } from "@tanstack/react-router";
import PreferencesPage from "../../pages/settings/preferences";
export const Route = createFileRoute("/settings/preferences")({
component: PreferencesPage,
});

View file

@ -0,0 +1,65 @@
import { createFileRoute, Outlet } from "@tanstack/react-router";
import { SidebarProvider, SidebarInset } from "@/components/ui/sidebar";
import { SettingsSidebar } from "../../components/settings-sidebar";
import { SiteHeader } from "@/components/site-header";
import { useLocation } from "@tanstack/react-router";
export const Route = createFileRoute("/settings")({
component: SettingsLayout,
});
function SettingsLayout() {
const location = useLocation();
const getSettingsPageTitle = (pathname: string): string => {
const routes: Record<string, string> = {
"/settings/preferences": "Preferences",
"/settings/dictation": "Dictation",
"/settings/vocabulary": "Vocabulary",
"/settings/shortcuts": "Shortcuts",
"/settings/ai-models": "AI Models",
"/settings/history": "History",
"/settings/advanced": "Advanced",
"/settings/about": "About",
};
return routes[pathname] || "Settings";
};
return (
<SidebarProvider
style={
{
"--sidebar-width": "calc(var(--spacing) * 52)",
"--header-height": "calc(var(--spacing) * 12)",
} as React.CSSProperties
}
>
<div className="flex h-screen w-screen flex-col">
<SiteHeader
currentView={`${getSettingsPageTitle(location.pathname)}`}
/>
<div className="flex flex-1 min-h-0">
<SettingsSidebar variant="inset" />
<SidebarInset className="mt-0!">
<div className="flex flex-1 flex-col min-h-0">
<div className="@container/settings flex flex-1 flex-col min-h-0 overflow-hidden">
<div className="flex-1 overflow-y-auto">
<div
className="mx-auto w-full flex flex-col gap-4 md:gap-6"
style={{
maxWidth: "var(--content-max-width)",
padding: "var(--content-padding)",
}}
>
<Outlet />
</div>
</div>
</div>
</div>
</SidebarInset>
</div>
</div>
</SidebarProvider>
);
}

View file

@ -0,0 +1,6 @@
import { createFileRoute } from "@tanstack/react-router";
import { ShortcutsSettingsPage } from "../../pages/settings/shortcuts";
export const Route = createFileRoute("/settings/shortcuts")({
component: ShortcutsSettingsPage,
});

View file

@ -0,0 +1,6 @@
import { createFileRoute } from "@tanstack/react-router";
import VocabularySettingsPage from "../../pages/settings/vocabulary";
export const Route = createFileRoute("/settings/vocabulary")({
component: VocabularySettingsPage,
});

View file

@ -10,39 +10,47 @@ import "@/styles/globals.css";
declare global {
interface Console {
original: {
log: (...args: any[]) => void;
info: (...args: any[]) => void;
warn: (...args: any[]) => void;
error: (...args: any[]) => void;
debug: (...args: any[]) => void;
log: (...data: unknown[]) => void;
info: (...data: unknown[]) => void;
warn: (...data: unknown[]) => void;
error: (...data: unknown[]) => void;
debug: (...data: unknown[]) => void;
};
}
}
// Widget scoped logger setup
const widgetLogger = window.electronAPI.log.scope("widget");
// Widget scoped logger setup with guards
const widgetLogger = window.electronAPI?.log?.scope?.("widget");
// Store original console methods with proper binding
const originalConsole = {
log: console.log.bind(console),
info: console.info.bind(console),
warn: console.warn.bind(console),
error: console.error.bind(console),
debug: console.debug.bind(console),
};
// Proxy console methods to use BOTH original console AND widget logger
const originalConsole = { ...console };
console.log = (...args: any[]) => {
console.log = (...args: unknown[]) => {
originalConsole.log(...args); // Show in dev console
widgetLogger.info(...args); // Send via IPC
widgetLogger?.info?.(...args); // Send via IPC if available
};
console.info = (...args: any[]) => {
console.info = (...args: unknown[]) => {
originalConsole.info(...args);
widgetLogger.info(...args);
widgetLogger?.info?.(...args);
};
console.warn = (...args: any[]) => {
console.warn = (...args: unknown[]) => {
originalConsole.warn(...args);
widgetLogger.warn(...args);
widgetLogger?.warn?.(...args);
};
console.error = (...args: any[]) => {
console.error = (...args: unknown[]) => {
originalConsole.error(...args);
widgetLogger.error(...args);
widgetLogger?.error?.(...args);
};
console.debug = (...args: any[]) => {
console.debug = (...args: unknown[]) => {
originalConsole.debug(...args);
widgetLogger.debug(...args);
widgetLogger?.debug?.(...args);
};
// Keep original methods available if needed

View file

@ -4,42 +4,67 @@ import * as path from "path";
import * as crypto from "crypto";
import { app } from "electron";
import {
Model,
AvailableWhisperModel,
DownloadProgress,
ModelManagerState,
AVAILABLE_MODELS,
} from "../constants/models";
import { DownloadedModel } from "../db/schema";
import { Model as DBModel, NewModel } from "../db/schema";
import {
getDownloadedModelsRecord,
createDownloadedModel,
deleteDownloadedModel,
validateDownloadedModels,
validateModelFile,
getValidDownloadedModels,
} from "../db/downloaded-models";
getModelsByProvider,
getDownloadedWhisperModels,
removeModel,
modelExists,
syncLocalWhisperModels,
getAllModels,
syncModelsForProvider,
removeModelsForProvider,
upsertModel,
getModelById,
} from "../db/models";
import {
ValidationResult,
OpenRouterResponse,
OllamaResponse,
OpenRouterModel,
OllamaModel,
} from "../types/providers";
import { SettingsService } from "./settings-service";
import { logger } from "../main/logger";
// Type for models fetched from external APIs
type FetchedModel = Pick<DBModel, "id" | "name" | "provider"> &
Partial<DBModel>;
interface ModelManagerEvents {
"download-progress": (modelId: string, progress: DownloadProgress) => void;
"download-complete": (
modelId: string,
downloadedModel: DownloadedModel,
) => void;
"download-complete": (modelId: string, downloadedModel: DBModel) => void;
"download-error": (modelId: string, error: Error) => void;
"download-cancelled": (modelId: string) => void;
"model-deleted": (modelId: string) => void;
"selection-changed": (
oldModelId: string | null,
newModelId: string | null,
reason:
| "manual"
| "auto-first-download"
| "auto-after-deletion"
| "cleared",
modelType: "speech" | "language" | "embedding",
) => void;
}
class ModelManagerService extends EventEmitter {
private state: ModelManagerState;
private modelsDirectory: string;
private settingsService: SettingsService;
constructor() {
constructor(settingsService: SettingsService) {
super();
this.state = {
activeDownloads: new Map(),
};
this.settingsService = settingsService;
// Create models directory in app data
this.modelsDirectory = path.join(app.getPath("userData"), "models");
@ -78,25 +103,74 @@ class ModelManagerService extends EventEmitter {
// Initialize and validate models on startup
async initialize(): Promise<void> {
try {
const validation = await validateDownloadedModels();
// Sync Whisper models with filesystem
const whisperModelsData = AVAILABLE_MODELS.map((model) => ({
id: model.id,
name: model.name,
description: model.description,
size: model.sizeFormatted,
checksum: model.checksum,
speed: model.speed,
accuracy: model.accuracy,
filename: model.filename,
}));
if (validation.cleaned > 0) {
logger.main.info("Cleaned up missing model records", {
cleaned: validation.cleaned,
valid: validation.valid.length,
missing: validation.missing.map((m) => ({
id: m.id,
path: m.localPath,
})),
});
}
const syncResult = await syncLocalWhisperModels(
this.modelsDirectory,
whisperModelsData,
);
logger.main.info("Model manager initialized", {
validModels: validation.valid.length,
cleanedRecords: validation.cleaned,
added: syncResult.added,
updated: syncResult.updated,
removed: syncResult.removed,
});
// Restore selected model from settings
const savedSelection = await this.settingsService.getDefaultSpeechModel();
if (!savedSelection) {
// No saved selection, check if we have downloaded models to auto-select
const downloadedModels = await this.getValidDownloadedModels();
const downloadedModelCount = Object.keys(downloadedModels).length;
if (downloadedModelCount > 0) {
// Auto-select the best available model using the preferred order
const preferredOrder = [
"whisper-large-v3-turbo",
"whisper-large-v1",
"whisper-medium",
"whisper-small",
"whisper-base",
"whisper-tiny",
];
for (const candidateId of preferredOrder) {
if (downloadedModels[candidateId]) {
await this.settingsService.setDefaultSpeechModel(candidateId);
this.emit(
"selection-changed",
null,
candidateId,
"auto-first-download",
"speech",
);
logger.main.info("Auto-selected speech model on initialization", {
modelId: candidateId,
availableModels: Object.keys(downloadedModels),
});
break;
}
}
}
}
// Validate all default models after sync
await this.validateAndClearInvalidDefaults();
} catch (error) {
logger.main.error("Error initializing model manager", { error });
logger.main.error("Error initializing model manager", {
error: error instanceof Error ? error.message : String(error),
});
}
}
@ -110,30 +184,33 @@ class ModelManagerService extends EventEmitter {
}
// Get all available models from manifest
getAvailableModels(): Model[] {
getAvailableModels(): AvailableWhisperModel[] {
return AVAILABLE_MODELS;
}
// Get downloaded models from database
async getDownloadedModels(): Promise<Record<string, DownloadedModel>> {
return await getDownloadedModelsRecord();
}
async getDownloadedModels(): Promise<Record<string, DBModel>> {
const models = await getDownloadedWhisperModels();
const record: Record<string, DBModel> = {};
// Get only valid downloaded models (files that exist on disk)
async getValidDownloadedModels(): Promise<Record<string, DownloadedModel>> {
const validModels = await getValidDownloadedModels();
const record: Record<string, DownloadedModel> = {};
for (const model of validModels) {
for (const model of models) {
record[model.id] = model;
}
return record;
}
// Check if a model is downloaded and file exists
// Get only valid downloaded models (files that exist on disk)
// Since we sync on init and only store downloaded models, all models in DB are valid
async getValidDownloadedModels(): Promise<Record<string, DBModel>> {
return this.getDownloadedModels();
}
// Check if a model is downloaded
// Since we only store downloaded models, just check if it exists in DB
async isModelDownloaded(modelId: string): Promise<boolean> {
return await validateModelFile(modelId);
const models = await getModelsByProvider("local-whisper");
return models.some((m) => m.id === modelId);
}
// Get download progress for a model
@ -256,17 +333,33 @@ class ModelManagerService extends EventEmitter {
}
}
// Create downloaded model record in database
const downloadedModel = await createDownloadedModel({
// Create/update model record in database with download info
await upsertModel({
id: model.id,
provider: "local-whisper",
name: model.name,
type: model.type,
localPath: downloadPath,
downloadedAt: new Date(),
size: stats.size,
type: "speech",
size: model.sizeFormatted,
description: model.description,
checksum: model.checksum,
speed: model.speed,
accuracy: model.accuracy,
localPath: downloadPath,
sizeBytes: stats.size,
downloadedAt: new Date(),
context: null,
originalModel: null,
});
// Get the updated model from database
const downloadedModel = await getModelsByProvider("local-whisper").then(
(models) => models.find((m) => m.id === model.id),
);
if (!downloadedModel) {
throw new Error("Failed to retrieve downloaded model from database");
}
// Clean up active download
this.state.activeDownloads.delete(modelId);
@ -276,6 +369,24 @@ class ModelManagerService extends EventEmitter {
size: stats.size,
});
// Auto-select if this is the first model
const allDownloadedModels = await this.getValidDownloadedModels();
const downloadedModelCount = Object.keys(allDownloadedModels).length;
const currentSelection =
await this.settingsService.getDefaultSpeechModel();
if (downloadedModelCount === 1 && !currentSelection) {
await this.settingsService.setDefaultSpeechModel(modelId);
this.emit(
"selection-changed",
null,
modelId,
"auto-first-download",
"speech",
);
logger.main.info("Auto-selected first downloaded model", { modelId });
}
this.emit("download-complete", modelId, downloadedModel);
} catch (error) {
// Clean up on error
@ -290,15 +401,15 @@ class ModelManagerService extends EventEmitter {
if (abortController.signal.aborted) {
logger.main.info("Model download cancelled", { modelId });
this.emit("download-cancelled", modelId);
return; // Don't throw - it's an intentional cancellation
} else {
logger.main.error("Model download failed", {
modelId,
error: err.message,
});
this.emit("download-error", modelId, err);
throw err; // Only throw for actual errors
}
throw err;
}
}
@ -321,15 +432,19 @@ class ModelManagerService extends EventEmitter {
// Delete a downloaded model
async deleteModel(modelId: string): Promise<void> {
const downloadedModels = await this.getDownloadedModels();
const downloadedModel = downloadedModels[modelId];
const models = await getModelsByProvider("local-whisper");
const downloadedModel = models.find((m) => m.id === modelId);
if (!downloadedModel) {
throw new Error(`Model not found: ${modelId}`);
}
// Check if this is the selected model BEFORE deletion
const currentSelection = await this.settingsService.getDefaultSpeechModel();
const wasSelected = currentSelection === modelId;
// Delete file
if (fs.existsSync(downloadedModel.localPath)) {
if (downloadedModel.localPath && fs.existsSync(downloadedModel.localPath)) {
fs.unlinkSync(downloadedModel.localPath);
logger.main.info("Deleted model file", {
modelId,
@ -337,10 +452,58 @@ class ModelManagerService extends EventEmitter {
});
}
// Remove from database
await deleteDownloadedModel(modelId);
// Remove the model record from database (we only store downloaded models)
await removeModel(downloadedModel.provider, downloadedModel.id);
// Handle selection update if needed
if (wasSelected) {
// Clear selection first
await this.settingsService.setDefaultSpeechModel(undefined);
// Try to auto-select next best model
const remainingModels = await this.getValidDownloadedModels();
const preferredOrder = [
"whisper-large-v3-turbo",
"whisper-large-v1",
"whisper-medium",
"whisper-small",
"whisper-base",
"whisper-tiny",
];
let autoSelected = false;
for (const candidateId of preferredOrder) {
if (remainingModels[candidateId]) {
await this.settingsService.setDefaultSpeechModel(candidateId);
this.emit(
"selection-changed",
modelId,
candidateId,
"auto-after-deletion",
"speech",
);
logger.main.info("Auto-selected new model after deletion", {
oldModel: modelId,
newModel: candidateId,
});
autoSelected = true;
break;
}
}
if (!autoSelected) {
// No models left, selection cleared
this.emit("selection-changed", modelId, null, "cleared", "speech");
logger.main.info(
"No models available for auto-selection after deletion",
);
}
}
this.emit("model-deleted", modelId);
// Validate all default models after deletion
await this.validateAndClearInvalidDefaults();
}
// Calculate file checksum (SHA-1)
@ -360,31 +523,6 @@ class ModelManagerService extends EventEmitter {
return this.modelsDirectory;
}
// Validate and clean up stale model records (can be called periodically)
async validateAndCleanup(): Promise<{ cleaned: number; valid: number }> {
try {
const validation = await validateDownloadedModels();
if (validation.cleaned > 0) {
logger.main.info("Periodic cleanup completed", {
cleaned: validation.cleaned,
valid: validation.valid.length,
});
}
return {
cleaned: validation.cleaned,
valid: validation.valid.length,
};
} catch (error) {
logger.main.error("Error during model validation cleanup", { error });
return { cleaned: 0, valid: 0 };
}
}
// Model selection for transcription (moved from LocalWhisperClient)
private selectedModelId: string | null = null;
// Check if any models are available for transcription
async isAvailable(): Promise<boolean> {
const downloadedModels = await this.getValidDownloadedModels();
@ -398,27 +536,44 @@ class ModelManagerService extends EventEmitter {
}
// Get currently selected model for transcription
getSelectedModel(): string | null {
return this.selectedModelId;
async getSelectedModel(): Promise<string | null> {
return (await this.settingsService.getDefaultSpeechModel()) || null;
}
// Set selected model for transcription
async setSelectedModel(modelId: string): Promise<void> {
const downloadedModels = await this.getValidDownloadedModels();
if (!downloadedModels[modelId]) {
throw new Error(`Model not downloaded: ${modelId}`);
async setSelectedModel(modelId: string | null): Promise<void> {
const oldModelId = await this.getSelectedModel();
// If setting to a specific model, validate it exists
if (modelId) {
const downloadedModels = await this.getValidDownloadedModels();
if (!downloadedModels[modelId]) {
throw new Error(`Model not downloaded: ${modelId}`);
}
}
// Update selection in settings
await this.settingsService.setDefaultSpeechModel(modelId || undefined);
// Emit change event if selection actually changed
if (oldModelId !== modelId) {
this.emit("selection-changed", oldModelId, modelId, "manual", "speech");
logger.main.info("Model selection changed", {
from: oldModelId,
to: modelId,
reason: "manual",
});
}
this.selectedModelId = modelId;
logger.main.info("Selected model for transcription", { modelId });
}
// Get best available model path for transcription (used by WhisperProvider)
async getBestAvailableModelPath(): Promise<string | null> {
const downloadedModels = await this.getValidDownloadedModels();
const selectedModelId = await this.getSelectedModel();
// If a specific model is selected and available, use it
if (this.selectedModelId && downloadedModels[this.selectedModelId]) {
return downloadedModels[this.selectedModelId].localPath;
if (selectedModelId && downloadedModels[selectedModelId]) {
return downloadedModels[selectedModelId].localPath;
}
// Otherwise, find the best available model (prioritize by quality)
@ -433,7 +588,7 @@ class ModelManagerService extends EventEmitter {
for (const modelId of preferredOrder) {
const model = downloadedModels[modelId];
if (model && fs.existsSync(model.localPath)) {
if (model?.localPath) {
return model.localPath;
}
}
@ -458,6 +613,374 @@ class ModelManagerService extends EventEmitter {
}
}
}
// ============================================
// Provider Model Methods (OpenRouter, Ollama)
// ============================================
/**
* Validate OpenRouter connection by testing API key
*/
async validateOpenRouterConnection(
apiKey: string,
): Promise<ValidationResult> {
try {
const response = await fetch("https://openrouter.ai/api/v1/key", {
method: "GET",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
const errorMessage =
(errorData as any)?.error?.message ||
`HTTP ${response.status}: ${response.statusText}`;
return {
success: false,
error: errorMessage,
};
}
return { success: true };
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : "Connection failed",
};
}
}
/**
* Validate Ollama connection by testing if Ollama is running
*/
async validateOllamaConnection(url: string): Promise<ValidationResult> {
try {
const cleanUrl = url.replace(/\/$/, "");
const versionUrl = `${cleanUrl}/api/version`;
const response = await fetch(versionUrl, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
if (!response.ok) {
return {
success: false,
error: `HTTP ${response.status}: ${response.statusText}`,
};
}
return { success: true };
} catch (error) {
return {
success: false,
error:
error instanceof Error
? error.message
: "Failed to connect to Ollama. Make sure Ollama is running.",
};
}
}
/**
* Fetch available models from OpenRouter
*/
async fetchOpenRouterModels(apiKey: string): Promise<FetchedModel[]> {
try {
const response = await fetch("https://openrouter.ai/api/v1/models", {
method: "GET",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data: OpenRouterResponse = await response.json();
// Transform OpenRouter models to unified format
return data.data.map((model: OpenRouterModel): FetchedModel => {
// Extract model size from name if possible
const nameParts = model.id.split("/");
const modelName = nameParts[nameParts.length - 1];
let size = "Unknown";
// Try to extract size from model name (e.g., "7b", "13b", "70b")
const sizeMatch = modelName.match(/(\d+)b/i);
if (sizeMatch) {
size = `${sizeMatch[1]}B`;
}
// Convert context length to readable format
const contextLength = model.context_length
? `${Math.floor(model.context_length / 1000)}k`
: "Unknown";
return {
id: model.id,
name: model.name,
provider: "OpenRouter",
size,
context: contextLength,
description: model.description,
originalModel: model,
};
});
} catch (error) {
throw new Error(
error instanceof Error
? error.message
: "Failed to fetch OpenRouter models",
);
}
}
/**
* Fetch available models from Ollama
*/
async fetchOllamaModels(url: string): Promise<FetchedModel[]> {
try {
const cleanUrl = url.replace(/\/$/, "");
const modelsUrl = `${cleanUrl}/api/tags`;
const response = await fetch(modelsUrl, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data: OllamaResponse = await response.json();
// Transform Ollama models to unified format
return data.models.map((model: OllamaModel): FetchedModel => {
// Extract model size from details or calculate from size
let size = "Unknown";
if (model.details?.parameter_size) {
size = model.details.parameter_size;
} else if (model.size) {
const sizeGB = (model.size / (1024 * 1024 * 1024)).toFixed(1);
size = `${sizeGB}GB`;
}
// Extract base model name (remove tags like :latest)
const baseName = model.name.split(":")[0];
const displayName =
baseName.charAt(0).toUpperCase() + baseName.slice(1);
// Estimate context length (most Ollama models have 4k-32k context)
const lowerName = model.name.toLowerCase();
let contextLength = "4k"; // Default
if (lowerName.includes("32k") || lowerName.includes("32000"))
contextLength = "32k";
else if (lowerName.includes("16k") || lowerName.includes("16000"))
contextLength = "16k";
else if (lowerName.includes("8k") || lowerName.includes("8000"))
contextLength = "8k";
return {
id: model.name,
name: displayName,
provider: "Ollama",
size,
context: contextLength,
description: model.details?.family || undefined,
originalModel: model,
};
});
} catch (error) {
throw new Error(
error instanceof Error
? error.message
: "Failed to fetch Ollama models",
);
}
}
/**
* Get all synced provider models from database
*/
async getSyncedProviderModels(): Promise<DBModel[]> {
const models = await getAllModels();
// Filter to only remote provider models (exclude local-whisper)
return models.filter((m) => m.provider !== "local-whisper");
}
/**
* Get synced models by provider
*/
async getSyncedModelsByProvider(provider: string): Promise<DBModel[]> {
const models = await getModelsByProvider(provider);
return models;
}
/**
* Sync provider models to database (replace all models for a provider)
*/
async syncProviderModelsToDatabase(
provider: string,
models: FetchedModel[],
): Promise<void> {
// Convert to NewModel format
const newModels: NewModel[] = models.map((m) => ({
id: m.id!,
provider: provider,
name: m.name!,
type:
provider === "Ollama" && m.name && m.name.includes("embed")
? "embedding"
: "language",
size: m.size || null,
context: m.context || null,
description: m.description || null,
originalModel: m.originalModel || null,
// Remote models don't have local fields
localPath: null,
sizeBytes: null,
checksum: null,
downloadedAt: null,
speed: null,
accuracy: null,
}));
await syncModelsForProvider(provider, newModels);
// Validate default models after sync
await this.validateAndClearInvalidDefaults();
}
/**
* Remove all models for a provider
*/
async removeProviderModels(provider: string): Promise<void> {
await removeModelsForProvider(provider);
// Validate default models after removal
await this.validateAndClearInvalidDefaults();
}
// ============================================
// Unified Model Selection Methods
// ============================================
/**
* Get default language model
*/
async getDefaultLanguageModel(): Promise<string | null> {
const modelId = await this.settingsService.getDefaultLanguageModel();
return modelId || null;
}
/**
* Set default language model
*/
async setDefaultLanguageModel(modelId: string | null): Promise<void> {
await this.settingsService.setDefaultLanguageModel(modelId || undefined);
}
/**
* Get default embedding model
*/
async getDefaultEmbeddingModel(): Promise<string | null> {
const modelId = await this.settingsService.getDefaultEmbeddingModel();
return modelId || null;
}
/**
* Set default embedding model
*/
async setDefaultEmbeddingModel(modelId: string | null): Promise<void> {
await this.settingsService.setDefaultEmbeddingModel(modelId || undefined);
}
/**
* Validate and clear invalid default models
* Checks if default models still exist in the database
* Clears any that don't exist and emits selection-changed events
*/
async validateAndClearInvalidDefaults(): Promise<void> {
// Check default speech model
const defaultSpeechModel =
await this.settingsService.getDefaultSpeechModel();
if (defaultSpeechModel) {
const exists = await modelExists("local-whisper", defaultSpeechModel);
if (!exists) {
logger.main.info("Clearing invalid default speech model", {
modelId: defaultSpeechModel,
});
await this.settingsService.setDefaultSpeechModel(undefined);
this.emit(
"selection-changed",
defaultSpeechModel,
null,
"auto-after-deletion",
"speech",
);
}
}
// Check default language model
const defaultLanguageModel =
await this.settingsService.getDefaultLanguageModel();
if (defaultLanguageModel) {
// Check all models to find if this ID exists with any provider
const allModels = await getAllModels();
const modelExists = allModels.some(
(m) => m.id === defaultLanguageModel && m.type === "language",
);
if (!modelExists) {
logger.main.info("Clearing invalid default language model", {
modelId: defaultLanguageModel,
});
await this.settingsService.setDefaultLanguageModel(undefined);
this.emit(
"selection-changed",
defaultLanguageModel,
null,
"auto-after-deletion",
"language",
);
}
}
// Check default embedding model
const defaultEmbeddingModel =
await this.settingsService.getDefaultEmbeddingModel();
if (defaultEmbeddingModel) {
// Check all models to find if this ID exists with any provider
const allModels = await getAllModels();
const modelExists = allModels.some(
(m) => m.id === defaultEmbeddingModel && m.type === "embedding",
);
if (!modelExists) {
logger.main.info("Clearing invalid default embedding model", {
modelId: defaultEmbeddingModel,
});
await this.settingsService.setDefaultEmbeddingModel(undefined);
this.emit(
"selection-changed",
defaultEmbeddingModel,
null,
"auto-after-deletion",
"embedding",
);
}
}
}
}
export { ModelManagerService };

View file

@ -95,6 +95,22 @@ export class SettingsService {
await updateSettingsSection("recording", recordingSettings);
}
/**
* Get dictation settings
*/
async getDictationSettings(): Promise<AppSettingsData["dictation"]> {
return await getSettingsSection("dictation");
}
/**
* Update dictation settings
*/
async setDictationSettings(
dictationSettings: AppSettingsData["dictation"],
): Promise<void> {
await updateSettingsSection("dictation", dictationSettings);
}
/**
* Get shortcuts configuration with defaults
*/
@ -118,4 +134,130 @@ export class SettingsService {
};
await updateSettingsSection("shortcuts", dataToStore);
}
/**
* Get model providers configuration
*/
async getModelProvidersConfig(): Promise<
AppSettingsData["modelProvidersConfig"]
> {
console.log(
"getModelProvidersConfig",
await getSettingsSection("modelProvidersConfig"),
);
return await getSettingsSection("modelProvidersConfig");
}
/**
* Update model providers configuration
*/
async setModelProvidersConfig(
config: AppSettingsData["modelProvidersConfig"],
): Promise<void> {
await updateSettingsSection("modelProvidersConfig", config);
}
/**
* Get OpenRouter configuration
*/
async getOpenRouterConfig(): Promise<{ apiKey: string } | undefined> {
const config = await this.getModelProvidersConfig();
return config?.openRouter;
}
/**
* Update OpenRouter configuration
*/
async setOpenRouterConfig(config: { apiKey: string }): Promise<void> {
const currentConfig = await this.getModelProvidersConfig();
await this.setModelProvidersConfig({
...currentConfig,
openRouter: config,
});
}
/**
* Get Ollama configuration
*/
async getOllamaConfig(): Promise<{ url: string } | undefined> {
const config = await this.getModelProvidersConfig();
return config?.ollama;
}
/**
* Update Ollama configuration
*/
async setOllamaConfig(config: { url: string }): Promise<void> {
const currentConfig = await this.getModelProvidersConfig();
// If URL is empty, remove the ollama config entirely
if (config.url === "") {
const updatedConfig = { ...currentConfig };
delete updatedConfig.ollama;
await this.setModelProvidersConfig(updatedConfig);
} else {
await this.setModelProvidersConfig({
...currentConfig,
ollama: config,
});
}
}
/**
* Get default speech model (Whisper)
*/
async getDefaultSpeechModel(): Promise<string | undefined> {
const config = await this.getModelProvidersConfig();
console.error("config is ", config);
return config?.defaultSpeechModel;
}
/**
* Set default speech model (Whisper)
*/
async setDefaultSpeechModel(modelId: string | undefined): Promise<void> {
const currentConfig = await this.getModelProvidersConfig();
await this.setModelProvidersConfig({
...currentConfig,
defaultSpeechModel: modelId,
});
}
/**
* Get default language model
*/
async getDefaultLanguageModel(): Promise<string | undefined> {
const config = await this.getModelProvidersConfig();
return config?.defaultLanguageModel;
}
/**
* Set default language model
*/
async setDefaultLanguageModel(modelId: string | undefined): Promise<void> {
const currentConfig = await this.getModelProvidersConfig();
await this.setModelProvidersConfig({
...currentConfig,
defaultLanguageModel: modelId,
});
}
/**
* Get default embedding model
*/
async getDefaultEmbeddingModel(): Promise<string | undefined> {
const config = await this.getModelProvidersConfig();
return config?.defaultEmbeddingModel;
}
/**
* Set default embedding model
*/
async setDefaultEmbeddingModel(modelId: string | undefined): Promise<void> {
const currentConfig = await this.getModelProvidersConfig();
await this.setModelProvidersConfig({
...currentConfig,
defaultEmbeddingModel: modelId,
});
}
}

View file

@ -1,28 +1,120 @@
import { observable } from "@trpc/server/observable";
import { z } from "zod";
import { createRouter, procedure } from "../trpc";
import type { Model, DownloadProgress } from "../../constants/models";
import type { DownloadedModel } from "../../db/schema";
import type {
AvailableWhisperModel,
DownloadProgress,
} from "../../constants/models";
import type { Model } from "../../db/schema";
import type { ValidationResult } from "../../types/providers";
import { removeModel } from "../../db/models";
export const modelsRouter = createRouter({
// Get available models
getAvailableModels: procedure.query(async ({ ctx }): Promise<Model[]> => {
const modelManagerService = ctx.serviceManager.getService(
"modelManagerService",
);
return modelManagerService?.getAvailableModels() || [];
}),
// Unified models fetching
getModels: procedure
.input(
z.object({
provider: z.string().optional(),
type: z.enum(["speech", "language", "embedding"]).optional(),
downloadedOnly: z.boolean().optional().default(false),
}),
)
.query(async ({ input, ctx }): Promise<Model[]> => {
const modelManagerService = ctx.serviceManager.getService(
"modelManagerService",
);
if (!modelManagerService) {
throw new Error("Model manager service not available");
}
// Get downloaded models
getDownloadedModels: procedure.query(async ({ ctx }) => {
const modelManagerService = ctx.serviceManager.getService(
"modelManagerService",
);
if (!modelManagerService) {
throw new Error("Model manager service not available");
}
return await modelManagerService.getDownloadedModels();
}),
// For speech models (local whisper)
if (input.type === "speech") {
if (input.downloadedOnly) {
const downloadedModels =
await modelManagerService.getDownloadedModels();
return Object.values(downloadedModels);
}
// Return all available whisper models as Model type
// We need to convert from AvailableWhisperModel to Model format
const availableModels = modelManagerService.getAvailableModels();
const downloadedModels =
await modelManagerService.getDownloadedModels();
// Map available models to Model format using downloaded data if available
return availableModels.map((m) => {
const downloaded = downloadedModels[m.id];
if (downloaded) {
return downloaded;
}
// Create a partial Model for non-downloaded models
return {
id: m.id,
name: m.name,
provider: m.provider,
type: "speech" as const,
size: m.sizeFormatted,
context: null,
description: m.description,
localPath: null,
sizeBytes: null,
checksum: null,
downloadedAt: null,
originalModel: null,
speed: m.speed,
accuracy: m.accuracy,
createdAt: new Date(),
updatedAt: new Date(),
} as Model;
});
}
// For language/embedding models (provider models)
let models = await modelManagerService.getSyncedProviderModels();
// Filter by provider if specified
if (input.provider) {
models = models.filter((m) => m.provider === input.provider);
}
// Filter by type if specified
if (input.type) {
models = models.filter((m) => {
if (input.type === "embedding") {
return (
m.provider === "Ollama" && m.name.toLowerCase().includes("embed")
);
}
// For language models, exclude embedding models
return !(
m.provider === "Ollama" && m.name.toLowerCase().includes("embed")
);
});
}
return models;
}),
// Legacy endpoints (kept for backward compatibility)
getAvailableModels: procedure.query(
async ({ ctx }): Promise<AvailableWhisperModel[]> => {
const modelManagerService = ctx.serviceManager.getService(
"modelManagerService",
);
return modelManagerService?.getAvailableModels() || [];
},
),
getDownloadedModels: procedure.query(
async ({ ctx }): Promise<Record<string, Model>> => {
const modelManagerService = ctx.serviceManager.getService(
"modelManagerService",
);
if (!modelManagerService) {
throw new Error("Model manager service not available");
}
return await modelManagerService.getDownloadedModels();
},
),
// Check if model is downloaded
isModelDownloaded: procedure
@ -87,7 +179,9 @@ export const modelsRouter = createRouter({
const modelManagerService = ctx.serviceManager.getService(
"modelManagerService",
);
return modelManagerService ? modelManagerService.getSelectedModel() : null;
return modelManagerService
? await modelManagerService.getSelectedModel()
: null;
}),
// Mutations
@ -128,7 +222,7 @@ export const modelsRouter = createRouter({
}),
setSelectedModel: procedure
.input(z.object({ modelId: z.string() }))
.input(z.object({ modelId: z.string().nullable() }))
.mutation(async ({ input, ctx }) => {
const modelManagerService = ctx.serviceManager.getService(
"modelManagerService",
@ -149,6 +243,304 @@ export const modelsRouter = createRouter({
return true;
}),
// Provider validation endpoints
validateOpenRouterConnection: procedure
.input(z.object({ apiKey: z.string() }))
.mutation(async ({ input, ctx }): Promise<ValidationResult> => {
const modelManagerService = ctx.serviceManager.getService(
"modelManagerService",
);
if (!modelManagerService) {
throw new Error("Model manager service not initialized");
}
return await modelManagerService.validateOpenRouterConnection(
input.apiKey,
);
}),
validateOllamaConnection: procedure
.input(z.object({ url: z.string() }))
.mutation(async ({ input, ctx }): Promise<ValidationResult> => {
const modelManagerService = ctx.serviceManager.getService(
"modelManagerService",
);
if (!modelManagerService) {
throw new Error("Model manager service not initialized");
}
return await modelManagerService.validateOllamaConnection(input.url);
}),
// Provider model fetching
fetchOpenRouterModels: procedure
.input(z.object({ apiKey: z.string() }))
.query(async ({ input, ctx }) => {
const modelManagerService = ctx.serviceManager.getService(
"modelManagerService",
);
if (!modelManagerService) {
throw new Error("Model manager service not initialized");
}
return await modelManagerService.fetchOpenRouterModels(input.apiKey);
}),
fetchOllamaModels: procedure
.input(z.object({ url: z.string() }))
.query(async ({ input, ctx }) => {
const modelManagerService = ctx.serviceManager.getService(
"modelManagerService",
);
if (!modelManagerService) {
throw new Error("Model manager service not initialized");
}
return await modelManagerService.fetchOllamaModels(input.url);
}),
// Provider model database sync
getSyncedProviderModels: procedure.query(
async ({ ctx }): Promise<Model[]> => {
const modelManagerService = ctx.serviceManager.getService(
"modelManagerService",
);
if (!modelManagerService) {
throw new Error("Model manager service not initialized");
}
return await modelManagerService.getSyncedProviderModels();
},
),
syncProviderModelsToDatabase: procedure
.input(
z.object({
provider: z.string(),
models: z.array(z.any()), // ProviderModel[]
}),
)
.mutation(async ({ input, ctx }) => {
const modelManagerService = ctx.serviceManager.getService(
"modelManagerService",
);
if (!modelManagerService) {
throw new Error("Model manager service not initialized");
}
await modelManagerService.syncProviderModelsToDatabase(
input.provider,
input.models,
);
return true;
}),
// Unified default model management
getDefaultModel: procedure
.input(
z.object({
type: z.enum(["speech", "language", "embedding"]),
}),
)
.query(async ({ input, ctx }) => {
const modelManagerService = ctx.serviceManager.getService(
"modelManagerService",
);
if (!modelManagerService) {
throw new Error("Model manager service not initialized");
}
switch (input.type) {
case "speech":
return await modelManagerService.getSelectedModel();
case "language":
return await modelManagerService.getDefaultLanguageModel();
case "embedding":
return await modelManagerService.getDefaultEmbeddingModel();
}
}),
setDefaultModel: procedure
.input(
z.object({
type: z.enum(["speech", "language", "embedding"]),
modelId: z.string().nullable(),
}),
)
.mutation(async ({ input, ctx }) => {
const modelManagerService = ctx.serviceManager.getService(
"modelManagerService",
);
if (!modelManagerService) {
throw new Error("Model manager service not initialized");
}
switch (input.type) {
case "speech":
await modelManagerService.setSelectedModel(input.modelId);
// Notify transcription service about model change
const transcriptionService = ctx.serviceManager.getService(
"transcriptionService",
);
if (transcriptionService) {
await transcriptionService.handleModelChange();
}
break;
case "language":
await modelManagerService.setDefaultLanguageModel(input.modelId);
break;
case "embedding":
await modelManagerService.setDefaultEmbeddingModel(input.modelId);
break;
}
return true;
}),
// Legacy endpoints (kept for backward compatibility, can be removed later)
getDefaultLanguageModel: procedure.query(async ({ ctx }) => {
const modelManagerService = ctx.serviceManager.getService(
"modelManagerService",
);
if (!modelManagerService) {
throw new Error("Model manager service not initialized");
}
return await modelManagerService.getDefaultLanguageModel();
}),
setDefaultLanguageModel: procedure
.input(z.object({ modelId: z.string().nullable() }))
.mutation(async ({ input, ctx }) => {
const modelManagerService = ctx.serviceManager.getService(
"modelManagerService",
);
if (!modelManagerService) {
throw new Error("Model manager service not initialized");
}
await modelManagerService.setDefaultLanguageModel(input.modelId);
return true;
}),
getDefaultEmbeddingModel: procedure.query(async ({ ctx }) => {
const modelManagerService = ctx.serviceManager.getService(
"modelManagerService",
);
if (!modelManagerService) {
throw new Error("Model manager service not initialized");
}
return await modelManagerService.getDefaultEmbeddingModel();
}),
setDefaultEmbeddingModel: procedure
.input(z.object({ modelId: z.string().nullable() }))
.mutation(async ({ input, ctx }) => {
const modelManagerService = ctx.serviceManager.getService(
"modelManagerService",
);
if (!modelManagerService) {
throw new Error("Model manager service not initialized");
}
await modelManagerService.setDefaultEmbeddingModel(input.modelId);
return true;
}),
// Remove provider model
removeProviderModel: procedure
.input(z.object({ modelId: z.string() }))
.mutation(async ({ input, ctx }) => {
const modelManagerService = ctx.serviceManager.getService(
"modelManagerService",
);
if (!modelManagerService) {
throw new Error("Model manager service not initialized");
}
// Find the model to get its provider
const allModels = await modelManagerService.getSyncedProviderModels();
const model = allModels.find((m) => m.id === input.modelId);
if (!model) {
throw new Error(`Model not found: ${input.modelId}`);
}
await removeModel(model.provider, input.modelId);
return true;
}),
// Remove provider endpoints
removeOpenRouterProvider: procedure.mutation(async ({ ctx }) => {
const modelManagerService = ctx.serviceManager.getService(
"modelManagerService",
);
if (!modelManagerService) {
throw new Error("Model manager service not initialized");
}
// Remove all OpenRouter models from database
await modelManagerService.removeProviderModels("OpenRouter");
// Clear OpenRouter config from settings
const settingsService = ctx.serviceManager.getService("settingsService");
if (settingsService) {
const currentConfig = await settingsService.getModelProvidersConfig();
const updatedConfig = { ...currentConfig };
delete updatedConfig.openRouter;
// Clear default if it's an OpenRouter model
const allModels = await modelManagerService.getSyncedProviderModels();
const openRouterModels = allModels.filter(
(m) => m.provider === "OpenRouter",
);
if (
currentConfig?.defaultLanguageModel &&
openRouterModels.some(
(m) => m.id === currentConfig.defaultLanguageModel,
)
) {
updatedConfig.defaultLanguageModel = undefined;
}
await settingsService.setModelProvidersConfig(updatedConfig);
}
return true;
}),
removeOllamaProvider: procedure.mutation(async ({ ctx }) => {
const modelManagerService = ctx.serviceManager.getService(
"modelManagerService",
);
if (!modelManagerService) {
throw new Error("Model manager service not initialized");
}
// Remove all Ollama models from database
await modelManagerService.removeProviderModels("Ollama");
// Clear Ollama config from settings
const settingsService = ctx.serviceManager.getService("settingsService");
if (settingsService) {
const currentConfig = await settingsService.getModelProvidersConfig();
const updatedConfig = { ...currentConfig };
delete updatedConfig.ollama;
// Clear defaults if they're Ollama models
const allModels = await modelManagerService.getSyncedProviderModels();
const ollamaModels = allModels.filter((m) => m.provider === "Ollama");
if (
currentConfig?.defaultLanguageModel &&
ollamaModels.some((m) => m.id === currentConfig.defaultLanguageModel)
) {
updatedConfig.defaultLanguageModel = undefined;
}
if (
currentConfig?.defaultEmbeddingModel &&
ollamaModels.some((m) => m.id === currentConfig.defaultEmbeddingModel)
) {
updatedConfig.defaultEmbeddingModel = undefined;
}
await settingsService.setModelProvidersConfig(updatedConfig);
}
return true;
}),
// Subscriptions using Observables
// Using Observable instead of async generator due to Symbol.asyncDispose conflict
// Modern Node.js (20+) adds Symbol.asyncDispose to async generators natively,
@ -188,7 +580,7 @@ export const modelsRouter = createRouter({
onDownloadComplete: procedure.subscription(({ ctx }) => {
return observable<{
modelId: string;
downloadedModel: DownloadedModel;
downloadedModel: Model;
}>((emit) => {
const modelManagerService = ctx.serviceManager.getService(
"modelManagerService",
@ -199,7 +591,7 @@ export const modelsRouter = createRouter({
const handleDownloadComplete = (
modelId: string,
downloadedModel: DownloadedModel,
downloadedModel: Model,
) => {
emit.next({ modelId, downloadedModel });
};
@ -284,4 +676,46 @@ export const modelsRouter = createRouter({
};
});
}),
// Using Observable instead of async generator due to Symbol.asyncDispose conflict
// eslint-disable-next-line deprecation/deprecation
onSelectionChanged: procedure.subscription(({ ctx }) => {
return observable<{
oldModelId: string | null;
newModelId: string | null;
reason:
| "manual"
| "auto-first-download"
| "auto-after-deletion"
| "cleared";
modelType: "speech" | "language" | "embedding";
}>((emit) => {
const modelManagerService = ctx.serviceManager.getService(
"modelManagerService",
);
if (!modelManagerService) {
throw new Error("Model manager service not initialized");
}
const handleSelectionChanged = (
oldModelId: string | null,
newModelId: string | null,
reason:
| "manual"
| "auto-first-download"
| "auto-after-deletion"
| "cleared",
modelType: "speech" | "language" | "embedding",
) => {
emit.next({ oldModelId, newModelId, reason, modelType });
};
modelManagerService.on("selection-changed", handleSelectionChanged);
// Cleanup function
return () => {
modelManagerService?.off("selection-changed", handleSelectionChanged);
};
});
}),
});

View file

@ -1,12 +1,11 @@
import { observable } from "@trpc/server/observable";
import { z } from "zod";
import { app } from "electron";
import { createRouter, procedure } from "../trpc";
// FormatterConfig schema
const FormatterConfigSchema = z.object({
provider: z.literal("openrouter"),
model: z.string(),
apiKey: z.string(),
model: z.string(), // Model ID from synced models
enabled: z.boolean(),
});
@ -16,6 +15,25 @@ const SetShortcutSchema = z.object({
shortcut: z.string(),
});
// Model providers schemas
const OpenRouterConfigSchema = z.object({
apiKey: z.string(),
});
const OllamaConfigSchema = z.object({
url: z.string().url().or(z.literal("")),
});
const ModelProvidersConfigSchema = z.object({
openRouter: OpenRouterConfigSchema.optional(),
ollama: OllamaConfigSchema.optional(),
});
const DictationSettingsSchema = z.object({
autoDetectEnabled: z.boolean(),
selectedLanguage: z.string().min(1), // Must be valid when autoDetectEnabled is false
});
export const settingsRouter = createRouter({
// Get all settings
getSettings: procedure.query(async ({ ctx }) => {
@ -175,7 +193,7 @@ export const settingsRouter = createRouter({
const logger = ctx.serviceManager.getLogger();
if (logger) {
logger.main.info("Shortcut updated", input);
logger?.main.info("Shortcut updated", input);
}
// Notify shortcut manager to reload shortcuts
@ -183,7 +201,9 @@ export const settingsRouter = createRouter({
ctx.serviceManager.getService("shortcutManager");
if (shortcutManager) {
await shortcutManager.reloadShortcuts();
logger.main.info("Shortcut manager notified of shortcut change");
if (logger) {
logger.main.info("Shortcut manager notified of shortcut change");
}
}
return true;
@ -302,4 +322,187 @@ export const settingsRouter = createRouter({
throw error;
}
}),
// Get app version
getAppVersion: procedure.query(() => {
return app.getVersion();
}),
// Get dictation settings
getDictationSettings: procedure.query(async ({ ctx }) => {
try {
const settingsService = ctx.serviceManager.getService("settingsService");
if (!settingsService) {
throw new Error("SettingsService not available");
}
const allSettings = await settingsService.getAllSettings();
return (
allSettings.dictation || {
autoDetectEnabled: true,
selectedLanguage: "en",
}
);
} catch (error) {
const logger = ctx.serviceManager.getLogger();
if (logger) {
logger.main.error("Error getting dictation settings:", error);
}
return {
autoDetectEnabled: true,
selectedLanguage: "en",
};
}
}),
// Set dictation settings
setDictationSettings: procedure
.input(DictationSettingsSchema)
.mutation(async ({ input, ctx }) => {
try {
const settingsService =
ctx.serviceManager.getService("settingsService");
if (!settingsService) {
throw new Error("SettingsService not available");
}
// Validation: if autoDetectEnabled is false, ensure selectedLanguage is valid
if (
!input.autoDetectEnabled &&
(!input.selectedLanguage || input.selectedLanguage === "auto")
) {
throw new Error(
"Selected language must be specified when auto-detect is disabled",
);
}
// Set default to "en" if switching from auto-detect enabled to disabled with invalid language
let selectedLanguage = input.selectedLanguage;
if (
!input.autoDetectEnabled &&
(!selectedLanguage || selectedLanguage === "auto")
) {
selectedLanguage = "en";
}
const dictationSettings = {
autoDetectEnabled: input.autoDetectEnabled,
selectedLanguage,
};
await settingsService.setDictationSettings(dictationSettings);
const logger = ctx.serviceManager.getLogger();
if (logger) {
logger.main.info("Dictation settings updated:", dictationSettings);
}
return true;
} catch (error) {
const logger = ctx.serviceManager.getLogger();
if (logger) {
logger.main.error("Error setting dictation settings:", error);
}
throw error;
}
}),
// Get model providers configuration
getModelProvidersConfig: procedure.query(async ({ ctx }) => {
try {
const settingsService = ctx.serviceManager.getService("settingsService");
if (!settingsService) {
throw new Error("SettingsService not available");
}
return await settingsService.getModelProvidersConfig();
} catch (error) {
const logger = ctx.serviceManager.getLogger();
if (logger) {
logger.main.error("Error getting model providers config:", error);
}
return null;
}
}),
// Set model providers configuration
setModelProvidersConfig: procedure
.input(ModelProvidersConfigSchema)
.mutation(async ({ input, ctx }) => {
try {
const settingsService =
ctx.serviceManager.getService("settingsService");
if (!settingsService) {
throw new Error("SettingsService not available");
}
await settingsService.setModelProvidersConfig(input);
const logger = ctx.serviceManager.getLogger();
if (logger) {
logger.main.info("Model providers configuration updated");
}
return true;
} catch (error) {
const logger = ctx.serviceManager.getLogger();
if (logger) {
logger.main.error("Error setting model providers config:", error);
}
throw error;
}
}),
// Set OpenRouter configuration
setOpenRouterConfig: procedure
.input(OpenRouterConfigSchema)
.mutation(async ({ input, ctx }) => {
try {
const settingsService =
ctx.serviceManager.getService("settingsService");
if (!settingsService) {
throw new Error("SettingsService not available");
}
await settingsService.setOpenRouterConfig(input);
const logger = ctx.serviceManager.getLogger();
if (logger) {
logger.main.info("OpenRouter configuration updated");
}
return true;
} catch (error) {
const logger = ctx.serviceManager.getLogger();
if (logger) {
logger.main.error("Error setting OpenRouter config:", error);
}
throw error;
}
}),
// Set Ollama configuration
setOllamaConfig: procedure
.input(OllamaConfigSchema)
.mutation(async ({ input, ctx }) => {
try {
const settingsService =
ctx.serviceManager.getService("settingsService");
if (!settingsService) {
throw new Error("SettingsService not available");
}
await settingsService.setOllamaConfig(input);
const logger = ctx.serviceManager.getLogger();
if (logger) {
logger.main.info("Ollama configuration updated");
}
return true;
} catch (error) {
const logger = ctx.serviceManager.getLogger();
if (logger) {
logger.main.error("Error setting Ollama config:", error);
}
throw error;
}
}),
});
// This comment prevents prettier from removing the trailing newline

View file

@ -23,16 +23,71 @@ const GetVocabularySchema = z.object({
search: z.string().optional(),
});
const CreateVocabularySchema = z.object({
word: z.string().min(1),
dateAdded: z.date().optional(),
});
const CreateVocabularySchema = z
.object({
word: z.string().min(1),
isReplacement: z.boolean().optional(),
replacementWord: z.string().optional(),
})
.refine(
(data) => {
// If isReplacement is true, replacementWord must be provided
if (data.isReplacement === true && !data.replacementWord) {
return false;
}
return true;
},
{
message: "replacementWord is required when isReplacement is true",
path: ["replacementWord"],
},
)
.refine(
(data) => {
// If both word and replacementWord are provided, they must be different
if (data.word && data.replacementWord) {
return data.word !== data.replacementWord;
}
return true;
},
{
path: ["replacementWord"],
message: "replacementWord must be different from word",
},
);
const UpdateVocabularySchema = z.object({
word: z.string().min(1).optional(),
dateAdded: z.date().optional(),
usageCount: z.number().optional(),
});
const UpdateVocabularySchema = z
.object({
word: z.string().min(1).optional(),
isReplacement: z.boolean().optional(),
replacementWord: z.string().optional(),
})
.refine(
(data) => {
// If isReplacement is true, replacementWord must be provided
if (data.isReplacement === true && !data.replacementWord) {
return false;
}
return true;
},
{
message: "replacementWord is required when isReplacement is true",
path: ["replacementWord"],
},
)
.refine(
(data) => {
// If both word and replacementWord are provided, they must be different
if (data.word && data.replacementWord) {
return data.word !== data.replacementWord;
}
return true;
},
{
message: "replacementWord must be different from word",
path: ["replacementWord"],
},
);
const BulkImportSchema = z.array(
z.object({

View file

@ -15,38 +15,7 @@ export interface ElectronAPI {
// Methods called from renderer to main become async (invoke/handle)
sendAudioChunk: (chunk: Float32Array, isFinalChunk: boolean) => Promise<void>;
// Model Management API
getAvailableModels: () => Promise<import("../constants/models").Model[]>;
getDownloadedModels: () => Promise<
Record<string, import("../db/schema").DownloadedModel>
>;
isModelDownloaded: (modelId: string) => Promise<boolean>;
getDownloadProgress: (
modelId: string,
) => Promise<import("../constants/models").DownloadProgress | null>;
getActiveDownloads: () => Promise<
import("../constants/models").DownloadProgress[]
>;
downloadModel: (modelId: string) => Promise<void>;
cancelDownload: (modelId: string) => Promise<void>;
deleteModel: (modelId: string) => Promise<void>;
getModelsDirectory: () => Promise<string>;
// Local Whisper API
isLocalWhisperAvailable: () => Promise<boolean>;
getLocalWhisperModels: () => Promise<string[]>;
getSelectedModel: () => Promise<string | null>;
setSelectedModel: (modelId: string) => Promise<void>;
setWhisperExecutablePath: (path: string) => Promise<void>;
// Formatter Configuration API
getFormatterConfig: () => Promise<
import("../types/formatter").FormatterConfig | null
>;
setFormatterConfig: (
config: import("../types/formatter").FormatterConfig,
) => Promise<void>;
// Model Management API (moved to tRPC)
// Transcription Database API (moved to tRPC)
on: (channel: string, callback: (...args: any[]) => void) => void;

View file

@ -1,6 +1,4 @@
export interface FormatterConfig {
provider: "openrouter";
model: string;
apiKey: string;
model: string; // Model ID from synced models
enabled: boolean;
}

View file

@ -0,0 +1,70 @@
export interface ValidationResult {
success: boolean;
error?: string;
}
export interface OpenRouterValidationRequest {
apiKey: string;
}
export interface OllamaValidationRequest {
url: string;
}
// OpenRouter API response types
export interface OpenRouterModel {
id: string;
name: string;
description?: string;
pricing?: {
prompt: number;
completion: number;
};
context_length: number;
architecture?: {
modality: string;
tokenizer: string;
instruct_type?: string;
};
top_provider?: {
max_completion_tokens?: number;
is_moderated: boolean;
};
}
export interface OpenRouterResponse {
data: OpenRouterModel[];
}
// Ollama API response types
export interface OllamaModel {
name: string;
model: string;
size: number;
digest: string;
details?: {
parent_model?: string;
format?: string;
family?: string;
families?: string[];
parameter_size?: string;
quantization_level?: string;
};
expires_at?: string;
size_vram?: number;
}
export interface OllamaResponse {
models: OllamaModel[];
}
// Unified model interface for UI
export interface ProviderModel {
id: string; // Unique identifier (model ID)
name: string; // Display name
provider: string; // "OpenRouter" | "Ollama"
size?: string; // Model size (e.g., "7B", "Large")
context: string; // Context length (e.g., "32k", "128k")
description?: string; // Optional description
originalModel?: OpenRouterModel | OllamaModel; // Keep original for reference
}

View file

@ -1,14 +1,15 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "nodenext",
"module": "ESNext",
"allowJs": true,
"skipLibCheck": true,
"types": ["node", "vite/client"],
"esModuleInterop": true,
"noImplicitAny": true,
"sourceMap": true,
"outDir": "dist",
"moduleResolution": "nodenext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"jsx": "react-jsx",
"lib": ["DOM", "DOM.Iterable", "ESNext"],

Some files were not shown because too many files have changed in this diff Show more