Desktop MVP (#23)
* chore: logging + transcription improvements * chore: add ax context call on rec start * chore: amical assets * chore: qol setup changes * chore: add sidebar * chore: transcriptions tab * chore: transcriptions ui * chore: frame improvements * chore: ui rework * chore logger fixes * chore: whisper model download func * chore: update model downloading * chore: transcription updates * chore: improved logging * chore: log whisper metrics + raw pcm proc * chore: ste up libsql * chore: layout fixes * chore: clean up ipcs * chore: integrate trpc * chore: formatting fixes * chroe: fix pnpm lock file * chore: clean up
This commit is contained in:
parent
17fdb72be2
commit
d7481f7398
79 changed files with 8306 additions and 940 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -36,6 +36,8 @@ yarn-error.log*
|
|||
# Misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
CLAUDE.md
|
||||
.serena
|
||||
|
||||
# Temp files
|
||||
/tmp
|
||||
/tmp
|
||||
|
|
|
|||
|
|
@ -1,24 +0,0 @@
|
|||
{
|
||||
"env": {
|
||||
"browser": true,
|
||||
"es6": true,
|
||||
"node": true
|
||||
},
|
||||
"extends": [
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"plugin:import/recommended",
|
||||
"plugin:import/typescript",
|
||||
"plugin:prettier/recommended"
|
||||
],
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"plugins": ["@typescript-eslint", "import", "prettier"],
|
||||
"rules": {
|
||||
"prettier/prettier": "error"
|
||||
},
|
||||
"settings": {
|
||||
"import/resolver": {
|
||||
"typescript": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
3
apps/electron/.gitignore
vendored
3
apps/electron/.gitignore
vendored
|
|
@ -94,3 +94,6 @@ out/
|
|||
# Swift Build
|
||||
.build/
|
||||
bin/
|
||||
|
||||
# VSCode
|
||||
.vscode/
|
||||
|
|
|
|||
9
apps/electron/assets/logo.svg
Normal file
9
apps/electron/assets/logo.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 108 KiB |
|
|
@ -5,6 +5,6 @@ export default {
|
|||
out: './src/db/migrations',
|
||||
dialect: 'sqlite',
|
||||
dbCredentials: {
|
||||
url: 'file:./db.sqlite',
|
||||
url: 'file:./amical.db',
|
||||
},
|
||||
} satisfies Config;
|
||||
|
|
|
|||
4
apps/electron/eslint.config.mjs
Normal file
4
apps/electron/eslint.config.mjs
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
import { config } from "@amical/eslint-config/base";
|
||||
|
||||
/** @type {import("eslint").Linter.Config} */
|
||||
export default config;
|
||||
|
|
@ -3,7 +3,6 @@ import { MakerSquirrel } from '@electron-forge/maker-squirrel';
|
|||
import { MakerZIP } from '@electron-forge/maker-zip';
|
||||
import { MakerDeb } from '@electron-forge/maker-deb';
|
||||
import { MakerRpm } from '@electron-forge/maker-rpm';
|
||||
import { MakerDMG } from '@electron-forge/maker-dmg';
|
||||
import { VitePlugin } from '@electron-forge/plugin-vite';
|
||||
import { FusesPlugin } from '@electron-forge/plugin-fuses';
|
||||
import { FuseV1Options, FuseVersion } from '@electron/fuses';
|
||||
|
|
@ -11,18 +10,17 @@ import { FuseV1Options, FuseVersion } from '@electron/fuses';
|
|||
const config: ForgeConfig = {
|
||||
packagerConfig: {
|
||||
asar: true,
|
||||
name: 'Amical',
|
||||
executableName: 'Amical',
|
||||
icon: './assets/logo', // Path to your icon file (without extension)
|
||||
extraResource: ['../../packages/native-helpers/swift-helper/bin'],
|
||||
extendInfo: {
|
||||
NSMicrophoneUsageDescription: "This app needs access to your microphone to record audio for transcription.",
|
||||
NSMicrophoneUsageDescription:
|
||||
'This app needs access to your microphone to record audio for transcription.',
|
||||
},
|
||||
},
|
||||
rebuildConfig: {},
|
||||
makers: [
|
||||
new MakerSquirrel({}),
|
||||
new MakerZIP({}, ['darwin']),
|
||||
new MakerRpm({}),
|
||||
new MakerDeb({}),
|
||||
],
|
||||
makers: [new MakerSquirrel({}), new MakerZIP({}, ['darwin']), new MakerRpm({}), new MakerDeb({})],
|
||||
plugins: [
|
||||
new VitePlugin({
|
||||
// `build` can specify multiple entry builds, which can be Main process, Preload scripts, Worker process, etc.
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
"version": "1.0.0",
|
||||
"description": "Amical Electron application",
|
||||
"main": ".vite/build/main.js",
|
||||
"productName": "Amical",
|
||||
"scripts": {
|
||||
"start": "pnpm build:swift-helper && electron-forge start",
|
||||
"package": "pnpm build:swift-helper && electron-forge package",
|
||||
|
|
@ -10,19 +11,15 @@
|
|||
"publish": "electron-forge publish",
|
||||
"lint": "eslint --ext .ts,.tsx .",
|
||||
"format": "prettier --write \"**/*.{js,jsx,ts,tsx,json,md}\"",
|
||||
"db:generate": "drizzle-kit generate:sqlite",
|
||||
"db:push": "drizzle-kit push:sqlite",
|
||||
"ts:check": "tsc --noEmit",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:push": "drizzle-kit push",
|
||||
"db:migrate": "drizzle-kit migrate",
|
||||
"build:swift-helper": "pnpm --filter @amical/swift-helper build",
|
||||
"dev": "pnpm start"
|
||||
},
|
||||
"keywords": [],
|
||||
"license": "MIT",
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"electron",
|
||||
"electron-winstaller"
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@electron-forge/cli": "^7.8.1",
|
||||
"@electron-forge/maker-deb": "^7.8.1",
|
||||
|
|
@ -33,11 +30,10 @@
|
|||
"@electron-forge/plugin-fuses": "^7.8.1",
|
||||
"@electron-forge/plugin-vite": "^7.8.1",
|
||||
"@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",
|
||||
"@typescript-eslint/eslint-plugin": "^5.62.0",
|
||||
"@typescript-eslint/parser": "^5.62.0",
|
||||
"electron": "36.2.0",
|
||||
"eslint": "^9.26.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
|
|
@ -50,40 +46,52 @@
|
|||
"vite": "^5.4.19"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/openai": "^1.3.22",
|
||||
"@amical/eslint-config": "workspace:*",
|
||||
"@amical/types": "workspace:*",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/modifiers": "^9.0.0",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@hookform/resolvers": "^5.0.1",
|
||||
"@libsql/client": "^0.15.9",
|
||||
"@radix-ui/react-accordion": "^1.2.10",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.13",
|
||||
"@radix-ui/react-aspect-ratio": "^1.1.6",
|
||||
"@radix-ui/react-avatar": "^1.1.9",
|
||||
"@radix-ui/react-checkbox": "^1.3.1",
|
||||
"@radix-ui/react-avatar": "^1.1.10",
|
||||
"@radix-ui/react-checkbox": "^1.3.2",
|
||||
"@radix-ui/react-collapsible": "^1.1.10",
|
||||
"@radix-ui/react-context-menu": "^2.2.14",
|
||||
"@radix-ui/react-dialog": "^1.1.13",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.14",
|
||||
"@radix-ui/react-dialog": "^1.1.14",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
||||
"@radix-ui/react-hover-card": "^1.1.13",
|
||||
"@radix-ui/react-label": "^2.1.6",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-menubar": "^1.1.14",
|
||||
"@radix-ui/react-navigation-menu": "^1.2.12",
|
||||
"@radix-ui/react-popover": "^1.1.13",
|
||||
"@radix-ui/react-progress": "^1.1.6",
|
||||
"@radix-ui/react-radio-group": "^1.3.6",
|
||||
"@radix-ui/react-scroll-area": "^1.2.8",
|
||||
"@radix-ui/react-select": "^2.2.4",
|
||||
"@radix-ui/react-separator": "^1.1.6",
|
||||
"@radix-ui/react-select": "^2.2.5",
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
"@radix-ui/react-slider": "^1.3.4",
|
||||
"@radix-ui/react-slot": "^1.2.2",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-switch": "^1.2.4",
|
||||
"@radix-ui/react-tabs": "^1.1.11",
|
||||
"@radix-ui/react-toggle": "^1.1.8",
|
||||
"@radix-ui/react-toggle-group": "^1.1.9",
|
||||
"@radix-ui/react-tooltip": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.12",
|
||||
"@radix-ui/react-toggle": "^1.1.9",
|
||||
"@radix-ui/react-toggle-group": "^1.1.10",
|
||||
"@radix-ui/react-tooltip": "^1.2.7",
|
||||
"@ricky0123/vad-web": "^0.0.24",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@tabler/icons-react": "^3.34.0",
|
||||
"@tanstack/react-query": "^5.81.2",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@trpc/client": "^11.4.2",
|
||||
"@trpc/react-query": "^11.4.2",
|
||||
"@trpc/server": "^11.4.2",
|
||||
"@types/split2": "^4.2.3",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"ai": "^4.3.16",
|
||||
"async-mutex": "^0.5.0",
|
||||
"better-sqlite3": "^11.10.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
|
|
@ -91,12 +99,14 @@
|
|||
"dotenv": "^16.5.0",
|
||||
"drizzle-kit": "^0.31.1",
|
||||
"drizzle-orm": "^0.43.1",
|
||||
"electron-log": "^5.4.0",
|
||||
"electron-squirrel-startup": "^1.0.1",
|
||||
"electron-store": "^10.0.1",
|
||||
"electron-trpc-experimental": "1.0.0-alpha.1",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"framer-motion": "^12.10.5",
|
||||
"input-otp": "^1.4.2",
|
||||
"keytar": "^7.9.0",
|
||||
"libsql": "^0.5.13",
|
||||
"lucide-react": "^0.510.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"openai": "^4.98.0",
|
||||
|
|
@ -106,11 +116,14 @@
|
|||
"react-hook-form": "^7.56.3",
|
||||
"react-resizable-panels": "^3.0.2",
|
||||
"recharts": "^2.15.3",
|
||||
"smart-whisper": "0.2.0",
|
||||
"sonner": "^2.0.3",
|
||||
"split2": "^4.2.0",
|
||||
"superjson": "^2.2.2",
|
||||
"tailwind-merge": "^3.3.0",
|
||||
"tw-animate-css": "^1.2.9",
|
||||
"uuid": "^11.1.0",
|
||||
"vaul": "^1.1.2"
|
||||
"vaul": "^1.1.2",
|
||||
"zod": "^3.25.24"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
6
apps/electron/public/assets/discord-icon.svg
Normal file
6
apps/electron/public/assets/discord-icon.svg
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<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: 1.9 KiB |
38
apps/electron/public/assets/logo.svg
Normal file
38
apps/electron/public/assets/logo.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 119 KiB |
62
apps/electron/public/audio-recorder-worklet.js
Normal file
62
apps/electron/public/audio-recorder-worklet.js
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
// AudioWorklet processor for real-time audio capture
|
||||
// This runs in the audio rendering thread for low-latency processing
|
||||
/* eslint-env worker */
|
||||
/* global AudioWorkletProcessor, registerProcessor */
|
||||
|
||||
class AudioRecorderProcessor extends AudioWorkletProcessor {
|
||||
constructor() {
|
||||
super();
|
||||
this.bufferSize = 4096;
|
||||
this.buffer = new Float32Array(this.bufferSize);
|
||||
this.bufferIndex = 0;
|
||||
|
||||
// Listen for messages from main thread
|
||||
this.port.onmessage = (event) => {
|
||||
if (event.data.command === 'stop') {
|
||||
this.sendBufferedAudio(true); // Send final chunk
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
process(inputs, _outputs, _parameters) {
|
||||
const input = inputs[0];
|
||||
|
||||
// Check if we have input audio
|
||||
if (input && input.length > 0) {
|
||||
const inputChannel = input[0]; // Get first (mono) channel
|
||||
|
||||
// Buffer the audio data
|
||||
for (let i = 0; i < inputChannel.length; i++) {
|
||||
this.buffer[this.bufferIndex] = inputChannel[i];
|
||||
this.bufferIndex++;
|
||||
|
||||
// When buffer is full, send it to main thread
|
||||
if (this.bufferIndex >= this.bufferSize) {
|
||||
this.sendBufferedAudio(false);
|
||||
this.bufferIndex = 0; // Reset buffer
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Keep the processor alive
|
||||
return true;
|
||||
}
|
||||
|
||||
sendBufferedAudio(isFinal) {
|
||||
if (this.bufferIndex > 0 || isFinal) {
|
||||
// Create a copy of the current buffer data
|
||||
const audioData = new Float32Array(this.bufferIndex);
|
||||
audioData.set(this.buffer.subarray(0, this.bufferIndex));
|
||||
|
||||
// Send to main thread
|
||||
this.port.postMessage({
|
||||
type: 'audioData',
|
||||
audioData: audioData,
|
||||
isFinal: isFinal,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Register the processor
|
||||
registerProcessor('audio-recorder-processor', AudioRecorderProcessor);
|
||||
126
apps/electron/src/components/app-sidebar.tsx
Normal file
126
apps/electron/src/components/app-sidebar.tsx
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
import * as React from "react"
|
||||
import {
|
||||
IconDatabase,
|
||||
IconFileDescription,
|
||||
IconFileWord,
|
||||
IconReport,
|
||||
IconSettings,
|
||||
IconBookFilled,
|
||||
} from "@tabler/icons-react"
|
||||
|
||||
import { NavMain } from "@/components/nav-main"
|
||||
import { NavSecondary } from "@/components/nav-secondary"
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarHeader,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
} from "@/components/ui/sidebar"
|
||||
|
||||
// Custom Discord icon component
|
||||
const DiscordIcon = ({ className }: { className?: string }) => (
|
||||
<img
|
||||
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: "Vocabulary",
|
||||
url: "#",
|
||||
icon: IconFileWord,
|
||||
},
|
||||
{
|
||||
title: "Models",
|
||||
url: "#",
|
||||
icon: IconDatabase,
|
||||
},
|
||||
{
|
||||
title: "Settings",
|
||||
url: "#",
|
||||
icon: IconSettings,
|
||||
},
|
||||
],
|
||||
navSecondary: [
|
||||
{
|
||||
title: "Docs",
|
||||
url: "https://amical.ai/docs",
|
||||
icon: IconBookFilled,
|
||||
external: true,
|
||||
},
|
||||
{
|
||||
title: "Community",
|
||||
url: "https://amical.ai/community",
|
||||
icon: DiscordIcon,
|
||||
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, ...props }: AppSidebarProps) {
|
||||
return (
|
||||
<Sidebar collapsible="offcanvas" {...props}>
|
||||
<div className="h-[var(--header-height)]"></div>
|
||||
<SidebarHeader className="py-0 -mb-1">
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
className="data-[slot=sidebar-menu-button]:!p-1.5"
|
||||
>
|
||||
<a href="#" className="inline-flex items-center gap-2.5 font-semibold">
|
||||
<img src="assets/logo.svg" alt="Amical Logo" className="!size-7" />
|
||||
<span className="font-semibold">Amical</span>
|
||||
</a>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
<NavMain items={data.navMain} onNavigate={onNavigate} currentView={currentView} />
|
||||
<NavSecondary items={data.navSecondary} onNavigate={onNavigate} currentView={currentView} className="mt-auto" />
|
||||
</SidebarContent>
|
||||
<SidebarFooter>
|
||||
{/* <NavUser user={data.user} /> */}
|
||||
</SidebarFooter>
|
||||
</Sidebar>
|
||||
)
|
||||
}
|
||||
805
apps/electron/src/components/data-table.tsx
Normal file
805
apps/electron/src/components/data-table.tsx
Normal file
|
|
@ -0,0 +1,805 @@
|
|||
import * as React from "react"
|
||||
import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
KeyboardSensor,
|
||||
MouseSensor,
|
||||
TouchSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
type UniqueIdentifier,
|
||||
} from "@dnd-kit/core"
|
||||
import { restrictToVerticalAxis } from "@dnd-kit/modifiers"
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable"
|
||||
import { CSS } from "@dnd-kit/utilities"
|
||||
import {
|
||||
IconChevronDown,
|
||||
IconChevronLeft,
|
||||
IconChevronRight,
|
||||
IconChevronsLeft,
|
||||
IconChevronsRight,
|
||||
IconCircleCheckFilled,
|
||||
IconDotsVertical,
|
||||
IconGripVertical,
|
||||
IconLayoutColumns,
|
||||
IconLoader,
|
||||
IconPlus,
|
||||
IconTrendingUp,
|
||||
} from "@tabler/icons-react"
|
||||
import {
|
||||
ColumnDef,
|
||||
ColumnFiltersState,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getFacetedRowModel,
|
||||
getFacetedUniqueValues,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
Row,
|
||||
SortingState,
|
||||
useReactTable,
|
||||
VisibilityState,
|
||||
} from "@tanstack/react-table"
|
||||
import { Area, AreaChart, CartesianGrid, XAxis } from "recharts"
|
||||
import { toast } from "sonner"
|
||||
import { z } from "zod"
|
||||
|
||||
import { useIsMobile } from "@/hooks/use-mobile"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
ChartConfig,
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
} from "@/components/ui/chart"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerFooter,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from "@/components/ui/drawer"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table"
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@/components/ui/tabs"
|
||||
|
||||
export const schema = z.object({
|
||||
id: z.number(),
|
||||
header: z.string(),
|
||||
type: z.string(),
|
||||
status: z.string(),
|
||||
target: z.string(),
|
||||
limit: z.string(),
|
||||
reviewer: z.string(),
|
||||
})
|
||||
|
||||
// Create a separate component for the drag handle
|
||||
function DragHandle({ id }: { id: number }) {
|
||||
const { attributes, listeners } = useSortable({
|
||||
id,
|
||||
})
|
||||
|
||||
return (
|
||||
<Button
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-muted-foreground size-7 hover:bg-transparent"
|
||||
>
|
||||
<IconGripVertical className="text-muted-foreground size-3" />
|
||||
<span className="sr-only">Drag to reorder</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
const columns: ColumnDef<z.infer<typeof schema>>[] = [
|
||||
{
|
||||
id: "drag",
|
||||
header: () => null,
|
||||
cell: ({ row }) => <DragHandle id={row.original.id} />,
|
||||
},
|
||||
{
|
||||
id: "select",
|
||||
header: ({ table }) => (
|
||||
<div className="flex items-center justify-center">
|
||||
<Checkbox
|
||||
checked={
|
||||
table.getIsAllPageRowsSelected() ||
|
||||
(table.getIsSomePageRowsSelected() && "indeterminate")
|
||||
}
|
||||
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
||||
aria-label="Select all"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center justify-center">
|
||||
<Checkbox
|
||||
checked={row.getIsSelected()}
|
||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||
aria-label="Select row"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
},
|
||||
{
|
||||
accessorKey: "header",
|
||||
header: "Header",
|
||||
cell: ({ row }) => {
|
||||
return <TableCellViewer item={row.original} />
|
||||
},
|
||||
enableHiding: false,
|
||||
},
|
||||
{
|
||||
accessorKey: "type",
|
||||
header: "Section Type",
|
||||
cell: ({ row }) => (
|
||||
<div className="w-32">
|
||||
<Badge variant="outline" className="text-muted-foreground px-1.5">
|
||||
{row.original.type}
|
||||
</Badge>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "status",
|
||||
header: "Status",
|
||||
cell: ({ row }) => (
|
||||
<Badge variant="outline" className="text-muted-foreground px-1.5">
|
||||
{row.original.status === "Done" ? (
|
||||
<IconCircleCheckFilled className="fill-green-500 dark:fill-green-400" />
|
||||
) : (
|
||||
<IconLoader />
|
||||
)}
|
||||
{row.original.status}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "target",
|
||||
header: () => <div className="w-full text-right">Target</div>,
|
||||
cell: ({ row }) => (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
toast.promise(new Promise((resolve) => setTimeout(resolve, 1000)), {
|
||||
loading: `Saving ${row.original.header}`,
|
||||
success: "Done",
|
||||
error: "Error",
|
||||
})
|
||||
}}
|
||||
>
|
||||
<Label htmlFor={`${row.original.id}-target`} className="sr-only">
|
||||
Target
|
||||
</Label>
|
||||
<Input
|
||||
className="hover:bg-input/30 focus-visible:bg-background dark:hover:bg-input/30 dark:focus-visible:bg-input/30 h-8 w-16 border-transparent bg-transparent text-right shadow-none focus-visible:border dark:bg-transparent"
|
||||
defaultValue={row.original.target}
|
||||
id={`${row.original.id}-target`}
|
||||
/>
|
||||
</form>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "limit",
|
||||
header: () => <div className="w-full text-right">Limit</div>,
|
||||
cell: ({ row }) => (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
toast.promise(new Promise((resolve) => setTimeout(resolve, 1000)), {
|
||||
loading: `Saving ${row.original.header}`,
|
||||
success: "Done",
|
||||
error: "Error",
|
||||
})
|
||||
}}
|
||||
>
|
||||
<Label htmlFor={`${row.original.id}-limit`} className="sr-only">
|
||||
Limit
|
||||
</Label>
|
||||
<Input
|
||||
className="hover:bg-input/30 focus-visible:bg-background dark:hover:bg-input/30 dark:focus-visible:bg-input/30 h-8 w-16 border-transparent bg-transparent text-right shadow-none focus-visible:border dark:bg-transparent"
|
||||
defaultValue={row.original.limit}
|
||||
id={`${row.original.id}-limit`}
|
||||
/>
|
||||
</form>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "reviewer",
|
||||
header: "Reviewer",
|
||||
cell: ({ row }) => {
|
||||
const isAssigned = row.original.reviewer !== "Assign reviewer"
|
||||
|
||||
if (isAssigned) {
|
||||
return row.original.reviewer
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Label htmlFor={`${row.original.id}-reviewer`} className="sr-only">
|
||||
Reviewer
|
||||
</Label>
|
||||
<Select>
|
||||
<SelectTrigger
|
||||
className="w-38 **:data-[slot=select-value]:block **:data-[slot=select-value]:truncate"
|
||||
size="sm"
|
||||
id={`${row.original.id}-reviewer`}
|
||||
>
|
||||
<SelectValue placeholder="Assign reviewer" />
|
||||
</SelectTrigger>
|
||||
<SelectContent align="end">
|
||||
<SelectItem value="Eddie Lake">Eddie Lake</SelectItem>
|
||||
<SelectItem value="Jamik Tashpulatov">
|
||||
Jamik Tashpulatov
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
cell: () => (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="data-[state=open]:bg-muted text-muted-foreground flex size-8"
|
||||
size="icon"
|
||||
>
|
||||
<IconDotsVertical />
|
||||
<span className="sr-only">Open menu</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-32">
|
||||
<DropdownMenuItem>Edit</DropdownMenuItem>
|
||||
<DropdownMenuItem>Make a copy</DropdownMenuItem>
|
||||
<DropdownMenuItem>Favorite</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem variant="destructive">Delete</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
function DraggableRow({ row }: { row: Row<z.infer<typeof schema>> }) {
|
||||
const { transform, transition, setNodeRef, isDragging } = useSortable({
|
||||
id: row.original.id,
|
||||
})
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
data-dragging={isDragging}
|
||||
ref={setNodeRef}
|
||||
className="relative z-0 data-[dragging=true]:z-10 data-[dragging=true]:opacity-80"
|
||||
style={{
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition: transition,
|
||||
}}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
)
|
||||
}
|
||||
|
||||
export function DataTable({
|
||||
data: initialData,
|
||||
}: {
|
||||
data: z.infer<typeof schema>[]
|
||||
}) {
|
||||
const [data, setData] = React.useState(() => initialData)
|
||||
const [rowSelection, setRowSelection] = React.useState({})
|
||||
const [columnVisibility, setColumnVisibility] =
|
||||
React.useState<VisibilityState>({})
|
||||
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(
|
||||
[]
|
||||
)
|
||||
const [sorting, setSorting] = React.useState<SortingState>([])
|
||||
const [pagination, setPagination] = React.useState({
|
||||
pageIndex: 0,
|
||||
pageSize: 10,
|
||||
})
|
||||
const sortableId = React.useId()
|
||||
const sensors = useSensors(
|
||||
useSensor(MouseSensor, {}),
|
||||
useSensor(TouchSensor, {}),
|
||||
useSensor(KeyboardSensor, {})
|
||||
)
|
||||
|
||||
const dataIds = React.useMemo<UniqueIdentifier[]>(
|
||||
() => data?.map(({ id }) => id) || [],
|
||||
[data]
|
||||
)
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
state: {
|
||||
sorting,
|
||||
columnVisibility,
|
||||
rowSelection,
|
||||
columnFilters,
|
||||
pagination,
|
||||
},
|
||||
getRowId: (row) => row.id.toString(),
|
||||
enableRowSelection: true,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
onSortingChange: setSorting,
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
onPaginationChange: setPagination,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFacetedRowModel: getFacetedRowModel(),
|
||||
getFacetedUniqueValues: getFacetedUniqueValues(),
|
||||
})
|
||||
|
||||
function handleDragEnd(event: DragEndEvent) {
|
||||
const { active, over } = event
|
||||
if (active && over && active.id !== over.id) {
|
||||
setData((data) => {
|
||||
const oldIndex = dataIds.indexOf(active.id)
|
||||
const newIndex = dataIds.indexOf(over.id)
|
||||
return arrayMove(data, oldIndex, newIndex)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
defaultValue="outline"
|
||||
className="w-full flex-col justify-start gap-6"
|
||||
>
|
||||
<div className="flex items-center justify-between px-4 lg:px-6">
|
||||
<Label htmlFor="view-selector" className="sr-only">
|
||||
View
|
||||
</Label>
|
||||
<Select defaultValue="outline">
|
||||
<SelectTrigger
|
||||
className="flex w-fit @4xl/main:hidden"
|
||||
size="sm"
|
||||
id="view-selector"
|
||||
>
|
||||
<SelectValue placeholder="Select a view" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="outline">Outline</SelectItem>
|
||||
<SelectItem value="past-performance">Past Performance</SelectItem>
|
||||
<SelectItem value="key-personnel">Key Personnel</SelectItem>
|
||||
<SelectItem value="focus-documents">Focus Documents</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<TabsList className="**:data-[slot=badge]:bg-muted-foreground/30 hidden **:data-[slot=badge]:size-5 **:data-[slot=badge]:rounded-full **:data-[slot=badge]:px-1 @4xl/main:flex">
|
||||
<TabsTrigger value="outline">Outline</TabsTrigger>
|
||||
<TabsTrigger value="past-performance">
|
||||
Past Performance <Badge variant="secondary">3</Badge>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="key-personnel">
|
||||
Key Personnel <Badge variant="secondary">2</Badge>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="focus-documents">Focus Documents</TabsTrigger>
|
||||
</TabsList>
|
||||
<div className="flex items-center gap-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
<IconLayoutColumns />
|
||||
<span className="hidden lg:inline">Customize Columns</span>
|
||||
<span className="lg:hidden">Columns</span>
|
||||
<IconChevronDown />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
{table
|
||||
.getAllColumns()
|
||||
.filter(
|
||||
(column) =>
|
||||
typeof column.accessorFn !== "undefined" &&
|
||||
column.getCanHide()
|
||||
)
|
||||
.map((column) => {
|
||||
return (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={column.id}
|
||||
className="capitalize"
|
||||
checked={column.getIsVisible()}
|
||||
onCheckedChange={(value) =>
|
||||
column.toggleVisibility(!!value)
|
||||
}
|
||||
>
|
||||
{column.id}
|
||||
</DropdownMenuCheckboxItem>
|
||||
)
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button variant="outline" size="sm">
|
||||
<IconPlus />
|
||||
<span className="hidden lg:inline">Add Section</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<TabsContent
|
||||
value="outline"
|
||||
className="relative flex flex-col gap-4 overflow-auto px-4 lg:px-6"
|
||||
>
|
||||
<div className="overflow-hidden rounded-lg border">
|
||||
<DndContext
|
||||
collisionDetection={closestCenter}
|
||||
modifiers={[restrictToVerticalAxis]}
|
||||
onDragEnd={handleDragEnd}
|
||||
sensors={sensors}
|
||||
id={sortableId}
|
||||
>
|
||||
<Table>
|
||||
<TableHeader className="bg-muted sticky top-0 z-10">
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
return (
|
||||
<TableHead key={header.id} colSpan={header.colSpan}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</TableHead>
|
||||
)
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody className="**:data-[slot=table-cell]:first:w-8">
|
||||
{table.getRowModel().rows?.length ? (
|
||||
<SortableContext
|
||||
items={dataIds}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
<DraggableRow key={row.id} row={row} />
|
||||
))}
|
||||
</SortableContext>
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columns.length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
No results.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</DndContext>
|
||||
</div>
|
||||
<div className="flex items-center justify-between px-4">
|
||||
<div className="text-muted-foreground hidden flex-1 text-sm lg:flex">
|
||||
{table.getFilteredSelectedRowModel().rows.length} of{" "}
|
||||
{table.getFilteredRowModel().rows.length} row(s) selected.
|
||||
</div>
|
||||
<div className="flex w-full items-center gap-8 lg:w-fit">
|
||||
<div className="hidden items-center gap-2 lg:flex">
|
||||
<Label htmlFor="rows-per-page" className="text-sm font-medium">
|
||||
Rows per page
|
||||
</Label>
|
||||
<Select
|
||||
value={`${table.getState().pagination.pageSize}`}
|
||||
onValueChange={(value) => {
|
||||
table.setPageSize(Number(value))
|
||||
}}
|
||||
>
|
||||
<SelectTrigger size="sm" className="w-20" id="rows-per-page">
|
||||
<SelectValue
|
||||
placeholder={table.getState().pagination.pageSize}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent side="top">
|
||||
{[10, 20, 30, 40, 50].map((pageSize) => (
|
||||
<SelectItem key={pageSize} value={`${pageSize}`}>
|
||||
{pageSize}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex w-fit items-center justify-center text-sm font-medium">
|
||||
Page {table.getState().pagination.pageIndex + 1} of{" "}
|
||||
{table.getPageCount()}
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-2 lg:ml-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="hidden h-8 w-8 p-0 lg:flex"
|
||||
onClick={() => table.setPageIndex(0)}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<span className="sr-only">Go to first page</span>
|
||||
<IconChevronsLeft />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="size-8"
|
||||
size="icon"
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<span className="sr-only">Go to previous page</span>
|
||||
<IconChevronLeft />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="size-8"
|
||||
size="icon"
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<span className="sr-only">Go to next page</span>
|
||||
<IconChevronRight />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="hidden size-8 lg:flex"
|
||||
size="icon"
|
||||
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<span className="sr-only">Go to last page</span>
|
||||
<IconChevronsRight />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent
|
||||
value="past-performance"
|
||||
className="flex flex-col px-4 lg:px-6"
|
||||
>
|
||||
<div className="aspect-video w-full flex-1 rounded-lg border border-dashed"></div>
|
||||
</TabsContent>
|
||||
<TabsContent value="key-personnel" className="flex flex-col px-4 lg:px-6">
|
||||
<div className="aspect-video w-full flex-1 rounded-lg border border-dashed"></div>
|
||||
</TabsContent>
|
||||
<TabsContent
|
||||
value="focus-documents"
|
||||
className="flex flex-col px-4 lg:px-6"
|
||||
>
|
||||
<div className="aspect-video w-full flex-1 rounded-lg border border-dashed"></div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
)
|
||||
}
|
||||
|
||||
const chartData = [
|
||||
{ month: "January", desktop: 186, mobile: 80 },
|
||||
{ month: "February", desktop: 305, mobile: 200 },
|
||||
{ month: "March", desktop: 237, mobile: 120 },
|
||||
{ month: "April", desktop: 73, mobile: 190 },
|
||||
{ month: "May", desktop: 209, mobile: 130 },
|
||||
{ month: "June", desktop: 214, mobile: 140 },
|
||||
]
|
||||
|
||||
const chartConfig = {
|
||||
desktop: {
|
||||
label: "Desktop",
|
||||
color: "var(--primary)",
|
||||
},
|
||||
mobile: {
|
||||
label: "Mobile",
|
||||
color: "var(--primary)",
|
||||
},
|
||||
} satisfies ChartConfig
|
||||
|
||||
function TableCellViewer({ item }: { item: z.infer<typeof schema> }) {
|
||||
const isMobile = useIsMobile()
|
||||
|
||||
return (
|
||||
<Drawer direction={isMobile ? "bottom" : "right"}>
|
||||
<DrawerTrigger asChild>
|
||||
<Button variant="link" className="text-foreground w-fit px-0 text-left">
|
||||
{item.header}
|
||||
</Button>
|
||||
</DrawerTrigger>
|
||||
<DrawerContent>
|
||||
<DrawerHeader className="gap-1">
|
||||
<DrawerTitle>{item.header}</DrawerTitle>
|
||||
<DrawerDescription>
|
||||
Showing total visitors for the last 6 months
|
||||
</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
<div className="flex flex-col gap-4 overflow-y-auto px-4 text-sm">
|
||||
{!isMobile && (
|
||||
<>
|
||||
<ChartContainer config={chartConfig}>
|
||||
<AreaChart
|
||||
accessibilityLayer
|
||||
data={chartData}
|
||||
margin={{
|
||||
left: 0,
|
||||
right: 10,
|
||||
}}
|
||||
>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
dataKey="month"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
tickFormatter={(value) => value.slice(0, 3)}
|
||||
hide
|
||||
/>
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
content={<ChartTooltipContent indicator="dot" />}
|
||||
/>
|
||||
<Area
|
||||
dataKey="mobile"
|
||||
type="natural"
|
||||
fill="var(--color-mobile)"
|
||||
fillOpacity={0.6}
|
||||
stroke="var(--color-mobile)"
|
||||
stackId="a"
|
||||
/>
|
||||
<Area
|
||||
dataKey="desktop"
|
||||
type="natural"
|
||||
fill="var(--color-desktop)"
|
||||
fillOpacity={0.4}
|
||||
stroke="var(--color-desktop)"
|
||||
stackId="a"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
<Separator />
|
||||
<div className="grid gap-2">
|
||||
<div className="flex gap-2 leading-none font-medium">
|
||||
Trending up by 5.2% this month{" "}
|
||||
<IconTrendingUp className="size-4" />
|
||||
</div>
|
||||
<div className="text-muted-foreground">
|
||||
Showing total visitors for the last 6 months. This is just
|
||||
some random text to test the layout. It spans multiple lines
|
||||
and should wrap around.
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
</>
|
||||
)}
|
||||
<form className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-3">
|
||||
<Label htmlFor="header">Header</Label>
|
||||
<Input id="header" defaultValue={item.header} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="flex flex-col gap-3">
|
||||
<Label htmlFor="type">Type</Label>
|
||||
<Select defaultValue={item.type}>
|
||||
<SelectTrigger id="type" className="w-full">
|
||||
<SelectValue placeholder="Select a type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Table of Contents">
|
||||
Table of Contents
|
||||
</SelectItem>
|
||||
<SelectItem value="Executive Summary">
|
||||
Executive Summary
|
||||
</SelectItem>
|
||||
<SelectItem value="Technical Approach">
|
||||
Technical Approach
|
||||
</SelectItem>
|
||||
<SelectItem value="Design">Design</SelectItem>
|
||||
<SelectItem value="Capabilities">Capabilities</SelectItem>
|
||||
<SelectItem value="Focus Documents">
|
||||
Focus Documents
|
||||
</SelectItem>
|
||||
<SelectItem value="Narrative">Narrative</SelectItem>
|
||||
<SelectItem value="Cover Page">Cover Page</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
<Label htmlFor="status">Status</Label>
|
||||
<Select defaultValue={item.status}>
|
||||
<SelectTrigger id="status" className="w-full">
|
||||
<SelectValue placeholder="Select a status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Done">Done</SelectItem>
|
||||
<SelectItem value="In Progress">In Progress</SelectItem>
|
||||
<SelectItem value="Not Started">Not Started</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="flex flex-col gap-3">
|
||||
<Label htmlFor="target">Target</Label>
|
||||
<Input id="target" defaultValue={item.target} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
<Label htmlFor="limit">Limit</Label>
|
||||
<Input id="limit" defaultValue={item.limit} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
<Label htmlFor="reviewer">Reviewer</Label>
|
||||
<Select defaultValue={item.reviewer}>
|
||||
<SelectTrigger id="reviewer" className="w-full">
|
||||
<SelectValue placeholder="Select a reviewer" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Eddie Lake">Eddie Lake</SelectItem>
|
||||
<SelectItem value="Jamik Tashpulatov">
|
||||
Jamik Tashpulatov
|
||||
</SelectItem>
|
||||
<SelectItem value="Emily Whalen">Emily Whalen</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<DrawerFooter>
|
||||
<Button>Submit</Button>
|
||||
<DrawerClose asChild>
|
||||
<Button variant="outline">Done</Button>
|
||||
</DrawerClose>
|
||||
</DrawerFooter>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
358
apps/electron/src/components/models-view.tsx
Normal file
358
apps/electron/src/components/models-view.tsx
Normal file
|
|
@ -0,0 +1,358 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { Button } from './ui/button';
|
||||
import { Progress } from './ui/progress';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from './ui/tabs';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './ui/card';
|
||||
import { RadioGroup, RadioGroupItem } from './ui/radio-group';
|
||||
import { Label } from './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 './ui/alert-dialog';
|
||||
import { Model, DownloadedModel, DownloadProgress } from '../constants/models';
|
||||
|
||||
export const ModelsView: React.FC = () => {
|
||||
const [availableModels, setAvailableModels] = useState<Model[]>([]);
|
||||
const [downloadedModels, setDownloadedModels] = useState<Record<string, DownloadedModel>>({});
|
||||
const [downloadProgress, setDownloadProgress] = useState<Record<string, DownloadProgress>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isLocalWhisperAvailable, setIsLocalWhisperAvailable] = useState(false);
|
||||
const [selectedModel, setSelectedModel] = useState<string | null>(null);
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
const [modelToDelete, setModelToDelete] = useState<string | null>(null);
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const [available, downloaded, activeDownloads, whisperAvailable, currentSelectedModel] = await Promise.all([
|
||||
window.electronAPI.getAvailableModels(),
|
||||
window.electronAPI.getDownloadedModels(),
|
||||
window.electronAPI.getActiveDownloads(),
|
||||
window.electronAPI.isLocalWhisperAvailable(),
|
||||
window.electronAPI.getSelectedModel(),
|
||||
]);
|
||||
|
||||
setAvailableModels(available);
|
||||
setDownloadedModels(downloaded);
|
||||
setIsLocalWhisperAvailable(whisperAvailable);
|
||||
setSelectedModel(currentSelectedModel);
|
||||
|
||||
// Set up active downloads progress
|
||||
const progressMap: Record<string, DownloadProgress> = {};
|
||||
for (const downloadProgress of activeDownloads) {
|
||||
progressMap[downloadProgress.modelId] = downloadProgress;
|
||||
}
|
||||
setDownloadProgress(progressMap);
|
||||
} catch (err) {
|
||||
console.error('Failed to load models data:', err);
|
||||
toast.error('Failed to load models data');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
|
||||
const handleDownloadProgress = (modelId: string, progress: DownloadProgress) => {
|
||||
setDownloadProgress(prev => ({
|
||||
...prev,
|
||||
[modelId]: progress
|
||||
}));
|
||||
};
|
||||
|
||||
const handleDownloadComplete = (modelId: string, downloadedModel: DownloadedModel) => {
|
||||
setDownloadedModels(prev => ({
|
||||
...prev,
|
||||
[modelId]: downloadedModel
|
||||
}));
|
||||
setDownloadProgress(prev => {
|
||||
const updated = { ...prev };
|
||||
delete updated[modelId];
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
|
||||
const handleDownloadError = (modelId: string, errorMessage: string) => {
|
||||
setDownloadProgress(prev => ({
|
||||
...prev,
|
||||
[modelId]: {
|
||||
...prev[modelId],
|
||||
status: 'error',
|
||||
error: errorMessage
|
||||
}
|
||||
}));
|
||||
};
|
||||
|
||||
const handleDownloadCancelled = (modelId: string) => {
|
||||
setDownloadProgress(prev => {
|
||||
const updated = { ...prev };
|
||||
delete updated[modelId];
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
|
||||
const handleModelDeleted = (modelId: string) => {
|
||||
setDownloadedModels(prev => {
|
||||
const updated = { ...prev };
|
||||
delete updated[modelId];
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
|
||||
// Listen to events from main process
|
||||
window.electronAPI.on('model-download-progress', handleDownloadProgress);
|
||||
window.electronAPI.on('model-download-complete', handleDownloadComplete);
|
||||
window.electronAPI.on('model-download-error', handleDownloadError);
|
||||
window.electronAPI.on('model-download-cancelled', handleDownloadCancelled);
|
||||
window.electronAPI.on('model-deleted', handleModelDeleted);
|
||||
|
||||
return () => {
|
||||
// Cleanup event listeners
|
||||
window.electronAPI.off('model-download-progress', handleDownloadProgress);
|
||||
window.electronAPI.off('model-download-complete', handleDownloadComplete);
|
||||
window.electronAPI.off('model-download-error', handleDownloadError);
|
||||
window.electronAPI.off('model-download-cancelled', handleDownloadCancelled);
|
||||
window.electronAPI.off('model-deleted', handleModelDeleted);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleDownload = async (modelId: string, event?: React.MouseEvent) => {
|
||||
event?.preventDefault();
|
||||
event?.stopPropagation();
|
||||
|
||||
console.log('Start download clicked for:', modelId);
|
||||
|
||||
try {
|
||||
console.log('Downloading model:', modelId);
|
||||
await window.electronAPI.downloadModel(modelId);
|
||||
console.log('Start download successful for:', modelId);
|
||||
} catch (err) {
|
||||
console.error('Failed to start download:', err);
|
||||
|
||||
// Don't show error for manual cancellations (AbortError)
|
||||
if (err instanceof Error && err.message.includes('AbortError')) {
|
||||
console.log('Download was manually aborted, not showing error');
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error('Failed to start download');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelDownload = async (modelId: string, event?: React.MouseEvent) => {
|
||||
event?.preventDefault();
|
||||
event?.stopPropagation();
|
||||
|
||||
console.log('Cancel download clicked for:', modelId);
|
||||
|
||||
try {
|
||||
await window.electronAPI.cancelDownload(modelId);
|
||||
console.log('Cancel download successful for:', modelId);
|
||||
} catch (err) {
|
||||
console.error('Failed to cancel download:', err);
|
||||
toast.error('Failed to cancel download');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteClick = (modelId: string) => {
|
||||
setModelToDelete(modelId);
|
||||
setShowDeleteDialog(true);
|
||||
};
|
||||
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (!modelToDelete) return;
|
||||
|
||||
try {
|
||||
await window.electronAPI.deleteModel(modelToDelete);
|
||||
} catch (err) {
|
||||
console.error('Failed to delete model:', err);
|
||||
toast.error('Failed to delete model');
|
||||
} finally {
|
||||
setShowDeleteDialog(false);
|
||||
setModelToDelete(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteCancel = () => {
|
||||
setShowDeleteDialog(false);
|
||||
setModelToDelete(null);
|
||||
};
|
||||
|
||||
|
||||
const handleSelectModel = async (modelId: string) => {
|
||||
try {
|
||||
await window.electronAPI.setSelectedModel(modelId);
|
||||
setSelectedModel(modelId);
|
||||
} catch (err) {
|
||||
console.error('Failed to select model:', err);
|
||||
toast.error('Failed to select model');
|
||||
}
|
||||
};
|
||||
|
||||
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];
|
||||
};
|
||||
|
||||
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="h-full p-6">
|
||||
<Tabs defaultValue="speech-recognition" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-1">
|
||||
<TabsTrigger value="speech-recognition">Speech Recognition</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="speech-recognition" className="space-y-6 mt-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 || !isLocalWhisperAvailable}
|
||||
/>
|
||||
<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>
|
||||
</TabsContent>
|
||||
|
||||
</Tabs>
|
||||
|
||||
<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>
|
||||
);
|
||||
};
|
||||
45
apps/electron/src/components/nav-main.tsx
Normal file
45
apps/electron/src/components/nav-main.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { IconCirclePlusFilled, IconMail, type Icon } from "@tabler/icons-react"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
} from "@/components/ui/sidebar"
|
||||
|
||||
export function NavMain({
|
||||
items,
|
||||
onNavigate,
|
||||
currentView,
|
||||
}: {
|
||||
items: {
|
||||
title: string
|
||||
url: string
|
||||
icon?: Icon
|
||||
}[]
|
||||
onNavigate?: (item: { title: string }) => void
|
||||
currentView?: string
|
||||
}) {
|
||||
return (
|
||||
<SidebarGroup>
|
||||
<SidebarGroupContent className="flex flex-col gap-2">
|
||||
<SidebarMenu>
|
||||
{items.map((item) => (
|
||||
<SidebarMenuItem key={item.title}>
|
||||
<SidebarMenuButton
|
||||
tooltip={item.title}
|
||||
isActive={currentView === item.title}
|
||||
onClick={() => onNavigate?.(item)}
|
||||
>
|
||||
{item.icon && <item.icon />}
|
||||
<span>{item.title}</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
)
|
||||
}
|
||||
48
apps/electron/src/components/nav-secondary.tsx
Normal file
48
apps/electron/src/components/nav-secondary.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { type Icon } from "@tabler/icons-react"
|
||||
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
} from "@/components/ui/sidebar"
|
||||
|
||||
export function NavSecondary({
|
||||
items,
|
||||
onNavigate,
|
||||
currentView,
|
||||
...props
|
||||
}: {
|
||||
items: {
|
||||
title: string
|
||||
url: string
|
||||
icon: Icon
|
||||
external?: boolean
|
||||
}[]
|
||||
onNavigate?: (item: { title: string }) => void
|
||||
currentView?: string
|
||||
} & React.ComponentPropsWithoutRef<typeof SidebarGroup>) {
|
||||
return (
|
||||
<SidebarGroup {...props}>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{items.map((item) => (
|
||||
<SidebarMenuItem key={item.title}>
|
||||
<SidebarMenuButton
|
||||
isActive={currentView === item.title}
|
||||
onClick={() => onNavigate?.(item)}
|
||||
>
|
||||
<item.icon />
|
||||
<span>{item.title}</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
)
|
||||
}
|
||||
366
apps/electron/src/components/settings-view.tsx
Normal file
366
apps/electron/src/components/settings-view.tsx
Normal file
|
|
@ -0,0 +1,366 @@
|
|||
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 { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { ThemeToggle } from '@/components/theme-toggle';
|
||||
import { FormatterConfig } from '@/modules/formatter';
|
||||
import { api } from '@/trpc/react';
|
||||
import { trpcClient } from '@/trpc/react';
|
||||
|
||||
// OpenRouter models list
|
||||
const OPENROUTER_MODELS = [
|
||||
{ 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 SettingsView() {
|
||||
const [formatterProvider, setFormatterProvider] = useState<'openrouter'>('openrouter');
|
||||
const [openrouterModel, setOpenrouterModel] = useState('');
|
||||
const [openrouterApiKey, setOpenrouterApiKey] = useState('');
|
||||
const [formatterEnabled, setFormatterEnabled] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// tRPC test state
|
||||
const [trpcTestResult, setTrpcTestResult] = useState<string>('');
|
||||
const [trpcTestLoading, setTrpcTestLoading] = useState(false);
|
||||
|
||||
// Load configuration on component mount
|
||||
useEffect(() => {
|
||||
loadFormatterConfig();
|
||||
}, []);
|
||||
|
||||
const loadFormatterConfig = async () => {
|
||||
try {
|
||||
const config = await window.electronAPI.getFormatterConfig();
|
||||
if (config) {
|
||||
setFormatterProvider(config.provider);
|
||||
setOpenrouterModel(config.model);
|
||||
setOpenrouterApiKey(config.apiKey);
|
||||
setFormatterEnabled(config.enabled);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load formatter config:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const saveFormatterConfig = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const config: FormatterConfig = {
|
||||
provider: formatterProvider,
|
||||
model: openrouterModel,
|
||||
apiKey: openrouterApiKey,
|
||||
enabled: formatterEnabled,
|
||||
};
|
||||
|
||||
await window.electronAPI.setFormatterConfig(config);
|
||||
|
||||
alert('Configuration saved successfully!');
|
||||
} catch (error) {
|
||||
console.error('Failed to save formatter config:', error);
|
||||
alert('Failed to save configuration. Please try again.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const testTrpcConnection = async () => {
|
||||
setTrpcTestLoading(true);
|
||||
setTrpcTestResult('');
|
||||
|
||||
try {
|
||||
// Test ping procedure
|
||||
const pingResult = await trpcClient.ping.query();
|
||||
|
||||
// Test greeting procedure
|
||||
const greetingResult = await trpcClient.greeting.query({ name: 'Amical User' });
|
||||
|
||||
// Test echo mutation
|
||||
const echoResult = await trpcClient.echo.mutate({ message: 'tRPC is working!' });
|
||||
|
||||
// Test vocabulary router
|
||||
const vocabularyCount = await trpcClient.vocabulary.getVocabularyCount.query({});
|
||||
|
||||
setTrpcTestResult(`✅ tRPC Connection Test Results:
|
||||
|
||||
Ping: ${pingResult.message}
|
||||
Timestamp: ${pingResult.timestamp.toLocaleString()}
|
||||
|
||||
Greeting: ${greetingResult.text}
|
||||
Timestamp: ${greetingResult.timestamp.toLocaleString()}
|
||||
|
||||
Echo: ${echoResult.echo}
|
||||
Timestamp: ${echoResult.timestamp.toLocaleString()}
|
||||
|
||||
Vocabulary Count: ${vocabularyCount} words in database`);
|
||||
} catch (error) {
|
||||
setTrpcTestResult(`❌ tRPC Connection Failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||
} finally {
|
||||
setTrpcTestLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
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="trpc-test">tRPC Test</TabsTrigger>
|
||||
<TabsTrigger value="advanced">Advanced</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="general" className="space-y-6">
|
||||
<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>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="microphone" className="space-y-6">
|
||||
<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 id="microphone-select" className="w-full border rounded px-3 py-2">
|
||||
<option>System Default</option>
|
||||
<option>Built-in Microphone</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="input-volume">Input Volume</Label>
|
||||
<input type="range" id="input-volume" className="w-full" min="0" max="100" defaultValue="75" />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch id="noise-reduction" />
|
||||
<Label htmlFor="noise-reduction">Enable noise reduction</Label>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="shortcuts" className="space-y-6">
|
||||
<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>Global Shortcut</Label>
|
||||
<p className="text-sm text-muted-foreground">Start/stop recording</p>
|
||||
</div>
|
||||
<kbd className="px-2 py-1 bg-muted rounded text-sm">Ctrl+Shift+Space</kbd>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label>Toggle Window</Label>
|
||||
<p className="text-sm text-muted-foreground">Show/hide main window</p>
|
||||
</div>
|
||||
<kbd className="px-2 py-1 bg-muted rounded text-sm">Ctrl+Shift+A</kbd>
|
||||
</div>
|
||||
|
||||
<Button variant="outline">Customize Shortcuts</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="formatter" className="space-y-6">
|
||||
<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={isLoading || !openrouterModel || !openrouterApiKey}
|
||||
>
|
||||
{isLoading ? 'Saving...' : 'Save Configuration'}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="trpc-test" className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>tRPC Connection Test</CardTitle>
|
||||
<CardDescription>Test the tRPC connection between renderer and main process</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This test verifies that tRPC is properly configured and working between the renderer and main processes.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={testTrpcConnection}
|
||||
disabled={trpcTestLoading}
|
||||
variant="outline"
|
||||
>
|
||||
{trpcTestLoading ? 'Testing...' : 'Test tRPC Connection'}
|
||||
</Button>
|
||||
|
||||
{trpcTestResult && (
|
||||
<div className="mt-4 p-4 border rounded-md">
|
||||
<pre className="whitespace-pre-wrap text-sm font-mono">
|
||||
{trpcTestResult}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="advanced" className="space-y-6">
|
||||
<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="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>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
52
apps/electron/src/components/site-header.tsx
Normal file
52
apps/electron/src/components/site-header.tsx
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { Button } from "@/components/ui/button"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { SidebarTrigger } from "@/components/ui/sidebar"
|
||||
|
||||
interface SiteHeaderProps {
|
||||
currentView?: string;
|
||||
}
|
||||
|
||||
export function SiteHeader({ currentView }: SiteHeaderProps) {
|
||||
return (
|
||||
<header
|
||||
className="flex h-[var(--header-height)] shrink-0 items-center gap-2 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 sticky top-0 z-50 w-full"
|
||||
style={{ WebkitAppRegion: 'drag' } as React.CSSProperties}
|
||||
>
|
||||
<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">
|
||||
<SidebarTrigger
|
||||
className="-ml-1"
|
||||
style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}
|
||||
/>
|
||||
<Separator
|
||||
orientation="vertical"
|
||||
className="mx-2 data-[orientation=vertical]:h-4"
|
||||
/>
|
||||
<h1 className="text-base font-medium">{currentView || 'Amical'}</h1>
|
||||
</div>
|
||||
|
||||
{/* <div className="ml-auto flex items-center gap-2 px-4 lg:px-6">
|
||||
<Button
|
||||
variant="ghost"
|
||||
asChild
|
||||
size="sm"
|
||||
className="hidden sm:flex"
|
||||
style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}
|
||||
>
|
||||
<a
|
||||
href="https://github.com/shadcn-ui/ui/tree/main/apps/v4/app/(examples)/dashboard"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
className="dark:text-foreground"
|
||||
>
|
||||
GitHub
|
||||
</a>
|
||||
</Button>
|
||||
</div> */}
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
18
apps/electron/src/components/theme-provider.tsx
Normal file
18
apps/electron/src/components/theme-provider.tsx
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import * as React from "react"
|
||||
import { ThemeProvider as NextThemesProvider } from "next-themes"
|
||||
|
||||
type ThemeProviderProps = React.ComponentProps<typeof NextThemesProvider>
|
||||
|
||||
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
||||
return (
|
||||
<NextThemesProvider
|
||||
attribute="class"
|
||||
defaultTheme="system"
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</NextThemesProvider>
|
||||
)
|
||||
}
|
||||
63
apps/electron/src/components/theme-toggle.tsx
Normal file
63
apps/electron/src/components/theme-toggle.tsx
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import * as React from "react"
|
||||
import { Moon, Sun, Monitor } from "lucide-react"
|
||||
import { useTheme } from "next-themes"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { setTheme, theme } = useTheme()
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="icon">
|
||||
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
||||
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
||||
<span className="sr-only">Toggle theme</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => setTheme("light")}>
|
||||
<Sun className="mr-2 h-4 w-4" />
|
||||
<span>Light</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme("dark")}>
|
||||
<Moon className="mr-2 h-4 w-4" />
|
||||
<span>Dark</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme("system")}>
|
||||
<Monitor className="mr-2 h-4 w-4" />
|
||||
<span>System</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
export function ThemeToggleSimple() {
|
||||
const { setTheme, theme } = useTheme()
|
||||
|
||||
const toggleTheme = () => {
|
||||
if (theme === "light") {
|
||||
setTheme("dark")
|
||||
} else if (theme === "dark") {
|
||||
setTheme("system")
|
||||
} else {
|
||||
setTheme("light")
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Button variant="outline" size="icon" onClick={toggleTheme}>
|
||||
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
||||
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
||||
<span className="sr-only">Toggle theme</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
273
apps/electron/src/components/transcriptions-list.tsx
Normal file
273
apps/electron/src/components/transcriptions-list.tsx
Normal file
|
|
@ -0,0 +1,273 @@
|
|||
import React, { useState, useEffect } 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 {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { Copy, Play, Trash2, Download, FileText, Search, Filter, MoreHorizontal } 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';
|
||||
|
||||
// Using database Transcription type from schema
|
||||
|
||||
export const TranscriptionsList: React.FC = () => {
|
||||
const [transcriptions, setTranscriptions] = useState<Transcription[]>([]);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
|
||||
// Load transcriptions from database
|
||||
const loadTranscriptions = async (search?: string) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const options = {
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
sortBy: 'timestamp' as const,
|
||||
sortOrder: 'desc' as const,
|
||||
search: search || undefined,
|
||||
};
|
||||
|
||||
const [transcriptionsData, count] = await Promise.all([
|
||||
window.electronAPI.getTranscriptions(options),
|
||||
window.electronAPI.getTranscriptionsCount(search),
|
||||
]);
|
||||
|
||||
setTranscriptions(transcriptionsData);
|
||||
setTotalCount(count);
|
||||
} catch (error) {
|
||||
console.error('Error loading transcriptions:', error);
|
||||
// Fallback to empty array on error
|
||||
setTranscriptions([]);
|
||||
setTotalCount(0);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Load transcriptions on component mount and when search term changes
|
||||
useEffect(() => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
loadTranscriptions(searchTerm);
|
||||
}, searchTerm ? 300 : 0); // Debounce search
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [searchTerm]);
|
||||
|
||||
|
||||
const copyToClipboard = async (text: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
// You could add a toast notification here
|
||||
console.log('Copied to clipboard');
|
||||
} catch (err) {
|
||||
console.error('Failed to copy text: ', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
try {
|
||||
await window.electronAPI.deleteTranscription(id);
|
||||
// Reload transcriptions after deletion
|
||||
await loadTranscriptions(searchTerm);
|
||||
} catch (error) {
|
||||
console.error('Error deleting transcription:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePlayAudio = (audioFile: string) => {
|
||||
// Implement audio playback functionality
|
||||
console.log('Playing audio:', audioFile);
|
||||
};
|
||||
|
||||
const handleDownload = (transcription: Transcription) => {
|
||||
// Create and download a text file with the transcription
|
||||
const element = document.createElement('a');
|
||||
const file = new Blob([transcription.text], { type: 'text/plain' });
|
||||
element.href = URL.createObjectURL(file);
|
||||
element.download = `transcription-${transcription.id}.txt`;
|
||||
document.body.appendChild(element);
|
||||
element.click();
|
||||
document.body.removeChild(element);
|
||||
};
|
||||
|
||||
// Since we're already filtering on the backend, use all transcriptions
|
||||
const filteredTranscriptions = transcriptions;
|
||||
|
||||
const getTitle = (text: string) => {
|
||||
const firstSentence = text.split('.')[0];
|
||||
return firstSentence.length > 50 ? firstSentence.substring(0, 50) + '...' : firstSentence;
|
||||
};
|
||||
|
||||
const getWordCount = (text: string) => {
|
||||
return text.split(' ').length;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div></div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button variant="outline">Export All</Button>
|
||||
<Button>New Recording</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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>
|
||||
<Button variant="outline" size="sm">
|
||||
<Filter className="h-4 w-4 mr-2" />
|
||||
Filter
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Transcriptions Grid */}
|
||||
{loading ? (
|
||||
<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>
|
||||
) : filteredTranscriptions.length === 0 ? (
|
||||
<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.' : 'Start recording to see your transcriptions here.'}
|
||||
</p>
|
||||
{!searchTerm && (
|
||||
<Button className="mt-4">Start Recording</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid gap-3">
|
||||
{filteredTranscriptions.map((transcription) => (
|
||||
<Card key={transcription.id} className="hover:shadow-md transition-shadow">
|
||||
<CardContent className="px-4 py-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1 min-w-0 mr-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<h3 className="font-medium truncate flex-1">
|
||||
{getTitle(transcription.text)}
|
||||
</h3>
|
||||
<div className="flex items-center space-x-2 text-sm text-muted-foreground shrink-0">
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{getWordCount(transcription.text)} words
|
||||
</Badge>
|
||||
<span>{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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-1">
|
||||
<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.audioFile!)}
|
||||
>
|
||||
<Play className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Play audio</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
|
||||
<DropdownMenu>
|
||||
<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>
|
||||
<DropdownMenuItem onClick={() => handleDownload(transcription)}>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Download
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleDelete(transcription.id)}
|
||||
className="text-destructive"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && filteredTranscriptions.length > 0 && (
|
||||
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
||||
<span>
|
||||
Showing {filteredTranscriptions.length} of {totalCount} transcription{totalCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
<span>
|
||||
Total: {transcriptions.reduce((acc, t) => acc + getWordCount(t.text), 0)} words
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
6
apps/electron/src/components/transcriptions-view.tsx
Normal file
6
apps/electron/src/components/transcriptions-view.tsx
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import React from 'react';
|
||||
import { TranscriptionsList } from './transcriptions-list';
|
||||
|
||||
export function TranscriptionsView() {
|
||||
return <TranscriptionsList />;
|
||||
}
|
||||
197
apps/electron/src/components/vocabulary-manager.tsx
Normal file
197
apps/electron/src/components/vocabulary-manager.tsx
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
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>
|
||||
)
|
||||
}
|
||||
6
apps/electron/src/components/vocabulary-view.tsx
Normal file
6
apps/electron/src/components/vocabulary-view.tsx
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import React from 'react';
|
||||
import { VocabularyManager } from './vocabulary-manager';
|
||||
|
||||
export function VocabularyView() {
|
||||
return <VocabularyManager />;
|
||||
}
|
||||
94
apps/electron/src/constants/models.ts
Normal file
94
apps/electron/src/constants/models.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
export interface Model {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'whisper' | 'tts' | 'other';
|
||||
size: number; // Approximate size in bytes (for UI display only)
|
||||
sizeFormatted: string; // Human readable size (e.g., "~39 MB")
|
||||
description: string;
|
||||
downloadUrl: string;
|
||||
filename: string; // Expected filename after download
|
||||
checksum?: string; // Optional checksum for validation
|
||||
}
|
||||
|
||||
export interface DownloadedModel {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
localPath: string;
|
||||
downloadedAt: string; // ISO date string
|
||||
size: number;
|
||||
checksum?: string;
|
||||
}
|
||||
|
||||
export interface DownloadProgress {
|
||||
modelId: string;
|
||||
progress: number; // 0-100
|
||||
status: 'downloading' | 'paused' | 'cancelling' | 'error';
|
||||
bytesDownloaded: number;
|
||||
totalBytes: number;
|
||||
error?: string;
|
||||
abortController?: AbortController;
|
||||
}
|
||||
|
||||
export interface ModelManagerState {
|
||||
activeDownloads: Map<string, DownloadProgress>;
|
||||
}
|
||||
|
||||
// Available Whisper models manifest
|
||||
export const AVAILABLE_MODELS: Model[] = [
|
||||
{
|
||||
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',
|
||||
},
|
||||
];
|
||||
154
apps/electron/src/db/app-settings.ts
Normal file
154
apps/electron/src/db/app-settings.ts
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
import { eq } from 'drizzle-orm';
|
||||
import { db } from './config';
|
||||
import { appSettings, type NewAppSettings, type AppSettingsData } from './schema';
|
||||
|
||||
// Singleton ID for app settings (we only have one settings record)
|
||||
const SETTINGS_ID = 1;
|
||||
|
||||
// Default settings
|
||||
const defaultSettings: AppSettingsData = {
|
||||
formatterConfig: {
|
||||
provider: 'openrouter',
|
||||
model: 'anthropic/claude-3-haiku',
|
||||
apiKey: '',
|
||||
enabled: false,
|
||||
},
|
||||
ui: {
|
||||
theme: 'system',
|
||||
sidebarOpen: false,
|
||||
currentView: 'Voice Recording',
|
||||
},
|
||||
transcription: {
|
||||
language: 'en',
|
||||
autoTranscribe: true,
|
||||
confidenceThreshold: 0.8,
|
||||
enablePunctuation: true,
|
||||
enableTimestamps: false,
|
||||
},
|
||||
recording: {
|
||||
defaultFormat: 'wav',
|
||||
sampleRate: 16000,
|
||||
autoStopSilence: true,
|
||||
silenceThreshold: 3,
|
||||
maxRecordingDuration: 60,
|
||||
},
|
||||
};
|
||||
|
||||
// Get all app settings
|
||||
export async function getAppSettings(): Promise<AppSettingsData> {
|
||||
const result = await db.select().from(appSettings).where(eq(appSettings.id, SETTINGS_ID));
|
||||
|
||||
if (result.length === 0) {
|
||||
// Create default settings if none exist
|
||||
await createDefaultSettings();
|
||||
return defaultSettings;
|
||||
}
|
||||
|
||||
return result[0].data;
|
||||
}
|
||||
|
||||
// Update app settings (merges with existing settings)
|
||||
export async function updateAppSettings(
|
||||
newSettings: Partial<AppSettingsData>
|
||||
): Promise<AppSettingsData> {
|
||||
const currentSettings = await getAppSettings();
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
|
||||
await db
|
||||
.update(appSettings)
|
||||
.set({
|
||||
data: mergedSettings,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(appSettings.id, SETTINGS_ID));
|
||||
|
||||
return mergedSettings;
|
||||
}
|
||||
|
||||
// Replace all app settings (complete override)
|
||||
export async function replaceAppSettings(newSettings: AppSettingsData): Promise<AppSettingsData> {
|
||||
const now = new Date();
|
||||
|
||||
await db
|
||||
.update(appSettings)
|
||||
.set({
|
||||
data: newSettings,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(appSettings.id, SETTINGS_ID));
|
||||
|
||||
return newSettings;
|
||||
}
|
||||
|
||||
// Get a specific setting section
|
||||
export async function getSettingsSection<K extends keyof AppSettingsData>(
|
||||
section: K
|
||||
): Promise<AppSettingsData[K]> {
|
||||
const settings = await getAppSettings();
|
||||
return settings[section];
|
||||
}
|
||||
|
||||
// Update a specific setting section
|
||||
export async function updateSettingsSection<K extends keyof AppSettingsData>(
|
||||
section: K,
|
||||
newData: AppSettingsData[K]
|
||||
): Promise<AppSettingsData> {
|
||||
return await updateAppSettings({ [section]: newData } as Partial<AppSettingsData>);
|
||||
}
|
||||
|
||||
// Reset settings to defaults
|
||||
export async function resetAppSettings(): Promise<AppSettingsData> {
|
||||
return await replaceAppSettings(defaultSettings);
|
||||
}
|
||||
|
||||
// Create default settings (internal helper)
|
||||
async function createDefaultSettings(): Promise<void> {
|
||||
const now = new Date();
|
||||
|
||||
const newSettings: NewAppSettings = {
|
||||
id: SETTINGS_ID,
|
||||
data: defaultSettings,
|
||||
version: 1,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
await db.insert(appSettings).values(newSettings);
|
||||
}
|
||||
|
||||
// Export default settings for reference
|
||||
export { defaultSettings };
|
||||
|
|
@ -1,18 +1,74 @@
|
|||
/* import { drizzle } from 'drizzle-orm/better-sqlite3';
|
||||
import Database from 'better-sqlite3';
|
||||
import { drizzle } from 'drizzle-orm/libsql';
|
||||
import { migrate } from 'drizzle-orm/libsql/migrator';
|
||||
import { app } from 'electron';
|
||||
import path from 'path';
|
||||
import * as path from 'path';
|
||||
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');
|
||||
|
||||
// Create SQLite database instance
|
||||
const sqlite = new Database(dbPath);
|
||||
export const db = drizzle(`file:${dbPath}`, {
|
||||
schema: {
|
||||
...schema,
|
||||
},
|
||||
});
|
||||
|
||||
// Create Drizzle instance
|
||||
export const db = drizzle(sqlite, { schema });
|
||||
// Initialize database with migrations
|
||||
let isInitialized = false;
|
||||
|
||||
// Export the SQLite instance in case we need it for migrations
|
||||
export const sqliteDb = sqlite;
|
||||
*/
|
||||
export async function initializeDatabase() {
|
||||
if (isInitialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Determine the correct migrations folder path
|
||||
const isDev = process.env.NODE_ENV === 'development' || !app.isPackaged;
|
||||
let migrationsPath: string;
|
||||
|
||||
if (isDev) {
|
||||
// Development: use source path relative to the app's working directory
|
||||
migrationsPath = path.join(process.cwd(), 'src', 'db', 'migrations');
|
||||
} else {
|
||||
// Production: migrations should be in app resources
|
||||
migrationsPath = path.join(
|
||||
process.resourcesPath,
|
||||
'app.asar.unpacked',
|
||||
'dist',
|
||||
'main',
|
||||
'db',
|
||||
'migrations'
|
||||
);
|
||||
}
|
||||
|
||||
console.log('Attempting to run migrations from:', migrationsPath);
|
||||
console.log('__dirname:', __dirname);
|
||||
console.log('process.cwd():', process.cwd());
|
||||
console.log('isDev:', isDev);
|
||||
|
||||
// Check if the migrations path exists
|
||||
if (!fs.existsSync(migrationsPath)) {
|
||||
throw new Error(`Migrations folder not found at: ${migrationsPath}`);
|
||||
}
|
||||
|
||||
const journalPath = path.join(migrationsPath, 'meta', '_journal.json');
|
||||
if (!fs.existsSync(journalPath)) {
|
||||
throw new Error(`Journal file not found at: ${journalPath}`);
|
||||
}
|
||||
|
||||
// Run migrations to ensure database is up to date
|
||||
await migrate(db, {
|
||||
migrationsFolder: migrationsPath,
|
||||
});
|
||||
|
||||
console.log('Database initialized and migrations completed successfully');
|
||||
isInitialized = true;
|
||||
} catch (error) {
|
||||
console.error('FATAL: Error initializing database:', error);
|
||||
console.error('Application cannot continue without a working database. Exiting...');
|
||||
|
||||
// Fatal exit - app cannot function without database
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
129
apps/electron/src/db/downloaded-models.ts
Normal file
129
apps/electron/src/db/downloaded-models.ts
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
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 (for backward compatibility)
|
||||
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;
|
||||
}
|
||||
|
|
@ -1,14 +1,13 @@
|
|||
/* import { migrate } from 'drizzle-orm/better-sqlite3/migrator';
|
||||
import { migrate } from 'drizzle-orm/libsql/migrator';
|
||||
import { db } from './config';
|
||||
|
||||
export function runMigrations() {
|
||||
export async function runMigrations() {
|
||||
try {
|
||||
// Run migrations
|
||||
migrate(db, { migrationsFolder: './src/db/migrations' });
|
||||
await migrate(db, { migrationsFolder: './src/db/migrations' });
|
||||
console.log('Migrations completed successfully');
|
||||
} catch (error) {
|
||||
console.error('Error running migrations:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
CREATE TABLE `recordings` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL
|
||||
);
|
||||
45
apps/electron/src/db/migrations/0000_worried_black_bird.sql
Normal file
45
apps/electron/src/db/migrations/0000_worried_black_bird.sql
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
CREATE TABLE `app_settings` (
|
||||
`id` integer PRIMARY KEY NOT NULL,
|
||||
`data` text NOT NULL,
|
||||
`version` integer DEFAULT 1 NOT NULL,
|
||||
`created_at` integer DEFAULT (unixepoch()) NOT NULL,
|
||||
`updated_at` integer DEFAULT (unixepoch()) NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `downloaded_models` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`type` text NOT NULL,
|
||||
`local_path` text NOT NULL,
|
||||
`downloaded_at` integer DEFAULT (unixepoch()) NOT NULL,
|
||||
`size` integer NOT NULL,
|
||||
`checksum` text,
|
||||
`created_at` integer DEFAULT (unixepoch()) NOT NULL,
|
||||
`updated_at` integer DEFAULT (unixepoch()) NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `transcriptions` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`text` text NOT NULL,
|
||||
`timestamp` integer DEFAULT (unixepoch()) NOT NULL,
|
||||
`language` text DEFAULT 'en',
|
||||
`audio_file` text,
|
||||
`confidence` real,
|
||||
`duration` integer,
|
||||
`speech_model` text,
|
||||
`formatting_model` text,
|
||||
`meta` text,
|
||||
`created_at` integer DEFAULT (unixepoch()) NOT NULL,
|
||||
`updated_at` integer DEFAULT (unixepoch()) NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `vocabulary` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`word` text NOT NULL,
|
||||
`date_added` integer DEFAULT (unixepoch()) NOT NULL,
|
||||
`usage_count` integer DEFAULT 0,
|
||||
`created_at` integer DEFAULT (unixepoch()) NOT NULL,
|
||||
`updated_at` integer DEFAULT (unixepoch()) NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `vocabulary_word_unique` ON `vocabulary` (`word`);
|
||||
|
|
@ -1,11 +1,135 @@
|
|||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "83b0a7fd-235b-4c8c-ac92-cdfa215950a3",
|
||||
"id": "ab7ec8ad-f088-4400-aa05-c799133e7ada",
|
||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||
"tables": {
|
||||
"recordings": {
|
||||
"name": "recordings",
|
||||
"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": {}
|
||||
},
|
||||
"downloaded_models": {
|
||||
"name": "downloaded_models",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"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
|
||||
},
|
||||
"local_path": {
|
||||
"name": "local_path",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"downloaded_at": {
|
||||
"name": "downloaded_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(unixepoch())"
|
||||
},
|
||||
"size": {
|
||||
"name": "size",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"checksum": {
|
||||
"name": "checksum",
|
||||
"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": {}
|
||||
},
|
||||
"transcriptions": {
|
||||
"name": "transcriptions",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
|
|
@ -13,6 +137,87 @@
|
|||
"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": {},
|
||||
|
|
@ -20,6 +225,68 @@
|
|||
"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
|
||||
},
|
||||
"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": {},
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@
|
|||
{
|
||||
"idx": 0,
|
||||
"version": "6",
|
||||
"when": 1747051560665,
|
||||
"tag": "0000_square_avengers",
|
||||
"when": 1750751926568,
|
||||
"tag": "0000_worried_black_bird",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,7 +1,117 @@
|
|||
import { sql } from 'drizzle-orm';
|
||||
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
|
||||
import { sqliteTable, text, integer, real } from 'drizzle-orm/sqlite-core';
|
||||
|
||||
// Example table - you can add more tables as needed
|
||||
export const recordings = sqliteTable('recordings', {
|
||||
// Transcriptions table
|
||||
export const transcriptions = sqliteTable('transcriptions', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
text: text('text').notNull(),
|
||||
timestamp: integer('timestamp', { mode: 'timestamp' })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
language: text('language').default('en'),
|
||||
audioFile: text('audio_file'), // Path to the audio file
|
||||
confidence: real('confidence'), // AI confidence score (0-1)
|
||||
duration: integer('duration'), // Duration in seconds
|
||||
speechModel: text('speech_model'), // Model used for speech recognition
|
||||
formattingModel: text('formatting_model'), // Model used for formatting
|
||||
meta: text('meta', { mode: 'json' }), // Additional metadata as JSON
|
||||
createdAt: integer('created_at', { mode: 'timestamp' })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
updatedAt: integer('updated_at', { mode: 'timestamp' })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
});
|
||||
|
||||
// Vocabulary table
|
||||
export const vocabulary = sqliteTable('vocabulary', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
word: text('word').notNull().unique(),
|
||||
dateAdded: integer('date_added', { mode: 'timestamp' })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
usageCount: integer('usage_count').default(0), // How many times this word appeared in transcriptions
|
||||
createdAt: integer('created_at', { mode: 'timestamp' })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
updatedAt: integer('updated_at', { mode: 'timestamp' })
|
||||
.notNull()
|
||||
.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(),
|
||||
data: text('data', { mode: 'json' }).$type<AppSettingsData>().notNull(),
|
||||
version: integer('version').notNull().default(1), // For migrations
|
||||
createdAt: integer('created_at', { mode: 'timestamp' })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
updatedAt: integer('updated_at', { mode: 'timestamp' })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
});
|
||||
|
||||
// Define the shape of our settings JSON
|
||||
export interface AppSettingsData {
|
||||
formatterConfig?: {
|
||||
provider: 'openrouter';
|
||||
model: string;
|
||||
apiKey: string;
|
||||
enabled: boolean;
|
||||
};
|
||||
ui?: {
|
||||
theme: 'light' | 'dark' | 'system';
|
||||
sidebarOpen?: boolean;
|
||||
currentView?: string;
|
||||
windowBounds?: {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
};
|
||||
transcription?: {
|
||||
language: string;
|
||||
autoTranscribe: boolean;
|
||||
confidenceThreshold: number;
|
||||
enablePunctuation: boolean;
|
||||
enableTimestamps: boolean;
|
||||
};
|
||||
recording?: {
|
||||
defaultFormat: 'wav' | 'mp3' | 'flac';
|
||||
sampleRate: 16000 | 22050 | 44100 | 48000;
|
||||
autoStopSilence: boolean;
|
||||
silenceThreshold: number;
|
||||
maxRecordingDuration: number;
|
||||
};
|
||||
}
|
||||
|
||||
// Export types for TypeScript
|
||||
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 AppSettings = typeof appSettings.$inferSelect;
|
||||
export type NewAppSettings = typeof appSettings.$inferInsert;
|
||||
|
|
|
|||
128
apps/electron/src/db/transcriptions.ts
Normal file
128
apps/electron/src/db/transcriptions.ts
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
import { eq, desc, asc, and, like, count, gte, lte } from 'drizzle-orm';
|
||||
import { db } from './config';
|
||||
import { transcriptions, type Transcription, type NewTranscription } from './schema';
|
||||
|
||||
// Create a new transcription
|
||||
export async function createTranscription(
|
||||
data: Omit<NewTranscription, 'id' | 'createdAt' | 'updatedAt'>
|
||||
) {
|
||||
const now = new Date();
|
||||
|
||||
const newTranscription: NewTranscription = {
|
||||
...data,
|
||||
timestamp: data.timestamp || now,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
const result = await db.insert(transcriptions).values(newTranscription).returning();
|
||||
return result[0];
|
||||
}
|
||||
|
||||
// Get all transcriptions with pagination and sorting
|
||||
export async function getTranscriptions(
|
||||
options: {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
sortBy?: 'timestamp' | 'createdAt';
|
||||
sortOrder?: 'asc' | 'desc';
|
||||
search?: string;
|
||||
} = {}
|
||||
) {
|
||||
const { limit = 50, offset = 0, sortBy = 'timestamp', sortOrder = 'desc', search } = options;
|
||||
|
||||
// Build query with conditional where clause
|
||||
const sortColumn = sortBy === 'timestamp' ? transcriptions.timestamp : transcriptions.createdAt;
|
||||
const orderFn = sortOrder === 'asc' ? asc : desc;
|
||||
|
||||
if (search) {
|
||||
return await db
|
||||
.select()
|
||||
.from(transcriptions)
|
||||
.where(like(transcriptions.text, `%${search}%`))
|
||||
.orderBy(orderFn(sortColumn))
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
} else {
|
||||
return await db
|
||||
.select()
|
||||
.from(transcriptions)
|
||||
.orderBy(orderFn(sortColumn))
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
}
|
||||
}
|
||||
|
||||
// Get transcription by ID
|
||||
export async function getTranscriptionById(id: number) {
|
||||
const result = await db.select().from(transcriptions).where(eq(transcriptions.id, id));
|
||||
return result[0] || null;
|
||||
}
|
||||
|
||||
// Update transcription
|
||||
export async function updateTranscription(
|
||||
id: number,
|
||||
data: Partial<Omit<Transcription, 'id' | 'createdAt'>>
|
||||
) {
|
||||
const updateData = {
|
||||
...data,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const result = await db
|
||||
.update(transcriptions)
|
||||
.set(updateData)
|
||||
.where(eq(transcriptions.id, id))
|
||||
.returning();
|
||||
|
||||
return result[0] || null;
|
||||
}
|
||||
|
||||
// Delete transcription
|
||||
export async function deleteTranscription(id: number) {
|
||||
const result = await db.delete(transcriptions).where(eq(transcriptions.id, id)).returning();
|
||||
|
||||
return result[0] || null;
|
||||
}
|
||||
|
||||
// Get transcriptions count
|
||||
export async function getTranscriptionsCount(search?: string) {
|
||||
if (search) {
|
||||
const result = await db
|
||||
.select({ count: count() })
|
||||
.from(transcriptions)
|
||||
.where(like(transcriptions.text, `%${search}%`));
|
||||
return result[0]?.count || 0;
|
||||
} else {
|
||||
const result = await db.select({ count: count() }).from(transcriptions);
|
||||
return result[0]?.count || 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Get transcriptions by date range
|
||||
export async function getTranscriptionsByDateRange(startDate: Date, endDate: Date) {
|
||||
return await db
|
||||
.select()
|
||||
.from(transcriptions)
|
||||
.where(and(gte(transcriptions.timestamp, startDate), lte(transcriptions.timestamp, endDate)))
|
||||
.orderBy(desc(transcriptions.timestamp));
|
||||
}
|
||||
|
||||
// Get transcriptions by language
|
||||
export async function getTranscriptionsByLanguage(language: string) {
|
||||
return await db
|
||||
.select()
|
||||
.from(transcriptions)
|
||||
.where(eq(transcriptions.language, language))
|
||||
.orderBy(desc(transcriptions.timestamp));
|
||||
}
|
||||
|
||||
// Search transcriptions
|
||||
export async function searchTranscriptions(searchTerm: string, limit = 20) {
|
||||
return await db
|
||||
.select()
|
||||
.from(transcriptions)
|
||||
.where(like(transcriptions.text, `%${searchTerm}%`))
|
||||
.orderBy(desc(transcriptions.timestamp))
|
||||
.limit(limit);
|
||||
}
|
||||
169
apps/electron/src/db/vocabulary.ts
Normal file
169
apps/electron/src/db/vocabulary.ts
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
import { eq, desc, asc, like, count, gt, sql } from 'drizzle-orm';
|
||||
import { db } from './config';
|
||||
import { vocabulary, type Vocabulary, type NewVocabulary } from './schema';
|
||||
|
||||
// Create a new vocabulary word
|
||||
export async function createVocabularyWord(
|
||||
data: Omit<NewVocabulary, 'id' | 'createdAt' | 'updatedAt'>
|
||||
) {
|
||||
const now = new Date();
|
||||
|
||||
const newWord: NewVocabulary = {
|
||||
...data,
|
||||
dateAdded: data.dateAdded || now,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
const result = await db.insert(vocabulary).values(newWord).returning();
|
||||
return result[0];
|
||||
}
|
||||
|
||||
// Get all vocabulary words with pagination and sorting
|
||||
export async function getVocabulary(
|
||||
options: {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
sortBy?: 'word' | 'dateAdded' | 'usageCount';
|
||||
sortOrder?: 'asc' | 'desc';
|
||||
search?: string;
|
||||
} = {}
|
||||
) {
|
||||
const { limit = 50, offset = 0, sortBy = 'dateAdded', sortOrder = 'desc', search } = options;
|
||||
|
||||
// Determine sort column
|
||||
let sortColumn;
|
||||
switch (sortBy) {
|
||||
case 'word':
|
||||
sortColumn = vocabulary.word;
|
||||
break;
|
||||
case 'usageCount':
|
||||
sortColumn = vocabulary.usageCount;
|
||||
break;
|
||||
default:
|
||||
sortColumn = vocabulary.dateAdded;
|
||||
}
|
||||
|
||||
const orderFn = sortOrder === 'asc' ? asc : desc;
|
||||
|
||||
// Build query with conditional where clause
|
||||
if (search) {
|
||||
return await db
|
||||
.select()
|
||||
.from(vocabulary)
|
||||
.where(like(vocabulary.word, `%${search}%`))
|
||||
.orderBy(orderFn(sortColumn))
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
} else {
|
||||
return await db
|
||||
.select()
|
||||
.from(vocabulary)
|
||||
.orderBy(orderFn(sortColumn))
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
}
|
||||
}
|
||||
|
||||
// Get vocabulary word by ID
|
||||
export async function getVocabularyById(id: number) {
|
||||
const result = await db.select().from(vocabulary).where(eq(vocabulary.id, id));
|
||||
return result[0] || null;
|
||||
}
|
||||
|
||||
// Get vocabulary word by word text
|
||||
export async function getVocabularyByWord(word: string) {
|
||||
const result = await db.select().from(vocabulary).where(eq(vocabulary.word, word.toLowerCase()));
|
||||
return result[0] || null;
|
||||
}
|
||||
|
||||
// Update vocabulary word
|
||||
export async function updateVocabulary(
|
||||
id: number,
|
||||
data: Partial<Omit<Vocabulary, 'id' | 'createdAt'>>
|
||||
) {
|
||||
const updateData = {
|
||||
...data,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const result = await db
|
||||
.update(vocabulary)
|
||||
.set(updateData)
|
||||
.where(eq(vocabulary.id, id))
|
||||
.returning();
|
||||
|
||||
return result[0] || null;
|
||||
}
|
||||
|
||||
// Delete vocabulary word
|
||||
export async function deleteVocabulary(id: number) {
|
||||
const result = await db.delete(vocabulary).where(eq(vocabulary.id, id)).returning();
|
||||
|
||||
return result[0] || null;
|
||||
}
|
||||
|
||||
// Get vocabulary count
|
||||
export async function getVocabularyCount(search?: string) {
|
||||
if (search) {
|
||||
const result = await db
|
||||
.select({ count: count() })
|
||||
.from(vocabulary)
|
||||
.where(like(vocabulary.word, `%${search}%`));
|
||||
return result[0]?.count || 0;
|
||||
} else {
|
||||
const result = await db.select({ count: count() }).from(vocabulary);
|
||||
return result[0]?.count || 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Track word usage - increment usage count atomically
|
||||
export async function trackWordUsage(word: string) {
|
||||
// Use atomic update with SQL increment to avoid race conditions
|
||||
const result = await db
|
||||
.update(vocabulary)
|
||||
.set({
|
||||
usageCount: sql`${vocabulary.usageCount} + 1`,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(vocabulary.word, word.toLowerCase()))
|
||||
.returning();
|
||||
|
||||
return result[0] || null;
|
||||
}
|
||||
|
||||
// Get most frequently used words
|
||||
export async function getMostUsedWords(limit = 10) {
|
||||
return await db
|
||||
.select()
|
||||
.from(vocabulary)
|
||||
.where(gt(vocabulary.usageCount, 0)) // Only words that have been used
|
||||
.orderBy(desc(vocabulary.usageCount))
|
||||
.limit(limit);
|
||||
}
|
||||
|
||||
// Search vocabulary words
|
||||
export async function searchVocabulary(searchTerm: string, limit = 20) {
|
||||
return await db
|
||||
.select()
|
||||
.from(vocabulary)
|
||||
.where(like(vocabulary.word, `%${searchTerm}%`))
|
||||
.orderBy(asc(vocabulary.word))
|
||||
.limit(limit);
|
||||
}
|
||||
|
||||
// Bulk import vocabulary words
|
||||
export async function bulkImportVocabulary(
|
||||
words: Omit<NewVocabulary, 'id' | 'createdAt' | 'updatedAt'>[]
|
||||
) {
|
||||
const now = new Date();
|
||||
|
||||
const vocabularyWords = words.map((word) => ({
|
||||
...word,
|
||||
dateAdded: word.dateAdded || now,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}));
|
||||
|
||||
return await db.insert(vocabulary).values(vocabularyWords).returning();
|
||||
}
|
||||
|
|
@ -18,12 +18,7 @@ export interface UseRecordingOutput {
|
|||
stopRecording: () => Promise<void>;
|
||||
}
|
||||
|
||||
const cleanupMediaResources = (
|
||||
vadInstance: MicVAD | null,
|
||||
streamInstance: MediaStream | null,
|
||||
mediaRecorderInstance: MediaRecorder | null,
|
||||
onDataHandler: ((event: BlobEvent) => Promise<void>) | null
|
||||
) => {
|
||||
const cleanupMediaResources = (vadInstance: MicVAD | null, streamInstance: MediaStream | null) => {
|
||||
if (vadInstance) {
|
||||
try {
|
||||
vadInstance.destroy();
|
||||
|
|
@ -40,27 +35,18 @@ const cleanupMediaResources = (
|
|||
}
|
||||
});
|
||||
}
|
||||
if (mediaRecorderInstance && onDataHandler) {
|
||||
try {
|
||||
mediaRecorderInstance.removeEventListener('dataavailable', onDataHandler);
|
||||
} catch (e) {
|
||||
console.error('Error removing dataavailable listener:', e);
|
||||
}
|
||||
}
|
||||
console.log('Helper: Media resources cleaned up.');
|
||||
};
|
||||
|
||||
export const useRecording = ({
|
||||
onAudioChunk,
|
||||
chunkDurationMs = 2000,
|
||||
chunkDurationMs = 28000,
|
||||
onRecordingStartCallback,
|
||||
onRecordingStopCallback,
|
||||
}: UseRecordingParams): UseRecordingOutput => {
|
||||
const [recordingStatus, setRecordingStatus] = useState<RecordingStatus>('idle');
|
||||
const [voiceDetected, setVoiceDetected] = useState(false);
|
||||
|
||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||
const onDataHandlerRef = useRef<((event: BlobEvent) => Promise<void>) | null>(null);
|
||||
const streamRef = useRef<MediaStream | null>(null);
|
||||
const vadRef = useRef<MicVAD | null>(null);
|
||||
|
||||
|
|
@ -70,35 +56,46 @@ export const useRecording = ({
|
|||
const internalStopRecording = useCallback(
|
||||
async (callStopCallback: boolean) => {
|
||||
// This function assumes mutex is already acquired or not needed (e.g. unmount)
|
||||
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') {
|
||||
console.log('Hook: Internal: Calling mediaRecorder.stop().');
|
||||
mediaRecorderRef.current.stop(); // Triggers final 'dataavailable'
|
||||
// onRecordingStopCallback will be called by handleDataAvailable
|
||||
} else {
|
||||
// If no active media recorder, or already inactive
|
||||
cleanupMediaResources(
|
||||
vadRef.current,
|
||||
streamRef.current,
|
||||
mediaRecorderRef.current,
|
||||
onDataHandlerRef.current
|
||||
);
|
||||
vadRef.current = null;
|
||||
streamRef.current = null;
|
||||
mediaRecorderRef.current = null;
|
||||
onDataHandlerRef.current = null;
|
||||
console.log('Hook: Internal: Stopping recording and sending final chunk...');
|
||||
|
||||
setRecordingStatus('idle');
|
||||
setVoiceDetected(false);
|
||||
if (callStopCallback && onRecordingStopCallback) {
|
||||
try {
|
||||
await onRecordingStopCallback();
|
||||
console.log('Hook: onRecordingStopCallback executed (no active recorder).');
|
||||
} catch (e) {
|
||||
console.error('Hook: Error in onRecordingStopCallback (no active recorder):', e);
|
||||
}
|
||||
// Send final audio chunk before cleanup
|
||||
try {
|
||||
// Access the sendAudioChunk function from the current recording session
|
||||
// We need to store this reference when starting recording
|
||||
const sendFinalChunk = (window as any).currentSendAudioChunk;
|
||||
if (sendFinalChunk) {
|
||||
await sendFinalChunk(true); // Send final chunk
|
||||
console.log('Hook: Final audio chunk sent.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Hook: Error sending final audio chunk:', error);
|
||||
}
|
||||
|
||||
// Cleanup all resources
|
||||
cleanupMediaResources(vadRef.current, streamRef.current);
|
||||
|
||||
// Clear Web Audio API resources
|
||||
const cleanup = (window as any).currentWebAudioCleanup;
|
||||
if (cleanup) {
|
||||
cleanup();
|
||||
(window as any).currentWebAudioCleanup = null;
|
||||
(window as any).currentSendAudioChunk = null;
|
||||
}
|
||||
|
||||
vadRef.current = null;
|
||||
streamRef.current = null;
|
||||
|
||||
setRecordingStatus('idle');
|
||||
setVoiceDetected(false);
|
||||
|
||||
if (callStopCallback && onRecordingStopCallback) {
|
||||
try {
|
||||
await onRecordingStopCallback();
|
||||
console.log('Hook: onRecordingStopCallback executed.');
|
||||
} catch (e) {
|
||||
console.error('Hook: Error in onRecordingStopCallback:', e);
|
||||
}
|
||||
}
|
||||
// isRecording is set to false by the public stopRecording or by handleDataAvailable
|
||||
},
|
||||
[onRecordingStopCallback]
|
||||
);
|
||||
|
|
@ -116,8 +113,6 @@ export const useRecording = ({
|
|||
|
||||
let localStream: MediaStream | null = null;
|
||||
let localVad: MicVAD | null = null;
|
||||
let localMediaRecorder: MediaRecorder | null = null;
|
||||
let localOnDataHandler: ((event: BlobEvent) => Promise<void>) | null = null;
|
||||
|
||||
try {
|
||||
localStream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
|
|
@ -129,52 +124,107 @@ export const useRecording = ({
|
|||
|
||||
streamRef.current = localStream; // Assign to ref after callback
|
||||
|
||||
localMediaRecorder = new MediaRecorder(localStream);
|
||||
mediaRecorderRef.current = localMediaRecorder;
|
||||
// Use Web Audio API with AudioWorklet for raw PCM data
|
||||
const audioContext = new AudioContext({ sampleRate: 16000 });
|
||||
|
||||
localOnDataHandler = async (event: BlobEvent) => {
|
||||
const isFinalEvent = mediaRecorderRef.current?.state === 'inactive';
|
||||
if (event.data.size > 0) {
|
||||
const arrayBuffer = await event.data.arrayBuffer();
|
||||
try {
|
||||
await onAudioChunk(arrayBuffer, isFinalEvent);
|
||||
} catch (error) {
|
||||
console.error('Hook: Error processing audio chunk:', error);
|
||||
}
|
||||
}
|
||||
let audioWorkletNode: AudioWorkletNode | null = null;
|
||||
let source: MediaStreamAudioSourceNode | null = null;
|
||||
let chunkTimer: NodeJS.Timeout | null = null;
|
||||
let pendingAudioChunks: Float32Array[] = [];
|
||||
|
||||
if (isFinalEvent) {
|
||||
console.log('Hook: MediaRecorder inactive, final chunk processed.');
|
||||
// Mutex should not be needed here as this is event-driven from an existing recorder
|
||||
cleanupMediaResources(
|
||||
vadRef.current,
|
||||
streamRef.current,
|
||||
mediaRecorderRef.current,
|
||||
onDataHandlerRef.current
|
||||
);
|
||||
vadRef.current = null;
|
||||
streamRef.current = null;
|
||||
mediaRecorderRef.current = null;
|
||||
onDataHandlerRef.current = null;
|
||||
// Load AudioWorklet module
|
||||
await audioContext.audioWorklet.addModule('/audio-recorder-worklet.js');
|
||||
console.log('Hook: AudioWorklet module loaded successfully');
|
||||
|
||||
setRecordingStatus('idle');
|
||||
setVoiceDetected(false);
|
||||
if (onRecordingStopCallback) {
|
||||
try {
|
||||
await onRecordingStopCallback();
|
||||
console.log('Hook: onRecordingStopCallback executed after final chunk.');
|
||||
} catch (e) {
|
||||
console.error('Hook: Error in onRecordingStopCallback after final chunk:', e);
|
||||
}
|
||||
source = audioContext.createMediaStreamSource(localStream);
|
||||
|
||||
// Create AudioWorklet node
|
||||
audioWorkletNode = new AudioWorkletNode(audioContext, 'audio-recorder-processor');
|
||||
|
||||
// Handle messages from AudioWorklet
|
||||
audioWorkletNode.port.onmessage = (event) => {
|
||||
if (event.data.type === 'audioData') {
|
||||
const audioData = event.data.audioData as Float32Array;
|
||||
const isFinal = event.data.isFinal as boolean;
|
||||
|
||||
// Store the audio chunk
|
||||
pendingAudioChunks.push(audioData);
|
||||
|
||||
if (isFinal) {
|
||||
// Send final chunk immediately
|
||||
sendAudioChunk(true);
|
||||
}
|
||||
}
|
||||
};
|
||||
onDataHandlerRef.current = localOnDataHandler;
|
||||
localMediaRecorder.addEventListener('dataavailable', localOnDataHandler);
|
||||
localMediaRecorder.start(chunkDurationMs);
|
||||
console.log(
|
||||
`Hook: MediaRecorder started (status: starting), chunk duration ${chunkDurationMs}ms.`
|
||||
);
|
||||
|
||||
// Create function to send accumulated chunks
|
||||
const sendAudioChunk = async (isFinal = false) => {
|
||||
if (pendingAudioChunks.length > 0) {
|
||||
// Combine all pending chunks into one array
|
||||
const totalLength = pendingAudioChunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
||||
const combinedChunk = new Float32Array(totalLength);
|
||||
let offset = 0;
|
||||
|
||||
for (const chunk of pendingAudioChunks) {
|
||||
combinedChunk.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
}
|
||||
|
||||
// Convert Float32Array to ArrayBuffer for IPC
|
||||
const arrayBuffer = combinedChunk.buffer.slice(
|
||||
combinedChunk.byteOffset,
|
||||
combinedChunk.byteOffset + combinedChunk.byteLength
|
||||
);
|
||||
|
||||
try {
|
||||
await onAudioChunk(arrayBuffer, isFinal);
|
||||
console.log(
|
||||
`Hook: Sent audio chunk: ${combinedChunk.length} samples, final: ${isFinal}`
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Hook: Error processing audio chunk:', error);
|
||||
}
|
||||
|
||||
pendingAudioChunks = []; // Clear chunks after sending
|
||||
}
|
||||
};
|
||||
|
||||
// Set up periodic chunk sending
|
||||
chunkTimer = setInterval(() => {
|
||||
sendAudioChunk(false);
|
||||
}, chunkDurationMs);
|
||||
|
||||
// Connect the audio processing chain
|
||||
source.connect(audioWorkletNode);
|
||||
console.log('Hook: Connected AudioWorklet processing chain');
|
||||
|
||||
// Store cleanup functions for Web Audio API
|
||||
const cleanup = () => {
|
||||
if (chunkTimer) {
|
||||
clearInterval(chunkTimer);
|
||||
chunkTimer = null;
|
||||
}
|
||||
if (audioWorkletNode) {
|
||||
// Send stop command to worklet
|
||||
audioWorkletNode.port.postMessage({ command: 'stop' });
|
||||
audioWorkletNode.disconnect();
|
||||
audioWorkletNode = null;
|
||||
}
|
||||
if (source) {
|
||||
source.disconnect();
|
||||
source = null;
|
||||
}
|
||||
if (audioContext && audioContext.state !== 'closed') {
|
||||
audioContext.close();
|
||||
}
|
||||
console.log('Hook: Cleaned up AudioWorklet resources');
|
||||
};
|
||||
|
||||
// Store references for cleanup and final chunk sending
|
||||
(window as any).currentWebAudioCleanup = cleanup;
|
||||
(window as any).currentSendAudioChunk = sendAudioChunk;
|
||||
|
||||
console.log(`Hook: AudioWorklet recording started, chunk duration ${chunkDurationMs}ms.`);
|
||||
|
||||
localVad = await MicVAD.new({
|
||||
stream: localStream,
|
||||
|
|
@ -196,11 +246,9 @@ export const useRecording = ({
|
|||
console.log('Hook: Recording fully started (status: recording).');
|
||||
} catch (err) {
|
||||
console.error('Hook: Error starting recording:', err);
|
||||
cleanupMediaResources(localVad, localStream, localMediaRecorder, localOnDataHandler);
|
||||
cleanupMediaResources(localVad, localStream);
|
||||
streamRef.current = null; // Ensure refs are cleared on error
|
||||
vadRef.current = null;
|
||||
mediaRecorderRef.current = null;
|
||||
onDataHandlerRef.current = null;
|
||||
|
||||
setRecordingStatus('error');
|
||||
setVoiceDetected(false);
|
||||
|
|
@ -240,12 +288,8 @@ export const useRecording = ({
|
|||
|
||||
useEffect(() => {
|
||||
// Capture refs and callbacks needed for cleanup at the time the effect is established.
|
||||
const capturedOperationMutex = operationMutexRef.current;
|
||||
const capturedMediaRecorderRef = mediaRecorderRef;
|
||||
const capturedStreamRef = streamRef;
|
||||
const capturedVadRef = vadRef;
|
||||
const capturedOnDataHandlerRef = onDataHandlerRef;
|
||||
const capturedOnRecordingStopCallback = onRecordingStopCallback;
|
||||
|
||||
// We need to know if recording was active *at the time of unmount setup*
|
||||
// to decide if onRecordingStopCallback should be called.
|
||||
|
|
@ -262,27 +306,23 @@ export const useRecording = ({
|
|||
|
||||
// Directly clean up resources using captured refs.
|
||||
// This avoids issues with stale state in async mutex operations during unmount.
|
||||
const mr = capturedMediaRecorderRef.current;
|
||||
const str = capturedStreamRef.current;
|
||||
const vad = capturedVadRef.current;
|
||||
const odh = capturedOnDataHandlerRef.current;
|
||||
|
||||
if (mr && mr.state !== 'inactive') {
|
||||
console.log('Hook: Unmount: Active MediaRecorder found. Attempting to stop.');
|
||||
try {
|
||||
mr.stop(); // Best effort to trigger final data
|
||||
} catch (e) {
|
||||
console.error('Hook: Unmount: Error stopping media recorder:', e);
|
||||
}
|
||||
// Clean up VAD and Stream.
|
||||
cleanupMediaResources(vad, str);
|
||||
|
||||
// Clean up Web Audio API resources
|
||||
const cleanup = (window as any).currentWebAudioCleanup;
|
||||
if (cleanup) {
|
||||
cleanup();
|
||||
(window as any).currentWebAudioCleanup = null;
|
||||
(window as any).currentSendAudioChunk = null;
|
||||
}
|
||||
// Regardless of MediaRecorder state, clean up VAD and Stream.
|
||||
cleanupMediaResources(vad, str, mr, odh);
|
||||
|
||||
// Nullify refs after cleanup
|
||||
capturedMediaRecorderRef.current = null;
|
||||
capturedStreamRef.current = null;
|
||||
capturedVadRef.current = null;
|
||||
capturedOnDataHandlerRef.current = null;
|
||||
|
||||
// Note: Calling setIsRecording(false) etc. here has no effect as the component is unmounted.
|
||||
// onRecordingStopCallback might not be reliably called here if stop() was async and didn't complete.
|
||||
|
|
|
|||
|
|
@ -1,93 +1,166 @@
|
|||
import fs from 'node:fs';
|
||||
import dotenv from 'dotenv';
|
||||
dotenv.config();
|
||||
|
||||
import log from 'electron-log';
|
||||
import { app } from 'electron';
|
||||
import path from 'node:path';
|
||||
import { app as electronApp } from 'electron';
|
||||
|
||||
export class AppLogger {
|
||||
private logFilePath: string;
|
||||
private logFileName: string;
|
||||
// Configure electron-log immediately when module is imported
|
||||
const isDev = process.env.NODE_ENV === 'development' || !app.isPackaged;
|
||||
|
||||
constructor(logFileName: string) {
|
||||
this.logFileName = logFileName;
|
||||
// Configure main logger - check for LOG_LEVEL override
|
||||
const envLogLevel = process.env.LOG_LEVEL as 'error' | 'warn' | 'info' | 'debug' | undefined;
|
||||
const defaultFileLevel: 'debug' | 'info' = isDev ? 'debug' : 'info';
|
||||
const defaultConsoleLevel: 'debug' | 'warn' = isDev ? 'debug' : 'warn';
|
||||
|
||||
log.transports.file.level = envLogLevel || defaultFileLevel;
|
||||
log.transports.console.level = envLogLevel || defaultConsoleLevel;
|
||||
|
||||
// Configure file transport
|
||||
log.transports.file.maxSize = 10 * 1024 * 1024; // 10MB
|
||||
log.transports.file.format = '[{y}-{m}-{d} {h}:{i}:{s}.{ms}] [{level}] [{scope}] {text}';
|
||||
|
||||
// Set custom log file path
|
||||
const logPath = isDev
|
||||
? path.join(app.getPath('userData'), 'logs', 'amical-dev.log')
|
||||
: path.join(app.getPath('logs'), 'amical.log');
|
||||
|
||||
log.transports.file.resolvePathFn = () => logPath;
|
||||
|
||||
// Configure console transport for better development experience
|
||||
if (isDev) {
|
||||
log.transports.console.format = '[{y}-{m}-{d} {h}:{i}:{s}.{ms}] [{level}] [{scope}] {text}';
|
||||
log.transports.console.useStyles = true;
|
||||
} else {
|
||||
log.transports.console.format = '[{y}-{m}-{d} {h}:{i}:{s}.{ms}] [{level}] [{scope}] {text}';
|
||||
log.transports.console.useStyles = false;
|
||||
}
|
||||
|
||||
// Configure remote transport for production error reporting (optional)
|
||||
if (!isDev) {
|
||||
// You can configure remote logging here if needed
|
||||
// log.transports.remote.level = 'error';
|
||||
// log.transports.remote.url = 'your-logging-service-url';
|
||||
}
|
||||
|
||||
// -----------------------------------------------
|
||||
// Debug-scope configuration
|
||||
// -----------------------------------------------
|
||||
// `LOG_DEBUG_SCOPES` can be a comma-separated list of scope names (main,ai,swift)
|
||||
// or regex patterns wrapped in slashes (e.g. /ai.*/, /.*/)
|
||||
const rawDebugScopes = (process.env.LOG_DEBUG_SCOPES ?? '')
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
// Utility: escape regex special chars for exact-match tokens
|
||||
function escapeRegExp(str: string) {
|
||||
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
const debugScopePatterns: RegExp[] = rawDebugScopes.map((token) => {
|
||||
if (token.startsWith('/') && token.endsWith('/') && token.length > 1) {
|
||||
// Regex pattern (strip the leading & trailing slashes)
|
||||
const pattern = token.slice(1, -1);
|
||||
try {
|
||||
const logsPath = electronApp.getPath('logs');
|
||||
if (!fs.existsSync(logsPath)) {
|
||||
fs.mkdirSync(logsPath, { recursive: true });
|
||||
}
|
||||
this.logFilePath = path.join(logsPath, this.logFileName);
|
||||
this._log('INFO', `Log session started. Log file: ${this.logFilePath}`);
|
||||
} catch (e: any) {
|
||||
// Fallback if getPath or mkdir fails
|
||||
const tempPath = electronApp.getPath('temp');
|
||||
if (!fs.existsSync(tempPath)) {
|
||||
// Attempt to create temp path if it also doesn't exist (less likely)
|
||||
fs.mkdirSync(tempPath, { recursive: true });
|
||||
}
|
||||
this.logFilePath = path.join(tempPath, `${this.logFileName}.fallback.log`);
|
||||
console.error(`AppLogger (${this.logFileName}): Error setting up primary log path, using fallback: ${e.message}`);
|
||||
this._log('ERROR', `Error setting up primary log path: ${e.message}. Using fallback: ${this.logFilePath}`);
|
||||
return new RegExp(pattern, 'i');
|
||||
} catch {
|
||||
// Fall through to exact match if regex is invalid
|
||||
}
|
||||
}
|
||||
// Treat as exact scope name
|
||||
return new RegExp(`^${escapeRegExp(token)}$`, 'i');
|
||||
});
|
||||
|
||||
private _log(level: 'INFO' | 'ERROR' | 'WARN' | 'DEBUG', ...args: any[]): void {
|
||||
const timestamp = new Date().toISOString();
|
||||
const serializedArgs = args.map(arg => {
|
||||
if (arg instanceof Error) {
|
||||
return `Error: ${arg.message}${arg.stack ? '\nStack: ' + arg.stack : ''}`;
|
||||
}
|
||||
if (typeof arg === 'object' && arg !== null) {
|
||||
try {
|
||||
return JSON.stringify(arg);
|
||||
} catch (e) {
|
||||
return '[Unserializable Object]';
|
||||
}
|
||||
}
|
||||
return String(arg);
|
||||
});
|
||||
const formattedMessage = `[${timestamp}] [${level}] ${serializedArgs.join(' ')}\n`;
|
||||
export function isScopeDebug(scope: string): boolean {
|
||||
return debugScopePatterns.some((re) => re.test(scope));
|
||||
}
|
||||
|
||||
try {
|
||||
const logDir = path.dirname(this.logFilePath);
|
||||
if (!fs.existsSync(logDir)) {
|
||||
fs.mkdirSync(logDir, { recursive: true });
|
||||
}
|
||||
fs.appendFileSync(this.logFilePath, formattedMessage);
|
||||
} catch (e: any) {
|
||||
console.error(`AppLogger (${this.logFileName}): FATAL - Failed to write to log file:`, this.logFilePath, e.message);
|
||||
if (level === 'ERROR') console.error(...args);
|
||||
else if (level === 'WARN') console.warn(...args);
|
||||
else console.log(...args); // Default to console.log for INFO/DEBUG if file write fails
|
||||
return;
|
||||
// Set up hooks to handle scope-based debug filtering
|
||||
if (debugScopePatterns.length > 0) {
|
||||
log.hooks.push((message) => {
|
||||
// Only filter debug messages
|
||||
if (message.level !== 'debug') return message;
|
||||
|
||||
// Check if this scope should have debug enabled
|
||||
const scope = message.scope;
|
||||
if (scope && isScopeDebug(scope)) {
|
||||
return message; // Allow debug messages for this scope
|
||||
}
|
||||
|
||||
// Also log to the actual console based on level
|
||||
if (level === 'ERROR') {
|
||||
console.error(`[${this.logFileName}]`, ...args);
|
||||
} else if (level === 'WARN') {
|
||||
console.warn(`[${this.logFileName}]`, ...args);
|
||||
} else if (level === 'INFO') {
|
||||
console.info(`[${this.logFileName}]`, ...args);
|
||||
} else { // DEBUG
|
||||
console.debug(`[${this.logFileName}]`, ...args);
|
||||
}
|
||||
}
|
||||
// Block debug messages for scopes not in the debug list
|
||||
return false;
|
||||
});
|
||||
}
|
||||
// -----------------------------------------------
|
||||
|
||||
public info(...args: any[]): void {
|
||||
this._log('INFO', ...args);
|
||||
}
|
||||
// Helper to create a scoped logger
|
||||
log.scope.labelPadding = false;
|
||||
function createLoggerForScope(scope: string) {
|
||||
return log.scope(scope);
|
||||
}
|
||||
|
||||
public warn(...args: any[]): void {
|
||||
this._log('WARN', ...args);
|
||||
}
|
||||
// Create scoped loggers for different modules
|
||||
export const logger = {
|
||||
main: createLoggerForScope('main'),
|
||||
ipc: createLoggerForScope('ipc'),
|
||||
renderer: createLoggerForScope('renderer'),
|
||||
network: createLoggerForScope('network'),
|
||||
audio: createLoggerForScope('audio'),
|
||||
ai: createLoggerForScope('ai'),
|
||||
swift: createLoggerForScope('swift'),
|
||||
ui: createLoggerForScope('ui'),
|
||||
db: createLoggerForScope('db'),
|
||||
};
|
||||
|
||||
public error(...args: any[]): void {
|
||||
this._log('ERROR', ...args);
|
||||
}
|
||||
// Log startup information
|
||||
logger.main.info('Logger initialized', {
|
||||
isDev,
|
||||
fileLogLevel: log.transports.file.level,
|
||||
consoleLogLevel: log.transports.console.level,
|
||||
envLogLevel: envLogLevel || 'not set',
|
||||
logPath,
|
||||
version: app.getVersion(),
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
});
|
||||
|
||||
public debug(...args: any[]): void {
|
||||
this._log('DEBUG', ...args);
|
||||
}
|
||||
// Export the main logger instance for direct use
|
||||
export { log };
|
||||
|
||||
public getLogFilePath(): string {
|
||||
return this.logFilePath;
|
||||
// Utility function to create custom scoped loggers
|
||||
export function createScopedLogger(scope: string) {
|
||||
return createLoggerForScope(scope);
|
||||
}
|
||||
|
||||
// Error handling utilities
|
||||
export function logError(error: Error, context?: string, metadata?: Record<string, any>) {
|
||||
const errorInfo = {
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
name: error.name,
|
||||
context,
|
||||
...metadata,
|
||||
};
|
||||
|
||||
logger?.main.error('Error occurred:', errorInfo);
|
||||
}
|
||||
|
||||
export function logPerformance(
|
||||
operation: string,
|
||||
startTime: number,
|
||||
metadata?: Record<string, any>
|
||||
) {
|
||||
const duration = Date.now() - startTime;
|
||||
logger?.main.info(`Performance: ${operation}`, {
|
||||
duration: `${duration}ms`,
|
||||
...metadata,
|
||||
});
|
||||
}
|
||||
|
||||
// Development helpers
|
||||
export function logDebugInfo(component: string, data: any) {
|
||||
if (process.env.NODE_ENV === 'development' || !app.isPackaged) {
|
||||
logger?.main.debug(`[${component}]`, data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,7 @@
|
|||
// Load .env file FIRST before any other imports
|
||||
import dotenv from 'dotenv';
|
||||
dotenv.config();
|
||||
|
||||
import {
|
||||
app,
|
||||
BrowserWindow,
|
||||
|
|
@ -9,18 +13,22 @@ import {
|
|||
} from 'electron';
|
||||
import path from 'node:path';
|
||||
import fsPromises from 'node:fs/promises'; // For reading the audio file (async)
|
||||
import dotenv from 'dotenv';
|
||||
import started from 'electron-squirrel-startup';
|
||||
import Store from 'electron-store';
|
||||
//import { runMigrations } from '../db/migrate';
|
||||
import { initializeDatabase } from '../db/config';
|
||||
import { HelperEvent, KeyEventPayload } from '@amical/types';
|
||||
|
||||
dotenv.config(); // Load .env file
|
||||
import { logger, logError, logPerformance } from './logger';
|
||||
import { AudioCapture } from '../modules/audio/audio-capture';
|
||||
import { setupApplicationMenu } from './menu';
|
||||
import { OpenAIWhisperClient } from '../modules/ai/openai-whisper-client';
|
||||
import { AiService } from '../modules/ai/ai-service';
|
||||
import { SwiftIOBridge } from './swift-io-bridge'; // Added import
|
||||
import { DownloadedModel } from '../constants/models';
|
||||
import { ModelManagerService } from '../modules/models/model-manager';
|
||||
import { LocalWhisperClient } from '../modules/ai/local-whisper-client';
|
||||
import { TranscriptionSession, ChunkData } from '../modules/transcription/transcription-session';
|
||||
import { ContextualTranscriptionManager } from '../modules/transcription/contextual-transcription-manager';
|
||||
import { SettingsService } from '../modules/settings';
|
||||
import { createIPCHandler } from 'electron-trpc-experimental/main';
|
||||
import { router } from '../trpc/router';
|
||||
|
||||
// Handle creating/removing shortcuts on Windows when installing/uninstalling.
|
||||
if (started) {
|
||||
|
|
@ -37,22 +45,357 @@ let floatingButtonWindow: BrowserWindow | null = null;
|
|||
let audioCapture: AudioCapture | null = null;
|
||||
let aiService: AiService | null = null;
|
||||
let swiftIOBridgeClientInstance: SwiftIOBridge | null = null;
|
||||
let openAiApiKey: string | null = null;
|
||||
let modelManagerService: ModelManagerService | null = null;
|
||||
let localWhisperClient: LocalWhisperClient | null = null;
|
||||
let currentWindowDisplayId: number | null = null; // For tracking current display
|
||||
let activeSpaceChangeSubscriptionId: number | null = null; // For display change notifications
|
||||
|
||||
interface StoreSchema {
|
||||
'openai-api-key': string;
|
||||
}
|
||||
// New chunk-based transcription variables
|
||||
let contextualTranscriptionManager: ContextualTranscriptionManager | null = null;
|
||||
const activeTranscriptionSessions: Map<string, TranscriptionSession> = new Map();
|
||||
|
||||
const store = new Store<StoreSchema>();
|
||||
// Store is imported from '../lib/store' and is database-backed
|
||||
|
||||
ipcMain.handle('set-api-key', (event, apiKey: string) => {
|
||||
console.log('Main: Received set-api-key', event, ' API key:', apiKey);
|
||||
openAiApiKey = apiKey;
|
||||
store.set('openai-api-key', apiKey);
|
||||
// Function to create the local transcription client
|
||||
const createTranscriptionClient = () => {
|
||||
logger.ai.info('Using local Whisper inference');
|
||||
if (!localWhisperClient) {
|
||||
throw new Error('Local Whisper client not initialized');
|
||||
}
|
||||
return localWhisperClient;
|
||||
};
|
||||
|
||||
// Model Management IPC Handlers
|
||||
ipcMain.handle('get-available-models', () => {
|
||||
return modelManagerService?.getAvailableModels() || [];
|
||||
});
|
||||
|
||||
ipcMain.handle('get-downloaded-models', async () => {
|
||||
return modelManagerService ? await modelManagerService.getDownloadedModels() : {};
|
||||
});
|
||||
|
||||
ipcMain.handle('is-model-downloaded', async (event, modelId: string) => {
|
||||
return modelManagerService ? await modelManagerService.isModelDownloaded(modelId) : false;
|
||||
});
|
||||
|
||||
ipcMain.handle('get-download-progress', (event, modelId: string) => {
|
||||
return modelManagerService?.getDownloadProgress(modelId) || null;
|
||||
});
|
||||
|
||||
ipcMain.handle('get-active-downloads', () => {
|
||||
return modelManagerService?.getActiveDownloads() || [];
|
||||
});
|
||||
|
||||
ipcMain.handle('download-model', async (event, modelId: string) => {
|
||||
if (!modelManagerService) {
|
||||
throw new Error('Model manager service not initialized');
|
||||
}
|
||||
return await modelManagerService.downloadModel(modelId);
|
||||
});
|
||||
|
||||
ipcMain.handle('cancel-download', (event, modelId: string) => {
|
||||
if (!modelManagerService) {
|
||||
throw new Error('Model manager service not initialized');
|
||||
}
|
||||
return modelManagerService.cancelDownload(modelId);
|
||||
});
|
||||
|
||||
ipcMain.handle('delete-model', (event, modelId: string) => {
|
||||
if (!modelManagerService) {
|
||||
throw new Error('Model manager service not initialized');
|
||||
}
|
||||
return modelManagerService.deleteModel(modelId);
|
||||
});
|
||||
|
||||
ipcMain.handle('get-models-directory', () => {
|
||||
return modelManagerService?.getModelsDirectory() || '';
|
||||
});
|
||||
|
||||
// Local Whisper IPC Handlers
|
||||
ipcMain.handle('is-local-whisper-available', async () => {
|
||||
return localWhisperClient ? await localWhisperClient.isAvailable() : false;
|
||||
});
|
||||
|
||||
ipcMain.handle('get-local-whisper-models', async () => {
|
||||
return localWhisperClient ? await localWhisperClient.getAvailableModels() : [];
|
||||
});
|
||||
|
||||
ipcMain.handle('get-selected-model', () => {
|
||||
return localWhisperClient ? localWhisperClient.getSelectedModel() : null;
|
||||
});
|
||||
|
||||
ipcMain.handle('set-selected-model', async (event, modelId: string) => {
|
||||
if (!localWhisperClient) {
|
||||
throw new Error('Local whisper client not initialized');
|
||||
}
|
||||
return await localWhisperClient.setSelectedModel(modelId);
|
||||
});
|
||||
|
||||
ipcMain.handle('set-whisper-executable-path', (event, path: string) => {
|
||||
if (!localWhisperClient) {
|
||||
throw new Error('Local whisper client not initialized');
|
||||
}
|
||||
// Executable path setting is no longer needed with smart-whisper
|
||||
logger.ai.info('Whisper executable path setting skipped - using smart-whisper integration');
|
||||
});
|
||||
|
||||
// Formatter Configuration IPC Handlers
|
||||
ipcMain.handle('get-formatter-config', async () => {
|
||||
try {
|
||||
const settingsService = SettingsService.getInstance();
|
||||
return await settingsService.getFormatterConfig();
|
||||
} catch (error) {
|
||||
logger.ai.error('Error getting formatter config:', error);
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('set-formatter-config', async (event, config) => {
|
||||
try {
|
||||
const settingsService = SettingsService.getInstance();
|
||||
await settingsService.setFormatterConfig(config);
|
||||
|
||||
// Update AI service with new formatter configuration
|
||||
if (aiService) {
|
||||
aiService.configureFormatter(config);
|
||||
logger.ai.info('Formatter configuration updated');
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.ai.error('Error setting formatter config:', error);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
// Transcription Database API
|
||||
ipcMain.handle('get-transcriptions', async (event, options = {}) => {
|
||||
try {
|
||||
const { getTranscriptions } = await import('../db/transcriptions');
|
||||
return await getTranscriptions(options);
|
||||
} catch (error) {
|
||||
logger.db.error('Error getting transcriptions', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('get-transcription-by-id', async (event, id: number) => {
|
||||
try {
|
||||
const { getTranscriptionById } = await import('../db/transcriptions');
|
||||
return await getTranscriptionById(id);
|
||||
} catch (error) {
|
||||
logger.db.error('Error getting transcription by ID', {
|
||||
id,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('create-transcription', async (event, data) => {
|
||||
try {
|
||||
const { createTranscription } = await import('../db/transcriptions');
|
||||
return await createTranscription(data);
|
||||
} catch (error) {
|
||||
logger.db.error('Error creating transcription', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('update-transcription', async (event, id: number, data) => {
|
||||
try {
|
||||
const { updateTranscription } = await import('../db/transcriptions');
|
||||
return await updateTranscription(id, data);
|
||||
} catch (error) {
|
||||
logger.db.error('Error updating transcription', {
|
||||
id,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('delete-transcription', async (event, id: number) => {
|
||||
try {
|
||||
const { deleteTranscription } = await import('../db/transcriptions');
|
||||
return await deleteTranscription(id);
|
||||
} catch (error) {
|
||||
logger.db.error('Error deleting transcription', {
|
||||
id,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('get-transcriptions-count', async (event, search?: string) => {
|
||||
try {
|
||||
const { getTranscriptionsCount } = await import('../db/transcriptions');
|
||||
return await getTranscriptionsCount(search);
|
||||
} catch (error) {
|
||||
logger.db.error('Error getting transcriptions count', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('search-transcriptions', async (event, searchTerm: string, limit = 20) => {
|
||||
try {
|
||||
const { searchTranscriptions } = await import('../db/transcriptions');
|
||||
return await searchTranscriptions(searchTerm, limit);
|
||||
} catch (error) {
|
||||
logger.db.error('Error searching transcriptions', {
|
||||
searchTerm,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
// Vocabulary Database API
|
||||
ipcMain.handle('get-vocabulary', async (event, options = {}) => {
|
||||
try {
|
||||
const { getVocabulary } = await import('../db/vocabulary');
|
||||
return await getVocabulary(options);
|
||||
} catch (error) {
|
||||
logger.db.error('Error getting vocabulary', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('get-vocabulary-by-id', async (event, id: number) => {
|
||||
try {
|
||||
const { getVocabularyById } = await import('../db/vocabulary');
|
||||
return await getVocabularyById(id);
|
||||
} catch (error) {
|
||||
logger.db.error('Error getting vocabulary by ID', {
|
||||
id,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('get-vocabulary-by-word', async (event, word: string) => {
|
||||
try {
|
||||
const { getVocabularyByWord } = await import('../db/vocabulary');
|
||||
return await getVocabularyByWord(word);
|
||||
} catch (error) {
|
||||
logger.db.error('Error getting vocabulary by word', {
|
||||
word,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('create-vocabulary-word', async (event, data) => {
|
||||
try {
|
||||
const { createVocabularyWord } = await import('../db/vocabulary');
|
||||
return await createVocabularyWord(data);
|
||||
} catch (error) {
|
||||
logger.db.error('Error creating vocabulary word', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('update-vocabulary', async (event, id: number, data) => {
|
||||
try {
|
||||
const { updateVocabulary } = await import('../db/vocabulary');
|
||||
return await updateVocabulary(id, data);
|
||||
} catch (error) {
|
||||
logger.db.error('Error updating vocabulary', {
|
||||
id,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('delete-vocabulary', async (event, id: number) => {
|
||||
try {
|
||||
const { deleteVocabulary } = await import('../db/vocabulary');
|
||||
return await deleteVocabulary(id);
|
||||
} catch (error) {
|
||||
logger.db.error('Error deleting vocabulary', {
|
||||
id,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('get-vocabulary-count', async (event, search?: string) => {
|
||||
try {
|
||||
const { getVocabularyCount } = await import('../db/vocabulary');
|
||||
return await getVocabularyCount(search);
|
||||
} catch (error) {
|
||||
logger.db.error('Error getting vocabulary count', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('search-vocabulary', async (event, searchTerm: string, limit = 20) => {
|
||||
try {
|
||||
const { searchVocabulary } = await import('../db/vocabulary');
|
||||
return await searchVocabulary(searchTerm, limit);
|
||||
} catch (error) {
|
||||
logger.db.error('Error searching vocabulary', {
|
||||
searchTerm,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('bulk-import-vocabulary', async (event, words) => {
|
||||
try {
|
||||
const { bulkImportVocabulary } = await import('../db/vocabulary');
|
||||
return await bulkImportVocabulary(words);
|
||||
} catch (error) {
|
||||
logger.db.error('Error bulk importing vocabulary', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('track-word-usage', async (event, word: string) => {
|
||||
try {
|
||||
const { trackWordUsage } = await import('../db/vocabulary');
|
||||
return await trackWordUsage(word);
|
||||
} catch (error) {
|
||||
logger.db.error('Error tracking word usage', {
|
||||
word,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('get-most-used-words', async (event, limit = 10) => {
|
||||
try {
|
||||
const { getMostUsedWords } = await import('../db/vocabulary');
|
||||
return await getMostUsedWords(limit);
|
||||
} catch (error) {
|
||||
logger.db.error('Error getting most used words', {
|
||||
limit,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
const requestPermissions = async () => {
|
||||
try {
|
||||
// Request accessibility permissions
|
||||
|
|
@ -69,25 +412,28 @@ const requestPermissions = async () => {
|
|||
|
||||
// Request microphone permissions
|
||||
const microphoneEnabled = systemPreferences.getMediaAccessStatus('microphone');
|
||||
console.log('Main: Microphone access status:', microphoneEnabled);
|
||||
logger.main.info('Microphone access status:', { status: microphoneEnabled });
|
||||
if (microphoneEnabled !== 'granted') {
|
||||
await systemPreferences.askForMediaAccess('microphone');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error requesting permissions:', error);
|
||||
logError(error instanceof Error ? error : new Error(String(error)), 'requesting permissions');
|
||||
}
|
||||
};
|
||||
|
||||
const createOrShowSettingsWindow = () => {
|
||||
const createOrShowMainWindow = () => {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.show();
|
||||
mainWindow.focus();
|
||||
return;
|
||||
}
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 800,
|
||||
height: 600,
|
||||
frame: true,
|
||||
width: 1200,
|
||||
height: 800,
|
||||
frame: false,
|
||||
titleBarStyle: 'hidden',
|
||||
trafficLightPosition: { x: 20, y: 16 },
|
||||
useContentSize: true,
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'preload.js'),
|
||||
nodeIntegration: false,
|
||||
|
|
@ -102,6 +448,12 @@ const createOrShowSettingsWindow = () => {
|
|||
mainWindow.on('closed', () => {
|
||||
mainWindow = null;
|
||||
});
|
||||
|
||||
// Update tRPC handler to include the main window
|
||||
createIPCHandler({
|
||||
router,
|
||||
windows: [mainWindow, floatingButtonWindow].filter(Boolean) as BrowserWindow[],
|
||||
});
|
||||
};
|
||||
|
||||
const createFloatingButtonWindow = () => {
|
||||
|
|
@ -153,73 +505,174 @@ const createFloatingButtonWindow = () => {
|
|||
// initialization and is ready to create browser windows.
|
||||
// Some APIs can only be used after this event occurs.
|
||||
app.on('ready', async () => {
|
||||
// Run database migrations first
|
||||
// Initialize database and run migrations first
|
||||
try {
|
||||
//runMigrations();
|
||||
console.log('Database migrations completed successfully');
|
||||
await initializeDatabase();
|
||||
logger.db.info('Database initialized and migrations completed successfully');
|
||||
} catch (error) {
|
||||
console.error('Failed to run database migrations:', error);
|
||||
logError(error instanceof Error ? error : new Error(String(error)), 'initializing database');
|
||||
// You might want to handle this error differently, perhaps showing a dialog to the user
|
||||
}
|
||||
|
||||
await requestPermissions();
|
||||
createFloatingButtonWindow();
|
||||
|
||||
// Setup tRPC IPC handler
|
||||
createIPCHandler({
|
||||
router,
|
||||
windows: [floatingButtonWindow],
|
||||
});
|
||||
|
||||
if (process.platform === 'darwin' && app.dock) {
|
||||
app.dock.show();
|
||||
}
|
||||
|
||||
audioCapture = new AudioCapture();
|
||||
|
||||
openAiApiKey = store.get('openai-api-key') || null;
|
||||
if (openAiApiKey) {
|
||||
console.log('Main: Loaded API key from store.');
|
||||
} else {
|
||||
console.log('Main: No API key found in store.');
|
||||
}
|
||||
// Initialize Model Manager Service
|
||||
modelManagerService = new ModelManagerService();
|
||||
await modelManagerService.initialize();
|
||||
|
||||
if (!openAiApiKey) {
|
||||
console.warn('OPENAI_API_KEY not provided. Transcription will not work.');
|
||||
} else {
|
||||
// Initialize Local Whisper Client
|
||||
localWhisperClient = new LocalWhisperClient(modelManagerService);
|
||||
|
||||
// Initialize Contextual Transcription Manager
|
||||
contextualTranscriptionManager = new ContextualTranscriptionManager(modelManagerService);
|
||||
|
||||
// Set up model manager event listeners
|
||||
modelManagerService.on('download-progress', (modelId, progress) => {
|
||||
// Send progress updates to all windows
|
||||
BrowserWindow.getAllWindows().forEach((window) => {
|
||||
window.webContents.send('model-download-progress', modelId, progress);
|
||||
});
|
||||
});
|
||||
|
||||
modelManagerService.on('download-complete', (modelId, downloadedModel) => {
|
||||
BrowserWindow.getAllWindows().forEach((window) => {
|
||||
window.webContents.send('model-download-complete', modelId, downloadedModel);
|
||||
});
|
||||
});
|
||||
|
||||
modelManagerService.on('download-error', (modelId, error) => {
|
||||
BrowserWindow.getAllWindows().forEach((window) => {
|
||||
window.webContents.send('model-download-error', modelId, error.message);
|
||||
});
|
||||
});
|
||||
|
||||
modelManagerService.on('download-cancelled', (modelId) => {
|
||||
BrowserWindow.getAllWindows().forEach((window) => {
|
||||
window.webContents.send('model-download-cancelled', modelId);
|
||||
});
|
||||
});
|
||||
|
||||
modelManagerService.on('model-deleted', (modelId) => {
|
||||
BrowserWindow.getAllWindows().forEach((window) => {
|
||||
window.webContents.send('model-deleted', modelId);
|
||||
});
|
||||
});
|
||||
|
||||
// Initialize AI service with the appropriate client based on configuration
|
||||
try {
|
||||
const transcriptionClient = createTranscriptionClient();
|
||||
aiService = new AiService(transcriptionClient);
|
||||
|
||||
// Load and configure formatter
|
||||
try {
|
||||
const whisperClient = new OpenAIWhisperClient(openAiApiKey);
|
||||
aiService = new AiService(whisperClient);
|
||||
console.log('AI Service initialized with OpenAI Whisper client.');
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize AI Service:', error);
|
||||
const settingsService = SettingsService.getInstance();
|
||||
const formatterConfig = await settingsService.getFormatterConfig();
|
||||
if (formatterConfig) {
|
||||
aiService.configureFormatter(formatterConfig);
|
||||
logger.ai.info('Formatter configured', {
|
||||
provider: formatterConfig.provider,
|
||||
enabled: formatterConfig.enabled,
|
||||
});
|
||||
}
|
||||
} catch (formatterError) {
|
||||
logger.ai.warn('Failed to load formatter configuration:', formatterError);
|
||||
}
|
||||
|
||||
logger.ai.info('AI Service initialized', {
|
||||
client: 'Local Whisper',
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error instanceof Error ? error : new Error(String(error)), 'initializing AI Service');
|
||||
logger.ai.warn('Transcription will not work until configuration is fixed');
|
||||
aiService = null;
|
||||
}
|
||||
|
||||
audioCapture.on('recording-finished', async (filePath: string) => {
|
||||
openAiApiKey = store.get('openai-api-key') || 'test123'; // Ensure there is a fallback or handle error
|
||||
const whisperClient = new OpenAIWhisperClient(openAiApiKey); // Re-init or ensure client is valid
|
||||
aiService = new AiService(whisperClient); // Re-init or ensure service is valid
|
||||
// Ensure AI service is available and up-to-date
|
||||
if (!aiService) {
|
||||
try {
|
||||
const transcriptionClient = createTranscriptionClient();
|
||||
aiService = new AiService(transcriptionClient);
|
||||
|
||||
console.log(`Main: Recording finished, file available at: ${filePath}`);
|
||||
// Load and configure formatter
|
||||
try {
|
||||
const settingsService = SettingsService.getInstance();
|
||||
const formatterConfig = await settingsService.getFormatterConfig();
|
||||
if (formatterConfig) {
|
||||
aiService.configureFormatter(formatterConfig);
|
||||
logger.ai.info('Formatter reconfigured', {
|
||||
provider: formatterConfig.provider,
|
||||
enabled: formatterConfig.enabled,
|
||||
});
|
||||
}
|
||||
} catch (formatterError) {
|
||||
logger.ai.warn('Failed to reload formatter configuration:', formatterError);
|
||||
}
|
||||
|
||||
logger.ai.info('AI Service reinitialized', {
|
||||
client: 'Local Whisper',
|
||||
});
|
||||
} catch (error) {
|
||||
logError(
|
||||
error instanceof Error ? error : new Error(String(error)),
|
||||
'reinitializing AI Service'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
logger.audio.info('Recording finished', { filePath });
|
||||
if (aiService) {
|
||||
try {
|
||||
const startTime = Date.now();
|
||||
const audioBuffer = await fsPromises.readFile(filePath);
|
||||
console.log(`Main: Read audio file of size: ${audioBuffer.length} bytes. Transcribing...`);
|
||||
logger.audio.info('Audio file read', {
|
||||
size: audioBuffer.length,
|
||||
sizeKB: Math.round(audioBuffer.length / 1024),
|
||||
});
|
||||
|
||||
const transcription = await aiService.transcribeAudio(audioBuffer);
|
||||
console.log('Main: Transcription result:', transcription);
|
||||
logPerformance('audio transcription', startTime, {
|
||||
audioSizeKB: Math.round(audioBuffer.length / 1024),
|
||||
transcriptionLength: transcription?.length || 0,
|
||||
});
|
||||
logger.ai.info('Transcription completed', {
|
||||
resultLength: transcription?.length || 0,
|
||||
hasResult: !!transcription,
|
||||
});
|
||||
|
||||
// Copy transcription to clipboard
|
||||
if (transcription && typeof transcription === 'string') {
|
||||
console.log('Main: Transcription copied to clipboard.');
|
||||
logger.main.info('Transcription pasted to active application');
|
||||
// Attempt to paste into the active application
|
||||
swiftIOBridgeClientInstance!.call('pasteText', { transcript: transcription });
|
||||
} else {
|
||||
console.warn('Main: Transcription result was empty or not a string, not copying.');
|
||||
logger.main.warn('Transcription result was empty or not a string, not copying');
|
||||
}
|
||||
|
||||
// Optionally, delete the audio file after processing
|
||||
// await fs.unlink(filePath);
|
||||
// console.log(`Main: Deleted audio file: ${filePath}`);
|
||||
} catch (error) {
|
||||
console.error('Main: Error during transcription or file handling:', error);
|
||||
logError(
|
||||
error instanceof Error ? error : new Error(String(error)),
|
||||
'transcription or file handling'
|
||||
);
|
||||
}
|
||||
} else {
|
||||
console.warn('Main: AI Service not available, cannot transcribe audio.');
|
||||
logger.ai.warn('AI Service not available, cannot transcribe audio');
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -227,6 +680,81 @@ app.on('ready', async () => {
|
|||
console.error('Main: Received recording error from AudioCapture:', error);
|
||||
});
|
||||
|
||||
// Handle individual audio chunks for real-time transcription
|
||||
audioCapture.on('chunk-ready', async (chunkData: ChunkData) => {
|
||||
logger.audio.info('Received chunk for transcription', {
|
||||
sessionId: chunkData.sessionId,
|
||||
chunkId: chunkData.chunkId,
|
||||
audioDataSize: chunkData.audioData.length,
|
||||
isFinalChunk: chunkData.isFinalChunk,
|
||||
});
|
||||
|
||||
try {
|
||||
// Get or create transcription session for this recording session
|
||||
let transcriptionSession = activeTranscriptionSessions.get(chunkData.sessionId);
|
||||
|
||||
if (!transcriptionSession) {
|
||||
// Create new transcription session
|
||||
const transcriptionClient = contextualTranscriptionManager!.createDefaultClient();
|
||||
|
||||
transcriptionSession = new TranscriptionSession(chunkData.sessionId, transcriptionClient);
|
||||
activeTranscriptionSessions.set(chunkData.sessionId, transcriptionSession);
|
||||
|
||||
// Set up session event handlers
|
||||
transcriptionSession.on('chunk-completed', (result) => {
|
||||
logger.ai.info('Chunk transcription completed', {
|
||||
sessionId: chunkData.sessionId,
|
||||
chunkId: result.chunkId,
|
||||
textLength: result.text.length,
|
||||
processingTimeMs: result.processingTimeMs,
|
||||
});
|
||||
});
|
||||
|
||||
transcriptionSession.on('session-completed', (sessionResult) => {
|
||||
logger.ai.info('Transcription session completed', {
|
||||
sessionId: sessionResult.sessionId,
|
||||
finalTextLength: sessionResult.finalText.length,
|
||||
totalChunks: sessionResult.chunkResults.length,
|
||||
totalProcessingTimeMs: sessionResult.totalProcessingTimeMs,
|
||||
});
|
||||
|
||||
// Paste the final result to active application
|
||||
if (sessionResult.finalText && sessionResult.finalText.trim().length > 0) {
|
||||
logger.main.info('Final transcription pasted to active application', {
|
||||
textLength: sessionResult.finalText.length,
|
||||
});
|
||||
swiftIOBridgeClientInstance!.call('pasteText', { transcript: sessionResult.finalText });
|
||||
} else {
|
||||
logger.main.warn('Final transcription was empty, not pasting');
|
||||
}
|
||||
|
||||
// Clean up completed session
|
||||
activeTranscriptionSessions.delete(chunkData.sessionId);
|
||||
});
|
||||
|
||||
transcriptionSession.on('chunk-error', (errorInfo) => {
|
||||
logger.ai.error('Chunk transcription error', {
|
||||
sessionId: chunkData.sessionId,
|
||||
chunkId: errorInfo.chunkId,
|
||||
error: errorInfo.error,
|
||||
});
|
||||
// Continue processing other chunks even if one fails
|
||||
});
|
||||
|
||||
logger.ai.info('Created new transcription session', { sessionId: chunkData.sessionId });
|
||||
}
|
||||
|
||||
// Add chunk to session for processing
|
||||
transcriptionSession.addChunk(chunkData);
|
||||
} catch (error) {
|
||||
logger.ai.error('Error handling chunk-ready event', {
|
||||
sessionId: chunkData.sessionId,
|
||||
chunkId: chunkData.chunkId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Handle audio data chunks from renderer
|
||||
ipcMain.handle('audio-data-chunk', (event, chunk: ArrayBuffer, isFinalChunk: boolean) => {
|
||||
if (chunk instanceof ArrayBuffer) {
|
||||
|
|
@ -250,6 +778,32 @@ app.on('ready', async () => {
|
|||
|
||||
ipcMain.handle('recording-starting', async () => {
|
||||
console.log('Main: Received recording-starting event.');
|
||||
|
||||
// Preload the transcription model for fast processing
|
||||
try {
|
||||
if (contextualTranscriptionManager) {
|
||||
if (!contextualTranscriptionManager.isModelLoaded()) {
|
||||
logger.ai.info('Preloading transcription model for recording session');
|
||||
await contextualTranscriptionManager.preloadModel();
|
||||
logger.ai.info('Transcription model preloaded successfully');
|
||||
} else {
|
||||
logger.ai.info('Transcription model already loaded');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.ai.error('Error preloading transcription model', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
|
||||
// Get accessibility context when recording starts
|
||||
try {
|
||||
//const accessibilityContext = await swiftIOBridgeClientInstance!.call('getAccessibilityContext', { editableOnly: true });
|
||||
//console.log('Main: Accessibility context captured:', JSON.stringify(accessibilityContext, null, 2));
|
||||
} catch (error) {
|
||||
console.error('Main: Error getting accessibility context:', error);
|
||||
}
|
||||
|
||||
await swiftIOBridgeClientInstance!.call('muteSystemAudio', {});
|
||||
});
|
||||
|
||||
|
|
@ -262,38 +816,37 @@ app.on('ready', async () => {
|
|||
swiftIOBridgeClientInstance = new SwiftIOBridge();
|
||||
|
||||
swiftIOBridgeClientInstance.on('helperEvent', (event: HelperEvent) => {
|
||||
console.log('Main: Received helperEvent from SwiftIOBridge:', JSON.stringify(event, null, 2));
|
||||
logger.swift.debug('Received helperEvent from SwiftIOBridge', { event });
|
||||
|
||||
switch (event.type) {
|
||||
case 'flagsChanged': {
|
||||
const payload = event.payload;
|
||||
console.log(
|
||||
'Main: Received flagsChanged event. Fn key pressed state:',
|
||||
payload?.fnKeyPressed
|
||||
);
|
||||
logger.swift.debug('Received flagsChanged event', {
|
||||
fnKeyPressed: payload?.fnKeyPressed,
|
||||
});
|
||||
// Use flagsChanged for more reliable Fn key state tracking
|
||||
if (payload?.fnKeyPressed !== undefined) {
|
||||
console.log(`Main: Setting recording state to: ${payload.fnKeyPressed}`);
|
||||
logger.swift.info('Setting recording state', { state: payload.fnKeyPressed });
|
||||
floatingButtonWindow!.webContents.send('recording-state-changed', payload.fnKeyPressed);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'keyDown': {
|
||||
const payload = event.payload;
|
||||
console.log(`Main: Received keyDown for key: ${payload?.key}.`);
|
||||
// console.log(`Main: Received keyDown for key: ${payload?.key}.`);
|
||||
// Keep keyDown handling as fallback, but flagsChanged should be primary
|
||||
if (payload?.key?.toLowerCase() === 'fn') {
|
||||
console.log('Main: Fn keyDown detected (fallback)');
|
||||
// console.log('Main: Fn keyDown detected (fallback)');
|
||||
// Don't send recording-state-changed here as flagsChanged should handle it
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'keyUp': {
|
||||
const payload = event.payload;
|
||||
console.log(`Main: Received keyUp for key: ${payload?.key}.`);
|
||||
// console.log(`Main: Received keyUp for key: ${payload?.key}.`);
|
||||
// Keep keyUp handling as fallback, but flagsChanged should be primary
|
||||
if (payload?.key?.toLowerCase() === 'fn') {
|
||||
console.log('Main: Fn keyUp detected (fallback)');
|
||||
// console.log('Main: Fn keyUp detected (fallback)');
|
||||
// Don't send recording-state-changed here as flagsChanged should handle it
|
||||
}
|
||||
break;
|
||||
|
|
@ -306,21 +859,21 @@ app.on('ready', async () => {
|
|||
});
|
||||
|
||||
swiftIOBridgeClientInstance.on('error', (error) => {
|
||||
console.error('Main: SwiftIOBridge error:', error);
|
||||
logError(error instanceof Error ? error : new Error(String(error)), 'SwiftIOBridge error');
|
||||
// Potentially notify the user or attempt to restart
|
||||
});
|
||||
|
||||
swiftIOBridgeClientInstance.on('close', (code) => {
|
||||
console.log(`Main: Swift helper process closed with code: ${code}`);
|
||||
logger.swift.warn('Swift helper process closed', { code });
|
||||
// Handle unexpected close, maybe attempt restart
|
||||
});
|
||||
|
||||
setupApplicationMenu(createOrShowSettingsWindow);
|
||||
setupApplicationMenu(createOrShowMainWindow);
|
||||
|
||||
if (process.platform === 'darwin') {
|
||||
try {
|
||||
console.log("Main: Setting up display change notifications");
|
||||
|
||||
console.log('Main: Setting up display change notifications');
|
||||
|
||||
activeSpaceChangeSubscriptionId = systemPreferences.subscribeWorkspaceNotification(
|
||||
'NSWorkspaceActiveDisplayDidChangeNotification',
|
||||
() => {
|
||||
|
|
@ -364,6 +917,14 @@ app.on('will-quit', () => {
|
|||
console.log('Main: Stopping Swift helper...');
|
||||
swiftIOBridgeClientInstance.stopHelper();
|
||||
}
|
||||
if (modelManagerService) {
|
||||
console.log('Main: Cleaning up model downloads...');
|
||||
modelManagerService.cleanup();
|
||||
}
|
||||
if (contextualTranscriptionManager) {
|
||||
console.log('Main: Cleaning up transcription models...');
|
||||
contextualTranscriptionManager.dispose();
|
||||
}
|
||||
if (process.platform === 'darwin' && activeSpaceChangeSubscriptionId !== null) {
|
||||
systemPreferences.unsubscribeWorkspaceNotification(activeSpaceChangeSubscriptionId);
|
||||
console.log('Main: Unsubscribed from display change notifications');
|
||||
|
|
@ -384,7 +945,7 @@ app.on('activate', () => {
|
|||
// On OS X it's common to re-create a window in the app when the
|
||||
// dock icon is clicked and there are no other windows open.
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
// If no windows are open, just re-create the FAB. Settings window should be opened via menu.
|
||||
// If no windows are open, create both FAB and main window
|
||||
createFloatingButtonWindow();
|
||||
} else {
|
||||
// If there are windows, ensure FAB is visible.
|
||||
|
|
@ -393,11 +954,9 @@ app.on('activate', () => {
|
|||
} else {
|
||||
floatingButtonWindow.show();
|
||||
}
|
||||
// Optionally, if main window exists and is minimized, it could be shown,
|
||||
// but the primary action of dock click is usually for the main app presence,
|
||||
// which is now the FAB by default.
|
||||
// If mainWindow and !mainWindow.isDestroyed() and mainWindow.isMinimized()
|
||||
// mainWindow.restore();
|
||||
|
||||
// Always show/create the main window when dock icon is clicked
|
||||
createOrShowMainWindow();
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -408,11 +967,11 @@ app.on('activate', () => {
|
|||
async function logAccessibilityTree() {
|
||||
if (swiftIOBridgeClientInstance && swiftIOBridgeClientInstance.isHelperRunning()) {
|
||||
try {
|
||||
console.log('Main: Requesting full accessibility tree...');
|
||||
// console.log('Main: Requesting full accessibility tree...');
|
||||
// Call with empty params for the whole tree, as per schema for GetAccessibilityTreeDetailsParams
|
||||
const result = await swiftIOBridgeClientInstance.call('getAccessibilityTreeDetails', {});
|
||||
// Using JSON.stringify to see the whole structure since it's 'any' for now
|
||||
console.log('Main: Accessibility tree received:', JSON.stringify(result, null, 2));
|
||||
// console.log('Main: Accessibility tree received:', JSON.stringify(result, null, 2));
|
||||
} catch (error) {
|
||||
console.error('Main: Error calling getAccessibilityTreeDetails:', error);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,11 @@
|
|||
// https://www.electronjs.org/docs/latest/tutorial/process-model#preload-scripts
|
||||
|
||||
import { contextBridge, ipcRenderer, IpcRendererEvent } from 'electron';
|
||||
import log from 'electron-log/renderer';
|
||||
import { exposeElectronTRPC } from 'electron-trpc-experimental/preload';
|
||||
import type { ElectronAPI } from '../types/electron-api';
|
||||
import type { FormatterConfig } from '../modules/formatter';
|
||||
import type { Transcription, NewTranscription, Vocabulary, NewVocabulary } from '../db/schema';
|
||||
|
||||
interface ShortcutData {
|
||||
shortcut: string;
|
||||
|
|
@ -49,7 +53,112 @@ const api: ElectronAPI = {
|
|||
// removeAllGlobalShortcutListeners: () => {
|
||||
// ipcRenderer.removeAllListeners('global-shortcut-event');
|
||||
// }
|
||||
setApiKey: (apiKey: string) => ipcRenderer.invoke('set-api-key', apiKey),
|
||||
|
||||
// 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),
|
||||
|
||||
// Transcription Database API
|
||||
getTranscriptions: (options?: {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
sortBy?: 'timestamp' | 'createdAt';
|
||||
sortOrder?: 'asc' | 'desc';
|
||||
search?: string;
|
||||
}) => ipcRenderer.invoke('get-transcriptions', options),
|
||||
getTranscriptionById: (id: number) => ipcRenderer.invoke('get-transcription-by-id', id),
|
||||
createTranscription: (data: Omit<NewTranscription, 'id' | 'createdAt' | 'updatedAt'>) =>
|
||||
ipcRenderer.invoke('create-transcription', data),
|
||||
updateTranscription: (id: number, data: Partial<Omit<Transcription, 'id' | 'createdAt'>>) =>
|
||||
ipcRenderer.invoke('update-transcription', id, data),
|
||||
deleteTranscription: (id: number) => ipcRenderer.invoke('delete-transcription', id),
|
||||
getTranscriptionsCount: (search?: string) =>
|
||||
ipcRenderer.invoke('get-transcriptions-count', search),
|
||||
searchTranscriptions: (searchTerm: string, limit?: number) =>
|
||||
ipcRenderer.invoke('search-transcriptions', searchTerm, limit),
|
||||
|
||||
// Vocabulary Database API
|
||||
getVocabulary: (options?: {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
sortBy?: 'word' | 'dateAdded' | 'usageCount';
|
||||
sortOrder?: 'asc' | 'desc';
|
||||
search?: string;
|
||||
}) => ipcRenderer.invoke('get-vocabulary', options),
|
||||
getVocabularyById: (id: number) => ipcRenderer.invoke('get-vocabulary-by-id', id),
|
||||
getVocabularyByWord: (word: string) => ipcRenderer.invoke('get-vocabulary-by-word', word),
|
||||
createVocabularyWord: (data: Omit<NewVocabulary, 'id' | 'createdAt' | 'updatedAt'>) =>
|
||||
ipcRenderer.invoke('create-vocabulary-word', data),
|
||||
updateVocabulary: (id: number, data: Partial<Omit<Vocabulary, 'id' | 'createdAt'>>) =>
|
||||
ipcRenderer.invoke('update-vocabulary', id, data),
|
||||
deleteVocabulary: (id: number) => ipcRenderer.invoke('delete-vocabulary', id),
|
||||
getVocabularyCount: (search?: string) => ipcRenderer.invoke('get-vocabulary-count', search),
|
||||
searchVocabulary: (searchTerm: string, limit?: number) =>
|
||||
ipcRenderer.invoke('search-vocabulary', searchTerm, limit),
|
||||
bulkImportVocabulary: (words: Omit<NewVocabulary, 'id' | 'createdAt' | 'updatedAt'>[]) =>
|
||||
ipcRenderer.invoke('bulk-import-vocabulary', words),
|
||||
trackWordUsage: (word: string) => ipcRenderer.invoke('track-word-usage', word),
|
||||
getMostUsedWords: (limit?: number) => ipcRenderer.invoke('get-most-used-words', limit), // Model management event listeners
|
||||
on: (channel: string, callback: (...args: any[]) => void) => {
|
||||
const handler = (_event: IpcRendererEvent, ...args: any[]) => callback(...args);
|
||||
ipcRenderer.on(channel, handler);
|
||||
// Store the handler mapping for proper cleanup
|
||||
if (!(window as any).__electronEventHandlers) {
|
||||
(window as any).__electronEventHandlers = new Map();
|
||||
}
|
||||
if (!(window as any).__electronEventHandlers.has(channel)) {
|
||||
(window as any).__electronEventHandlers.set(channel, []);
|
||||
}
|
||||
(window as any).__electronEventHandlers.get(channel).push({ original: callback, handler });
|
||||
},
|
||||
off: (channel: string, callback: (...args: any[]) => void) => {
|
||||
if (
|
||||
(window as any).__electronEventHandlers &&
|
||||
(window as any).__electronEventHandlers.has(channel)
|
||||
) {
|
||||
const handlers = (window as any).__electronEventHandlers.get(channel);
|
||||
const handlerInfo = handlers.find((h: any) => h.original === callback);
|
||||
if (handlerInfo) {
|
||||
ipcRenderer.removeListener(channel, handlerInfo.handler);
|
||||
const index = handlers.indexOf(handlerInfo);
|
||||
handlers.splice(index, 1);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Logging API for renderer process
|
||||
log: {
|
||||
info: (...args: any[]) => log.info(...args),
|
||||
warn: (...args: any[]) => log.warn(...args),
|
||||
error: (...args: any[]) => log.error(...args),
|
||||
debug: (...args: any[]) => log.debug(...args),
|
||||
scope: (name: string) => log.scope(name),
|
||||
},
|
||||
};
|
||||
|
||||
contextBridge.exposeInMainWorld('electronAPI', api);
|
||||
|
||||
// Expose tRPC for electron-trpc-experimental
|
||||
process.once('loaded', async () => {
|
||||
exposeElectronTRPC();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -7,15 +7,18 @@ import split2 from 'split2';
|
|||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import { EventEmitter } from 'events';
|
||||
import { createScopedLogger } from './logger';
|
||||
import {
|
||||
RpcRequestSchema,
|
||||
RpcRequest,
|
||||
RpcResponseSchema,
|
||||
RpcResponseSchema,
|
||||
RpcResponse,
|
||||
HelperEventSchema,
|
||||
HelperEvent,
|
||||
GetAccessibilityTreeDetailsParams,
|
||||
GetAccessibilityTreeDetailsResult,
|
||||
GetAccessibilityContextParams,
|
||||
GetAccessibilityContextResult,
|
||||
PasteTextParams,
|
||||
PasteTextResult,
|
||||
MuteSystemAudioParams,
|
||||
|
|
@ -30,6 +33,10 @@ interface RPCMethods {
|
|||
params: GetAccessibilityTreeDetailsParams;
|
||||
result: GetAccessibilityTreeDetailsResult;
|
||||
};
|
||||
getAccessibilityContext: {
|
||||
params: GetAccessibilityContextParams;
|
||||
result: GetAccessibilityContextResult;
|
||||
};
|
||||
pasteText: {
|
||||
params: PasteTextParams;
|
||||
result: PasteTextResult;
|
||||
|
|
@ -56,8 +63,9 @@ interface SwiftIOBridgeEvents {
|
|||
|
||||
export class SwiftIOBridge extends EventEmitter {
|
||||
private proc: ChildProcessWithoutNullStreams | null = null;
|
||||
private pending = new Map<string, (resp: RpcResponse) => void>();
|
||||
private pending = new Map<string, { callback: (resp: RpcResponse) => void; startTime: number }>();
|
||||
private helperPath: string;
|
||||
private logger = createScopedLogger('swift-bridge');
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
|
@ -69,16 +77,25 @@ export class SwiftIOBridge extends EventEmitter {
|
|||
const helperName = 'SwiftHelper'; // Swift native helper executable
|
||||
return electronApp.isPackaged
|
||||
? path.join(process.resourcesPath, 'bin', helperName)
|
||||
: path.join(electronApp.getAppPath(), '..', '..', 'packages', 'native-helpers', 'swift-helper', 'bin', helperName);
|
||||
: path.join(
|
||||
electronApp.getAppPath(),
|
||||
'..',
|
||||
'..',
|
||||
'packages',
|
||||
'native-helpers',
|
||||
'swift-helper',
|
||||
'bin',
|
||||
helperName
|
||||
);
|
||||
}
|
||||
|
||||
private startHelperProcess(): void {
|
||||
try {
|
||||
fs.accessSync(this.helperPath, fs.constants.X_OK);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`SwiftIOBridge: SwiftHelper executable not found or not executable at ${this.helperPath}.`
|
||||
);
|
||||
this.logger.error('SwiftHelper executable not found or not executable', {
|
||||
helperPath: this.helperPath,
|
||||
});
|
||||
this.emit(
|
||||
'error',
|
||||
new Error(
|
||||
|
|
@ -89,22 +106,22 @@ export class SwiftIOBridge extends EventEmitter {
|
|||
return;
|
||||
}
|
||||
|
||||
console.log(`SwiftIOBridge: Spawning SwiftHelper from: ${this.helperPath}`);
|
||||
this.logger.info('Spawning SwiftHelper', { helperPath: this.helperPath });
|
||||
this.proc = spawn(this.helperPath, [], { stdio: ['pipe', 'pipe', 'pipe'] });
|
||||
|
||||
this.proc.stdout.pipe(split2()).on('data', (line: string) => {
|
||||
if (!line.trim()) return; // Ignore empty lines
|
||||
try {
|
||||
const message = JSON.parse(line);
|
||||
console.log('SwiftIOBridge: Received message from helper:', message);
|
||||
this.logger.debug('Received message from helper', { message });
|
||||
|
||||
// Try to parse as RpcResponse first
|
||||
const responseValidation = RpcResponseSchema.safeParse(message);
|
||||
if (responseValidation.success) {
|
||||
const rpcResponse = responseValidation.data;
|
||||
if (this.pending.has(rpcResponse.id)) {
|
||||
const handler = this.pending.get(rpcResponse.id);
|
||||
handler!(rpcResponse); // Non-null assertion as we checked with has()
|
||||
const pendingItem = this.pending.get(rpcResponse.id);
|
||||
pendingItem!.callback(rpcResponse); // Non-null assertion as we checked with has()
|
||||
return; // Handled as an RPC response
|
||||
}
|
||||
}
|
||||
|
|
@ -118,30 +135,28 @@ export class SwiftIOBridge extends EventEmitter {
|
|||
}
|
||||
|
||||
// If it's neither a recognized RPC response nor a helper event
|
||||
console.warn('SwiftIOBridge: Received unknown message from helper:', message);
|
||||
this.logger.warn('Received unknown message from helper', { message });
|
||||
} catch (e) {
|
||||
console.error('SwiftIOBridge: Error parsing JSON from helper:', e, 'Received line:', line);
|
||||
this.logger.error('Error parsing JSON from helper', { error: e, line });
|
||||
this.emit('error', new Error(`Error parsing JSON from helper: ${line}`));
|
||||
}
|
||||
});
|
||||
|
||||
this.proc.stderr.on('data', (data: Buffer) => {
|
||||
const errorMsg = data.toString();
|
||||
console.error(`SwiftIOBridge: SwiftHelper stderr: ${errorMsg}`);
|
||||
this.logger.warn('SwiftHelper stderr output', { message: errorMsg });
|
||||
// Don't emit as error since stderr is often just debug info
|
||||
// this.emit('error', new Error(`Helper stderr: ${errorMsg}`));
|
||||
});
|
||||
|
||||
this.proc.on('error', (err) => {
|
||||
console.error('SwiftIOBridge: Failed to start SwiftHelper process:', err);
|
||||
this.logger.error('Failed to start SwiftHelper process', { error: err });
|
||||
this.emit('error', err);
|
||||
this.proc = null;
|
||||
});
|
||||
|
||||
this.proc.on('close', (code, signal) => {
|
||||
console.log(
|
||||
`SwiftIOBridge: SwiftHelper process exited with code ${code} and signal ${signal}`
|
||||
);
|
||||
this.logger.info('SwiftHelper process exited', { code, signal });
|
||||
this.emit('close', code, signal);
|
||||
this.proc = null;
|
||||
// Optionally, implement retry logic or notify further
|
||||
|
|
@ -150,7 +165,7 @@ export class SwiftIOBridge extends EventEmitter {
|
|||
process.nextTick(() => {
|
||||
this.emit('ready'); // Emit ready on next tick
|
||||
});
|
||||
console.log('SwiftIOBridge: Helper process started and listeners attached.');
|
||||
this.logger.info('Helper process started and listeners attached');
|
||||
}
|
||||
|
||||
public call<M extends keyof RPCMethods>(
|
||||
|
|
@ -165,50 +180,65 @@ export class SwiftIOBridge extends EventEmitter {
|
|||
}
|
||||
|
||||
const id = uuid();
|
||||
const startTime = Date.now();
|
||||
const requestPayload: RpcRequest = { id, method, params };
|
||||
|
||||
// Validate request payload before sending
|
||||
const validationResult = RpcRequestSchema.safeParse(requestPayload);
|
||||
if (!validationResult.success) {
|
||||
console.error(
|
||||
'SwiftIOBridge: Invalid RPC request payload:',
|
||||
validationResult.error.flatten()
|
||||
);
|
||||
this.logger.error('Invalid RPC request payload', {
|
||||
method,
|
||||
error: validationResult.error.flatten(),
|
||||
});
|
||||
return Promise.reject(
|
||||
new Error(`Invalid RPC request payload: ${validationResult.error.message}`)
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`SwiftIOBridge: Sending RPC request: ${method} (id: ${id})`);
|
||||
this.logger.debug('Sending RPC request', {
|
||||
method,
|
||||
id,
|
||||
startedAt: new Date(startTime).toISOString(),
|
||||
});
|
||||
this.proc.stdin.write(JSON.stringify(requestPayload) + '\n', (err) => {
|
||||
if (err) {
|
||||
console.error('SwiftIOBridge: Error writing to helper stdin:', err);
|
||||
this.logger.error('Error writing to helper stdin', { method, id, error: err });
|
||||
// Note: The promise might have already been set up, consider how to reject it.
|
||||
// For now, this error will be logged. The timeout will eventually reject.
|
||||
} else {
|
||||
console.log(`SwiftIOBridge: Successfully sent RPC request: ${method} (id: ${id})`);
|
||||
this.logger.debug('Successfully sent RPC request', { method, id });
|
||||
}
|
||||
});
|
||||
|
||||
const responsePromise = new Promise<RPCMethods[M]['result']>((resolve, reject) => {
|
||||
this.pending.set(id, (resp: RpcResponse) => {
|
||||
this.pending.delete(id); // Clean up immediately
|
||||
if (resp.error) {
|
||||
const error = new Error(resp.error.message);
|
||||
(error as any).code = resp.error.code;
|
||||
(error as any).data = resp.error.data;
|
||||
reject(error);
|
||||
} else {
|
||||
// Log the raw resp.result before resolving
|
||||
console.log(
|
||||
'SwiftIOBridge: Raw resp.result received:',
|
||||
JSON.stringify(resp.result, null, 2)
|
||||
);
|
||||
// Here, we might need to validate resp.result against the specific method's result schema
|
||||
// For now, casting as any, but for type safety, validation is better.
|
||||
// Example: const resultValidation = RPCMethods[method].resultSchema.safeParse(resp.result);
|
||||
resolve(resp.result as any);
|
||||
}
|
||||
this.pending.set(id, {
|
||||
callback: (resp: RpcResponse) => {
|
||||
this.pending.delete(id); // Clean up immediately
|
||||
const completedAt = Date.now();
|
||||
const duration = completedAt - startTime;
|
||||
|
||||
if (resp.error) {
|
||||
const error = new Error(resp.error.message);
|
||||
(error as any).code = resp.error.code;
|
||||
(error as any).data = resp.error.data;
|
||||
reject(error);
|
||||
} else {
|
||||
// Log the raw resp.result with timing information
|
||||
this.logger.debug('Raw RPC response result received', {
|
||||
method,
|
||||
id,
|
||||
result: resp.result,
|
||||
startedAt: new Date(startTime).toISOString(),
|
||||
completedAt: new Date(completedAt).toISOString(),
|
||||
durationMs: duration,
|
||||
});
|
||||
// Here, we might need to validate resp.result against the specific method's result schema
|
||||
// For now, casting as any, but for type safety, validation is better.
|
||||
// Example: const resultValidation = RPCMethods[method].resultSchema.safeParse(resp.result);
|
||||
resolve(resp.result as any);
|
||||
}
|
||||
},
|
||||
startTime,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -217,9 +247,11 @@ export class SwiftIOBridge extends EventEmitter {
|
|||
if (this.pending.has(id)) {
|
||||
// Check if still pending before rejecting
|
||||
this.pending.delete(id);
|
||||
const timedOutAt = Date.now();
|
||||
const duration = timedOutAt - startTime;
|
||||
reject(
|
||||
new Error(
|
||||
`SwiftIOBridge: RPC call "${method}" (id: ${id}) timed out after ${timeoutMs}ms`
|
||||
`SwiftIOBridge: RPC call "${method}" (id: ${id}) timed out after ${timeoutMs}ms (duration: ${duration}ms, started: ${new Date(startTime).toISOString()})`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
@ -235,7 +267,7 @@ export class SwiftIOBridge extends EventEmitter {
|
|||
|
||||
public stopHelper(): void {
|
||||
if (this.proc) {
|
||||
console.log('SwiftIOBridge: Stopping SwiftHelper process...');
|
||||
this.logger.info('Stopping SwiftHelper process');
|
||||
this.proc.kill();
|
||||
this.proc = null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,17 +1,41 @@
|
|||
import { TranscriptionClient } from './transcription-client';
|
||||
import { FormatterService } from '../formatter';
|
||||
|
||||
export class AiService {
|
||||
private transcriptionClient: TranscriptionClient;
|
||||
private formatterService: FormatterService;
|
||||
|
||||
constructor(transcriptionClient: TranscriptionClient) {
|
||||
this.transcriptionClient = transcriptionClient;
|
||||
this.formatterService = new FormatterService();
|
||||
}
|
||||
|
||||
async transcribeAudio(audioData: Buffer): Promise<string> {
|
||||
if (!this.transcriptionClient) {
|
||||
throw new Error('Transcription client is not initialized.');
|
||||
}
|
||||
return this.transcriptionClient.transcribe(audioData);
|
||||
|
||||
// Step 1: Transcribe audio
|
||||
const transcribedText = await this.transcriptionClient.transcribe(audioData);
|
||||
|
||||
// Step 2: Format the transcribed text if formatter is enabled
|
||||
const formattedText = await this.formatterService.formatText(transcribedText);
|
||||
|
||||
return formattedText;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set formatter configuration
|
||||
*/
|
||||
configureFormatter(config: any): void {
|
||||
this.formatterService.configure(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get formatter service instance
|
||||
*/
|
||||
getFormatterService(): FormatterService {
|
||||
return this.formatterService;
|
||||
}
|
||||
|
||||
// Future methods for other AI functionalities can be added here
|
||||
|
|
|
|||
190
apps/electron/src/modules/ai/local-whisper-client.ts
Normal file
190
apps/electron/src/modules/ai/local-whisper-client.ts
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
import { TranscriptionClient } from './transcription-client';
|
||||
import * as fs from 'fs';
|
||||
import { logger } from '../../main/logger';
|
||||
import { ModelManagerService } from '../models/model-manager';
|
||||
|
||||
export class LocalWhisperClient implements TranscriptionClient {
|
||||
private modelManager: ModelManagerService;
|
||||
private selectedModelId: string | null = null;
|
||||
private whisperInstance: any = null; // Will be imported from smart-whisper
|
||||
|
||||
constructor(modelManager: ModelManagerService, selectedModelId?: string) {
|
||||
this.modelManager = modelManager;
|
||||
this.selectedModelId = selectedModelId || null;
|
||||
}
|
||||
|
||||
private async initializeWhisper(): Promise<void> {
|
||||
if (this.whisperInstance) {
|
||||
return; // Already initialized
|
||||
}
|
||||
|
||||
const modelPath = await this.getBestAvailableModel();
|
||||
if (!modelPath) {
|
||||
throw new Error('No Whisper models available. Please download a model first.');
|
||||
}
|
||||
|
||||
try {
|
||||
const { Whisper } = await import('smart-whisper');
|
||||
this.whisperInstance = new Whisper(modelPath, { gpu: true });
|
||||
logger.ai.info('Smart-whisper initialized', { modelPath });
|
||||
} catch (error) {
|
||||
logger.ai.error('Failed to initialize smart-whisper', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
modelPath,
|
||||
});
|
||||
throw new Error(`Failed to initialize smart-whisper: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
async transcribe(audioData: Buffer): Promise<string> {
|
||||
try {
|
||||
await this.initializeWhisper();
|
||||
|
||||
// Convert audio buffer to the format expected by smart-whisper
|
||||
const audioFloat32Array = await this.convertAudioBuffer(audioData);
|
||||
|
||||
logger.ai.info('Starting smart-whisper transcription', {
|
||||
audioDataSize: audioData.length,
|
||||
convertedSize: audioFloat32Array.length,
|
||||
});
|
||||
|
||||
// Transcribe using smart-whisper
|
||||
const { result } = await this.whisperInstance.transcribe(audioFloat32Array, {
|
||||
language: 'auto',
|
||||
});
|
||||
|
||||
const transcription = await result;
|
||||
|
||||
logger.ai.info('Smart-whisper transcription completed', {
|
||||
resultLength: transcription.length,
|
||||
});
|
||||
|
||||
return transcription;
|
||||
} catch (error) {
|
||||
logger.ai.error('Smart-whisper transcription failed', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
throw new Error(`Transcription failed: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async convertAudioBuffer(audioData: Buffer): Promise<Float32Array> {
|
||||
// Smart-whisper expects Float32Array with 16kHz mono audio
|
||||
// This is a simplified conversion - you may need more sophisticated audio processing
|
||||
try {
|
||||
// For now, assume the audio data is already in the correct format
|
||||
// In a real implementation, you'd use an audio processing library like node-wav
|
||||
// to properly decode and resample the audio
|
||||
|
||||
// Convert buffer to Float32Array (simplified)
|
||||
const float32Array = new Float32Array(audioData.length / 4);
|
||||
for (let i = 0; i < float32Array.length; i++) {
|
||||
// Read 32-bit float from buffer (little-endian)
|
||||
float32Array[i] = audioData.readFloatLE(i * 4);
|
||||
}
|
||||
|
||||
return float32Array;
|
||||
} catch (error) {
|
||||
logger.ai.warn('Audio conversion failed, trying alternative method', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
|
||||
// Fallback: convert as if it's PCM data
|
||||
const samples = new Float32Array(audioData.length / 2);
|
||||
for (let i = 0; i < samples.length; i++) {
|
||||
// Convert 16-bit signed PCM to float (-1 to 1)
|
||||
const sample = audioData.readInt16LE(i * 2);
|
||||
samples[i] = sample / 32768.0;
|
||||
}
|
||||
|
||||
return samples;
|
||||
}
|
||||
}
|
||||
|
||||
private async getBestAvailableModel(): Promise<string | null> {
|
||||
const downloadedModels = await this.modelManager.getDownloadedModels();
|
||||
|
||||
// If a specific model is selected and available, use it
|
||||
if (this.selectedModelId && downloadedModels[this.selectedModelId]) {
|
||||
const model = downloadedModels[this.selectedModelId];
|
||||
if (fs.existsSync(model.localPath)) {
|
||||
return model.localPath;
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise, find the best available model (prioritize by quality)
|
||||
const preferredOrder = [
|
||||
'whisper-large-v1',
|
||||
'whisper-medium',
|
||||
'whisper-small',
|
||||
'whisper-base',
|
||||
'whisper-tiny',
|
||||
];
|
||||
|
||||
for (const modelId of preferredOrder) {
|
||||
const model = downloadedModels[modelId];
|
||||
if (model && fs.existsSync(model.localPath)) {
|
||||
return model.localPath;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Set the model to use for transcription
|
||||
async setSelectedModel(modelId: string): Promise<void> {
|
||||
const downloadedModels = await this.modelManager.getDownloadedModels();
|
||||
if (!downloadedModels[modelId]) {
|
||||
throw new Error(`Model not downloaded: ${modelId}`);
|
||||
}
|
||||
|
||||
// If we're changing models, free the current instance
|
||||
if (this.selectedModelId !== modelId && this.whisperInstance) {
|
||||
this.freeWhisperInstance();
|
||||
}
|
||||
|
||||
this.selectedModelId = modelId;
|
||||
logger.ai.info('Selected model for transcription', { modelId });
|
||||
}
|
||||
|
||||
// Get the currently selected model
|
||||
getSelectedModel(): string | null {
|
||||
return this.selectedModelId;
|
||||
}
|
||||
|
||||
// Check if whisper is available
|
||||
async isAvailable(): Promise<boolean> {
|
||||
const downloadedModels = await this.modelManager.getDownloadedModels();
|
||||
return Object.keys(downloadedModels).some((modelId) =>
|
||||
fs.existsSync(downloadedModels[modelId].localPath)
|
||||
);
|
||||
}
|
||||
|
||||
// Get available models
|
||||
async getAvailableModels(): Promise<string[]> {
|
||||
const downloadedModels = await this.modelManager.getDownloadedModels();
|
||||
return Object.keys(downloadedModels).filter((modelId) =>
|
||||
fs.existsSync(downloadedModels[modelId].localPath)
|
||||
);
|
||||
}
|
||||
|
||||
// Free resources
|
||||
async dispose(): Promise<void> {
|
||||
await this.freeWhisperInstance();
|
||||
}
|
||||
|
||||
private async freeWhisperInstance(): Promise<void> {
|
||||
if (this.whisperInstance) {
|
||||
try {
|
||||
await this.whisperInstance.free();
|
||||
logger.ai.info('Smart-whisper instance freed');
|
||||
} catch (error) {
|
||||
logger.ai.warn('Error freeing smart-whisper instance', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
} finally {
|
||||
this.whisperInstance = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
import { TranscriptionClient } from './transcription-client';
|
||||
import OpenAI from 'openai';
|
||||
|
||||
export class OpenAIWhisperClient implements TranscriptionClient {
|
||||
private openai: OpenAI;
|
||||
|
||||
constructor(apiKey: string) {
|
||||
this.openai = new OpenAI({ apiKey });
|
||||
}
|
||||
|
||||
async transcribe(audioData: Buffer): Promise<string> {
|
||||
if (!audioData || audioData.length === 0) {
|
||||
console.error('OpenAIWhisperClient: Received empty audio data.');
|
||||
throw new Error('Cannot transcribe empty audio data.');
|
||||
}
|
||||
try {
|
||||
// Use OpenAI.toFile to correctly prepare the audio data
|
||||
const audioFile = await OpenAI.toFile(audioData, 'audio.webm', {
|
||||
type: 'audio/webm',
|
||||
});
|
||||
|
||||
console.log(
|
||||
`OpenAIWhisperClient: Transcribing audio file of size: ${audioData.length} bytes.`
|
||||
);
|
||||
console.log('OpenAIWhisperClient: audioFile object created by OpenAI.toFile:', audioFile); // Log the object
|
||||
|
||||
if (!audioFile) {
|
||||
console.error('OpenAIWhisperClient: OpenAI.toFile returned undefined or null.');
|
||||
throw new Error('Failed to prepare audio file for OpenAI SDK.');
|
||||
}
|
||||
|
||||
const response = await this.openai.audio.transcriptions.create({
|
||||
model: 'whisper-1',
|
||||
file: audioFile,
|
||||
});
|
||||
|
||||
return response.text;
|
||||
} catch (error) {
|
||||
console.error('Error transcribing audio with OpenAI Whisper:', error);
|
||||
throw error; // Rethrow or handle as appropriate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -6,6 +6,8 @@ import { EventEmitter } from 'node:events';
|
|||
export class AudioCapture extends EventEmitter {
|
||||
private currentRecordingPath: string | null = null;
|
||||
private writableStream: fs.WriteStream | null = null;
|
||||
private chunkCounter: number = 0;
|
||||
private sessionId: string | null = null;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
|
@ -61,6 +63,8 @@ export class AudioCapture extends EventEmitter {
|
|||
// Only nullify currentRecordingPath if it matches the one being finalized.
|
||||
if (this.currentRecordingPath === recordingPathToFinalize) {
|
||||
this.currentRecordingPath = null;
|
||||
this.sessionId = null;
|
||||
this.chunkCounter = 0;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
@ -73,6 +77,8 @@ export class AudioCapture extends EventEmitter {
|
|||
// Clean up path if still relevant, though .end() callback should handle primary cleanup.
|
||||
if (this.currentRecordingPath === recordingPathToFinalize) {
|
||||
this.currentRecordingPath = null;
|
||||
this.sessionId = null;
|
||||
this.chunkCounter = 0;
|
||||
}
|
||||
});
|
||||
// Note: The 'error' handler for streamToClose was set up when it was created.
|
||||
|
|
@ -85,6 +91,8 @@ export class AudioCapture extends EventEmitter {
|
|||
if (chunk.length > 0) {
|
||||
// First non-empty chunk: Start a new recording
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
this.sessionId = `session-${timestamp}`;
|
||||
this.chunkCounter = 0;
|
||||
this.currentRecordingPath = path.join(
|
||||
app.getPath('userData'),
|
||||
'recordings',
|
||||
|
|
@ -142,6 +150,17 @@ export class AudioCapture extends EventEmitter {
|
|||
}
|
||||
return; // Don't proceed to final chunk logic if initial write fails
|
||||
}
|
||||
|
||||
// Emit chunk-ready event for immediate transcription
|
||||
this.chunkCounter++;
|
||||
console.log(`AudioCapture: Emitting chunk-ready for chunk ${this.chunkCounter}`);
|
||||
this.emit('chunk-ready', {
|
||||
sessionId: this.sessionId,
|
||||
chunkId: this.chunkCounter,
|
||||
audioData: chunk,
|
||||
isFinalChunk: isFinalChunk,
|
||||
});
|
||||
|
||||
// If this very first chunk is also the final chunk
|
||||
if (isFinalChunk) {
|
||||
console.log(
|
||||
|
|
@ -184,9 +203,21 @@ export class AudioCapture extends EventEmitter {
|
|||
// If `isFinalChunk` was true, `finalizeRecording` won't be called due to return/error.
|
||||
// Consider calling finalizeRecording or a similar cleanup if write error on final chunk.
|
||||
// For now, relying on the stream's 'error' event for full cleanup.
|
||||
} else if (isFinalChunk) {
|
||||
console.log('AudioCapture: Final chunk written successfully. Finalizing recording.');
|
||||
this.finalizeRecording();
|
||||
} else {
|
||||
// Emit chunk-ready event for immediate transcription
|
||||
this.chunkCounter++;
|
||||
console.log(`AudioCapture: Emitting chunk-ready for chunk ${this.chunkCounter}`);
|
||||
this.emit('chunk-ready', {
|
||||
sessionId: this.sessionId,
|
||||
chunkId: this.chunkCounter,
|
||||
audioData: chunk,
|
||||
isFinalChunk: isFinalChunk,
|
||||
});
|
||||
|
||||
if (isFinalChunk) {
|
||||
console.log('AudioCapture: Final chunk written successfully. Finalizing recording.');
|
||||
this.finalizeRecording();
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
|
|
@ -198,6 +229,13 @@ export class AudioCapture extends EventEmitter {
|
|||
console.log(
|
||||
'AudioCapture: Empty final chunk received during active recording. Finalizing recording.'
|
||||
);
|
||||
// Still emit the final chunk event even if empty
|
||||
this.emit('chunk-ready', {
|
||||
sessionId: this.sessionId,
|
||||
chunkId: this.chunkCounter, // Don't increment for empty chunks
|
||||
audioData: chunk,
|
||||
isFinalChunk: true,
|
||||
});
|
||||
this.finalizeRecording();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
16
apps/electron/src/modules/formatter/formatter-client.ts
Normal file
16
apps/electron/src/modules/formatter/formatter-client.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
/**
|
||||
* Abstract base class for text formatting clients
|
||||
*/
|
||||
export abstract class FormatterClient {
|
||||
abstract formatText(text: string): Promise<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration interface for formatter clients
|
||||
*/
|
||||
export interface FormatterConfig {
|
||||
provider: 'openrouter';
|
||||
model: string;
|
||||
apiKey: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
62
apps/electron/src/modules/formatter/formatter-service.ts
Normal file
62
apps/electron/src/modules/formatter/formatter-service.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import { FormatterClient, FormatterConfig } from './formatter-client';
|
||||
import { OpenRouterFormatterClient } from './openrouter-formatter-client';
|
||||
|
||||
/**
|
||||
* Main formatter service that manages different formatting providers
|
||||
*/
|
||||
export class FormatterService {
|
||||
private client: FormatterClient | null = null;
|
||||
private config: FormatterConfig | null = null;
|
||||
|
||||
/**
|
||||
* Configure the formatter service with the given configuration
|
||||
*/
|
||||
configure(config: FormatterConfig): void {
|
||||
this.config = config;
|
||||
|
||||
if (!config.enabled) {
|
||||
this.client = null;
|
||||
return;
|
||||
}
|
||||
|
||||
switch (config.provider) {
|
||||
case 'openrouter':
|
||||
this.client = new OpenRouterFormatterClient(config.apiKey, config.model);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unsupported formatter provider: ${config.provider}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format the given text using the configured formatter
|
||||
* Returns the original text if formatter is not configured or disabled
|
||||
*/
|
||||
async formatText(text: string): Promise<string> {
|
||||
if (!this.client || !this.config?.enabled) {
|
||||
return text;
|
||||
}
|
||||
|
||||
try {
|
||||
return await this.client.formatText(text);
|
||||
} catch (error) {
|
||||
console.error('Error in formatter service:', error);
|
||||
// Return original text if formatting fails
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the formatter is configured and enabled
|
||||
*/
|
||||
isEnabled(): boolean {
|
||||
return this.config?.enabled === true && this.client !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current configuration
|
||||
*/
|
||||
getConfiguration(): FormatterConfig | null {
|
||||
return this.config;
|
||||
}
|
||||
}
|
||||
3
apps/electron/src/modules/formatter/index.ts
Normal file
3
apps/electron/src/modules/formatter/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export { FormatterService } from './formatter-service';
|
||||
export { FormatterClient, FormatterConfig } from './formatter-client';
|
||||
export { OpenRouterFormatterClient } from './openrouter-formatter-client';
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
import { createOpenAI } from '@ai-sdk/openai';
|
||||
import { generateText } from 'ai';
|
||||
import { FormatterClient } from './formatter-client';
|
||||
|
||||
/**
|
||||
* OpenRouter-based text formatter client
|
||||
*/
|
||||
export class OpenRouterFormatterClient extends FormatterClient {
|
||||
private provider: any;
|
||||
private model: string;
|
||||
|
||||
constructor(apiKey: string, model: string) {
|
||||
super();
|
||||
|
||||
// Configure OpenRouter provider
|
||||
this.provider = createOpenAI({
|
||||
baseURL: 'https://openrouter.ai/api/v1',
|
||||
apiKey: apiKey,
|
||||
});
|
||||
|
||||
this.model = model;
|
||||
}
|
||||
|
||||
async formatText(text: string): Promise<string> {
|
||||
try {
|
||||
const { text: formattedText } = await generateText({
|
||||
model: this.provider(this.model),
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: `You are a professional text formatter. Your task is to clean up and improve the formatting of transcribed text while preserving the original meaning and content.
|
||||
|
||||
Please:
|
||||
1. Fix obvious transcription errors and typos
|
||||
2. Add proper punctuation where missing
|
||||
3. Organize the text into proper paragraphs
|
||||
4. Capitalize proper nouns and sentence beginnings
|
||||
5. Remove unnecessary filler words (um, uh, etc.) but keep natural speech patterns
|
||||
6. Maintain the speaker's original tone and style
|
||||
|
||||
Return only the formatted text without any explanations or additional commentary.`,
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: `Please format this transcribed text:\n\n${text}`,
|
||||
},
|
||||
],
|
||||
temperature: 0.1, // Low temperature for consistent formatting
|
||||
maxTokens: 2000,
|
||||
});
|
||||
|
||||
return formattedText;
|
||||
} catch (error) {
|
||||
console.error('Error formatting text with OpenRouter:', error);
|
||||
// Return original text if formatting fails
|
||||
return text;
|
||||
}
|
||||
}
|
||||
}
|
||||
367
apps/electron/src/modules/models/model-manager.ts
Normal file
367
apps/electron/src/modules/models/model-manager.ts
Normal file
|
|
@ -0,0 +1,367 @@
|
|||
import { EventEmitter } from 'events';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as crypto from 'crypto';
|
||||
import { app } from 'electron';
|
||||
import {
|
||||
Model,
|
||||
DownloadProgress,
|
||||
ModelManagerState,
|
||||
AVAILABLE_MODELS,
|
||||
} from '../../constants/models';
|
||||
import { DownloadedModel } from '../../db/schema';
|
||||
import {
|
||||
getDownloadedModelsRecord,
|
||||
createDownloadedModel,
|
||||
deleteDownloadedModel,
|
||||
validateDownloadedModels,
|
||||
validateModelFile,
|
||||
getValidDownloadedModels,
|
||||
} from '../../db/downloaded-models';
|
||||
import { logger } from '../../main/logger';
|
||||
|
||||
interface ModelManagerEvents {
|
||||
'download-progress': (modelId: string, progress: DownloadProgress) => void;
|
||||
'download-complete': (modelId: string, downloadedModel: DownloadedModel) => void;
|
||||
'download-error': (modelId: string, error: Error) => void;
|
||||
'download-cancelled': (modelId: string) => void;
|
||||
'model-deleted': (modelId: string) => void;
|
||||
}
|
||||
|
||||
declare interface ModelManagerService {
|
||||
on<U extends keyof ModelManagerEvents>(event: U, listener: ModelManagerEvents[U]): this;
|
||||
emit<U extends keyof ModelManagerEvents>(
|
||||
event: U,
|
||||
...args: Parameters<ModelManagerEvents[U]>
|
||||
): boolean;
|
||||
}
|
||||
|
||||
class ModelManagerService extends EventEmitter {
|
||||
private state: ModelManagerState;
|
||||
private modelsDirectory: string;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.state = {
|
||||
activeDownloads: new Map(),
|
||||
};
|
||||
|
||||
// Create models directory in app data
|
||||
this.modelsDirectory = path.join(app.getPath('userData'), 'models');
|
||||
this.ensureModelsDirectory();
|
||||
}
|
||||
|
||||
// Initialize and validate models on startup
|
||||
async initialize(): Promise<void> {
|
||||
try {
|
||||
const validation = await validateDownloadedModels();
|
||||
|
||||
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 })),
|
||||
});
|
||||
}
|
||||
|
||||
logger.main.info('Model manager initialized', {
|
||||
validModels: validation.valid.length,
|
||||
cleanedRecords: validation.cleaned,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.main.error('Error initializing model manager', { error });
|
||||
}
|
||||
}
|
||||
|
||||
private ensureModelsDirectory(): void {
|
||||
if (!fs.existsSync(this.modelsDirectory)) {
|
||||
fs.mkdirSync(this.modelsDirectory, { recursive: true });
|
||||
logger.main.info('Created models directory', { path: this.modelsDirectory });
|
||||
}
|
||||
}
|
||||
|
||||
// Get all available models from manifest
|
||||
getAvailableModels(): Model[] {
|
||||
return AVAILABLE_MODELS;
|
||||
}
|
||||
|
||||
// Get downloaded models from database
|
||||
async getDownloadedModels(): Promise<Record<string, DownloadedModel>> {
|
||||
return await getDownloadedModelsRecord();
|
||||
}
|
||||
|
||||
// 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) {
|
||||
record[model.id] = model;
|
||||
}
|
||||
|
||||
return record;
|
||||
}
|
||||
|
||||
// Check if a model is downloaded and file exists
|
||||
async isModelDownloaded(modelId: string): Promise<boolean> {
|
||||
return await validateModelFile(modelId);
|
||||
}
|
||||
|
||||
// Get download progress for a model
|
||||
getDownloadProgress(modelId: string): DownloadProgress | null {
|
||||
return this.state.activeDownloads.get(modelId) || null;
|
||||
}
|
||||
|
||||
// Get all active downloads
|
||||
getActiveDownloads(): DownloadProgress[] {
|
||||
return Array.from(this.state.activeDownloads.values());
|
||||
}
|
||||
|
||||
// Download a model
|
||||
async downloadModel(modelId: string): Promise<void> {
|
||||
const model = AVAILABLE_MODELS.find((m) => m.id === modelId);
|
||||
if (!model) {
|
||||
throw new Error(`Model not found: ${modelId}`);
|
||||
}
|
||||
|
||||
if (await this.isModelDownloaded(modelId)) {
|
||||
throw new Error(`Model already downloaded: ${modelId}`);
|
||||
}
|
||||
|
||||
if (this.state.activeDownloads.has(modelId)) {
|
||||
throw new Error(`Download already in progress: ${modelId}`);
|
||||
}
|
||||
|
||||
const abortController = new AbortController();
|
||||
const downloadPath = path.join(this.modelsDirectory, model.filename);
|
||||
|
||||
const progress: DownloadProgress = {
|
||||
modelId,
|
||||
progress: 0,
|
||||
status: 'downloading',
|
||||
bytesDownloaded: 0,
|
||||
totalBytes: model.size,
|
||||
abortController,
|
||||
};
|
||||
|
||||
this.state.activeDownloads.set(modelId, progress);
|
||||
this.emit('download-progress', modelId, progress);
|
||||
|
||||
try {
|
||||
logger.main.info('Starting model download', {
|
||||
modelId,
|
||||
size: model.sizeFormatted,
|
||||
url: model.downloadUrl,
|
||||
});
|
||||
|
||||
const response = await fetch(model.downloadUrl, {
|
||||
signal: abortController.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to download: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const totalBytes = parseInt(response.headers.get('content-length') || '0') || model.size;
|
||||
progress.totalBytes = totalBytes;
|
||||
|
||||
const fileStream = fs.createWriteStream(downloadPath);
|
||||
let bytesDownloaded = 0;
|
||||
let lastProgressEmit = 0;
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) {
|
||||
throw new Error('Failed to get response reader');
|
||||
}
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
|
||||
if (done) break;
|
||||
|
||||
if (abortController.signal.aborted) {
|
||||
fileStream.close();
|
||||
fs.unlinkSync(downloadPath);
|
||||
throw new Error('Download cancelled');
|
||||
}
|
||||
|
||||
fileStream.write(value);
|
||||
bytesDownloaded += value.length;
|
||||
|
||||
progress.bytesDownloaded = bytesDownloaded;
|
||||
progress.progress = Math.round((bytesDownloaded / totalBytes) * 100);
|
||||
|
||||
// Emit progress every 1% or 1MB to avoid too many events
|
||||
const progressPercent = progress.progress;
|
||||
if (
|
||||
progressPercent - lastProgressEmit >= 1 ||
|
||||
bytesDownloaded - (lastProgressEmit * totalBytes) / 100 >= 1024 * 1024
|
||||
) {
|
||||
this.emit('download-progress', modelId, { ...progress });
|
||||
lastProgressEmit = progressPercent;
|
||||
}
|
||||
}
|
||||
|
||||
fileStream.end();
|
||||
|
||||
// Get actual file size (no validation against expected size)
|
||||
const stats = fs.statSync(downloadPath);
|
||||
logger.main.info('Download completed', {
|
||||
modelId,
|
||||
expectedSize: totalBytes,
|
||||
actualSize: stats.size,
|
||||
sizeDifference: Math.abs(stats.size - totalBytes),
|
||||
});
|
||||
|
||||
// Verify checksum if provided
|
||||
if (model.checksum) {
|
||||
const fileChecksum = await this.calculateFileChecksum(downloadPath);
|
||||
if (fileChecksum !== model.checksum) {
|
||||
fs.unlinkSync(downloadPath);
|
||||
throw new Error(`Checksum mismatch. Expected: ${model.checksum}, Got: ${fileChecksum}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Create downloaded model record in database
|
||||
const downloadedModel = await createDownloadedModel({
|
||||
id: model.id,
|
||||
name: model.name,
|
||||
type: model.type,
|
||||
localPath: downloadPath,
|
||||
downloadedAt: new Date(),
|
||||
size: stats.size,
|
||||
checksum: model.checksum,
|
||||
});
|
||||
|
||||
// Clean up active download
|
||||
this.state.activeDownloads.delete(modelId);
|
||||
|
||||
logger.main.info('Model download completed', {
|
||||
modelId,
|
||||
path: downloadPath,
|
||||
size: stats.size,
|
||||
});
|
||||
|
||||
this.emit('download-complete', modelId, downloadedModel);
|
||||
} catch (error) {
|
||||
// Clean up on error
|
||||
this.state.activeDownloads.delete(modelId);
|
||||
|
||||
if (fs.existsSync(downloadPath)) {
|
||||
fs.unlinkSync(downloadPath);
|
||||
}
|
||||
|
||||
const err = error instanceof Error ? error : new Error(String(error));
|
||||
|
||||
if (abortController.signal.aborted) {
|
||||
logger.main.info('Model download cancelled', { modelId });
|
||||
this.emit('download-cancelled', modelId);
|
||||
} else {
|
||||
logger.main.error('Model download failed', { modelId, error: err.message });
|
||||
this.emit('download-error', modelId, err);
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// Cancel a model download
|
||||
cancelDownload(modelId: string): void {
|
||||
const download = this.state.activeDownloads.get(modelId);
|
||||
if (!download) {
|
||||
throw new Error(`No active download found for model: ${modelId}`);
|
||||
}
|
||||
|
||||
download.status = 'cancelling';
|
||||
download.abortController?.abort();
|
||||
|
||||
// Immediately remove from active downloads to prevent restart issues
|
||||
this.state.activeDownloads.delete(modelId);
|
||||
|
||||
logger.main.info('Cancelled model download', { modelId });
|
||||
this.emit('download-cancelled', modelId);
|
||||
}
|
||||
|
||||
// Delete a downloaded model
|
||||
async deleteModel(modelId: string): Promise<void> {
|
||||
const downloadedModels = await this.getDownloadedModels();
|
||||
const downloadedModel = downloadedModels[modelId];
|
||||
|
||||
if (!downloadedModel) {
|
||||
throw new Error(`Model not found: ${modelId}`);
|
||||
}
|
||||
|
||||
// Delete file
|
||||
if (fs.existsSync(downloadedModel.localPath)) {
|
||||
fs.unlinkSync(downloadedModel.localPath);
|
||||
logger.main.info('Deleted model file', {
|
||||
modelId,
|
||||
path: downloadedModel.localPath,
|
||||
});
|
||||
}
|
||||
|
||||
// Remove from database
|
||||
await deleteDownloadedModel(modelId);
|
||||
|
||||
this.emit('model-deleted', modelId);
|
||||
}
|
||||
|
||||
// Calculate file checksum (SHA-1)
|
||||
private async calculateFileChecksum(filePath: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const hash = crypto.createHash('sha1');
|
||||
const stream = fs.createReadStream(filePath);
|
||||
|
||||
stream.on('data', (data) => hash.update(data));
|
||||
stream.on('end', () => resolve(hash.digest('hex')));
|
||||
stream.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
// Get models directory path
|
||||
getModelsDirectory(): string {
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup - cancel all active downloads
|
||||
cleanup(): void {
|
||||
logger.main.info('Cleaning up model downloads', {
|
||||
activeDownloads: this.state.activeDownloads.size,
|
||||
});
|
||||
|
||||
for (const [modelId] of this.state.activeDownloads) {
|
||||
try {
|
||||
this.cancelDownload(modelId);
|
||||
} catch (error) {
|
||||
logger.main.warn('Error cancelling download during cleanup', {
|
||||
modelId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { ModelManagerService };
|
||||
1
apps/electron/src/modules/settings/index.ts
Normal file
1
apps/electron/src/modules/settings/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { SettingsService } from './settings-service';
|
||||
97
apps/electron/src/modules/settings/settings-service.ts
Normal file
97
apps/electron/src/modules/settings/settings-service.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import { FormatterConfig } from '../formatter';
|
||||
import {
|
||||
getSettingsSection,
|
||||
updateSettingsSection,
|
||||
getAppSettings,
|
||||
updateAppSettings,
|
||||
} from '../../db/app-settings';
|
||||
import type { AppSettingsData } from '../../db/schema';
|
||||
|
||||
/**
|
||||
* Database-backed settings service with typed configuration
|
||||
*/
|
||||
export class SettingsService {
|
||||
private static instance: SettingsService;
|
||||
|
||||
private constructor() {}
|
||||
|
||||
static getInstance(): SettingsService {
|
||||
if (!SettingsService.instance) {
|
||||
SettingsService.instance = new SettingsService();
|
||||
}
|
||||
return SettingsService.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get formatter configuration
|
||||
*/
|
||||
async getFormatterConfig(): Promise<FormatterConfig | null> {
|
||||
const formatterConfig = await getSettingsSection('formatterConfig');
|
||||
return formatterConfig || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set formatter configuration
|
||||
*/
|
||||
async setFormatterConfig(config: FormatterConfig): Promise<void> {
|
||||
await updateSettingsSection('formatterConfig', config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all app settings
|
||||
*/
|
||||
async getAllSettings(): Promise<AppSettingsData> {
|
||||
return await getAppSettings();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update multiple settings at once
|
||||
*/
|
||||
async updateSettings(settings: Partial<AppSettingsData>): Promise<AppSettingsData> {
|
||||
return await updateAppSettings(settings);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get UI settings
|
||||
*/
|
||||
async getUISettings(): Promise<AppSettingsData['ui']> {
|
||||
return await getSettingsSection('ui');
|
||||
}
|
||||
|
||||
/**
|
||||
* Update UI settings
|
||||
*/
|
||||
async setUISettings(uiSettings: AppSettingsData['ui']): Promise<void> {
|
||||
await updateSettingsSection('ui', uiSettings);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get transcription settings
|
||||
*/
|
||||
async getTranscriptionSettings(): Promise<AppSettingsData['transcription']> {
|
||||
return await getSettingsSection('transcription');
|
||||
}
|
||||
|
||||
/**
|
||||
* Update transcription settings
|
||||
*/
|
||||
async setTranscriptionSettings(
|
||||
transcriptionSettings: AppSettingsData['transcription']
|
||||
): Promise<void> {
|
||||
await updateSettingsSection('transcription', transcriptionSettings);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recording settings
|
||||
*/
|
||||
async getRecordingSettings(): Promise<AppSettingsData['recording']> {
|
||||
return await getSettingsSection('recording');
|
||||
}
|
||||
|
||||
/**
|
||||
* Update recording settings
|
||||
*/
|
||||
async setRecordingSettings(recordingSettings: AppSettingsData['recording']): Promise<void> {
|
||||
await updateSettingsSection('recording', recordingSettings);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,354 @@
|
|||
import { ContextualTranscriptionClient } from './transcription-session';
|
||||
import * as fs from 'fs';
|
||||
import { logger } from '../../main/logger';
|
||||
import { ModelManagerService } from '../models/model-manager';
|
||||
import { TranscribeFormat, TranscribeParams, Whisper } from 'smart-whisper';
|
||||
|
||||
export class ContextualLocalWhisperClient implements ContextualTranscriptionClient {
|
||||
private modelManager: ModelManagerService;
|
||||
private selectedModelId: string | null = null;
|
||||
private whisperInstance: Whisper | null = null; // Will be imported from smart-whisper
|
||||
private lastUsedTimestamp: number = 0;
|
||||
private cleanupTimer: NodeJS.Timeout | null = null;
|
||||
private readonly MODEL_CLEANUP_DELAY_MS = 30000; // 30 seconds after last use (configurable)
|
||||
|
||||
constructor(modelManager: ModelManagerService, selectedModelId?: string) {
|
||||
this.modelManager = modelManager;
|
||||
this.selectedModelId = selectedModelId || null;
|
||||
}
|
||||
|
||||
private async initializeWhisper(): Promise<void> {
|
||||
if (this.whisperInstance) {
|
||||
return; // Already initialized
|
||||
}
|
||||
|
||||
const modelPath = await this.getBestAvailableModel();
|
||||
if (!modelPath) {
|
||||
throw new Error('No Whisper models available. Please download a model first.');
|
||||
}
|
||||
|
||||
try {
|
||||
//! esure gpu is used if available
|
||||
this.whisperInstance = new Whisper(modelPath, { gpu: true });
|
||||
logger.ai.info('Smart-whisper instance created for contextual transcription', { modelPath });
|
||||
// Actually load the model into memory
|
||||
await this.whisperInstance.load();
|
||||
logger.ai.info('Smart-whisper model loaded into memory for contextual transcription', {
|
||||
modelPath,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.ai.error('Failed to initialize and load smart-whisper for contextual transcription', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
modelPath,
|
||||
});
|
||||
throw new Error(`Failed to initialize and load smart-whisper: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
async transcribeWithContext(audioData: Buffer, previousContext: string): Promise<string> {
|
||||
try {
|
||||
await this.initializeWhisper();
|
||||
this.updateLastUsedTimestamp(); // Update timestamp when model is used
|
||||
|
||||
// Convert audio buffer to the format expected by smart-whisper
|
||||
const audioFloat32Array = await this.convertAudioBuffer(audioData);
|
||||
|
||||
// Prepare initial prompt with context for better continuity
|
||||
let prompt = '';
|
||||
if (previousContext && previousContext.trim().length > 0) {
|
||||
// Use last ~50 words as context/prompt
|
||||
const contextWords = previousContext.trim().split(/\s+/);
|
||||
const maxWords = 50;
|
||||
prompt =
|
||||
contextWords.length > maxWords
|
||||
? contextWords.slice(-maxWords).join(' ')
|
||||
: previousContext.trim();
|
||||
}
|
||||
|
||||
const modelInfo = await this.getCurrentModelInfo();
|
||||
logger.ai.info('Starting smart-whisper contextual transcription', {
|
||||
audioDataSize: audioData.length,
|
||||
convertedSize: audioFloat32Array.length,
|
||||
hasContext: prompt.length > 0,
|
||||
contextLength: prompt.length,
|
||||
modelId: modelInfo.modelId,
|
||||
modelPath: modelInfo.modelPath,
|
||||
});
|
||||
|
||||
// Transcribe using smart-whisper with initial prompt for context
|
||||
const transcriptionOptions: Partial<TranscribeParams<TranscribeFormat>> = {
|
||||
language: 'auto',
|
||||
};
|
||||
|
||||
// Add initial prompt if we have context
|
||||
if (prompt) {
|
||||
transcriptionOptions.initial_prompt = prompt;
|
||||
}
|
||||
|
||||
const { result } = await this.whisperInstance!.transcribe(
|
||||
audioFloat32Array,
|
||||
transcriptionOptions
|
||||
);
|
||||
const transcription = await result;
|
||||
|
||||
// Extract text from the result object
|
||||
const transcriptionText = transcription.reduce((acc, curr) => acc + curr.text, '');
|
||||
|
||||
logger.ai.info('Smart-whisper contextual transcription completed', {
|
||||
resultLength: transcriptionText.length,
|
||||
hadContext: prompt.length > 0,
|
||||
resultType: typeof result,
|
||||
modelId: modelInfo.modelId,
|
||||
modelPath: modelInfo.modelPath,
|
||||
});
|
||||
|
||||
return transcriptionText;
|
||||
} catch (error) {
|
||||
logger.ai.error('Smart-whisper contextual transcription failed', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
throw new Error(`Contextual transcription failed: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async convertAudioBuffer(audioData: Buffer): Promise<Float32Array> {
|
||||
// Smart-whisper expects Float32Array with 16kHz mono audio
|
||||
// Now we're receiving raw Float32Array data from Web Audio API
|
||||
|
||||
logger.ai.info('Converting audio buffer', {
|
||||
bufferLength: audioData.length,
|
||||
expectedFloat32Length: audioData.length / 4,
|
||||
});
|
||||
|
||||
try {
|
||||
// The audioData should now be raw Float32Array from Web Audio API (16kHz, mono)
|
||||
// Check if buffer length is divisible by 4 (Float32 = 4 bytes)
|
||||
if (audioData.length % 4 !== 0) {
|
||||
logger.ai.warn('Audio buffer length not divisible by 4, may not be Float32Array', {
|
||||
length: audioData.length,
|
||||
remainder: audioData.length % 4,
|
||||
});
|
||||
}
|
||||
|
||||
// Convert buffer back to Float32Array
|
||||
const float32Array = new Float32Array(
|
||||
audioData.buffer,
|
||||
audioData.byteOffset,
|
||||
audioData.length / 4
|
||||
);
|
||||
|
||||
logger.ai.info('Successfully converted audio buffer', {
|
||||
sampleCount: float32Array.length,
|
||||
sampleRate: '16kHz (assumed)',
|
||||
format: 'Float32Array',
|
||||
});
|
||||
|
||||
return float32Array;
|
||||
} catch (error) {
|
||||
logger.ai.error('Audio conversion failed', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
|
||||
// Fallback: try to interpret as different formats
|
||||
try {
|
||||
// Try as 16-bit PCM
|
||||
const samples = new Float32Array(audioData.length / 2);
|
||||
for (let i = 0; i < samples.length; i++) {
|
||||
const sample = audioData.readInt16LE(i * 2);
|
||||
samples[i] = sample / 32768.0;
|
||||
}
|
||||
|
||||
logger.ai.info('Fallback: converted as 16-bit PCM', { sampleCount: samples.length });
|
||||
return samples;
|
||||
} catch (fallbackError) {
|
||||
logger.ai.error('All audio conversion methods failed', {
|
||||
originalError: error instanceof Error ? error.message : String(error),
|
||||
fallbackError:
|
||||
fallbackError instanceof Error ? fallbackError.message : String(fallbackError),
|
||||
});
|
||||
|
||||
// Return empty array as last resort
|
||||
return new Float32Array(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async getBestAvailableModel(): Promise<string | null> {
|
||||
const downloadedModels = await this.modelManager.getDownloadedModels();
|
||||
|
||||
// If a specific model is selected and available, use it
|
||||
if (this.selectedModelId && downloadedModels[this.selectedModelId]) {
|
||||
const model = downloadedModels[this.selectedModelId];
|
||||
if (fs.existsSync(model.localPath)) {
|
||||
return model.localPath;
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise, find the best available model (prioritize by quality)
|
||||
const preferredOrder = [
|
||||
'whisper-large-v1',
|
||||
'whisper-medium',
|
||||
'whisper-small',
|
||||
'whisper-base',
|
||||
'whisper-tiny',
|
||||
];
|
||||
|
||||
for (const modelId of preferredOrder) {
|
||||
const model = downloadedModels[modelId];
|
||||
if (model && fs.existsSync(model.localPath)) {
|
||||
return model.localPath;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Set the model to use for transcription
|
||||
async setSelectedModel(modelId: string): Promise<void> {
|
||||
const downloadedModels = await this.modelManager.getDownloadedModels();
|
||||
if (!downloadedModels[modelId]) {
|
||||
throw new Error(`Model not downloaded: ${modelId}`);
|
||||
}
|
||||
|
||||
// If we're changing models, free the current instance
|
||||
if (this.selectedModelId !== modelId && this.whisperInstance) {
|
||||
this.freeWhisperInstance();
|
||||
}
|
||||
|
||||
this.selectedModelId = modelId;
|
||||
logger.ai.info('Selected model for contextual transcription', { modelId });
|
||||
}
|
||||
|
||||
// Get the currently selected model
|
||||
getSelectedModel(): string | null {
|
||||
return this.selectedModelId;
|
||||
}
|
||||
|
||||
// Check if whisper is available
|
||||
async isAvailable(): Promise<boolean> {
|
||||
const downloadedModels = await this.modelManager.getDownloadedModels();
|
||||
return Object.keys(downloadedModels).some((modelId) =>
|
||||
fs.existsSync(downloadedModels[modelId].localPath)
|
||||
);
|
||||
}
|
||||
|
||||
// Get available models
|
||||
async getAvailableModels(): Promise<string[]> {
|
||||
const downloadedModels = await this.modelManager.getDownloadedModels();
|
||||
return Object.keys(downloadedModels).filter((modelId) =>
|
||||
fs.existsSync(downloadedModels[modelId].localPath)
|
||||
);
|
||||
}
|
||||
|
||||
// Get current model information for logging
|
||||
async getCurrentModelInfo(): Promise<{ modelId: string | null; modelPath: string | null }> {
|
||||
const downloadedModels = await this.modelManager.getDownloadedModels();
|
||||
|
||||
// If a specific model is selected and available, use it
|
||||
if (this.selectedModelId && downloadedModels[this.selectedModelId]) {
|
||||
const model = downloadedModels[this.selectedModelId];
|
||||
if (fs.existsSync(model.localPath)) {
|
||||
return {
|
||||
modelId: this.selectedModelId,
|
||||
modelPath: model.localPath,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise, find the best available model (same logic as getBestAvailableModel)
|
||||
const preferredOrder = [
|
||||
'whisper-large-v1',
|
||||
'whisper-medium',
|
||||
'whisper-small',
|
||||
'whisper-base',
|
||||
'whisper-tiny',
|
||||
];
|
||||
|
||||
for (const modelId of preferredOrder) {
|
||||
const model = downloadedModels[modelId];
|
||||
if (model && fs.existsSync(model.localPath)) {
|
||||
return {
|
||||
modelId: modelId,
|
||||
modelPath: model.localPath,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { modelId: null, modelPath: null };
|
||||
}
|
||||
|
||||
// Public method to preload the model
|
||||
async loadModel(): Promise<void> {
|
||||
await this.initializeWhisper();
|
||||
this.updateLastUsedTimestamp();
|
||||
logger.ai.info('Model preloaded successfully', {
|
||||
modelLoaded: this.isModelLoaded(),
|
||||
cleanupDelayMs: this.MODEL_CLEANUP_DELAY_MS,
|
||||
});
|
||||
}
|
||||
|
||||
// Public method to free the model
|
||||
async freeModel(): Promise<void> {
|
||||
this.clearCleanupTimer();
|
||||
await this.freeWhisperInstance();
|
||||
logger.ai.info('Model freed manually');
|
||||
}
|
||||
|
||||
// Check if model is currently loaded
|
||||
isModelLoaded(): boolean {
|
||||
return this.whisperInstance !== null;
|
||||
}
|
||||
|
||||
// Free resources
|
||||
async dispose(): Promise<void> {
|
||||
this.clearCleanupTimer();
|
||||
await this.freeWhisperInstance();
|
||||
}
|
||||
|
||||
private async freeWhisperInstance(): Promise<void> {
|
||||
if (this.whisperInstance) {
|
||||
try {
|
||||
await this.whisperInstance.free();
|
||||
logger.ai.info('Smart-whisper contextual instance freed');
|
||||
} catch (error) {
|
||||
logger.ai.warn('Error freeing smart-whisper contextual instance', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
} finally {
|
||||
this.whisperInstance = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private updateLastUsedTimestamp(): void {
|
||||
this.lastUsedTimestamp = Date.now();
|
||||
this.scheduleCleanup();
|
||||
}
|
||||
|
||||
private scheduleCleanup(): void {
|
||||
this.clearCleanupTimer();
|
||||
|
||||
this.cleanupTimer = setTimeout(async () => {
|
||||
const timeSinceLastUse = Date.now() - this.lastUsedTimestamp;
|
||||
|
||||
if (timeSinceLastUse >= this.MODEL_CLEANUP_DELAY_MS) {
|
||||
logger.ai.info('Auto-freeing model after inactivity', {
|
||||
inactiveTimeMs: timeSinceLastUse,
|
||||
thresholdMs: this.MODEL_CLEANUP_DELAY_MS,
|
||||
});
|
||||
await this.freeWhisperInstance();
|
||||
} else {
|
||||
// Reschedule if model was used recently
|
||||
const remainingTime = this.MODEL_CLEANUP_DELAY_MS - timeSinceLastUse;
|
||||
this.cleanupTimer = setTimeout(() => this.scheduleCleanup(), remainingTime);
|
||||
}
|
||||
}, this.MODEL_CLEANUP_DELAY_MS);
|
||||
}
|
||||
|
||||
private clearCleanupTimer(): void {
|
||||
if (this.cleanupTimer) {
|
||||
clearTimeout(this.cleanupTimer);
|
||||
this.cleanupTimer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
import { ContextualTranscriptionClient } from './transcription-session';
|
||||
import { ContextualLocalWhisperClient } from './contextual-local-whisper-client';
|
||||
import { ModelManagerService } from '../models/model-manager';
|
||||
import { createScopedLogger } from '../../main/logger';
|
||||
|
||||
export class ContextualTranscriptionManager {
|
||||
private logger = createScopedLogger('contextual-transcription-manager');
|
||||
private defaultClient: ContextualLocalWhisperClient | null = null;
|
||||
|
||||
constructor(private modelManagerService: ModelManagerService | null = null) {}
|
||||
|
||||
createTranscriptionClient(
|
||||
provider: 'local',
|
||||
options: { modelId?: string } = {}
|
||||
): ContextualTranscriptionClient {
|
||||
switch (provider) {
|
||||
case 'local':
|
||||
if (!this.modelManagerService) {
|
||||
throw new Error('ModelManagerService is required for local transcription client');
|
||||
}
|
||||
this.logger.info('Creating local Whisper contextual transcription client', {
|
||||
selectedModelId: options.modelId,
|
||||
});
|
||||
return new ContextualLocalWhisperClient(this.modelManagerService, options.modelId);
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown transcription provider: ${provider}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Get the default provider based on configuration
|
||||
getDefaultProvider(): 'local' {
|
||||
return 'local';
|
||||
}
|
||||
|
||||
// Create default client with current configuration
|
||||
createDefaultClient(): ContextualTranscriptionClient {
|
||||
if (!this.defaultClient) {
|
||||
this.defaultClient = this.createTranscriptionClient('local') as ContextualLocalWhisperClient;
|
||||
}
|
||||
return this.defaultClient;
|
||||
}
|
||||
|
||||
// Preload the model for faster transcription
|
||||
async preloadModel(): Promise<void> {
|
||||
const client = this.createDefaultClient() as ContextualLocalWhisperClient;
|
||||
await client.loadModel();
|
||||
this.logger.info('Model preloaded for contextual transcription');
|
||||
}
|
||||
|
||||
// Free the model to save memory
|
||||
async freeModel(): Promise<void> {
|
||||
if (this.defaultClient) {
|
||||
await this.defaultClient.freeModel();
|
||||
this.logger.info('Model freed for contextual transcription');
|
||||
}
|
||||
}
|
||||
|
||||
// Check if model is loaded
|
||||
isModelLoaded(): boolean {
|
||||
return this.defaultClient ? this.defaultClient.isModelLoaded() : false;
|
||||
}
|
||||
|
||||
// Cleanup resources
|
||||
async dispose(): Promise<void> {
|
||||
if (this.defaultClient) {
|
||||
await this.defaultClient.dispose();
|
||||
this.defaultClient = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
280
apps/electron/src/modules/transcription/transcription-session.ts
Normal file
280
apps/electron/src/modules/transcription/transcription-session.ts
Normal file
|
|
@ -0,0 +1,280 @@
|
|||
import { EventEmitter } from 'node:events';
|
||||
import { createScopedLogger } from '../../main/logger';
|
||||
|
||||
export interface ChunkData {
|
||||
sessionId: string;
|
||||
chunkId: number;
|
||||
audioData: Buffer;
|
||||
isFinalChunk: boolean;
|
||||
}
|
||||
|
||||
export interface ChunkResult {
|
||||
chunkId: number;
|
||||
text: string;
|
||||
processingTimeMs: number;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
modelInfo?: {
|
||||
modelId: string | null;
|
||||
modelPath: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ContextualTranscriptionClient {
|
||||
transcribeWithContext(audioData: Buffer, previousContext: string): Promise<string>;
|
||||
getCurrentModelInfo?: () => Promise<{ modelId: string | null; modelPath: string | null }>;
|
||||
}
|
||||
|
||||
export class TranscriptionSession extends EventEmitter {
|
||||
private logger = createScopedLogger('transcription-session');
|
||||
private sessionId: string;
|
||||
private transcriptionClient: ContextualTranscriptionClient;
|
||||
|
||||
private chunkQueue: ChunkData[] = [];
|
||||
private results: ChunkResult[] = [];
|
||||
private accumulatedText: string = '';
|
||||
private isProcessing: boolean = false;
|
||||
private expectedChunkId: number = 1;
|
||||
private isComplete: boolean = false;
|
||||
private sessionStartTime: number;
|
||||
|
||||
constructor(sessionId: string, transcriptionClient: ContextualTranscriptionClient) {
|
||||
super();
|
||||
this.sessionId = sessionId;
|
||||
this.transcriptionClient = transcriptionClient;
|
||||
this.sessionStartTime = Date.now();
|
||||
|
||||
this.logger.info('TranscriptionSession created', {
|
||||
sessionId,
|
||||
sessionStartTime: this.sessionStartTime,
|
||||
sessionStartTimeISO: new Date(this.sessionStartTime).toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
public addChunk(chunkData: ChunkData): void {
|
||||
if (chunkData.sessionId !== this.sessionId) {
|
||||
this.logger.warn('Received chunk for different session', {
|
||||
expected: this.sessionId,
|
||||
received: chunkData.sessionId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.isComplete) {
|
||||
this.logger.warn('Session already complete, ignoring chunk', {
|
||||
sessionId: this.sessionId,
|
||||
chunkId: chunkData.chunkId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.info('Adding chunk to queue', {
|
||||
sessionId: this.sessionId,
|
||||
chunkId: chunkData.chunkId,
|
||||
isFinalChunk: chunkData.isFinalChunk,
|
||||
audioDataSize: chunkData.audioData.length,
|
||||
});
|
||||
|
||||
this.chunkQueue.push(chunkData);
|
||||
this.processNextChunk();
|
||||
}
|
||||
|
||||
private async processNextChunk(): Promise<void> {
|
||||
if (this.isProcessing || this.chunkQueue.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the next expected chunk in sequence
|
||||
const nextChunkIndex = this.chunkQueue.findIndex(
|
||||
(chunk) => chunk.chunkId === this.expectedChunkId
|
||||
);
|
||||
|
||||
if (nextChunkIndex === -1) {
|
||||
this.logger.debug('Next expected chunk not yet available', {
|
||||
expectedChunkId: this.expectedChunkId,
|
||||
availableChunks: this.chunkQueue.map((c) => c.chunkId),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const chunk = this.chunkQueue.splice(nextChunkIndex, 1)[0];
|
||||
this.isProcessing = true;
|
||||
|
||||
try {
|
||||
await this.transcribeChunk(chunk);
|
||||
} catch (error) {
|
||||
this.logger.error('Error processing chunk', {
|
||||
sessionId: this.sessionId,
|
||||
chunkId: chunk.chunkId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
this.emit('chunk-error', { chunkId: chunk.chunkId, error });
|
||||
} finally {
|
||||
this.isProcessing = false;
|
||||
this.expectedChunkId++;
|
||||
|
||||
// Check if this was the final chunk
|
||||
if (chunk.isFinalChunk) {
|
||||
this.completeSession();
|
||||
} else {
|
||||
// Process next chunk if available
|
||||
this.processNextChunk();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async transcribeChunk(chunk: ChunkData): Promise<void> {
|
||||
const startTime = Date.now();
|
||||
const modelInfo = this.transcriptionClient.getCurrentModelInfo
|
||||
? await this.transcriptionClient.getCurrentModelInfo()
|
||||
: { modelId: null, modelPath: null };
|
||||
|
||||
this.logger.info('Starting transcription for chunk', {
|
||||
sessionId: this.sessionId,
|
||||
chunkId: chunk.chunkId,
|
||||
audioDataSize: chunk.audioData.length,
|
||||
contextLength: this.accumulatedText.length,
|
||||
startTime,
|
||||
startTimeISO: new Date(startTime).toISOString(),
|
||||
modelId: modelInfo.modelId,
|
||||
modelPath: modelInfo.modelPath,
|
||||
});
|
||||
|
||||
// Skip transcription for empty chunks (but still process them for completion)
|
||||
if (chunk.audioData.length === 0) {
|
||||
const endTime = Date.now();
|
||||
const processingTimeMs = endTime - startTime;
|
||||
|
||||
this.logger.info('Skipping transcription for empty chunk', {
|
||||
sessionId: this.sessionId,
|
||||
chunkId: chunk.chunkId,
|
||||
startTime,
|
||||
endTime,
|
||||
processingTimeMs,
|
||||
startTimeISO: new Date(startTime).toISOString(),
|
||||
endTimeISO: new Date(endTime).toISOString(),
|
||||
modelId: modelInfo.modelId,
|
||||
modelPath: modelInfo.modelPath,
|
||||
});
|
||||
|
||||
const result: ChunkResult = {
|
||||
chunkId: chunk.chunkId,
|
||||
text: '',
|
||||
processingTimeMs,
|
||||
startTime,
|
||||
endTime,
|
||||
modelInfo,
|
||||
};
|
||||
|
||||
this.results.push(result);
|
||||
this.emit('chunk-completed', result);
|
||||
return;
|
||||
}
|
||||
|
||||
const transcriptionText = await this.transcriptionClient.transcribeWithContext(
|
||||
chunk.audioData,
|
||||
this.accumulatedText
|
||||
);
|
||||
|
||||
console.error('transcriptionText result ', transcriptionText);
|
||||
|
||||
const endTime = Date.now();
|
||||
const processingTimeMs = endTime - startTime;
|
||||
|
||||
const result: ChunkResult = {
|
||||
chunkId: chunk.chunkId,
|
||||
text: transcriptionText,
|
||||
processingTimeMs,
|
||||
startTime,
|
||||
endTime,
|
||||
modelInfo,
|
||||
};
|
||||
|
||||
// Accumulate the transcription text for context
|
||||
this.accumulatedText += (this.accumulatedText ? ' ' : '') + transcriptionText;
|
||||
|
||||
this.results.push(result);
|
||||
|
||||
this.logger.error('Chunk transcription completed', {
|
||||
sessionId: this.sessionId,
|
||||
chunkId: chunk.chunkId,
|
||||
textLength: transcriptionText.length,
|
||||
processingTimeMs,
|
||||
startTime,
|
||||
endTime,
|
||||
startTimeISO: new Date(startTime).toISOString(),
|
||||
endTimeISO: new Date(endTime).toISOString(),
|
||||
accumulatedTextLength: this.accumulatedText.length,
|
||||
modelId: modelInfo.modelId,
|
||||
modelPath: modelInfo.modelPath,
|
||||
});
|
||||
|
||||
this.emit('chunk-completed', result);
|
||||
}
|
||||
|
||||
private completeSession(): void {
|
||||
this.isComplete = true;
|
||||
|
||||
const sessionEndTime = Date.now();
|
||||
const totalSessionTimeMs = sessionEndTime - this.sessionStartTime;
|
||||
const totalProcessingTime = this.results.reduce(
|
||||
(sum, result) => sum + result.processingTimeMs,
|
||||
0
|
||||
);
|
||||
|
||||
// Get model info from the last successful chunk result
|
||||
const lastChunkWithModel = this.results.find((r) => r.modelInfo);
|
||||
const sessionModelInfo = lastChunkWithModel?.modelInfo || { modelId: null, modelPath: null };
|
||||
|
||||
this.logger.error('Transcription session completed', {
|
||||
sessionId: this.sessionId,
|
||||
totalChunks: this.results.length,
|
||||
finalTextLength: this.accumulatedText.length,
|
||||
sessionStartTime: this.sessionStartTime,
|
||||
sessionEndTime,
|
||||
sessionStartTimeISO: new Date(this.sessionStartTime).toISOString(),
|
||||
sessionEndTimeISO: new Date(sessionEndTime).toISOString(),
|
||||
totalSessionTimeMs,
|
||||
totalProcessingTimeMs: totalProcessingTime,
|
||||
averageProcessingTimePerChunkMs:
|
||||
this.results.length > 0 ? Math.round(totalProcessingTime / this.results.length) : 0,
|
||||
processingEfficiency:
|
||||
totalSessionTimeMs > 0 ? Math.round((totalProcessingTime / totalSessionTimeMs) * 100) : 0,
|
||||
modelId: sessionModelInfo.modelId,
|
||||
modelPath: sessionModelInfo.modelPath,
|
||||
chunkTimings: this.results.map((r) => ({
|
||||
chunkId: r.chunkId,
|
||||
processingTimeMs: r.processingTimeMs,
|
||||
startTime: r.startTime,
|
||||
endTime: r.endTime,
|
||||
textLength: r.text.length,
|
||||
})),
|
||||
});
|
||||
|
||||
this.emit('session-completed', {
|
||||
sessionId: this.sessionId,
|
||||
finalText: this.accumulatedText,
|
||||
chunkResults: this.results,
|
||||
totalProcessingTimeMs: totalProcessingTime,
|
||||
totalSessionTimeMs,
|
||||
sessionStartTime: this.sessionStartTime,
|
||||
sessionEndTime,
|
||||
});
|
||||
}
|
||||
|
||||
public getSessionId(): string {
|
||||
return this.sessionId;
|
||||
}
|
||||
|
||||
public getAccumulatedText(): string {
|
||||
return this.accumulatedText;
|
||||
}
|
||||
|
||||
public getResults(): ChunkResult[] {
|
||||
return [...this.results];
|
||||
}
|
||||
|
||||
public isSessionComplete(): boolean {
|
||||
return this.isComplete;
|
||||
}
|
||||
}
|
||||
|
|
@ -26,55 +26,120 @@
|
|||
* ```
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { ipcLink } from 'electron-trpc-experimental/renderer';
|
||||
import superjson from 'superjson';
|
||||
import { SidebarProvider, SidebarInset, SidebarTrigger } from '@/components/ui/sidebar';
|
||||
import { AppSidebar } from '@/components/app-sidebar';
|
||||
import { ThemeProvider } from '@/components/theme-provider';
|
||||
import { TranscriptionsView } from '@/components/transcriptions-view';
|
||||
import { VocabularyView } from '@/components/vocabulary-view';
|
||||
import { ModelsView } from '@/components/models-view';
|
||||
import { SettingsView } from '@/components/settings-view';
|
||||
import '@/styles/globals.css';
|
||||
import ShortcutIndicator from '../components/ShortcutIndicator';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||
import { SiteHeader } from '@/components/site-header';
|
||||
import { api } 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
|
||||
|
||||
const App: React.FC = () => {
|
||||
const [apiKey, setApiKey] = useState('');
|
||||
// Create a client
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const handleApiKeyChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setApiKey(event.target.value);
|
||||
// Create tRPC client
|
||||
const trpcClient = api.createClient({
|
||||
links: [ipcLink({ transformer: superjson })],
|
||||
});
|
||||
|
||||
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 handleSaveApiKey = () => {
|
||||
window.electronAPI.setApiKey(apiKey);
|
||||
alert('API Key sent to main process!');
|
||||
const renderContent = () => {
|
||||
switch (currentView) {
|
||||
case 'Transcriptions':
|
||||
return <TranscriptionsView />;
|
||||
case 'Vocabulary':
|
||||
return <VocabularyView />;
|
||||
case 'Models':
|
||||
return <ModelsView />;
|
||||
case 'Settings':
|
||||
return <SettingsView />;
|
||||
default:
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-2xl font-bold">Welcome to Amical</h2>
|
||||
<p>Select an option from the sidebar to get started.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-1 space-y-4 p-8 pt-6">
|
||||
<Tabs defaultValue="dictionary" className="w-[400px]">
|
||||
<TabsList>
|
||||
<TabsTrigger value="dictionary">Dictionary</TabsTrigger>
|
||||
<TabsTrigger value="api">Configure API Key</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="dictionary">Dictionary Tab Content</TabsContent>
|
||||
<TabsContent value="api">API Key Configuration Content</TabsContent>
|
||||
<TabsContent value="api">
|
||||
<div>
|
||||
<label htmlFor="apiKey">API Key:</label>
|
||||
<input
|
||||
type="password"
|
||||
id="apiKey"
|
||||
name="apiKey"
|
||||
className="border rounded px-2 py-1"
|
||||
value={apiKey}
|
||||
onChange={handleApiKeyChange}
|
||||
/>
|
||||
<Button onClick={handleSaveApiKey}>Save API Key</Button>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
<api.Provider client={trpcClient} queryClient={queryClient}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider>
|
||||
<SidebarProvider
|
||||
style={
|
||||
{
|
||||
'--sidebar-width': 'calc(var(--spacing) * 72)',
|
||||
'--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">
|
||||
<AppSidebar
|
||||
variant="inset"
|
||||
onNavigate={handleNavigation}
|
||||
currentView={currentView}
|
||||
/>
|
||||
<SidebarInset>
|
||||
<div className="flex flex-1 flex-col">
|
||||
<div className="@container/main flex flex-1 flex-col">
|
||||
<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>
|
||||
</SidebarInset>
|
||||
</div>
|
||||
</div>
|
||||
</SidebarProvider>
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
</api.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -2,8 +2,9 @@ body {
|
|||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica,
|
||||
Arial, sans-serif;
|
||||
margin: auto;
|
||||
max-width: 38rem;
|
||||
padding: 2rem;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@import "tailwindcss";
|
||||
|
|
@ -45,6 +46,8 @@ body {
|
|||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
--content-padding: 2rem;
|
||||
--content-max-width: 75rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
|
|
|
|||
13
apps/electron/src/trpc/react.ts
Normal file
13
apps/electron/src/trpc/react.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { createTRPCReact } from '@trpc/react-query';
|
||||
import { createTRPCProxyClient } from '@trpc/client';
|
||||
import { ipcLink } from 'electron-trpc-experimental/renderer';
|
||||
import superjson from 'superjson';
|
||||
import type { AppRouter } from './router';
|
||||
|
||||
// Create the tRPC React hooks
|
||||
export const api = createTRPCReact<AppRouter>();
|
||||
|
||||
// Create the vanilla tRPC client (for use outside React components)
|
||||
export const trpcClient = createTRPCProxyClient<AppRouter>({
|
||||
links: [ipcLink({ transformer: superjson })],
|
||||
});
|
||||
40
apps/electron/src/trpc/router.ts
Normal file
40
apps/electron/src/trpc/router.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { initTRPC } from '@trpc/server';
|
||||
import superjson from 'superjson';
|
||||
import { z } from 'zod';
|
||||
import { vocabularyRouter } from './routers/vocabulary';
|
||||
|
||||
const t = initTRPC.create({
|
||||
isServer: true,
|
||||
transformer: superjson,
|
||||
});
|
||||
|
||||
export const router = t.router({
|
||||
// Test procedures
|
||||
greeting: t.procedure.input(z.object({ name: z.string() })).query((req) => {
|
||||
return {
|
||||
text: `Hello ${req.input.name}`,
|
||||
timestamp: new Date(), // Date objects require transformation
|
||||
};
|
||||
}),
|
||||
|
||||
// Example of a simple procedure without input
|
||||
ping: t.procedure.query(() => {
|
||||
return {
|
||||
message: 'pong',
|
||||
timestamp: new Date(),
|
||||
};
|
||||
}),
|
||||
|
||||
// Example mutation
|
||||
echo: t.procedure.input(z.object({ message: z.string() })).mutation((req) => {
|
||||
return {
|
||||
echo: req.input.message,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
}),
|
||||
|
||||
// Vocabulary router
|
||||
vocabulary: vocabularyRouter,
|
||||
});
|
||||
|
||||
export type AppRouter = typeof router;
|
||||
125
apps/electron/src/trpc/routers/vocabulary.ts
Normal file
125
apps/electron/src/trpc/routers/vocabulary.ts
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
import { initTRPC } from '@trpc/server';
|
||||
import superjson from 'superjson';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
getVocabulary,
|
||||
getVocabularyById,
|
||||
getVocabularyByWord,
|
||||
createVocabularyWord,
|
||||
updateVocabulary,
|
||||
deleteVocabulary,
|
||||
getVocabularyCount,
|
||||
searchVocabulary,
|
||||
bulkImportVocabulary,
|
||||
trackWordUsage,
|
||||
getMostUsedWords,
|
||||
} from '../../db/vocabulary';
|
||||
|
||||
const t = initTRPC.create({
|
||||
isServer: true,
|
||||
transformer: superjson,
|
||||
});
|
||||
|
||||
// Input schemas
|
||||
const GetVocabularySchema = z.object({
|
||||
limit: z.number().optional(),
|
||||
offset: z.number().optional(),
|
||||
sortBy: z.enum(['word', 'dateAdded', 'usageCount']).optional(),
|
||||
sortOrder: z.enum(['asc', 'desc']).optional(),
|
||||
search: z.string().optional(),
|
||||
});
|
||||
|
||||
const CreateVocabularySchema = z.object({
|
||||
word: z.string().min(1),
|
||||
dateAdded: z.date().optional(),
|
||||
});
|
||||
|
||||
const UpdateVocabularySchema = z.object({
|
||||
word: z.string().min(1).optional(),
|
||||
dateAdded: z.date().optional(),
|
||||
usageCount: z.number().optional(),
|
||||
});
|
||||
|
||||
const BulkImportSchema = z.array(
|
||||
z.object({
|
||||
word: z.string().min(1),
|
||||
dateAdded: z.date().optional(),
|
||||
})
|
||||
);
|
||||
|
||||
export const vocabularyRouter = t.router({
|
||||
// Get vocabulary list with pagination and filtering
|
||||
getVocabulary: t.procedure.input(GetVocabularySchema).query(async ({ input }) => {
|
||||
return await getVocabulary(input);
|
||||
}),
|
||||
|
||||
// Get vocabulary count
|
||||
getVocabularyCount: t.procedure
|
||||
.input(z.object({ search: z.string().optional() }))
|
||||
.query(async ({ input }) => {
|
||||
return await getVocabularyCount(input.search);
|
||||
}),
|
||||
|
||||
// Get vocabulary by ID
|
||||
getVocabularyById: t.procedure.input(z.object({ id: z.number() })).query(async ({ input }) => {
|
||||
return await getVocabularyById(input.id);
|
||||
}),
|
||||
|
||||
// Get vocabulary by word
|
||||
getVocabularyByWord: t.procedure
|
||||
.input(z.object({ word: z.string() }))
|
||||
.query(async ({ input }) => {
|
||||
return await getVocabularyByWord(input.word);
|
||||
}),
|
||||
|
||||
// Search vocabulary
|
||||
searchVocabulary: t.procedure
|
||||
.input(
|
||||
z.object({
|
||||
searchTerm: z.string(),
|
||||
limit: z.number().optional(),
|
||||
})
|
||||
)
|
||||
.query(async ({ input }) => {
|
||||
return await searchVocabulary(input.searchTerm, input.limit);
|
||||
}),
|
||||
|
||||
// Get most used words
|
||||
getMostUsedWords: t.procedure
|
||||
.input(z.object({ limit: z.number().optional() }))
|
||||
.query(async ({ input }) => {
|
||||
return await getMostUsedWords(input.limit);
|
||||
}),
|
||||
|
||||
// Create vocabulary word
|
||||
createVocabularyWord: t.procedure.input(CreateVocabularySchema).mutation(async ({ input }) => {
|
||||
return await createVocabularyWord(input);
|
||||
}),
|
||||
|
||||
// Update vocabulary word
|
||||
updateVocabulary: t.procedure
|
||||
.input(
|
||||
z.object({
|
||||
id: z.number(),
|
||||
data: UpdateVocabularySchema,
|
||||
})
|
||||
)
|
||||
.mutation(async ({ input }) => {
|
||||
return await updateVocabulary(input.id, input.data);
|
||||
}),
|
||||
|
||||
// Delete vocabulary word
|
||||
deleteVocabulary: t.procedure.input(z.object({ id: z.number() })).mutation(async ({ input }) => {
|
||||
return await deleteVocabulary(input.id);
|
||||
}),
|
||||
|
||||
// Track word usage
|
||||
trackWordUsage: t.procedure.input(z.object({ word: z.string() })).mutation(async ({ input }) => {
|
||||
return await trackWordUsage(input.word);
|
||||
}),
|
||||
|
||||
// Bulk import vocabulary
|
||||
bulkImportVocabulary: t.procedure.input(BulkImportSchema).mutation(async ({ input }) => {
|
||||
return await bulkImportVocabulary(input);
|
||||
}),
|
||||
});
|
||||
|
|
@ -16,6 +16,95 @@ export interface ElectronAPI {
|
|||
onRecordingStarting: () => Promise<void>;
|
||||
onRecordingStopping: () => Promise<void>;
|
||||
|
||||
// New method for setting the API key
|
||||
setApiKey: (apiKey: string) => Promise<void>;
|
||||
// Model Management API
|
||||
getAvailableModels: () => Promise<import('../constants/models').Model[]>;
|
||||
getDownloadedModels: () => Promise<Record<string, import('../constants/models').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('../modules/formatter').FormatterConfig | null>;
|
||||
setFormatterConfig: (config: import('../modules/formatter').FormatterConfig) => Promise<void>;
|
||||
|
||||
// Transcription Database API
|
||||
getTranscriptions: (options?: {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
sortBy?: 'timestamp' | 'createdAt';
|
||||
sortOrder?: 'asc' | 'desc';
|
||||
search?: string;
|
||||
}) => Promise<import('../db/schema').Transcription[]>;
|
||||
getTranscriptionById: (id: number) => Promise<import('../db/schema').Transcription | null>;
|
||||
createTranscription: (
|
||||
data: Omit<import('../db/schema').NewTranscription, 'id' | 'createdAt' | 'updatedAt'>
|
||||
) => Promise<import('../db/schema').Transcription>;
|
||||
updateTranscription: (
|
||||
id: number,
|
||||
data: Partial<Omit<import('../db/schema').Transcription, 'id' | 'createdAt'>>
|
||||
) => Promise<import('../db/schema').Transcription | null>;
|
||||
deleteTranscription: (id: number) => Promise<import('../db/schema').Transcription | null>;
|
||||
getTranscriptionsCount: (search?: string) => Promise<number>;
|
||||
searchTranscriptions: (
|
||||
searchTerm: string,
|
||||
limit?: number
|
||||
) => Promise<import('../db/schema').Transcription[]>;
|
||||
|
||||
// Vocabulary Database API
|
||||
getVocabulary: (options?: {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
sortBy?: 'word' | 'dateAdded' | 'usageCount';
|
||||
sortOrder?: 'asc' | 'desc';
|
||||
search?: string;
|
||||
}) => Promise<import('../db/schema').Vocabulary[]>;
|
||||
getVocabularyById: (id: number) => Promise<import('../db/schema').Vocabulary | null>;
|
||||
getVocabularyByWord: (word: string) => Promise<import('../db/schema').Vocabulary | null>;
|
||||
createVocabularyWord: (
|
||||
data: Omit<import('../db/schema').NewVocabulary, 'id' | 'createdAt' | 'updatedAt'>
|
||||
) => Promise<import('../db/schema').Vocabulary>;
|
||||
updateVocabulary: (
|
||||
id: number,
|
||||
data: Partial<Omit<import('../db/schema').Vocabulary, 'id' | 'createdAt'>>
|
||||
) => Promise<import('../db/schema').Vocabulary | null>;
|
||||
deleteVocabulary: (id: number) => Promise<import('../db/schema').Vocabulary | null>;
|
||||
getVocabularyCount: (search?: string) => Promise<number>;
|
||||
searchVocabulary: (
|
||||
searchTerm: string,
|
||||
limit?: number
|
||||
) => Promise<import('../db/schema').Vocabulary[]>;
|
||||
bulkImportVocabulary: (
|
||||
words: Omit<import('../db/schema').NewVocabulary, 'id' | 'createdAt' | 'updatedAt'>[]
|
||||
) => Promise<import('../db/schema').Vocabulary[]>;
|
||||
trackWordUsage: (word: string) => Promise<import('../db/schema').Vocabulary | null>;
|
||||
getMostUsedWords: (limit?: number) => Promise<import('../db/schema').Vocabulary[]>; // Model management event listeners
|
||||
on: (channel: string, callback: (...args: any[]) => void) => void;
|
||||
off: (channel: string, callback: (...args: any[]) => void) => void;
|
||||
|
||||
// Logging API for renderer process
|
||||
log: {
|
||||
info: (...args: any[]) => void;
|
||||
warn: (...args: any[]) => void;
|
||||
error: (...args: any[]) => void;
|
||||
debug: (...args: any[]) => void;
|
||||
scope: (name: string) => {
|
||||
info: (...args: any[]) => void;
|
||||
warn: (...args: any[]) => void;
|
||||
error: (...args: any[]) => void;
|
||||
debug: (...args: any[]) => void;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,10 +14,11 @@
|
|||
"lib": ["DOM", "DOM.Iterable", "ESNext"],
|
||||
"strict": true,
|
||||
"baseUrl": ".",
|
||||
"noEmit": true,
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["**/*.ts", "**/*.tsx", "**/*.d.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
"include": ["src/**/*", "forge.config.ts", "vite.*.config.mts", "drizzle.config.ts"],
|
||||
"exclude": ["node_modules", "dist", ".vite", "out"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,19 @@ import { resolve } from 'path';
|
|||
export default defineConfig({
|
||||
build: {
|
||||
rollupOptions: {
|
||||
external: ['better-sqlite3'],
|
||||
external: [
|
||||
'better-sqlite3',
|
||||
'smart-whisper',
|
||||
'@libsql/client',
|
||||
'@libsql/darwin-arm64',
|
||||
'@libsql/darwin-x64',
|
||||
'@libsql/linux-x64-gnu',
|
||||
'@libsql/linux-x64-musl',
|
||||
'@libsql/win32-x64-msvc',
|
||||
'libsql',
|
||||
/^node:/,
|
||||
/^electron$/,
|
||||
],
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
|
|
@ -14,6 +26,6 @@ export default defineConfig({
|
|||
},
|
||||
},
|
||||
optimizeDeps: {
|
||||
exclude: ['better-sqlite3'],
|
||||
exclude: ['better-sqlite3', 'smart-whisper', 'drizzle-orm', '@libsql/client'],
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ export default defineConfig(async () => {
|
|||
|
||||
return {
|
||||
plugins: [tailwindcss()],
|
||||
publicDir: 'public',
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(__dirname, 'src'),
|
||||
|
|
|
|||
13
package.json
13
package.json
|
|
@ -20,14 +20,23 @@
|
|||
"pnpm": {
|
||||
"ignoredBuiltDependencies": [
|
||||
"@tailwindcss/oxide",
|
||||
"better-sqlite3",
|
||||
"core-js-pure",
|
||||
"electron",
|
||||
"electron-winstaller",
|
||||
"esbuild",
|
||||
"keytar",
|
||||
"protobufjs",
|
||||
"sharp"
|
||||
"sharp",
|
||||
"smart-whisper",
|
||||
"drizzle-orm/libsql",
|
||||
"@libsql"
|
||||
],
|
||||
"onlyBuiltDependencies": [
|
||||
"electron",
|
||||
"electron-winstaller",
|
||||
"smart-whisper",
|
||||
"drizzle-orm/libsql",
|
||||
"@libsql"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,17 +16,24 @@ export const config = [
|
|||
{
|
||||
plugins: {
|
||||
turbo: turboPlugin,
|
||||
onlyWarn,
|
||||
},
|
||||
rules: {
|
||||
"turbo/no-undeclared-env-vars": "warn",
|
||||
},
|
||||
},
|
||||
{
|
||||
plugins: {
|
||||
onlyWarn,
|
||||
},
|
||||
},
|
||||
{
|
||||
ignores: ["dist/**"],
|
||||
ignores: [
|
||||
"dist/**",
|
||||
".vite/**",
|
||||
"build/**",
|
||||
"node_modules/**",
|
||||
".turbo/**",
|
||||
"coverage/**",
|
||||
".next/**",
|
||||
"out/**",
|
||||
"*.min.js",
|
||||
"*.bundle.js",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
|
|
|||
3
packages/eslint-config/react-internal.js
vendored
3
packages/eslint-config/react-internal.js
vendored
|
|
@ -12,9 +12,6 @@ import { config as baseConfig } from "./base.js";
|
|||
* @type {import("eslint").Linter.Config[]} */
|
||||
export const config = [
|
||||
...baseConfig,
|
||||
js.configs.recommended,
|
||||
eslintConfigPrettier,
|
||||
...tseslint.configs.recommended,
|
||||
pluginReact.configs.flat.recommended,
|
||||
{
|
||||
languageOptions: {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,510 @@
|
|||
import Foundation
|
||||
import ApplicationServices
|
||||
import AppKit
|
||||
|
||||
// Apps that need manual accessibility enabling
|
||||
let appsManuallyEnableAx: Set<String> = ["com.google.Chrome", "org.mozilla.firefox", "com.microsoft.edgemac", "com.apple.Safari"]
|
||||
|
||||
struct ProcessInfo {
|
||||
let pid: pid_t
|
||||
let name: String?
|
||||
let bundleIdentifier: String?
|
||||
let version: String?
|
||||
}
|
||||
|
||||
struct Selection {
|
||||
let text: String
|
||||
let process: ProcessInfo
|
||||
let preSelection: String?
|
||||
let postSelection: String?
|
||||
let fullContent: String?
|
||||
let selectionRange: NSRange?
|
||||
let isEditable: Bool
|
||||
let elementType: String?
|
||||
}
|
||||
|
||||
class AccessibilityContextService {
|
||||
|
||||
static func checkAccessibilityPermissions(prompt: Bool = false) -> Bool {
|
||||
let options: [String: Any] = [kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: prompt]
|
||||
return AXIsProcessTrustedWithOptions(options as CFDictionary)
|
||||
}
|
||||
|
||||
static func getFrontProcessID() -> pid_t {
|
||||
guard let frontmostApp = NSWorkspace.shared.frontmostApplication else {
|
||||
FileHandle.standardError.write("❌ No frontmost application found\n".data(using: .utf8)!)
|
||||
return 0
|
||||
}
|
||||
return frontmostApp.processIdentifier
|
||||
}
|
||||
|
||||
static func getProcessName(pid: pid_t) -> String? {
|
||||
guard let application = NSRunningApplication(processIdentifier: pid),
|
||||
let url = application.executableURL else {
|
||||
return nil
|
||||
}
|
||||
return url.lastPathComponent
|
||||
}
|
||||
|
||||
static func getBundleIdentifier(pid: pid_t) -> String? {
|
||||
guard let application = NSRunningApplication(processIdentifier: pid) else {
|
||||
return nil
|
||||
}
|
||||
return application.bundleIdentifier
|
||||
}
|
||||
|
||||
static func getApplicationVersion(pid: pid_t) -> String? {
|
||||
guard let application = NSRunningApplication(processIdentifier: pid),
|
||||
let bundle = Bundle(url: application.bundleURL ?? URL(fileURLWithPath: "")) else {
|
||||
return nil
|
||||
}
|
||||
return bundle.infoDictionary?["CFBundleShortVersionString"] as? String
|
||||
}
|
||||
|
||||
static func touchDescendantElements(_ element: AXUIElement, maxDepth: Int) {
|
||||
guard maxDepth > 0 else { return }
|
||||
|
||||
var children: CFTypeRef?
|
||||
let error = AXUIElementCopyAttributeValue(element, kAXChildrenAttribute as CFString, &children)
|
||||
|
||||
guard error == .success, let childrenArray = children as? [AXUIElement] else {
|
||||
return
|
||||
}
|
||||
|
||||
// Limit to 8 children to avoid performance issues
|
||||
let limitedChildren = Array(childrenArray.prefix(8))
|
||||
for child in limitedChildren {
|
||||
touchDescendantElements(child, maxDepth: maxDepth - 1)
|
||||
}
|
||||
}
|
||||
|
||||
static func _getFocusedElement(pid: pid_t) -> AXUIElement? {
|
||||
let application = AXUIElementCreateApplication(pid)
|
||||
|
||||
// Enable manual accessibility for specific apps
|
||||
if let bundleId: String = getBundleIdentifier(pid: pid),
|
||||
appsManuallyEnableAx.contains(bundleId) {
|
||||
FileHandle.standardError.write("🔧 Enabling manual accessibility for \(bundleId)\n".data(using: .utf8)!)
|
||||
AXUIElementSetAttributeValue(application, "AXManualAccessibility" as CFString, kCFBooleanTrue)
|
||||
AXUIElementSetAttributeValue(application, "AXEnhancedUserInterface" as CFString, kCFBooleanTrue)
|
||||
}
|
||||
|
||||
var focusedElement: CFTypeRef?
|
||||
var error = AXUIElementCopyAttributeValue(application, kAXFocusedUIElementAttribute as CFString, &focusedElement)
|
||||
|
||||
// Fallback to focused window if focused element fails
|
||||
if error != .success {
|
||||
FileHandle.standardError.write("⚠️ Failed to get focused element, trying focused window...\n".data(using: .utf8)!)
|
||||
error = AXUIElementCopyAttributeValue(application, kAXFocusedWindowAttribute as CFString, &focusedElement)
|
||||
}
|
||||
|
||||
guard error == .success, let element = focusedElement else {
|
||||
FileHandle.standardError.write("❌ Failed to get focused element or window. Error: \(error.rawValue)\n".data(using: .utf8)!)
|
||||
return nil
|
||||
}
|
||||
|
||||
return (element as! AXUIElement)
|
||||
}
|
||||
|
||||
static func getAttributeValue(element: AXUIElement, attribute: String) -> String? {
|
||||
var value: CFTypeRef?
|
||||
let error = AXUIElementCopyAttributeValue(element, attribute as CFString, &value)
|
||||
|
||||
if error == .success {
|
||||
if let stringValue = value as? String {
|
||||
return stringValue
|
||||
} else if let numberValue = value as? NSNumber {
|
||||
return numberValue.stringValue
|
||||
} else if let boolValue = value as? Bool {
|
||||
return boolValue ? "true" : "false"
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
static func getAttributeNames(element: AXUIElement) -> [String] {
|
||||
var attributeNames: CFArray?
|
||||
let error = AXUIElementCopyAttributeNames(element, &attributeNames)
|
||||
|
||||
if error == .success, let names = attributeNames as? [String] {
|
||||
return names
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
static func isElementEditable(element: AXUIElement) -> Bool {
|
||||
let role = getAttributeValue(element: element, attribute: kAXRoleAttribute)
|
||||
let subrole = getAttributeValue(element: element, attribute: kAXSubroleAttribute)
|
||||
|
||||
// Check for editable roles
|
||||
let editableRoles = ["AXTextField", "AXTextArea", "AXComboBox"]
|
||||
if let role = role, editableRoles.contains(role) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check for editable subroles
|
||||
let editableSubroles = ["AXSecureTextField", "AXSearchField"]
|
||||
if let subrole = subrole, editableSubroles.contains(subrole) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check if element has AXValue attribute (often indicates editability)
|
||||
let attributes = getAttributeNames(element: element)
|
||||
return attributes.contains(kAXValueAttribute)
|
||||
}
|
||||
|
||||
static func getParentChain(element: AXUIElement, maxDepth: Int = 10) -> [String] {
|
||||
var chain: [String] = []
|
||||
var currentElement = element
|
||||
|
||||
for _ in 0..<maxDepth {
|
||||
var parent: CFTypeRef?
|
||||
let error = AXUIElementCopyAttributeValue(currentElement, kAXParentAttribute as CFString, &parent)
|
||||
|
||||
if error == .success, let parentElement = parent {
|
||||
// Check if the parent is actually an AXUIElement
|
||||
if CFGetTypeID(parentElement) == AXUIElementGetTypeID() {
|
||||
let axParent = parentElement as! AXUIElement
|
||||
if let role = getAttributeValue(element: axParent, attribute: kAXRoleAttribute) {
|
||||
chain.append(role)
|
||||
}
|
||||
currentElement = axParent
|
||||
} else {
|
||||
break
|
||||
}
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return chain
|
||||
}
|
||||
|
||||
static func getTextSelection(element: AXUIElement) -> TextSelection? {
|
||||
// Get selected text
|
||||
guard let selectedText = getAttributeValue(element: element, attribute: kAXSelectedTextAttribute),
|
||||
!selectedText.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get full content
|
||||
let fullContent = getAttributeValue(element: element, attribute: kAXValueAttribute)
|
||||
|
||||
// Get selection range
|
||||
var selectionRange: SelectionRange? = nil
|
||||
var rangeValue: CFTypeRef?
|
||||
let rangeError = AXUIElementCopyAttributeValue(element, kAXSelectedTextRangeAttribute as CFString, &rangeValue)
|
||||
|
||||
if rangeError == .success, let axValue = rangeValue {
|
||||
var range = CFRange()
|
||||
if AXValueGetValue(axValue as! AXValue, .cfRange, &range) {
|
||||
selectionRange = SelectionRange(length: Int(range.length), location: Int(range.location))
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate pre and post selection text
|
||||
var preSelectionText: String? = nil
|
||||
var postSelectionText: String? = nil
|
||||
|
||||
if let fullContent = fullContent, let range = selectionRange {
|
||||
let nsString = fullContent as NSString
|
||||
|
||||
if range.location > 0 {
|
||||
let preRange = NSRange(location: 0, length: range.location)
|
||||
preSelectionText = nsString.substring(with: preRange)
|
||||
}
|
||||
|
||||
let postStart = range.location + range.length
|
||||
if postStart < nsString.length {
|
||||
let postRange = NSRange(location: postStart, length: nsString.length - postStart)
|
||||
postSelectionText = nsString.substring(with: postRange)
|
||||
}
|
||||
}
|
||||
|
||||
let isEditable = isElementEditable(element: element)
|
||||
|
||||
return TextSelection(
|
||||
fullContent: fullContent,
|
||||
isEditable: isEditable,
|
||||
postSelectionText: postSelectionText,
|
||||
preSelectionText: preSelectionText,
|
||||
selectedText: selectedText,
|
||||
selectionRange: selectionRange
|
||||
)
|
||||
}
|
||||
|
||||
static func getBrowserURL(windowElement: AXUIElement, bundleId: String?) -> String? {
|
||||
var foundURL: String? = nil
|
||||
var urlSource = "none"
|
||||
|
||||
// Debug: Print all window attributes
|
||||
FileHandle.standardError.write("🔍 Window attributes:\n".data(using: .utf8)!)
|
||||
let attributes = getAttributeNames(element: windowElement)
|
||||
for attribute in attributes {
|
||||
if let value = getAttributeValue(element: windowElement, attribute: attribute) {
|
||||
FileHandle.standardError.write(" \(attribute): \(value)\n".data(using: .utf8)!)
|
||||
} else {
|
||||
FileHandle.standardError.write(" \(attribute): <no value>\n".data(using: .utf8)!)
|
||||
}
|
||||
}
|
||||
|
||||
// Determine browser type for conditional logic
|
||||
let isChromiumBrowser = bundleId?.lowercased().contains("chrome") == true ||
|
||||
bundleId?.lowercased().contains("chromium") == true ||
|
||||
bundleId == "com.microsoft.edgemac" ||
|
||||
bundleId == "com.brave.Browser" ||
|
||||
bundleId == "com.operasoftware.Opera" ||
|
||||
bundleId == "com.vivaldi.Vivaldi"
|
||||
|
||||
let isFirefox = bundleId == "org.mozilla.firefox"
|
||||
|
||||
FileHandle.standardError.write("🔍 Browser type - Chromium: \(isChromiumBrowser), Firefox: \(isFirefox), Bundle: \(bundleId ?? "unknown")\n".data(using: .utf8)!)
|
||||
|
||||
// For Chromium browsers and Firefox: Prioritize AXWebArea (live URL)
|
||||
if isChromiumBrowser || isFirefox {
|
||||
FileHandle.standardError.write("🔍 Using AXWebArea priority for Chromium/Firefox browser\n".data(using: .utf8)!)
|
||||
foundURL = findURLInChildren(element: windowElement, depth: 0, maxDepth: 30)
|
||||
if foundURL != nil {
|
||||
urlSource = "tree_walking_priority"
|
||||
FileHandle.standardError.write("🔍 Found URL from AXWebArea (priority): \(foundURL!)\n".data(using: .utf8)!)
|
||||
return foundURL
|
||||
}
|
||||
}
|
||||
|
||||
// Try window-level attributes (reliable for Safari, fallback for others)
|
||||
var urlRef: CFTypeRef?
|
||||
let docErr = AXUIElementCopyAttributeValue(windowElement,
|
||||
kAXDocumentAttribute as CFString,
|
||||
&urlRef)
|
||||
if docErr == .success, let urlString = urlRef as? String, !urlString.isEmpty {
|
||||
foundURL = urlString
|
||||
urlSource = "window_document"
|
||||
FileHandle.standardError.write("🔍 Found URL from window document: \(urlString)\n".data(using: .utf8)!)
|
||||
|
||||
// For Safari and other WebKit browsers, this is reliable, return immediately
|
||||
if !isChromiumBrowser && !isFirefox {
|
||||
return foundURL
|
||||
}
|
||||
// For Chromium/Firefox, keep this as fallback but continue looking
|
||||
}
|
||||
|
||||
if AXUIElementCopyAttributeValue(windowElement,
|
||||
kAXURLAttribute as CFString,
|
||||
&urlRef) == .success,
|
||||
let urlString = urlRef as? String, !urlString.isEmpty {
|
||||
if foundURL == nil {
|
||||
foundURL = urlString
|
||||
urlSource = "window_url"
|
||||
FileHandle.standardError.write("🔍 Found URL from window URL attribute: \(urlString)\n".data(using: .utf8)!)
|
||||
|
||||
// For Safari and other WebKit browsers, this is reliable, return immediately
|
||||
if !isChromiumBrowser && !isFirefox {
|
||||
return foundURL
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For non-Chromium browsers that didn't find window URLs, try tree walking
|
||||
if !isChromiumBrowser && !isFirefox && foundURL == nil {
|
||||
foundURL = findURLInChildren(element: windowElement, depth: 0, maxDepth: 3)
|
||||
if foundURL != nil {
|
||||
urlSource = "tree_walking_fallback"
|
||||
FileHandle.standardError.write("🔍 Found URL from tree walking (fallback): \(foundURL!)\n".data(using: .utf8)!)
|
||||
return foundURL
|
||||
}
|
||||
}
|
||||
|
||||
if foundURL != nil {
|
||||
FileHandle.standardError.write("🔍 Returning URL (\(urlSource)): \(foundURL!)\n".data(using: .utf8)!)
|
||||
return foundURL
|
||||
}
|
||||
|
||||
FileHandle.standardError.write("🔍 No URL found from any method\n".data(using: .utf8)!)
|
||||
return nil
|
||||
}
|
||||
|
||||
static func findURLInChildren(element: AXUIElement, depth: Int, maxDepth: Int) -> String? {
|
||||
guard depth < maxDepth else { return nil }
|
||||
|
||||
// BFS implementation using a queue
|
||||
var queue: [(element: AXUIElement, depth: Int)] = [(element, depth)]
|
||||
|
||||
while !queue.isEmpty {
|
||||
let (currentElement, currentDepth) = queue.removeFirst()
|
||||
|
||||
// Skip if we've exceeded max depth
|
||||
guard currentDepth < maxDepth else { continue }
|
||||
|
||||
var childrenRef: CFTypeRef?
|
||||
guard AXUIElementCopyAttributeValue(currentElement,
|
||||
kAXChildrenAttribute as CFString,
|
||||
&childrenRef) == .success,
|
||||
let children = childrenRef as? [AXUIElement] else {
|
||||
continue
|
||||
}
|
||||
|
||||
// Process all children at current level first (BFS)
|
||||
for child in children {
|
||||
// Check role first
|
||||
var roleRef: CFTypeRef?
|
||||
guard AXUIElementCopyAttributeValue(child,
|
||||
kAXRoleAttribute as CFString,
|
||||
&roleRef) == .success,
|
||||
let role = roleRef as? String else {
|
||||
continue
|
||||
}
|
||||
|
||||
// log role
|
||||
FileHandle.standardError.write("🔍 Found element with role: \(role) at depth \(currentDepth + 1)\n".data(using: .utf8)!)
|
||||
// log all attribute names
|
||||
FileHandle.standardError.write("🔍 Element attributes: \(getAttributeNames(element: child))\n".data(using: .utf8)!)
|
||||
// log kAXURLAttribute
|
||||
FileHandle.standardError.write("🔍 kAXURLAttribute: \(getAttributeValue(element: child, attribute: kAXURLAttribute) ?? "none")\n".data(using: .utf8)!)
|
||||
|
||||
// Priority 1: Address/search fields (most current)
|
||||
if role == "AXTextField" || role == "AXComboBox" || role == "AXSafariAddressAndSearchField" {
|
||||
var valueRef: CFTypeRef?
|
||||
if AXUIElementCopyAttributeValue(child,
|
||||
kAXValueAttribute as CFString,
|
||||
&valueRef) == .success,
|
||||
let value = valueRef as? String,
|
||||
!value.isEmpty,
|
||||
(value.hasPrefix("http://") || value.hasPrefix("https://") || value.contains(".")) {
|
||||
FileHandle.standardError.write("🔍 Found URL in address field (\(role)): \(value)\n".data(using: .utf8)!)
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 2: Web areas
|
||||
if role == "AXWebArea" {
|
||||
FileHandle.standardError.write("🔍 Found AXWebArea element at depth \(currentDepth + 1)\n".data(using: .utf8)!)
|
||||
// list all attributes for this element
|
||||
FileHandle.standardError.write("🔍 AXWebArea attributes: \(getAttributeNames(element: child))\n".data(using: .utf8)!)
|
||||
// iterate and list value for all attributes
|
||||
for attribute in getAttributeNames(element: child) {
|
||||
FileHandle.standardError.write("🔍 \(attribute): \(getAttributeValue(element: child, attribute: attribute) ?? "none")\n".data(using: .utf8)!)
|
||||
}
|
||||
var urlRef: CFTypeRef?
|
||||
if AXUIElementCopyAttributeValue(child,
|
||||
kAXURLAttribute as CFString,
|
||||
&urlRef) == .success,
|
||||
let urlString = urlRef as? String, !urlString.isEmpty {
|
||||
FileHandle.standardError.write("🔍 Found URL in web area: \(urlString)\n".data(using: .utf8)!)
|
||||
return urlString
|
||||
}
|
||||
|
||||
if AXUIElementCopyAttributeValue(child,
|
||||
kAXDocumentAttribute as CFString,
|
||||
&urlRef) == .success,
|
||||
let urlString = urlRef as? String, !urlString.isEmpty {
|
||||
FileHandle.standardError.write("🔍 Found URL in web area document: \(urlString)\n".data(using: .utf8)!)
|
||||
return urlString
|
||||
}
|
||||
}
|
||||
|
||||
// Add child to queue for next level processing
|
||||
queue.append((child, currentDepth + 1))
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
static func getWindowInfo(pid: pid_t) -> WindowInfo? {
|
||||
let application = AXUIElementCreateApplication(pid)
|
||||
|
||||
// Get main window
|
||||
var mainWindow: CFTypeRef?
|
||||
let error = AXUIElementCopyAttributeValue(application, kAXMainWindowAttribute as CFString, &mainWindow)
|
||||
|
||||
guard error == .success, let windowRef = mainWindow else {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if the window is actually an AXUIElement
|
||||
guard CFGetTypeID(windowRef) == AXUIElementGetTypeID() else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let window = windowRef as! AXUIElement
|
||||
let title = getAttributeValue(element: window, attribute: kAXTitleAttribute)
|
||||
|
||||
// Get URL if this is a browser
|
||||
let url = getBrowserURL(windowElement: window, bundleId: getBundleIdentifier(pid: pid))
|
||||
|
||||
return WindowInfo(
|
||||
title: title,
|
||||
url: url
|
||||
)
|
||||
}
|
||||
|
||||
static func getAccessibilityContext(editableOnly: Bool = false) -> Context? {
|
||||
// Check accessibility permissions
|
||||
guard checkAccessibilityPermissions() else {
|
||||
FileHandle.standardError.write("❌ Accessibility permissions not granted\n".data(using: .utf8)!)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get frontmost application
|
||||
let pid = getFrontProcessID()
|
||||
guard pid > 0 else {
|
||||
FileHandle.standardError.write("❌ Could not get frontmost application PID\n".data(using: .utf8)!)
|
||||
return nil
|
||||
}
|
||||
|
||||
let processName = getProcessName(pid: pid)
|
||||
let bundleId = getBundleIdentifier(pid: pid)
|
||||
let version = getApplicationVersion(pid: pid)
|
||||
|
||||
// Create application info
|
||||
let applicationInfo = Application(
|
||||
bundleIdentifier: bundleId,
|
||||
name: processName,
|
||||
version: version
|
||||
)
|
||||
|
||||
// Get focused element
|
||||
var focusedElementInfo: FocusedElement? = nil
|
||||
var textSelectionInfo: TextSelection? = nil
|
||||
|
||||
if let focusedElement = _getFocusedElement(pid: pid) {
|
||||
// Touch descendant elements to ensure they're accessible
|
||||
touchDescendantElements(focusedElement, maxDepth: 3)
|
||||
|
||||
let role = getAttributeValue(element: focusedElement, attribute: kAXRoleAttribute)
|
||||
let title = getAttributeValue(element: focusedElement, attribute: kAXTitleAttribute)
|
||||
let description = getAttributeValue(element: focusedElement, attribute: kAXDescriptionAttribute)
|
||||
let value = getAttributeValue(element: focusedElement, attribute: kAXValueAttribute)
|
||||
let isEditable = isElementEditable(element: focusedElement)
|
||||
|
||||
focusedElementInfo = FocusedElement(
|
||||
description: description,
|
||||
isEditable: isEditable,
|
||||
role: role,
|
||||
title: title,
|
||||
value: value
|
||||
)
|
||||
|
||||
// Get text selection if available and not filtered by editableOnly
|
||||
if let textSelection = getTextSelection(element: focusedElement) {
|
||||
if !editableOnly || textSelection.isEditable {
|
||||
textSelectionInfo = textSelection
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get window info
|
||||
let windowInfo = getWindowInfo(pid: pid)
|
||||
|
||||
// Create context
|
||||
let context = Context(
|
||||
application: applicationInfo,
|
||||
focusedElement: focusedElementInfo,
|
||||
textSelection: textSelectionInfo,
|
||||
timestamp: Date().timeIntervalSince1970,
|
||||
windowInfo: windowInfo
|
||||
)
|
||||
|
||||
return context
|
||||
}
|
||||
}
|
||||
|
|
@ -46,11 +46,23 @@ struct AccessibilityElementNode: Codable {
|
|||
class AccessibilityService {
|
||||
|
||||
private let maxDepth = 10 // To prevent excessively deep recursion and large payloads
|
||||
private let dateFormatter: DateFormatter
|
||||
|
||||
// Properties to store original audio states
|
||||
private var originalSystemMuteState: Bool?
|
||||
private var originalSystemVolume: Float32?
|
||||
|
||||
init() {
|
||||
self.dateFormatter = DateFormatter()
|
||||
self.dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss.SSS"
|
||||
}
|
||||
|
||||
private func logToStderr(_ message: String) {
|
||||
let timestamp = dateFormatter.string(from: Date())
|
||||
let logMessage = "[\(timestamp)] \(message)\n"
|
||||
FileHandle.standardError.write(logMessage.data(using: .utf8)!)
|
||||
}
|
||||
|
||||
// Fetches a value for a given accessibility attribute from an element.
|
||||
private func getAttributeValue(element: AXUIElement, attribute: String) -> String? {
|
||||
var value: AnyObject?
|
||||
|
|
@ -94,7 +106,7 @@ class AccessibilityService {
|
|||
if status == noErr && deviceID != kAudioObjectUnknown {
|
||||
return deviceID
|
||||
} else {
|
||||
FileHandle.standardError.write("[AccessibilityService] Error getting default output device: \(status).\\n".data(using: .utf8)!)
|
||||
logToStderr("[AccessibilityService] Error getting default output device: \(status).")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
|
@ -111,7 +123,7 @@ class AccessibilityService {
|
|||
var isSettable: DarwinBoolean = false
|
||||
let infoStatus = AudioObjectIsPropertySettable(deviceID, &propertyAddress, &isSettable)
|
||||
if infoStatus != noErr || !isSettable.boolValue {
|
||||
FileHandle.standardError.write("[AccessibilityService] Mute property not supported or not settable for device \(deviceID).\\n".data(using: .utf8)!)
|
||||
logToStderr("[AccessibilityService] Mute property not supported or not settable for device \(deviceID).")
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -127,7 +139,7 @@ class AccessibilityService {
|
|||
if status == noErr {
|
||||
return isMuted == 1
|
||||
} else {
|
||||
FileHandle.standardError.write("[AccessibilityService] Error getting mute state for device \(deviceID): \(status).\\n".data(using: .utf8)!)
|
||||
logToStderr("[AccessibilityService] Error getting mute state for device \(deviceID): \(status).")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
|
@ -144,11 +156,11 @@ class AccessibilityService {
|
|||
var isSettable: DarwinBoolean = false
|
||||
let infoStatus = AudioObjectIsPropertySettable(deviceID, &propertyAddress, &isSettable)
|
||||
if infoStatus != noErr {
|
||||
FileHandle.standardError.write("[AccessibilityService] Error checking if mute is settable for device \(deviceID): \(infoStatus).\\n".data(using: .utf8)!)
|
||||
logToStderr("[AccessibilityService] Error checking if mute is settable for device \(deviceID): \(infoStatus).")
|
||||
return infoStatus
|
||||
}
|
||||
if !isSettable.boolValue {
|
||||
FileHandle.standardError.write("[AccessibilityService] Mute property is not settable for device \(deviceID).\\n".data(using: .utf8)!)
|
||||
logToStderr("[AccessibilityService] Mute property is not settable for device \(deviceID).")
|
||||
return kAudioHardwareUnsupportedOperationError
|
||||
}
|
||||
|
||||
|
|
@ -161,7 +173,7 @@ class AccessibilityService {
|
|||
&muteVal
|
||||
)
|
||||
if status != noErr {
|
||||
FileHandle.standardError.write("[AccessibilityService] Error setting mute state for device \(deviceID) to \(mute): \(status).\\n".data(using: .utf8)!)
|
||||
logToStderr("[AccessibilityService] Error setting mute state for device \(deviceID) to \(mute): \(status).")
|
||||
}
|
||||
return status
|
||||
}
|
||||
|
|
@ -176,7 +188,7 @@ class AccessibilityService {
|
|||
var propertySize = UInt32(MemoryLayout<Float32>.size)
|
||||
|
||||
if AudioObjectHasProperty(deviceID, &propertyAddress) == false {
|
||||
FileHandle.standardError.write("[AccessibilityService] Volume scalar property not supported for device \(deviceID).\\n".data(using: .utf8)!)
|
||||
logToStderr("[AccessibilityService] Volume scalar property not supported for device \(deviceID).")
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -192,7 +204,7 @@ class AccessibilityService {
|
|||
if status == noErr {
|
||||
return volume
|
||||
} else {
|
||||
FileHandle.standardError.write("[AccessibilityService] Error getting volume for device \(deviceID): \(status).\\n".data(using: .utf8)!)
|
||||
logToStderr("[AccessibilityService] Error getting volume for device \(deviceID): \(status).")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
|
@ -209,11 +221,11 @@ class AccessibilityService {
|
|||
var isSettable: DarwinBoolean = false
|
||||
let infoStatus = AudioObjectIsPropertySettable(deviceID, &propertyAddress, &isSettable)
|
||||
if infoStatus != noErr {
|
||||
FileHandle.standardError.write("[AccessibilityService] Error checking if volume is settable for device \(deviceID): \(infoStatus).\\n".data(using: .utf8)!)
|
||||
logToStderr("[AccessibilityService] Error checking if volume is settable for device \(deviceID): \(infoStatus).")
|
||||
return infoStatus
|
||||
}
|
||||
if !isSettable.boolValue {
|
||||
FileHandle.standardError.write("[AccessibilityService] Volume property is not settable for device \(deviceID).\\n".data(using: .utf8)!)
|
||||
logToStderr("[AccessibilityService] Volume property is not settable for device \(deviceID).")
|
||||
return kAudioHardwareUnsupportedOperationError
|
||||
}
|
||||
|
||||
|
|
@ -226,7 +238,7 @@ class AccessibilityService {
|
|||
&newVolume
|
||||
)
|
||||
if status != noErr {
|
||||
FileHandle.standardError.write("[AccessibilityService] Error setting volume for device \(deviceID) to \(newVolume): \(status).\\n".data(using: .utf8)!)
|
||||
logToStderr("[AccessibilityService] Error setting volume for device \(deviceID) to \(newVolume): \(status).")
|
||||
}
|
||||
return status
|
||||
}
|
||||
|
|
@ -277,53 +289,53 @@ class AccessibilityService {
|
|||
// For `rootId`: if nil, gets system-wide. If "focused", gets the focused application.
|
||||
// Otherwise, it could be a bundle identifier (not implemented here yet).
|
||||
public func fetchFullAccessibilityTree(rootId: String?) -> AccessibilityElementNode? {
|
||||
FileHandle.standardError.write("[AccessibilityService] Starting fetchFullAccessibilityTree. rootId: \(rootId ?? "nil")\n".data(using: .utf8)!)
|
||||
logToStderr("[AccessibilityService] Starting fetchFullAccessibilityTree. rootId: \(rootId ?? "nil")")
|
||||
|
||||
var rootElement: AXUIElement?
|
||||
|
||||
if let id = rootId, id.lowercased() == "focusedapp" {
|
||||
// Get the focused application
|
||||
guard let frontmostApp = NSWorkspace.shared.frontmostApplication else {
|
||||
FileHandle.standardError.write("[AccessibilityService] Could not get frontmost application.\n".data(using: .utf8)!)
|
||||
logToStderr("[AccessibilityService] Could not get frontmost application.")
|
||||
return nil
|
||||
}
|
||||
rootElement = AXUIElementCreateApplication(frontmostApp.processIdentifier)
|
||||
FileHandle.standardError.write("[AccessibilityService] Targeting focused app: \(frontmostApp.localizedName ?? "Unknown App") (PID: \(frontmostApp.processIdentifier))\n".data(using: .utf8)!)
|
||||
logToStderr("[AccessibilityService] Targeting focused app: \(frontmostApp.localizedName ?? "Unknown App") (PID: \(frontmostApp.processIdentifier))")
|
||||
} else if let id = rootId, !id.isEmpty {
|
||||
// Basic PID lookup if rootId is a number (representing a PID)
|
||||
// More robust app lookup by bundle ID would be better for non-PID rootIds.
|
||||
if let pid = Int32(id) {
|
||||
rootElement = AXUIElementCreateApplication(pid)
|
||||
FileHandle.standardError.write("[AccessibilityService] Targeting PID: \(pid)\n".data(using: .utf8)!)
|
||||
logToStderr("[AccessibilityService] Targeting PID: \(pid)")
|
||||
} else {
|
||||
FileHandle.standardError.write("[AccessibilityService] rootId '\(id)' is not 'focusedapp' or a valid PID. Falling back to system-wide (or implement bundle ID lookup).\n".data(using: .utf8)!)
|
||||
logToStderr("[AccessibilityService] rootId '\(id)' is not 'focusedapp' or a valid PID. Falling back to system-wide (or implement bundle ID lookup).")
|
||||
// Fallback or specific error for unhandled rootId format
|
||||
// For now, let's try system-wide if rootId isn't 'focusedapp' or PID.
|
||||
rootElement = AXUIElementCreateSystemWide()
|
||||
FileHandle.standardError.write("[AccessibilityService] Defaulting to system-wide due to unhandled rootId.\n".data(using: .utf8)!)
|
||||
logToStderr("[AccessibilityService] Defaulting to system-wide due to unhandled rootId.")
|
||||
}
|
||||
} else {
|
||||
// Default to system-wide if rootId is nil or empty
|
||||
rootElement = AXUIElementCreateSystemWide()
|
||||
FileHandle.standardError.write("[AccessibilityService] Targeting system-wide accessibility tree.\n".data(using: .utf8)!)
|
||||
logToStderr("[AccessibilityService] Targeting system-wide accessibility tree.")
|
||||
}
|
||||
|
||||
guard let element = rootElement else {
|
||||
FileHandle.standardError.write("[AccessibilityService] Failed to create root AXUIElement.\n".data(using: .utf8)!)
|
||||
logToStderr("[AccessibilityService] Failed to create root AXUIElement.")
|
||||
return nil
|
||||
}
|
||||
|
||||
let tree = buildTree(fromElement: element, currentDepth: 0)
|
||||
FileHandle.standardError.write("[AccessibilityService] Finished buildTree. Result is \(tree == nil ? "nil" : "not nil").\\n".data(using: .utf8)!)
|
||||
logToStderr("[AccessibilityService] Finished buildTree. Result is \(tree == nil ? "nil" : "not nil").")
|
||||
return tree
|
||||
}
|
||||
|
||||
// MARK: - System Audio Control
|
||||
|
||||
public func muteSystemAudio() -> Bool {
|
||||
FileHandle.standardError.write("[AccessibilityService] Attempting to mute system audio.\\n".data(using: .utf8)!)
|
||||
logToStderr("[AccessibilityService] Attempting to mute system audio.")
|
||||
guard let deviceID = getDefaultOutputDeviceID() else {
|
||||
FileHandle.standardError.write("[AccessibilityService] Could not get default output device to mute audio.\\n".data(using: .utf8)!)
|
||||
logToStderr("[AccessibilityService] Could not get default output device to mute audio.")
|
||||
return false
|
||||
}
|
||||
|
||||
|
|
@ -331,38 +343,38 @@ class AccessibilityService {
|
|||
self.originalSystemMuteState = isDeviceMuted(deviceID: deviceID)
|
||||
self.originalSystemVolume = getDeviceVolume(deviceID: deviceID)
|
||||
|
||||
FileHandle.standardError.write("[AccessibilityService] Original mute state: \(String(describing: self.originalSystemMuteState)), Original volume: \(String(describing: self.originalSystemVolume)).\\n".data(using: .utf8)!)
|
||||
logToStderr("[AccessibilityService] Original mute state: \(String(describing: self.originalSystemMuteState)), Original volume: \(String(describing: self.originalSystemVolume)).")
|
||||
|
||||
// Attempt to mute
|
||||
let muteStatus = setDeviceMute(deviceID: deviceID, mute: true)
|
||||
if muteStatus == noErr {
|
||||
FileHandle.standardError.write("[AccessibilityService] System audio muted successfully via mute property.\\n".data(using: .utf8)!)
|
||||
logToStderr("[AccessibilityService] System audio muted successfully via mute property.")
|
||||
return true
|
||||
} else {
|
||||
FileHandle.standardError.write("[AccessibilityService] Failed to set mute property (status: \(muteStatus)). Attempting to set volume to 0.\\n".data(using: .utf8)!)
|
||||
logToStderr("[AccessibilityService] Failed to set mute property (status: \(muteStatus)). Attempting to set volume to 0.")
|
||||
let volumeStatus = setDeviceVolume(deviceID: deviceID, volume: 0.0)
|
||||
if volumeStatus == noErr {
|
||||
FileHandle.standardError.write("[AccessibilityService] System audio silenced by setting volume to 0.\\n".data(using: .utf8)!)
|
||||
logToStderr("[AccessibilityService] System audio silenced by setting volume to 0.")
|
||||
} else {
|
||||
FileHandle.standardError.write("[AccessibilityService] Failed to silence system audio by setting volume to 0 (status: \(volumeStatus)).\\n".data(using: .utf8)!)
|
||||
logToStderr("[AccessibilityService] Failed to silence system audio by setting volume to 0 (status: \(volumeStatus)).")
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
public func restoreSystemAudio() -> Bool {
|
||||
FileHandle.standardError.write("[AccessibilityService] Attempting to restore system audio.\\n".data(using: .utf8)!)
|
||||
logToStderr("[AccessibilityService] Attempting to restore system audio.")
|
||||
guard let deviceID = getDefaultOutputDeviceID() else {
|
||||
FileHandle.standardError.write("[AccessibilityService] Could not get default output device to restore audio.\\n".data(using: .utf8)!)
|
||||
logToStderr("[AccessibilityService] Could not get default output device to restore audio.")
|
||||
return false
|
||||
}
|
||||
|
||||
if let originalMute = self.originalSystemMuteState {
|
||||
let muteStatus = setDeviceMute(deviceID: deviceID, mute: originalMute)
|
||||
if muteStatus == noErr {
|
||||
FileHandle.standardError.write("[AccessibilityService] System mute state restored to \(originalMute).\\n".data(using: .utf8)!)
|
||||
logToStderr("[AccessibilityService] System mute state restored to \(originalMute).")
|
||||
} else {
|
||||
FileHandle.standardError.write("[AccessibilityService] Failed to restore original mute state (status: \(muteStatus)).\\n".data(using: .utf8)!)
|
||||
logToStderr("[AccessibilityService] Failed to restore original mute state (status: \(muteStatus)).")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -371,21 +383,21 @@ class AccessibilityService {
|
|||
if shouldRestoreVolume, let originalVolume = self.originalSystemVolume {
|
||||
let volumeStatus = setDeviceVolume(deviceID: deviceID, volume: originalVolume)
|
||||
if volumeStatus == noErr {
|
||||
FileHandle.standardError.write("[AccessibilityService] System volume restored to \(originalVolume).\\n".data(using: .utf8)!)
|
||||
logToStderr("[AccessibilityService] System volume restored to \(originalVolume).")
|
||||
} else {
|
||||
FileHandle.standardError.write("[AccessibilityService] Failed to restore original volume (status: \(volumeStatus)).\\n".data(using: .utf8)!)
|
||||
logToStderr("[AccessibilityService] Failed to restore original volume (status: \(volumeStatus)).")
|
||||
}
|
||||
}
|
||||
|
||||
self.originalSystemMuteState = nil
|
||||
self.originalSystemVolume = nil
|
||||
FileHandle.standardError.write("[AccessibilityService] System audio restoration attempt complete. Stored states cleared.\\n".data(using: .utf8)!)
|
||||
logToStderr("[AccessibilityService] System audio restoration attempt complete. Stored states cleared.")
|
||||
return true
|
||||
}
|
||||
|
||||
// Pastes the given text into the active application
|
||||
public func pasteText(transcript: String) -> Bool {
|
||||
FileHandle.standardError.write("[AccessibilityService] Attempting to paste transcript: \(transcript).\n".data(using: .utf8)!)
|
||||
logToStderr("[AccessibilityService] Attempting to paste transcript: \(transcript).")
|
||||
|
||||
let pasteboard = NSPasteboard.general
|
||||
let originalPasteboardItems = pasteboard.pasteboardItems?.compactMap { item -> NSPasteboardItem? in
|
||||
|
|
@ -406,7 +418,7 @@ class AccessibilityService {
|
|||
let success = pasteboard.setString(transcript, forType: .string)
|
||||
|
||||
if !success {
|
||||
FileHandle.standardError.write("[AccessibilityService] Failed to set string on pasteboard.\n".data(using: .utf8)!)
|
||||
logToStderr("[AccessibilityService] Failed to set string on pasteboard.")
|
||||
// Restore original content before returning
|
||||
restorePasteboard(pasteboard: pasteboard, items: originalPasteboardItems, originalChangeCount: originalChangeCount)
|
||||
return false
|
||||
|
|
@ -432,7 +444,7 @@ class AccessibilityService {
|
|||
// No flags needed for key up typically, or just .maskCommand if it was held
|
||||
|
||||
if cmdDown == nil || vDown == nil || vUp == nil || cmdUp == nil {
|
||||
FileHandle.standardError.write("[AccessibilityService] Failed to create CGEvent for paste.\n".data(using: .utf8)!)
|
||||
logToStderr("[AccessibilityService] Failed to create CGEvent for paste.")
|
||||
restorePasteboard(pasteboard: pasteboard, items: originalPasteboardItems, originalChangeCount: originalChangeCount)
|
||||
return false
|
||||
}
|
||||
|
|
@ -444,7 +456,7 @@ class AccessibilityService {
|
|||
vUp!.post(tap: loc)
|
||||
cmdUp!.post(tap: loc)
|
||||
|
||||
FileHandle.standardError.write("[AccessibilityService] Paste keyboard events posted.\\n".data(using: .utf8)!)
|
||||
logToStderr("[AccessibilityService] Paste keyboard events posted.")
|
||||
|
||||
// Restore the original pasteboard content after a short delay
|
||||
// to allow the paste action to complete.
|
||||
|
|
@ -464,12 +476,12 @@ class AccessibilityService {
|
|||
if !items.isEmpty {
|
||||
pasteboard.writeObjects(items)
|
||||
}
|
||||
FileHandle.standardError.write("[AccessibilityService] Original pasteboard content restored.\\n".data(using: .utf8)!)
|
||||
logToStderr("[AccessibilityService] Original pasteboard content restored.")
|
||||
} else {
|
||||
// If changeCount is different, it means another app or the user has modified the pasteboard
|
||||
// after we set our transcript but before this restoration block was executed.
|
||||
// In this case, we should not interfere with the new pasteboard content.
|
||||
FileHandle.standardError.write("[AccessibilityService] Pasteboard changed by another process or a new copy occurred (expected changeCount: \(originalChangeCount + 1), current: \(pasteboard.changeCount)); not restoring original content to avoid conflict.\\n".data(using: .utf8)!)
|
||||
logToStderr("[AccessibilityService] Pasteboard changed by another process or a new copy occurred (expected changeCount: \(originalChangeCount + 1), current: \(pasteboard.changeCount)); not restoring original content to avoid conflict.")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -484,29 +496,29 @@ class AccessibilityService {
|
|||
let keyCode = CGKeyCode(event.getIntegerValueField(.keyboardEventKeycode))
|
||||
|
||||
// Uncomment for verbose logging from Swift helper:
|
||||
// FileHandle.standardError.write("[AccessibilityService] shouldForwardKeyboardEvent: type=\(type.rawValue), keyCode=\(keyCode), flags=\(event.flags.rawValue)\n".data(using: .utf8)!)
|
||||
// logToStderr("[AccessibilityService] shouldForwardKeyboardEvent: type=\(type.rawValue), keyCode=\(keyCode), flags=\(event.flags.rawValue)")
|
||||
|
||||
if type == .flagsChanged {
|
||||
// Always forward flagsChanged events. These are crucial for Electron to know
|
||||
// the state of modifier keys, including when the Fn key itself is pressed or released,
|
||||
// which is used to control recording.
|
||||
// FileHandle.standardError.write("[AccessibilityService] Forwarding flagsChanged event.\n".data(using: .utf8)!)
|
||||
// logToStderr("[AccessibilityService] Forwarding flagsChanged event.")
|
||||
return true
|
||||
}
|
||||
|
||||
if type == .keyDown || type == .keyUp {
|
||||
// For keyDown and keyUp events, only forward if the event is FOR THE Fn KEY ITSELF.
|
||||
if keyCode == kVK_Function {
|
||||
// FileHandle.standardError.write("[AccessibilityService] Forwarding \(type == .keyDown ? "keyDown" : "keyUp") event because it IS the Fn key (keyCode: \(keyCode)).\n".data(using: .utf8)!)
|
||||
// logToStderr("[AccessibilityService] Forwarding \(type == .keyDown ? "keyDown" : "keyUp") event because it IS the Fn key (keyCode: \(keyCode)).")
|
||||
return true
|
||||
} else {
|
||||
// FileHandle.standardError.write("[AccessibilityService] Suppressing \(type == .keyDown ? "keyDown" : "keyUp") event for keyCode \(keyCode) because it is NOT the Fn key.\n".data(using: .utf8)!)
|
||||
// logToStderr("[AccessibilityService] Suppressing \(type == .keyDown ? "keyDown" : "keyUp") event for keyCode \(keyCode) because it is NOT the Fn key.")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// For any other event types (e.g., mouse events, system-defined), don't forward by default.
|
||||
// FileHandle.standardError.write("[AccessibilityService] Suppressing event of unhandled type: \(type.rawValue).\n".data(using: .utf8)!)
|
||||
// logToStderr("[AccessibilityService] Suppressing event of unhandled type: \(type.rawValue).")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,18 +7,28 @@ class IOBridge: NSObject, AVAudioPlayerDelegate {
|
|||
private let accessibilityService: AccessibilityService
|
||||
private var audioPlayer: AVAudioPlayer?
|
||||
private var audioCompletionHandler: (() -> Void)?
|
||||
private let dateFormatter: DateFormatter
|
||||
|
||||
init(jsonEncoder: JSONEncoder, jsonDecoder: JSONDecoder) {
|
||||
self.jsonEncoder = jsonEncoder
|
||||
self.jsonDecoder = jsonDecoder
|
||||
self.accessibilityService = AccessibilityService()
|
||||
self.dateFormatter = DateFormatter()
|
||||
self.dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss.SSS"
|
||||
super.init()
|
||||
}
|
||||
|
||||
private func logToStderr(_ message: String) {
|
||||
let timestamp = dateFormatter.string(from: Date())
|
||||
let logMessage = "[\(timestamp)] \(message)\n"
|
||||
FileHandle.standardError.write(logMessage.data(using: .utf8)!)
|
||||
}
|
||||
|
||||
private func playSound(named soundName: String, completion: (() -> Void)? = nil) {
|
||||
FileHandle.standardError.write("[IOBridge] playSound called with soundName: \(soundName)\n".data(using: .utf8)!)
|
||||
logToStderr("[IOBridge] playSound called with soundName: \(soundName)")
|
||||
|
||||
if audioPlayer?.isPlaying == true {
|
||||
FileHandle.standardError.write("[IOBridge] Sound '\(audioPlayer?.url?.lastPathComponent ?? "previous")' is playing. Stopping it before playing \(soundName).\n".data(using: .utf8)!)
|
||||
logToStderr("[IOBridge] Sound '\(audioPlayer?.url?.lastPathComponent ?? "previous")' is playing. Stopping it before playing \(soundName).")
|
||||
audioPlayer?.delegate = nil
|
||||
audioPlayer?.stop()
|
||||
}
|
||||
|
|
@ -32,20 +42,20 @@ class IOBridge: NSObject, AVAudioPlayerDelegate {
|
|||
do {
|
||||
switch soundName {
|
||||
case "rec-start":
|
||||
FileHandle.standardError.write("[IOBridge] Attempting to load rec-start.mp3 from PackageResources\n".data(using: .utf8)!)
|
||||
logToStderr("[IOBridge] Attempting to load rec-start.mp3 from PackageResources")
|
||||
audioData = PackageResources.rec_start_mp3
|
||||
FileHandle.standardError.write("[IOBridge] Successfully loaded rec-start.mp3, data size: \(audioData.count) bytes\n".data(using: .utf8)!)
|
||||
logToStderr("[IOBridge] Successfully loaded rec-start.mp3, data size: \(audioData.count) bytes")
|
||||
case "rec-stop":
|
||||
FileHandle.standardError.write("[IOBridge] Attempting to load rec-stop.mp3 from PackageResources\n".data(using: .utf8)!)
|
||||
logToStderr("[IOBridge] Attempting to load rec-stop.mp3 from PackageResources")
|
||||
audioData = PackageResources.rec_stop_mp3
|
||||
FileHandle.standardError.write("[IOBridge] Successfully loaded rec-stop.mp3, data size: \(audioData.count) bytes\n".data(using: .utf8)!)
|
||||
logToStderr("[IOBridge] Successfully loaded rec-stop.mp3, data size: \(audioData.count) bytes")
|
||||
default:
|
||||
FileHandle.standardError.write("[IOBridge] Error: Unknown sound name '\(soundName)'. Completion will not be called.\n".data(using: .utf8)!)
|
||||
logToStderr("[IOBridge] Error: Unknown sound name '\(soundName)'. Completion will not be called.")
|
||||
self.audioCompletionHandler = nil
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
FileHandle.standardError.write("[IOBridge] Error loading embedded audio data for '\(soundName)': \(error.localizedDescription). Completion will not be called.\n".data(using: .utf8)!)
|
||||
logToStderr("[IOBridge] Error loading embedded audio data for '\(soundName)': \(error.localizedDescription). Completion will not be called.")
|
||||
self.audioCompletionHandler = nil
|
||||
return
|
||||
}
|
||||
|
|
@ -59,13 +69,13 @@ class IOBridge: NSObject, AVAudioPlayerDelegate {
|
|||
audioPlayer?.delegate = self
|
||||
|
||||
if audioPlayer?.play() == true {
|
||||
FileHandle.standardError.write("[IOBridge] Playing embedded sound: \(soundName).mp3. Delegate will handle completion.\n".data(using: .utf8)!)
|
||||
logToStderr("[IOBridge] Playing embedded sound: \(soundName).mp3. Delegate will handle completion.")
|
||||
} else {
|
||||
FileHandle.standardError.write("[IOBridge] Failed to start playing embedded sound: \(soundName).mp3 (audioPlayer.play() returned false or player is nil). Completion will not be called.\n".data(using: .utf8)!)
|
||||
logToStderr("[IOBridge] Failed to start playing embedded sound: \(soundName).mp3 (audioPlayer.play() returned false or player is nil). Completion will not be called.")
|
||||
self.audioCompletionHandler = nil
|
||||
}
|
||||
} catch {
|
||||
FileHandle.standardError.write("[IOBridge] Error initializing AVAudioPlayer for embedded \(soundName).mp3: \(error.localizedDescription). Completion will not be called.\n".data(using: .utf8)!)
|
||||
logToStderr("[IOBridge] Error initializing AVAudioPlayer for embedded \(soundName).mp3: \(error.localizedDescription). Completion will not be called.")
|
||||
self.audioCompletionHandler = nil
|
||||
}
|
||||
}
|
||||
|
|
@ -77,14 +87,14 @@ class IOBridge: NSObject, AVAudioPlayerDelegate {
|
|||
switch request.method {
|
||||
case .getAccessibilityTreeDetails:
|
||||
var accessibilityParams: GetAccessibilityTreeDetailsParamsSchema? = nil
|
||||
FileHandle.standardError.write("[IOBridge] Handling getAccessibilityTreeDetails for ID: \(request.id)\n".data(using: .utf8)!)
|
||||
logToStderr("[IOBridge] Handling getAccessibilityTreeDetails for ID: \(request.id)")
|
||||
if let paramsAnyCodable = request.params {
|
||||
do {
|
||||
let paramsData = try jsonEncoder.encode(paramsAnyCodable)
|
||||
accessibilityParams = try jsonDecoder.decode(GetAccessibilityTreeDetailsParamsSchema.self, from: paramsData)
|
||||
FileHandle.standardError.write("[IOBridge] Decoded accessibilityParams.rootID: \(accessibilityParams?.rootID ?? "nil") for ID: \(request.id)\n".data(using: .utf8)!)
|
||||
logToStderr("[IOBridge] Decoded accessibilityParams.rootID: \(accessibilityParams?.rootID ?? "nil") for ID: \(request.id)")
|
||||
} catch {
|
||||
FileHandle.standardError.write("[IOBridge] Error decoding getAccessibilityTreeDetails params: \(error.localizedDescription)\n".data(using: .utf8)!)
|
||||
logToStderr("[IOBridge] Error decoding getAccessibilityTreeDetails params: \(error.localizedDescription)")
|
||||
let errPayload = Error(code: -32602, data: request.params, message: "Invalid params: \(error.localizedDescription)")
|
||||
rpcResponse = RPCResponseSchema(error: errPayload, id: request.id, result: nil)
|
||||
sendRpcResponse(rpcResponse)
|
||||
|
|
@ -95,7 +105,7 @@ class IOBridge: NSObject, AVAudioPlayerDelegate {
|
|||
// Fetch REAL accessibility tree data using the service
|
||||
let actualTreeData: AccessibilityElementNode? = accessibilityService.fetchFullAccessibilityTree(rootId: accessibilityParams?.rootID)
|
||||
|
||||
FileHandle.standardError.write("[IOBridge] Fetched actualTreeData from AccessibilityService. Is nil? \(actualTreeData == nil). For ID: \(request.id)\n".data(using: .utf8)!)
|
||||
logToStderr("[IOBridge] Fetched actualTreeData from AccessibilityService. Is nil? \(actualTreeData == nil). For ID: \(request.id)")
|
||||
|
||||
var treeAsJsonAny: JSONAny? = nil
|
||||
if let dataToEncode = actualTreeData { // dataToEncode is AccessibilityElementNode?
|
||||
|
|
@ -103,10 +113,10 @@ class IOBridge: NSObject, AVAudioPlayerDelegate {
|
|||
let encodedData = try jsonEncoder.encode(dataToEncode) // Encodes AccessibilityElementNode
|
||||
treeAsJsonAny = try jsonDecoder.decode(JSONAny.self, from: encodedData)
|
||||
if let treeDataForLog = try? jsonEncoder.encode(treeAsJsonAny), let treeStringForLog = String(data: treeDataForLog, encoding: .utf8) {
|
||||
FileHandle.standardError.write("[IOBridge] treeAsJsonAny (after encoding actualTreeData): \(treeStringForLog) for ID: \(request.id)\n".data(using: .utf8)!)
|
||||
logToStderr("[IOBridge] treeAsJsonAny (after encoding actualTreeData): \(treeStringForLog) for ID: \(request.id)")
|
||||
}
|
||||
} catch {
|
||||
FileHandle.standardError.write("[IOBridge] Error encoding actualTreeData to JSONAny: \(error.localizedDescription) for ID: \(request.id)\n".data(using: .utf8)!)
|
||||
logToStderr("[IOBridge] Error encoding actualTreeData to JSONAny: \(error.localizedDescription) for ID: \(request.id)")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -114,10 +124,10 @@ class IOBridge: NSObject, AVAudioPlayerDelegate {
|
|||
do {
|
||||
let resultPayloadForLogData = try jsonEncoder.encode(resultPayload)
|
||||
if let resultPayloadStringForLog = String(data: resultPayloadForLogData, encoding: .utf8) {
|
||||
FileHandle.standardError.write("[IOBridge] GetAccessibilityTreeDetailsResultSchema (resultPayload) before final encoding: \(resultPayloadStringForLog) for ID: \(request.id)\n".data(using: .utf8)!)
|
||||
logToStderr("[IOBridge] GetAccessibilityTreeDetailsResultSchema (resultPayload) before final encoding: \(resultPayloadStringForLog) for ID: \(request.id)")
|
||||
}
|
||||
} catch {
|
||||
FileHandle.standardError.write("[IOBridge] Error encoding resultPayload for logging: \(error.localizedDescription) for ID: \(request.id)\n".data(using: .utf8)!)
|
||||
logToStderr("[IOBridge] Error encoding resultPayload for logging: \(error.localizedDescription) for ID: \(request.id)")
|
||||
}
|
||||
|
||||
var resultAsJsonAny: JSONAny? = nil
|
||||
|
|
@ -125,12 +135,58 @@ class IOBridge: NSObject, AVAudioPlayerDelegate {
|
|||
let resultPayloadData = try jsonEncoder.encode(resultPayload)
|
||||
resultAsJsonAny = try jsonDecoder.decode(JSONAny.self, from: resultPayloadData)
|
||||
} catch {
|
||||
FileHandle.standardError.write("Error encoding GetAccessibilityTreeDetailsResultSchema to JSONAny: \(error.localizedDescription)\n".data(using: .utf8)!)
|
||||
logToStderr("Error encoding GetAccessibilityTreeDetailsResultSchema to JSONAny: \(error.localizedDescription)")
|
||||
}
|
||||
rpcResponse = RPCResponseSchema(error: nil, id: request.id, result: resultAsJsonAny)
|
||||
|
||||
case .getAccessibilityContext:
|
||||
var contextParams: GetAccessibilityContextParamsSchema? = nil
|
||||
logToStderr("[IOBridge] Handling getAccessibilityContext for ID: \(request.id)")
|
||||
if let paramsAnyCodable = request.params {
|
||||
do {
|
||||
let paramsData = try jsonEncoder.encode(paramsAnyCodable)
|
||||
contextParams = try jsonDecoder.decode(GetAccessibilityContextParamsSchema.self, from: paramsData)
|
||||
logToStderr("[IOBridge] Decoded contextParams.editableOnly: \(contextParams?.editableOnly ?? false) for ID: \(request.id)")
|
||||
} catch {
|
||||
logToStderr("[IOBridge] Error decoding getAccessibilityContext params: \(error.localizedDescription)")
|
||||
let errPayload = Error(code: -32602, data: request.params, message: "Invalid params: \(error.localizedDescription)")
|
||||
rpcResponse = RPCResponseSchema(error: errPayload, id: request.id, result: nil)
|
||||
sendRpcResponse(rpcResponse)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Get accessibility context using the new service
|
||||
let editableOnly = contextParams?.editableOnly ?? false
|
||||
let contextData = AccessibilityContextService.getAccessibilityContext(editableOnly: editableOnly)
|
||||
|
||||
logToStderr("[IOBridge] Fetched contextData from AccessibilityContextService. Is nil? \(contextData == nil). For ID: \(request.id)")
|
||||
|
||||
var contextAsJsonAny: JSONAny? = nil
|
||||
if let dataToEncode = contextData {
|
||||
do {
|
||||
let encodedData = try jsonEncoder.encode(dataToEncode)
|
||||
contextAsJsonAny = try jsonDecoder.decode(JSONAny.self, from: encodedData)
|
||||
if let contextDataForLog = try? jsonEncoder.encode(contextAsJsonAny), let contextStringForLog = String(data: contextDataForLog, encoding: .utf8) {
|
||||
logToStderr("[IOBridge] contextAsJsonAny (after encoding contextData): \(contextStringForLog) for ID: \(request.id)")
|
||||
}
|
||||
} catch {
|
||||
logToStderr("[IOBridge] Error encoding contextData to JSONAny: \(error.localizedDescription) for ID: \(request.id)")
|
||||
}
|
||||
}
|
||||
|
||||
let resultPayload = GetAccessibilityContextResultSchema(context: contextData)
|
||||
var resultAsJsonAny: JSONAny? = nil
|
||||
do {
|
||||
let resultPayloadData = try jsonEncoder.encode(resultPayload)
|
||||
resultAsJsonAny = try jsonDecoder.decode(JSONAny.self, from: resultPayloadData)
|
||||
} catch {
|
||||
logToStderr("Error encoding GetAccessibilityContextResultSchema to JSONAny: \(error.localizedDescription)")
|
||||
}
|
||||
rpcResponse = RPCResponseSchema(error: nil, id: request.id, result: resultAsJsonAny)
|
||||
|
||||
case .pasteText: // Corrected to use enum case
|
||||
FileHandle.standardError.write("[IOBridge] Handling pasteText for ID: \(request.id)\n".data(using: .utf8)!)
|
||||
logToStderr("[IOBridge] Handling pasteText for ID: \(request.id)")
|
||||
guard let paramsAnyCodable = request.params else {
|
||||
let errPayload = Error(code: -32602, data: nil, message: "Missing params for pasteText")
|
||||
rpcResponse = RPCResponseSchema(error: errPayload, id: request.id, result: nil)
|
||||
|
|
@ -142,7 +198,7 @@ class IOBridge: NSObject, AVAudioPlayerDelegate {
|
|||
let paramsData = try jsonEncoder.encode(paramsAnyCodable)
|
||||
// Corrected to use generated Swift model name from models.swift
|
||||
let pasteParams = try jsonDecoder.decode(PasteTextParamsSchema.self, from: paramsData)
|
||||
FileHandle.standardError.write("[IOBridge] Decoded pasteParams.transcript for ID: \(request.id)\n".data(using: .utf8)!)
|
||||
logToStderr("[IOBridge] Decoded pasteParams.transcript for ID: \(request.id)")
|
||||
|
||||
// Call the actual paste function (to be implemented in AccessibilityService or similar)
|
||||
let success = accessibilityService.pasteText(transcript: pasteParams.transcript)
|
||||
|
|
@ -154,21 +210,23 @@ class IOBridge: NSObject, AVAudioPlayerDelegate {
|
|||
rpcResponse = RPCResponseSchema(error: nil, id: request.id, result: resultAsJsonAny)
|
||||
|
||||
} catch {
|
||||
FileHandle.standardError.write("[IOBridge] Error processing pasteText params or operation: \(error.localizedDescription) for ID: \(request.id)\n".data(using: .utf8)!)
|
||||
logToStderr("[IOBridge] Error processing pasteText params or operation: \(error.localizedDescription) for ID: \(request.id)")
|
||||
let errPayload = Error(code: -32602, data: request.params, message: "Invalid params or error during paste: \(error.localizedDescription)")
|
||||
rpcResponse = RPCResponseSchema(error: errPayload, id: request.id, result: nil)
|
||||
}
|
||||
|
||||
case .muteSystemAudio:
|
||||
FileHandle.standardError.write("[IOBridge] Handling muteSystemAudio for ID: \(request.id)\n".data(using: .utf8)!)
|
||||
logToStderr("[IOBridge] Handling muteSystemAudio for ID: \(request.id)")
|
||||
|
||||
playSound(named: "rec-start") { [weak self] in
|
||||
guard let self = self else {
|
||||
FileHandle.standardError.write("[IOBridge] self is nil in playSound completion for muteSystemAudio. ID: \(request.id)\n".data(using: .utf8)!)
|
||||
let timestamp = DateFormatter().string(from: Date())
|
||||
let logMessage = "[\(timestamp)] [IOBridge] self is nil in playSound completion for muteSystemAudio. ID: \(request.id)\n"
|
||||
FileHandle.standardError.write(logMessage.data(using: .utf8)!)
|
||||
return
|
||||
}
|
||||
|
||||
FileHandle.standardError.write("[IOBridge] rec-start.mp3 finished playing successfully. Proceeding to mute system audio. ID: \(request.id)\n".data(using: .utf8)!)
|
||||
self.logToStderr("[IOBridge] rec-start.mp3 finished playing successfully. Proceeding to mute system audio. ID: \(request.id)")
|
||||
let success = self.accessibilityService.muteSystemAudio()
|
||||
let resultPayload = MuteSystemAudioResultSchema(message: success ? "Mute command sent" : "Failed to send mute command", success: success)
|
||||
|
||||
|
|
@ -178,7 +236,7 @@ class IOBridge: NSObject, AVAudioPlayerDelegate {
|
|||
let resultAsJsonAny = try self.jsonDecoder.decode(JSONAny.self, from: resultData)
|
||||
responseToSend = RPCResponseSchema(error: nil, id: request.id, result: resultAsJsonAny)
|
||||
} catch {
|
||||
FileHandle.standardError.write("[IOBridge] Error encoding muteSystemAudio result: \(error.localizedDescription) for ID: \(request.id)\n".data(using: .utf8)!)
|
||||
self.logToStderr("[IOBridge] Error encoding muteSystemAudio result: \(error.localizedDescription) for ID: \(request.id)")
|
||||
let errPayload = Error(code: -32603, data: nil, message: "Error encoding result: \(error.localizedDescription)")
|
||||
responseToSend = RPCResponseSchema(error: errPayload, id: request.id, result: nil)
|
||||
}
|
||||
|
|
@ -187,7 +245,7 @@ class IOBridge: NSObject, AVAudioPlayerDelegate {
|
|||
return
|
||||
|
||||
case .restoreSystemAudio:
|
||||
FileHandle.standardError.write("[IOBridge] Handling restoreSystemAudio for ID: \(request.id)\n".data(using: .utf8)!)
|
||||
logToStderr("[IOBridge] Handling restoreSystemAudio for ID: \(request.id)")
|
||||
|
||||
let success = accessibilityService.restoreSystemAudio()
|
||||
if success { // Play sound only if restore was successful
|
||||
|
|
@ -200,13 +258,13 @@ class IOBridge: NSObject, AVAudioPlayerDelegate {
|
|||
let resultAsJsonAny = try jsonDecoder.decode(JSONAny.self, from: resultData)
|
||||
rpcResponse = RPCResponseSchema(error: nil, id: request.id, result: resultAsJsonAny)
|
||||
} catch {
|
||||
FileHandle.standardError.write("[IOBridge] Error encoding pauseSystemAudio result: \(error.localizedDescription) for ID: \(request.id)\n".data(using: .utf8)!)
|
||||
logToStderr("[IOBridge] Error encoding pauseSystemAudio result: \(error.localizedDescription) for ID: \(request.id)")
|
||||
let errPayload = Error(code: -32603, data: nil, message: "Error encoding result: \(error.localizedDescription)")
|
||||
rpcResponse = RPCResponseSchema(error: nil, id: request.id, result: nil)
|
||||
}
|
||||
|
||||
default:
|
||||
FileHandle.standardError.write("[IOBridge] Method not found: \(request.method) for ID: \(request.id)\n".data(using: .utf8)!)
|
||||
logToStderr("[IOBridge] Method not found: \(request.method) for ID: \(request.id)")
|
||||
let errPayload = Error(code: -32601, data: nil, message: "Method not found: \(request.method)")
|
||||
rpcResponse = RPCResponseSchema(error: errPayload, id: request.id, result: nil)
|
||||
}
|
||||
|
|
@ -217,48 +275,48 @@ class IOBridge: NSObject, AVAudioPlayerDelegate {
|
|||
do {
|
||||
let responseData = try jsonEncoder.encode(response)
|
||||
if let responseString = String(data: responseData, encoding: .utf8) {
|
||||
FileHandle.standardError.write("[Swift Biz Logic] FINAL JSON RESPONSE to stdout: \(responseString)\n".data(using: .utf8)!)
|
||||
logToStderr("[Swift Biz Logic] FINAL JSON RESPONSE to stdout: \(responseString)")
|
||||
print(responseString)
|
||||
fflush(stdout)
|
||||
}
|
||||
} catch {
|
||||
FileHandle.standardError.write("Error encoding RpcResponse: \(error.localizedDescription)\n".data(using: .utf8)!)
|
||||
logToStderr("Error encoding RpcResponse: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
// Main loop for processing RPC requests from stdin
|
||||
func processRpcRequests() {
|
||||
FileHandle.standardError.write("IOBridge: Starting RPC request processing loop.\n".data(using: .utf8)!)
|
||||
logToStderr("IOBridge: Starting RPC request processing loop.")
|
||||
while let line = readLine(strippingNewline: true) {
|
||||
guard !line.isEmpty, let data = line.data(using: .utf8) else {
|
||||
FileHandle.standardError.write("Warning: Received empty or non-UTF8 line on stdin.\n".data(using: .utf8)!)
|
||||
logToStderr("Warning: Received empty or non-UTF8 line on stdin.")
|
||||
continue
|
||||
}
|
||||
|
||||
do {
|
||||
let rpcRequest = try jsonDecoder.decode(RPCRequestSchema.self, from: data)
|
||||
FileHandle.standardError.write("IOBridge: Received RPC Request ID \(rpcRequest.id), Method: \(rpcRequest.method)\n".data(using: .utf8)!)
|
||||
logToStderr("IOBridge: Received RPC Request ID \(rpcRequest.id), Method: \(rpcRequest.method)")
|
||||
handleRpcRequest(rpcRequest)
|
||||
} catch {
|
||||
FileHandle.standardError.write("Error decoding RpcRequest from stdin: \(error.localizedDescription). Line: \(line)\n".data(using: .utf8)!)
|
||||
logToStderr("Error decoding RpcRequest from stdin: \(error.localizedDescription). Line: \(line)")
|
||||
// Consider sending a parse error if ID can be extracted
|
||||
}
|
||||
}
|
||||
FileHandle.standardError.write("IOBridge: RPC request processing loop finished (stdin closed).\n".data(using: .utf8)!)
|
||||
logToStderr("IOBridge: RPC request processing loop finished (stdin closed).")
|
||||
}
|
||||
|
||||
// MARK: - AVAudioPlayerDelegate
|
||||
func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) {
|
||||
FileHandle.standardError.write("[IOBridge] Sound playback finished (player URL: \(player.url?.lastPathComponent ?? "unknown"), successfully: \(flag)).\n".data(using: .utf8)!)
|
||||
logToStderr("[IOBridge] Sound playback finished (player URL: \(player.url?.lastPathComponent ?? "unknown"), successfully: \(flag)).")
|
||||
|
||||
let handlerToCall = audioCompletionHandler
|
||||
audioCompletionHandler = nil
|
||||
|
||||
if flag {
|
||||
FileHandle.standardError.write("[IOBridge] Sound finished successfully. Executing completion handler.\n".data(using: .utf8)!)
|
||||
logToStderr("[IOBridge] Sound finished successfully. Executing completion handler.")
|
||||
handlerToCall?()
|
||||
} else {
|
||||
FileHandle.standardError.write("[IOBridge] Sound did not finish successfully (e.g., stopped or error). Not executing completion handler.\n".data(using: .utf8)!)
|
||||
logToStderr("[IOBridge] Sound did not finish successfully (e.g., stopped or error). Not executing completion handler.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
4
packages/types/eslint.config.mjs
Normal file
4
packages/types/eslint.config.mjs
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
import { config } from "@amical/eslint-config/base";
|
||||
|
||||
/** @type {import("eslint").Linter.Config} */
|
||||
export default config;
|
||||
|
|
@ -12,7 +12,7 @@
|
|||
"generate:json-schemas": "tsx scripts/generate-json-schemas.ts",
|
||||
"generate:swift": "tsx scripts/generate-swift-models.ts",
|
||||
"generate:all": "pnpm run generate:json-schemas && pnpm run generate:swift",
|
||||
"lint": "eslint --ext .ts .",
|
||||
"lint": "eslint .",
|
||||
"check-types": "tsc --noEmit"
|
||||
},
|
||||
"keywords": ["amical", "types", "schemas", "rpc"],
|
||||
|
|
|
|||
|
|
@ -9,6 +9,10 @@ import {
|
|||
GetAccessibilityTreeDetailsParamsSchema,
|
||||
GetAccessibilityTreeDetailsResultSchema
|
||||
} from '../src/schemas/methods/get-accessibility-tree-details.js';
|
||||
import {
|
||||
GetAccessibilityContextParamsSchema,
|
||||
GetAccessibilityContextResultSchema
|
||||
} from '../src/schemas/methods/get-accessibility-context.js';
|
||||
import {
|
||||
PasteTextParamsSchema,
|
||||
PasteTextResultSchema
|
||||
|
|
@ -44,6 +48,16 @@ const schemasToGenerate = [
|
|||
name: 'GetAccessibilityTreeDetailsResult',
|
||||
category: 'methods',
|
||||
},
|
||||
{
|
||||
zod: GetAccessibilityContextParamsSchema,
|
||||
name: 'GetAccessibilityContextParams',
|
||||
category: 'methods',
|
||||
},
|
||||
{
|
||||
zod: GetAccessibilityContextResultSchema,
|
||||
name: 'GetAccessibilityContextResult',
|
||||
category: 'methods',
|
||||
},
|
||||
{ zod: KeyDownEventSchema, name: 'KeyDownEvent', category: 'events' },
|
||||
{ zod: KeyUpEventSchema, name: 'KeyUpEvent', category: 'events' },
|
||||
{ zod: FlagsChangedEventSchema, name: 'FlagsChangedEvent', category: 'events' },
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ try {
|
|||
'generated/json-schemas/rpc/rpc-response.schema.json ' +
|
||||
'generated/json-schemas/methods/get-accessibility-tree-details-params.schema.json ' +
|
||||
'generated/json-schemas/methods/get-accessibility-tree-details-result.schema.json ' +
|
||||
'generated/json-schemas/methods/get-accessibility-context-params.schema.json ' +
|
||||
'generated/json-schemas/methods/get-accessibility-context-result.schema.json ' +
|
||||
'generated/json-schemas/methods/paste-text-params.schema.json ' +
|
||||
'generated/json-schemas/methods/paste-text-result.schema.json ' +
|
||||
'generated/json-schemas/methods/mute-system-audio-params.schema.json ' +
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ export * from './schemas/rpc/response.js';
|
|||
|
||||
// Method Schemas (params + results)
|
||||
export * from './schemas/methods/get-accessibility-tree-details.js';
|
||||
export * from './schemas/methods/get-accessibility-context.js';
|
||||
export * from './schemas/methods/paste-text.js';
|
||||
export * from './schemas/methods/mute-system-audio.js';
|
||||
export * from './schemas/methods/restore-system-audio.js';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,67 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
// Request params
|
||||
export const GetAccessibilityContextParamsSchema = z.object({
|
||||
editableOnly: z.boolean().optional().default(true), // Only return text selection if element is editable
|
||||
});
|
||||
export type GetAccessibilityContextParams = z.infer<
|
||||
typeof GetAccessibilityContextParamsSchema
|
||||
>;
|
||||
|
||||
// Data structures for the result
|
||||
const SelectionRangeSchema = z.object({
|
||||
location: z.number().int(),
|
||||
length: z.number().int(),
|
||||
});
|
||||
|
||||
const ApplicationInfoSchema = z.object({
|
||||
name: z.string().nullable(),
|
||||
bundleIdentifier: z.string().nullable(),
|
||||
version: z.string().nullable(),
|
||||
});
|
||||
|
||||
const FocusedElementInfoSchema = z.object({
|
||||
role: z.string().nullable(), // Main accessibility role (e.g., "AXTextField", "AXButton")
|
||||
isEditable: z.boolean(),
|
||||
title: z.string().nullable(),
|
||||
description: z.string().nullable(),
|
||||
value: z.string().nullable(),
|
||||
});
|
||||
|
||||
const TextSelectionInfoSchema = z.object({
|
||||
selectedText: z.string(),
|
||||
fullContent: z.string().nullable(),
|
||||
preSelectionText: z.string().nullable(),
|
||||
postSelectionText: z.string().nullable(),
|
||||
selectionRange: SelectionRangeSchema.nullable(),
|
||||
isEditable: z.boolean(),
|
||||
});
|
||||
|
||||
const WindowInfoSchema = z.object({
|
||||
title: z.string().nullable(),
|
||||
url: z.string().nullable(), // Browser URL if available
|
||||
});
|
||||
|
||||
const AccessibilityContextSchema = z.object({
|
||||
application: ApplicationInfoSchema,
|
||||
focusedElement: FocusedElementInfoSchema.nullable(),
|
||||
textSelection: TextSelectionInfoSchema.nullable(),
|
||||
windowInfo: WindowInfoSchema.nullable(),
|
||||
timestamp: z.number(),
|
||||
});
|
||||
|
||||
// Response result
|
||||
export const GetAccessibilityContextResultSchema = z.object({
|
||||
context: AccessibilityContextSchema.nullable(),
|
||||
});
|
||||
export type GetAccessibilityContextResult = z.infer<
|
||||
typeof GetAccessibilityContextResultSchema
|
||||
>;
|
||||
|
||||
// Export individual schemas for potential reuse
|
||||
export type ApplicationInfo = z.infer<typeof ApplicationInfoSchema>;
|
||||
export type FocusedElementInfo = z.infer<typeof FocusedElementInfoSchema>;
|
||||
export type TextSelectionInfo = z.infer<typeof TextSelectionInfoSchema>;
|
||||
export type WindowInfo = z.infer<typeof WindowInfoSchema>;
|
||||
export type AccessibilityContext = z.infer<typeof AccessibilityContextSchema>;
|
||||
export type SelectionRange = z.infer<typeof SelectionRangeSchema>;
|
||||
|
|
@ -1,10 +1,12 @@
|
|||
import { z } from 'zod';
|
||||
import { GetAccessibilityTreeDetailsParamsSchema } from '../methods/get-accessibility-tree-details.js';
|
||||
import { GetAccessibilityContextParamsSchema } from '../methods/get-accessibility-context.js';
|
||||
import { PasteTextParamsSchema } from '../methods/paste-text.js';
|
||||
|
||||
// Define a union of all possible RPC method names
|
||||
const RPCMethodNameSchema = z.union([
|
||||
z.literal('getAccessibilityTreeDetails'),
|
||||
z.literal('getAccessibilityContext'),
|
||||
z.literal('pasteText'),
|
||||
z.literal('muteSystemAudio'),
|
||||
z.literal('restoreSystemAudio'),
|
||||
|
|
@ -31,6 +33,14 @@ export type GetAccessibilityTreeDetailsRequest = z.infer<
|
|||
typeof GetAccessibilityTreeDetailsRequestSchema
|
||||
>;
|
||||
|
||||
export const GetAccessibilityContextRequestSchema = RpcRequestSchema.extend({
|
||||
method: z.literal('getAccessibilityContext'),
|
||||
params: GetAccessibilityContextParamsSchema.optional(),
|
||||
});
|
||||
export type GetAccessibilityContextRequest = z.infer<
|
||||
typeof GetAccessibilityContextRequestSchema
|
||||
>;
|
||||
|
||||
export const PasteTextRequestSchema = RpcRequestSchema.extend({
|
||||
method: z.literal('pasteText'),
|
||||
params: PasteTextParamsSchema, // Assuming pasteText always requires params
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { config } from "@repo/eslint-config/react-internal";
|
||||
import { config } from "@amical/eslint-config/react-internal";
|
||||
|
||||
/** @type {import("eslint").Linter.Config} */
|
||||
export default config;
|
||||
|
|
|
|||
1108
pnpm-lock.yaml
generated
1108
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue