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:
Haritabh 2025-06-25 17:20:03 +05:30 committed by GitHub
parent 17fdb72be2
commit d7481f7398
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
79 changed files with 8306 additions and 940 deletions

4
.gitignore vendored
View file

@ -36,6 +36,8 @@ yarn-error.log*
# Misc
.DS_Store
*.pem
CLAUDE.md
.serena
# Temp files
/tmp
/tmp

View file

@ -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": {}
}
}
}

View file

@ -94,3 +94,6 @@ out/
# Swift Build
.build/
bin/
# VSCode
.vscode/

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 108 KiB

View file

@ -5,6 +5,6 @@ export default {
out: './src/db/migrations',
dialect: 'sqlite',
dbCredentials: {
url: 'file:./db.sqlite',
url: 'file:./amical.db',
},
} satisfies Config;

View file

@ -0,0 +1,4 @@
import { config } from "@amical/eslint-config/base";
/** @type {import("eslint").Linter.Config} */
export default config;

View file

@ -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.

View file

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

View 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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 119 KiB

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

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

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

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

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

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

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

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

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

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

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

View file

@ -0,0 +1,6 @@
import React from 'react';
import { TranscriptionsList } from './transcriptions-list';
export function TranscriptionsView() {
return <TranscriptionsList />;
}

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

View file

@ -0,0 +1,6 @@
import React from 'react';
import { VocabularyManager } from './vocabulary-manager';
export function VocabularyView() {
return <VocabularyManager />;
}

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

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

View file

@ -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);
}
}

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

View file

@ -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;
}
}
*/

View file

@ -1,3 +0,0 @@
CREATE TABLE `recordings` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL
);

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

View file

@ -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": {},

View file

@ -5,8 +5,8 @@
{
"idx": 0,
"version": "6",
"when": 1747051560665,
"tag": "0000_square_avengers",
"when": 1750751926568,
"tag": "0000_worried_black_bird",
"breakpoints": true
}
]

View file

@ -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;

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

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

View file

@ -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.

View file

@ -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);
}
}
}

View file

@ -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);
}

View file

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

View file

@ -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;
}

View file

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

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

View file

@ -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
}
}
}

View file

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

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

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

View file

@ -0,0 +1,3 @@
export { FormatterService } from './formatter-service';
export { FormatterClient, FormatterConfig } from './formatter-client';
export { OpenRouterFormatterClient } from './openrouter-formatter-client';

View file

@ -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;
}
}
}

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

View file

@ -0,0 +1 @@
export { SettingsService } from './settings-service';

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

View file

@ -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;
}
}
}

View file

@ -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;
}
}
}

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

View file

@ -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>
);
};

View file

@ -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 {

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

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

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

View file

@ -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;
};
};
}

View file

@ -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"]
}

View file

@ -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'],
},
});

View file

@ -7,6 +7,7 @@ export default defineConfig(async () => {
return {
plugins: [tailwindcss()],
publicDir: 'public',
resolve: {
alias: {
'@': resolve(__dirname, 'src'),

View file

@ -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"
]
}
}

View file

@ -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",
],
},
];

View file

@ -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: {

View file

@ -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
}
}

View file

@ -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
}
}

View file

@ -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.")
}
}
}

View file

@ -0,0 +1,4 @@
import { config } from "@amical/eslint-config/base";
/** @type {import("eslint").Linter.Config} */
export default config;

View file

@ -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"],

View file

@ -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' },

View file

@ -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 ' +

View file

@ -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';

View file

@ -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>;

View file

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

View file

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

File diff suppressed because it is too large Load diff