diff --git a/.gitignore b/.gitignore index dc13fa3..becf95e 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,8 @@ yarn-error.log* # Misc .DS_Store *.pem +CLAUDE.md +.serena # Temp files -/tmp \ No newline at end of file +/tmp diff --git a/apps/electron/.eslintrc.json b/apps/electron/.eslintrc.json deleted file mode 100644 index 79c9cf7..0000000 --- a/apps/electron/.eslintrc.json +++ /dev/null @@ -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": {} - } - } -} diff --git a/apps/electron/.gitignore b/apps/electron/.gitignore index 544ff1d..b11e778 100644 --- a/apps/electron/.gitignore +++ b/apps/electron/.gitignore @@ -94,3 +94,6 @@ out/ # Swift Build .build/ bin/ + +# VSCode +.vscode/ diff --git a/apps/electron/assets/logo.svg b/apps/electron/assets/logo.svg new file mode 100644 index 0000000..7f2127d --- /dev/null +++ b/apps/electron/assets/logo.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/apps/electron/drizzle.config.ts b/apps/electron/drizzle.config.ts index 9965328..e424d94 100644 --- a/apps/electron/drizzle.config.ts +++ b/apps/electron/drizzle.config.ts @@ -5,6 +5,6 @@ export default { out: './src/db/migrations', dialect: 'sqlite', dbCredentials: { - url: 'file:./db.sqlite', + url: 'file:./amical.db', }, } satisfies Config; diff --git a/apps/electron/eslint.config.mjs b/apps/electron/eslint.config.mjs new file mode 100644 index 0000000..924b7ce --- /dev/null +++ b/apps/electron/eslint.config.mjs @@ -0,0 +1,4 @@ +import { config } from "@amical/eslint-config/base"; + +/** @type {import("eslint").Linter.Config} */ +export default config; diff --git a/apps/electron/forge.config.ts b/apps/electron/forge.config.ts index 824df21..9475d60 100644 --- a/apps/electron/forge.config.ts +++ b/apps/electron/forge.config.ts @@ -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. diff --git a/apps/electron/package.json b/apps/electron/package.json index b1f885a..fb4d9c5 100644 --- a/apps/electron/package.json +++ b/apps/electron/package.json @@ -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" } } diff --git a/apps/electron/public/assets/discord-icon.svg b/apps/electron/public/assets/discord-icon.svg new file mode 100644 index 0000000..6797022 --- /dev/null +++ b/apps/electron/public/assets/discord-icon.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/apps/electron/public/assets/logo.svg b/apps/electron/public/assets/logo.svg new file mode 100644 index 0000000..d37c385 --- /dev/null +++ b/apps/electron/public/assets/logo.svg @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/electron/public/audio-recorder-worklet.js b/apps/electron/public/audio-recorder-worklet.js new file mode 100644 index 0000000..b36a9a3 --- /dev/null +++ b/apps/electron/public/audio-recorder-worklet.js @@ -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); diff --git a/apps/electron/src/components/app-sidebar.tsx b/apps/electron/src/components/app-sidebar.tsx new file mode 100644 index 0000000..658b29f --- /dev/null +++ b/apps/electron/src/components/app-sidebar.tsx @@ -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 }) => ( + Discord +) + +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 { + onNavigate?: (item: { title: string }) => void; + currentView?: string; +} + +export function AppSidebar({ onNavigate, currentView, ...props }: AppSidebarProps) { + return ( + +
+ + + + + + Amical Logo + Amical + + + + + + + + + + + {/* */} + +
+ ) +} diff --git a/apps/electron/src/components/data-table.tsx b/apps/electron/src/components/data-table.tsx new file mode 100644 index 0000000..0af1fc3 --- /dev/null +++ b/apps/electron/src/components/data-table.tsx @@ -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 ( + + ) +} + +const columns: ColumnDef>[] = [ + { + id: "drag", + header: () => null, + cell: ({ row }) => , + }, + { + id: "select", + header: ({ table }) => ( +
+ table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + /> +
+ ), + cell: ({ row }) => ( +
+ row.toggleSelected(!!value)} + aria-label="Select row" + /> +
+ ), + enableSorting: false, + enableHiding: false, + }, + { + accessorKey: "header", + header: "Header", + cell: ({ row }) => { + return + }, + enableHiding: false, + }, + { + accessorKey: "type", + header: "Section Type", + cell: ({ row }) => ( +
+ + {row.original.type} + +
+ ), + }, + { + accessorKey: "status", + header: "Status", + cell: ({ row }) => ( + + {row.original.status === "Done" ? ( + + ) : ( + + )} + {row.original.status} + + ), + }, + { + accessorKey: "target", + header: () =>
Target
, + cell: ({ row }) => ( +
{ + e.preventDefault() + toast.promise(new Promise((resolve) => setTimeout(resolve, 1000)), { + loading: `Saving ${row.original.header}`, + success: "Done", + error: "Error", + }) + }} + > + + +
+ ), + }, + { + accessorKey: "limit", + header: () =>
Limit
, + cell: ({ row }) => ( +
{ + e.preventDefault() + toast.promise(new Promise((resolve) => setTimeout(resolve, 1000)), { + loading: `Saving ${row.original.header}`, + success: "Done", + error: "Error", + }) + }} + > + + +
+ ), + }, + { + accessorKey: "reviewer", + header: "Reviewer", + cell: ({ row }) => { + const isAssigned = row.original.reviewer !== "Assign reviewer" + + if (isAssigned) { + return row.original.reviewer + } + + return ( + <> + + + + ) + }, + }, + { + id: "actions", + cell: () => ( + + + + + + Edit + Make a copy + Favorite + + Delete + + + ), + }, +] + +function DraggableRow({ row }: { row: Row> }) { + const { transform, transition, setNodeRef, isDragging } = useSortable({ + id: row.original.id, + }) + + return ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + ) +} + +export function DataTable({ + data: initialData, +}: { + data: z.infer[] +}) { + const [data, setData] = React.useState(() => initialData) + const [rowSelection, setRowSelection] = React.useState({}) + const [columnVisibility, setColumnVisibility] = + React.useState({}) + const [columnFilters, setColumnFilters] = React.useState( + [] + ) + const [sorting, setSorting] = React.useState([]) + 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( + () => 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 ( + +
+ + + + Outline + + Past Performance 3 + + + Key Personnel 2 + + Focus Documents + +
+ + + + + + {table + .getAllColumns() + .filter( + (column) => + typeof column.accessorFn !== "undefined" && + column.getCanHide() + ) + .map((column) => { + return ( + + column.toggleVisibility(!!value) + } + > + {column.id} + + ) + })} + + + +
+
+ +
+ + + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} + + ) + })} + + ))} + + + {table.getRowModel().rows?.length ? ( + + {table.getRowModel().rows.map((row) => ( + + ))} + + ) : ( + + + No results. + + + )} + +
+
+
+
+
+ {table.getFilteredSelectedRowModel().rows.length} of{" "} + {table.getFilteredRowModel().rows.length} row(s) selected. +
+
+
+ + +
+
+ Page {table.getState().pagination.pageIndex + 1} of{" "} + {table.getPageCount()} +
+
+ + + + +
+
+
+
+ +
+
+ +
+
+ +
+
+
+ ) +} + +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 }) { + const isMobile = useIsMobile() + + return ( + + + + + + + {item.header} + + Showing total visitors for the last 6 months + + +
+ {!isMobile && ( + <> + + + + value.slice(0, 3)} + hide + /> + } + /> + + + + + +
+
+ Trending up by 5.2% this month{" "} + +
+
+ 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. +
+
+ + + )} +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ + + + + + +
+
+ ) +} diff --git a/apps/electron/src/components/models-view.tsx b/apps/electron/src/components/models-view.tsx new file mode 100644 index 0000000..ada70bd --- /dev/null +++ b/apps/electron/src/components/models-view.tsx @@ -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([]); + const [downloadedModels, setDownloadedModels] = useState>({}); + const [downloadProgress, setDownloadProgress] = useState>({}); + const [loading, setLoading] = useState(true); + const [isLocalWhisperAvailable, setIsLocalWhisperAvailable] = useState(false); + const [selectedModel, setSelectedModel] = useState(null); + const [showDeleteDialog, setShowDeleteDialog] = useState(false); + const [modelToDelete, setModelToDelete] = useState(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 = {}; + 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 ( +
+ + Loading models... +
+ ); + } + + return ( +
+ + + Speech Recognition + + + + + + Whisper Speech Models + + Select and manage Whisper models for speech recognition + + + + + {availableModels.map((model) => { + const isDownloaded = !!downloadedModels[model.id]; + const progress = downloadProgress[model.id]; + const isDownloading = progress?.status === 'downloading'; + + return ( +
+
+ +
+ +
+ {model.description} +
+
+
+ +
+ {!isDownloaded && !isDownloading && ( + + )} + + {!isDownloaded && isDownloading && ( +
+ + + {/* Circular Progress Ring */} + {progress && ( + + + + + )} +
+ )} + + {isDownloaded && ( + + )} + +
+ {model.sizeFormatted} +
+
+
+ ); + })} +
+
+
+
+ +
+ + + + + Delete Model + + 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. + + + + Cancel + + Delete + + + + +
+ ); +}; \ No newline at end of file diff --git a/apps/electron/src/components/nav-main.tsx b/apps/electron/src/components/nav-main.tsx new file mode 100644 index 0000000..70e2ca0 --- /dev/null +++ b/apps/electron/src/components/nav-main.tsx @@ -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 ( + + + + {items.map((item) => ( + + onNavigate?.(item)} + > + {item.icon && } + {item.title} + + + ))} + + + + ) +} diff --git a/apps/electron/src/components/nav-secondary.tsx b/apps/electron/src/components/nav-secondary.tsx new file mode 100644 index 0000000..dabb269 --- /dev/null +++ b/apps/electron/src/components/nav-secondary.tsx @@ -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) { + return ( + + + + {items.map((item) => ( + + onNavigate?.(item)} + > + + {item.title} + + + ))} + + + + ) +} diff --git a/apps/electron/src/components/settings-view.tsx b/apps/electron/src/components/settings-view.tsx new file mode 100644 index 0000000..767a030 --- /dev/null +++ b/apps/electron/src/components/settings-view.tsx @@ -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(''); + 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 ( +
+ + + General + Microphone + Shortcuts + Formatter + tRPC Test + Advanced + + + + + + General Settings + Configure your general preferences + + +
+
+ +

Start Amical when you log in

+
+ +
+ +
+
+ +

Keep running in system tray when closed

+
+ +
+ +
+
+ +

Choose your preferred theme

+
+ +
+
+
+
+ + + + + Microphone Settings + Configure your microphone preferences + + +
+ + +
+ +
+ + +
+ +
+ + +
+
+
+
+ + + + + Keyboard Shortcuts + Customize your keyboard shortcuts + + +
+
+ +

Start/stop recording

+
+ Ctrl+Shift+Space +
+ +
+
+ +

Show/hide main window

+
+ Ctrl+Shift+A +
+ + +
+
+
+ + + + + Text Formatting Configuration + Configure AI-powered post-processing of transcriptions + + +
+ + +
+ + {formatterProvider === 'openrouter' && ( + <> +
+ + +
+ +
+ + setOpenrouterApiKey(e.target.value)} + /> +

+ Get your API key from openrouter.ai +

+
+ + )} + +
+
+ +

Apply AI formatting to transcriptions

+
+ +
+ +
+ +
+
+
+
+ + + + + tRPC Connection Test + Test the tRPC connection between renderer and main process + + +
+

+ This test verifies that tRPC is properly configured and working between the renderer and main processes. +

+
+ + + + {trpcTestResult && ( +
+
+                    {trpcTestResult}
+                  
+
+ )} +
+
+
+ + + + + Advanced Settings + Advanced configuration options + + +
+
+ +

Enable detailed logging

+
+ +
+ +
+
+ +

Automatically check for updates

+
+ +
+ +
+ +
+ + +
+
+
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/apps/electron/src/components/site-header.tsx b/apps/electron/src/components/site-header.tsx new file mode 100644 index 0000000..43ae466 --- /dev/null +++ b/apps/electron/src/components/site-header.tsx @@ -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 ( +
+
+ {/* macOS traffic light button spacing */} +
+ +
+ + +

{currentView || 'Amical'}

+
+ + {/*
+ +
*/} +
+
+ ) +} diff --git a/apps/electron/src/components/theme-provider.tsx b/apps/electron/src/components/theme-provider.tsx new file mode 100644 index 0000000..42649a7 --- /dev/null +++ b/apps/electron/src/components/theme-provider.tsx @@ -0,0 +1,18 @@ +import * as React from "react" +import { ThemeProvider as NextThemesProvider } from "next-themes" + +type ThemeProviderProps = React.ComponentProps + +export function ThemeProvider({ children, ...props }: ThemeProviderProps) { + return ( + + {children} + + ) +} \ No newline at end of file diff --git a/apps/electron/src/components/theme-toggle.tsx b/apps/electron/src/components/theme-toggle.tsx new file mode 100644 index 0000000..c1a70f7 --- /dev/null +++ b/apps/electron/src/components/theme-toggle.tsx @@ -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 ( + + + + + + setTheme("light")}> + + Light + + setTheme("dark")}> + + Dark + + setTheme("system")}> + + System + + + + ) +} + +export function ThemeToggleSimple() { + const { setTheme, theme } = useTheme() + + const toggleTheme = () => { + if (theme === "light") { + setTheme("dark") + } else if (theme === "dark") { + setTheme("system") + } else { + setTheme("light") + } + } + + return ( + + ) +} \ No newline at end of file diff --git a/apps/electron/src/components/transcriptions-list.tsx b/apps/electron/src/components/transcriptions-list.tsx new file mode 100644 index 0000000..a1afee0 --- /dev/null +++ b/apps/electron/src/components/transcriptions-list.tsx @@ -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([]); + 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 ( +
+
+
+
+ + +
+
+ + {/* Search and Filter Bar */} +
+
+ + setSearchTerm(e.target.value)} + className="pl-10" + /> +
+ +
+ + {/* Transcriptions Grid */} + {loading ? ( + + +
+
+

Loading transcriptions...

+
+
+
+ ) : filteredTranscriptions.length === 0 ? ( + + +
+ +

No transcriptions found

+

+ {searchTerm ? 'Try adjusting your search terms.' : 'Start recording to see your transcriptions here.'} +

+ {!searchTerm && ( + + )} +
+
+
+ ) : ( +
+ {filteredTranscriptions.map((transcription) => ( + + +
+
+
+

+ {getTitle(transcription.text)} +

+
+ + {getWordCount(transcription.text)} words + + {format(new Date(transcription.timestamp), 'MMM d')} + {format(new Date(transcription.timestamp), 'h:mm a')} + + {transcription.language?.toUpperCase() || 'EN'} + +
+
+
+ +
+ + + + + + Copy transcription + + + + {transcription.audioFile && ( + + + + + + Play audio + + + )} + + + + + + + Actions + handleDownload(transcription)}> + + Download + + + handleDelete(transcription.id)} + className="text-destructive" + > + + Delete + + + +
+
+
+
+ ))} +
+ )} + + {!loading && filteredTranscriptions.length > 0 && ( +
+ + Showing {filteredTranscriptions.length} of {totalCount} transcription{totalCount !== 1 ? 's' : ''} + + + Total: {transcriptions.reduce((acc, t) => acc + getWordCount(t.text), 0)} words + +
+ )} +
+ ); +}; \ No newline at end of file diff --git a/apps/electron/src/components/transcriptions-view.tsx b/apps/electron/src/components/transcriptions-view.tsx new file mode 100644 index 0000000..b5c6f32 --- /dev/null +++ b/apps/electron/src/components/transcriptions-view.tsx @@ -0,0 +1,6 @@ +import React from 'react'; +import { TranscriptionsList } from './transcriptions-list'; + +export function TranscriptionsView() { + return ; +} \ No newline at end of file diff --git a/apps/electron/src/components/vocabulary-manager.tsx b/apps/electron/src/components/vocabulary-manager.tsx new file mode 100644 index 0000000..52d2d91 --- /dev/null +++ b/apps/electron/src/components/vocabulary-manager.tsx @@ -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 ( +
+
+
+ + + + + + + Add Custom Word + +
+
+ + setNewWord({ ...newWord, word: e.target.value })} + /> +
+
+ + +
+
+
+
+
+ +
+ + + + Word + Date Added + Actions + + + + {loading ? ( + + +
+
+

Loading vocabulary...

+
+
+
+ ) : vocabulary.length === 0 ? ( + + +
+ +

No custom vocabulary words yet.

+

Add your first word to get started.

+
+
+
+ ) : ( + vocabulary.map((item) => ( + + {item.word} + + {format(new Date(item.dateAdded), 'MMM d, yyyy')} + + +
+ + +
+
+
+ )) + )} +
+
+
+ + {!loading && vocabulary.length > 0 && ( +
+ + Showing {vocabulary.length} of {totalCount} word{totalCount !== 1 ? 's' : ''} + + + Total: {totalCount} custom word{totalCount !== 1 ? 's' : ''} + +
+ )} +
+ ) +} \ No newline at end of file diff --git a/apps/electron/src/components/vocabulary-view.tsx b/apps/electron/src/components/vocabulary-view.tsx new file mode 100644 index 0000000..57b56d7 --- /dev/null +++ b/apps/electron/src/components/vocabulary-view.tsx @@ -0,0 +1,6 @@ +import React from 'react'; +import { VocabularyManager } from './vocabulary-manager'; + +export function VocabularyView() { + return ; +} \ No newline at end of file diff --git a/apps/electron/src/constants/models.ts b/apps/electron/src/constants/models.ts new file mode 100644 index 0000000..52876e8 --- /dev/null +++ b/apps/electron/src/constants/models.ts @@ -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; +} + +// 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', + }, +]; diff --git a/apps/electron/src/db/app-settings.ts b/apps/electron/src/db/app-settings.ts new file mode 100644 index 0000000..3e037f2 --- /dev/null +++ b/apps/electron/src/db/app-settings.ts @@ -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 { + 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 +): Promise { + 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 { + 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( + section: K +): Promise { + const settings = await getAppSettings(); + return settings[section]; +} + +// Update a specific setting section +export async function updateSettingsSection( + section: K, + newData: AppSettingsData[K] +): Promise { + return await updateAppSettings({ [section]: newData } as Partial); +} + +// Reset settings to defaults +export async function resetAppSettings(): Promise { + return await replaceAppSettings(defaultSettings); +} + +// Create default settings (internal helper) +async function createDefaultSettings(): Promise { + 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 }; diff --git a/apps/electron/src/db/config.ts b/apps/electron/src/db/config.ts index 79f9c65..d950421 100644 --- a/apps/electron/src/db/config.ts +++ b/apps/electron/src/db/config.ts @@ -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; - */ \ No newline at end of file +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); + } +} diff --git a/apps/electron/src/db/downloaded-models.ts b/apps/electron/src/db/downloaded-models.ts new file mode 100644 index 0000000..69867fd --- /dev/null +++ b/apps/electron/src/db/downloaded-models.ts @@ -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 +) { + 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> +) { + 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> { + const models = await getDownloadedModels(); + const record: Record = {}; + + 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 { + 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 { + const models = await getDownloadedModels(); + const validModels: DownloadedModel[] = []; + + for (const model of models) { + if (fs.existsSync(model.localPath)) { + validModels.push(model); + } + } + + return validModels; +} diff --git a/apps/electron/src/db/migrate.ts b/apps/electron/src/db/migrate.ts index 966d230..47f1ad8 100644 --- a/apps/electron/src/db/migrate.ts +++ b/apps/electron/src/db/migrate.ts @@ -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; } } - */ \ No newline at end of file diff --git a/apps/electron/src/db/migrations/0000_square_avengers.sql b/apps/electron/src/db/migrations/0000_square_avengers.sql deleted file mode 100644 index 7082bb8..0000000 --- a/apps/electron/src/db/migrations/0000_square_avengers.sql +++ /dev/null @@ -1,3 +0,0 @@ -CREATE TABLE `recordings` ( - `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL -); diff --git a/apps/electron/src/db/migrations/0000_worried_black_bird.sql b/apps/electron/src/db/migrations/0000_worried_black_bird.sql new file mode 100644 index 0000000..61ed278 --- /dev/null +++ b/apps/electron/src/db/migrations/0000_worried_black_bird.sql @@ -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`); \ No newline at end of file diff --git a/apps/electron/src/db/migrations/meta/0000_snapshot.json b/apps/electron/src/db/migrations/meta/0000_snapshot.json index 6a90c9b..f30825c 100644 --- a/apps/electron/src/db/migrations/meta/0000_snapshot.json +++ b/apps/electron/src/db/migrations/meta/0000_snapshot.json @@ -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": {}, diff --git a/apps/electron/src/db/migrations/meta/_journal.json b/apps/electron/src/db/migrations/meta/_journal.json index 913d5a4..5626b2f 100644 --- a/apps/electron/src/db/migrations/meta/_journal.json +++ b/apps/electron/src/db/migrations/meta/_journal.json @@ -5,8 +5,8 @@ { "idx": 0, "version": "6", - "when": 1747051560665, - "tag": "0000_square_avengers", + "when": 1750751926568, + "tag": "0000_worried_black_bird", "breakpoints": true } ] diff --git a/apps/electron/src/db/schema.ts b/apps/electron/src/db/schema.ts index 1484e35..425638f 100644 --- a/apps/electron/src/db/schema.ts +++ b/apps/electron/src/db/schema.ts @@ -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().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; diff --git a/apps/electron/src/db/transcriptions.ts b/apps/electron/src/db/transcriptions.ts new file mode 100644 index 0000000..f429e8c --- /dev/null +++ b/apps/electron/src/db/transcriptions.ts @@ -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 +) { + 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> +) { + 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); +} diff --git a/apps/electron/src/db/vocabulary.ts b/apps/electron/src/db/vocabulary.ts new file mode 100644 index 0000000..03b1701 --- /dev/null +++ b/apps/electron/src/db/vocabulary.ts @@ -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 +) { + 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> +) { + 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[] +) { + 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(); +} diff --git a/apps/electron/src/hooks/useRecording.ts b/apps/electron/src/hooks/useRecording.ts index b66e122..a996801 100644 --- a/apps/electron/src/hooks/useRecording.ts +++ b/apps/electron/src/hooks/useRecording.ts @@ -18,12 +18,7 @@ export interface UseRecordingOutput { stopRecording: () => Promise; } -const cleanupMediaResources = ( - vadInstance: MicVAD | null, - streamInstance: MediaStream | null, - mediaRecorderInstance: MediaRecorder | null, - onDataHandler: ((event: BlobEvent) => Promise) | 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('idle'); const [voiceDetected, setVoiceDetected] = useState(false); - const mediaRecorderRef = useRef(null); - const onDataHandlerRef = useRef<((event: BlobEvent) => Promise) | null>(null); const streamRef = useRef(null); const vadRef = useRef(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) | 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. diff --git a/apps/electron/src/main/logger.ts b/apps/electron/src/main/logger.ts index 0734a72..9c7e2b8 100644 --- a/apps/electron/src/main/logger.ts +++ b/apps/electron/src/main/logger.ts @@ -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) { + 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 +) { + 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); } -} \ No newline at end of file +} diff --git a/apps/electron/src/main/main.ts b/apps/electron/src/main/main.ts index 28e0c64..1a4c078 100644 --- a/apps/electron/src/main/main.ts +++ b/apps/electron/src/main/main.ts @@ -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 = new Map(); -const store = new Store(); +// 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); } diff --git a/apps/electron/src/main/preload.ts b/apps/electron/src/main/preload.ts index 3e5b982..ee05ef0 100644 --- a/apps/electron/src/main/preload.ts +++ b/apps/electron/src/main/preload.ts @@ -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) => + ipcRenderer.invoke('create-transcription', data), + updateTranscription: (id: number, data: Partial>) => + 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) => + ipcRenderer.invoke('create-vocabulary-word', data), + updateVocabulary: (id: number, data: Partial>) => + 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[]) => + 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(); +}); diff --git a/apps/electron/src/main/swift-io-bridge.ts b/apps/electron/src/main/swift-io-bridge.ts index 57285e6..c3a28ca 100644 --- a/apps/electron/src/main/swift-io-bridge.ts +++ b/apps/electron/src/main/swift-io-bridge.ts @@ -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 void>(); + private pending = new Map 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( @@ -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((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; } diff --git a/apps/electron/src/modules/ai/ai-service.ts b/apps/electron/src/modules/ai/ai-service.ts index 84e1f52..a533438 100644 --- a/apps/electron/src/modules/ai/ai-service.ts +++ b/apps/electron/src/modules/ai/ai-service.ts @@ -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 { 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 diff --git a/apps/electron/src/modules/ai/local-whisper-client.ts b/apps/electron/src/modules/ai/local-whisper-client.ts new file mode 100644 index 0000000..a228a2f --- /dev/null +++ b/apps/electron/src/modules/ai/local-whisper-client.ts @@ -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 { + 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 { + 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 { + // 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 { + 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 { + 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 { + const downloadedModels = await this.modelManager.getDownloadedModels(); + return Object.keys(downloadedModels).some((modelId) => + fs.existsSync(downloadedModels[modelId].localPath) + ); + } + + // Get available models + async getAvailableModels(): Promise { + const downloadedModels = await this.modelManager.getDownloadedModels(); + return Object.keys(downloadedModels).filter((modelId) => + fs.existsSync(downloadedModels[modelId].localPath) + ); + } + + // Free resources + async dispose(): Promise { + await this.freeWhisperInstance(); + } + + private async freeWhisperInstance(): Promise { + 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; + } + } + } +} diff --git a/apps/electron/src/modules/ai/openai-whisper-client.ts b/apps/electron/src/modules/ai/openai-whisper-client.ts deleted file mode 100644 index 68f3b20..0000000 --- a/apps/electron/src/modules/ai/openai-whisper-client.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { TranscriptionClient } from './transcription-client'; -import OpenAI from 'openai'; - -export class OpenAIWhisperClient implements TranscriptionClient { - private openai: OpenAI; - - constructor(apiKey: string) { - this.openai = new OpenAI({ apiKey }); - } - - async transcribe(audioData: Buffer): Promise { - 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 - } - } -} diff --git a/apps/electron/src/modules/audio/audio-capture.ts b/apps/electron/src/modules/audio/audio-capture.ts index 2e77c76..4ed7bfa 100644 --- a/apps/electron/src/modules/audio/audio-capture.ts +++ b/apps/electron/src/modules/audio/audio-capture.ts @@ -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(); } } diff --git a/apps/electron/src/modules/formatter/formatter-client.ts b/apps/electron/src/modules/formatter/formatter-client.ts new file mode 100644 index 0000000..8237477 --- /dev/null +++ b/apps/electron/src/modules/formatter/formatter-client.ts @@ -0,0 +1,16 @@ +/** + * Abstract base class for text formatting clients + */ +export abstract class FormatterClient { + abstract formatText(text: string): Promise; +} + +/** + * Configuration interface for formatter clients + */ +export interface FormatterConfig { + provider: 'openrouter'; + model: string; + apiKey: string; + enabled: boolean; +} diff --git a/apps/electron/src/modules/formatter/formatter-service.ts b/apps/electron/src/modules/formatter/formatter-service.ts new file mode 100644 index 0000000..5c3de7e --- /dev/null +++ b/apps/electron/src/modules/formatter/formatter-service.ts @@ -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 { + 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; + } +} diff --git a/apps/electron/src/modules/formatter/index.ts b/apps/electron/src/modules/formatter/index.ts new file mode 100644 index 0000000..d341553 --- /dev/null +++ b/apps/electron/src/modules/formatter/index.ts @@ -0,0 +1,3 @@ +export { FormatterService } from './formatter-service'; +export { FormatterClient, FormatterConfig } from './formatter-client'; +export { OpenRouterFormatterClient } from './openrouter-formatter-client'; diff --git a/apps/electron/src/modules/formatter/openrouter-formatter-client.ts b/apps/electron/src/modules/formatter/openrouter-formatter-client.ts new file mode 100644 index 0000000..62cdf50 --- /dev/null +++ b/apps/electron/src/modules/formatter/openrouter-formatter-client.ts @@ -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 { + 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; + } + } +} diff --git a/apps/electron/src/modules/models/model-manager.ts b/apps/electron/src/modules/models/model-manager.ts new file mode 100644 index 0000000..bdfe218 --- /dev/null +++ b/apps/electron/src/modules/models/model-manager.ts @@ -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(event: U, listener: ModelManagerEvents[U]): this; + emit( + event: U, + ...args: Parameters + ): 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 { + 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> { + return await getDownloadedModelsRecord(); + } + + // Get only valid downloaded models (files that exist on disk) + async getValidDownloadedModels(): Promise> { + const validModels = await getValidDownloadedModels(); + const record: Record = {}; + + 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 { + 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 { + 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 { + 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 { + 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 }; diff --git a/apps/electron/src/modules/settings/index.ts b/apps/electron/src/modules/settings/index.ts new file mode 100644 index 0000000..2a9bff8 --- /dev/null +++ b/apps/electron/src/modules/settings/index.ts @@ -0,0 +1 @@ +export { SettingsService } from './settings-service'; diff --git a/apps/electron/src/modules/settings/settings-service.ts b/apps/electron/src/modules/settings/settings-service.ts new file mode 100644 index 0000000..ac3d689 --- /dev/null +++ b/apps/electron/src/modules/settings/settings-service.ts @@ -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 { + const formatterConfig = await getSettingsSection('formatterConfig'); + return formatterConfig || null; + } + + /** + * Set formatter configuration + */ + async setFormatterConfig(config: FormatterConfig): Promise { + await updateSettingsSection('formatterConfig', config); + } + + /** + * Get all app settings + */ + async getAllSettings(): Promise { + return await getAppSettings(); + } + + /** + * Update multiple settings at once + */ + async updateSettings(settings: Partial): Promise { + return await updateAppSettings(settings); + } + + /** + * Get UI settings + */ + async getUISettings(): Promise { + return await getSettingsSection('ui'); + } + + /** + * Update UI settings + */ + async setUISettings(uiSettings: AppSettingsData['ui']): Promise { + await updateSettingsSection('ui', uiSettings); + } + + /** + * Get transcription settings + */ + async getTranscriptionSettings(): Promise { + return await getSettingsSection('transcription'); + } + + /** + * Update transcription settings + */ + async setTranscriptionSettings( + transcriptionSettings: AppSettingsData['transcription'] + ): Promise { + await updateSettingsSection('transcription', transcriptionSettings); + } + + /** + * Get recording settings + */ + async getRecordingSettings(): Promise { + return await getSettingsSection('recording'); + } + + /** + * Update recording settings + */ + async setRecordingSettings(recordingSettings: AppSettingsData['recording']): Promise { + await updateSettingsSection('recording', recordingSettings); + } +} diff --git a/apps/electron/src/modules/transcription/contextual-local-whisper-client.ts b/apps/electron/src/modules/transcription/contextual-local-whisper-client.ts new file mode 100644 index 0000000..45869ed --- /dev/null +++ b/apps/electron/src/modules/transcription/contextual-local-whisper-client.ts @@ -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 { + 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 { + 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> = { + 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 { + // 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 { + 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 { + 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 { + const downloadedModels = await this.modelManager.getDownloadedModels(); + return Object.keys(downloadedModels).some((modelId) => + fs.existsSync(downloadedModels[modelId].localPath) + ); + } + + // Get available models + async getAvailableModels(): Promise { + 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 { + 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 { + 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 { + this.clearCleanupTimer(); + await this.freeWhisperInstance(); + } + + private async freeWhisperInstance(): Promise { + 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; + } + } +} diff --git a/apps/electron/src/modules/transcription/contextual-transcription-manager.ts b/apps/electron/src/modules/transcription/contextual-transcription-manager.ts new file mode 100644 index 0000000..d58a08e --- /dev/null +++ b/apps/electron/src/modules/transcription/contextual-transcription-manager.ts @@ -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 { + 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 { + 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 { + if (this.defaultClient) { + await this.defaultClient.dispose(); + this.defaultClient = null; + } + } +} diff --git a/apps/electron/src/modules/transcription/transcription-session.ts b/apps/electron/src/modules/transcription/transcription-session.ts new file mode 100644 index 0000000..655a647 --- /dev/null +++ b/apps/electron/src/modules/transcription/transcription-session.ts @@ -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; + 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 { + 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 { + 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; + } +} diff --git a/apps/electron/src/renderer/renderer.tsx b/apps/electron/src/renderer/renderer.tsx index 7cee254..76a1850 100644 --- a/apps/electron/src/renderer/renderer.tsx +++ b/apps/electron/src/renderer/renderer.tsx @@ -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) => { - 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 ; + case 'Vocabulary': + return ; + case 'Models': + return ; + case 'Settings': + return ; + default: + return ( +
+

Welcome to Amical

+

Select an option from the sidebar to get started.

+
+ ); + } }; return ( -
- - - Dictionary - Configure API Key - - Dictionary Tab Content - API Key Configuration Content - -
- - - -
-
-
-
+ + + + +
+ {/* Header spans full width with traffic light spacing */} + + +
+ + +
+
+
+ {renderContent()} +
+
+
+
+
+
+
+
+
+
); }; diff --git a/apps/electron/src/styles/globals.css b/apps/electron/src/styles/globals.css index 3f3e4d5..392525b 100644 --- a/apps/electron/src/styles/globals.css +++ b/apps/electron/src/styles/globals.css @@ -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 { diff --git a/apps/electron/src/trpc/react.ts b/apps/electron/src/trpc/react.ts new file mode 100644 index 0000000..e8d0ca3 --- /dev/null +++ b/apps/electron/src/trpc/react.ts @@ -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(); + +// Create the vanilla tRPC client (for use outside React components) +export const trpcClient = createTRPCProxyClient({ + links: [ipcLink({ transformer: superjson })], +}); diff --git a/apps/electron/src/trpc/router.ts b/apps/electron/src/trpc/router.ts new file mode 100644 index 0000000..7301d7c --- /dev/null +++ b/apps/electron/src/trpc/router.ts @@ -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; diff --git a/apps/electron/src/trpc/routers/vocabulary.ts b/apps/electron/src/trpc/routers/vocabulary.ts new file mode 100644 index 0000000..e2abbbd --- /dev/null +++ b/apps/electron/src/trpc/routers/vocabulary.ts @@ -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); + }), +}); diff --git a/apps/electron/src/types/electron-api.ts b/apps/electron/src/types/electron-api.ts index 6f19d3b..c7ad03e 100644 --- a/apps/electron/src/types/electron-api.ts +++ b/apps/electron/src/types/electron-api.ts @@ -16,6 +16,95 @@ export interface ElectronAPI { onRecordingStarting: () => Promise; onRecordingStopping: () => Promise; - // New method for setting the API key - setApiKey: (apiKey: string) => Promise; + // Model Management API + getAvailableModels: () => Promise; + getDownloadedModels: () => Promise>; + isModelDownloaded: (modelId: string) => Promise; + getDownloadProgress: ( + modelId: string + ) => Promise; + getActiveDownloads: () => Promise; + downloadModel: (modelId: string) => Promise; + cancelDownload: (modelId: string) => Promise; + deleteModel: (modelId: string) => Promise; + getModelsDirectory: () => Promise; + + // Local Whisper API + isLocalWhisperAvailable: () => Promise; + getLocalWhisperModels: () => Promise; + getSelectedModel: () => Promise; + setSelectedModel: (modelId: string) => Promise; + setWhisperExecutablePath: (path: string) => Promise; + + // Formatter Configuration API + getFormatterConfig: () => Promise; + setFormatterConfig: (config: import('../modules/formatter').FormatterConfig) => Promise; + + // Transcription Database API + getTranscriptions: (options?: { + limit?: number; + offset?: number; + sortBy?: 'timestamp' | 'createdAt'; + sortOrder?: 'asc' | 'desc'; + search?: string; + }) => Promise; + getTranscriptionById: (id: number) => Promise; + createTranscription: ( + data: Omit + ) => Promise; + updateTranscription: ( + id: number, + data: Partial> + ) => Promise; + deleteTranscription: (id: number) => Promise; + getTranscriptionsCount: (search?: string) => Promise; + searchTranscriptions: ( + searchTerm: string, + limit?: number + ) => Promise; + + // Vocabulary Database API + getVocabulary: (options?: { + limit?: number; + offset?: number; + sortBy?: 'word' | 'dateAdded' | 'usageCount'; + sortOrder?: 'asc' | 'desc'; + search?: string; + }) => Promise; + getVocabularyById: (id: number) => Promise; + getVocabularyByWord: (word: string) => Promise; + createVocabularyWord: ( + data: Omit + ) => Promise; + updateVocabulary: ( + id: number, + data: Partial> + ) => Promise; + deleteVocabulary: (id: number) => Promise; + getVocabularyCount: (search?: string) => Promise; + searchVocabulary: ( + searchTerm: string, + limit?: number + ) => Promise; + bulkImportVocabulary: ( + words: Omit[] + ) => Promise; + trackWordUsage: (word: string) => Promise; + getMostUsedWords: (limit?: number) => Promise; // 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; + }; + }; } diff --git a/apps/electron/tsconfig.json b/apps/electron/tsconfig.json index 06f4d59..1f8e0e5 100644 --- a/apps/electron/tsconfig.json +++ b/apps/electron/tsconfig.json @@ -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"] } diff --git a/apps/electron/vite.main.config.mts b/apps/electron/vite.main.config.mts index 0431863..67939ba 100644 --- a/apps/electron/vite.main.config.mts +++ b/apps/electron/vite.main.config.mts @@ -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'], }, }); diff --git a/apps/electron/vite.renderer.config.mts b/apps/electron/vite.renderer.config.mts index b00fe33..9448f24 100644 --- a/apps/electron/vite.renderer.config.mts +++ b/apps/electron/vite.renderer.config.mts @@ -7,6 +7,7 @@ export default defineConfig(async () => { return { plugins: [tailwindcss()], + publicDir: 'public', resolve: { alias: { '@': resolve(__dirname, 'src'), diff --git a/package.json b/package.json index 2b7eabd..6dc75f3 100644 --- a/package.json +++ b/package.json @@ -20,14 +20,23 @@ "pnpm": { "ignoredBuiltDependencies": [ "@tailwindcss/oxide", - "better-sqlite3", "core-js-pure", "electron", "electron-winstaller", "esbuild", "keytar", "protobufjs", - "sharp" + "sharp", + "smart-whisper", + "drizzle-orm/libsql", + "@libsql" + ], + "onlyBuiltDependencies": [ + "electron", + "electron-winstaller", + "smart-whisper", + "drizzle-orm/libsql", + "@libsql" ] } } diff --git a/packages/eslint-config/base.js b/packages/eslint-config/base.js index 09d316e..2292d47 100644 --- a/packages/eslint-config/base.js +++ b/packages/eslint-config/base.js @@ -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", + ], }, ]; diff --git a/packages/eslint-config/react-internal.js b/packages/eslint-config/react-internal.js index daeccba..f204f9b 100644 --- a/packages/eslint-config/react-internal.js +++ b/packages/eslint-config/react-internal.js @@ -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: { diff --git a/packages/native-helpers/swift-helper/Sources/SwiftHelper/AccessibilityContextService.swift b/packages/native-helpers/swift-helper/Sources/SwiftHelper/AccessibilityContextService.swift new file mode 100644 index 0000000..7faddc6 --- /dev/null +++ b/packages/native-helpers/swift-helper/Sources/SwiftHelper/AccessibilityContextService.swift @@ -0,0 +1,510 @@ +import Foundation +import ApplicationServices +import AppKit + +// Apps that need manual accessibility enabling +let appsManuallyEnableAx: Set = ["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.. 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): \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 + } +} \ No newline at end of file diff --git a/packages/native-helpers/swift-helper/Sources/SwiftHelper/AccessibilityService.swift b/packages/native-helpers/swift-helper/Sources/SwiftHelper/AccessibilityService.swift index 0628ddd..6ff3587 100644 --- a/packages/native-helpers/swift-helper/Sources/SwiftHelper/AccessibilityService.swift +++ b/packages/native-helpers/swift-helper/Sources/SwiftHelper/AccessibilityService.swift @@ -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.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 } } diff --git a/packages/native-helpers/swift-helper/Sources/SwiftHelper/RpcHandler.swift b/packages/native-helpers/swift-helper/Sources/SwiftHelper/RpcHandler.swift index 4fd573f..1f17d58 100644 --- a/packages/native-helpers/swift-helper/Sources/SwiftHelper/RpcHandler.swift +++ b/packages/native-helpers/swift-helper/Sources/SwiftHelper/RpcHandler.swift @@ -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.") } } } diff --git a/packages/types/eslint.config.mjs b/packages/types/eslint.config.mjs new file mode 100644 index 0000000..924b7ce --- /dev/null +++ b/packages/types/eslint.config.mjs @@ -0,0 +1,4 @@ +import { config } from "@amical/eslint-config/base"; + +/** @type {import("eslint").Linter.Config} */ +export default config; diff --git a/packages/types/package.json b/packages/types/package.json index 0d7bab0..8b69591 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -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"], diff --git a/packages/types/scripts/generate-json-schemas.ts b/packages/types/scripts/generate-json-schemas.ts index 7916807..603910a 100644 --- a/packages/types/scripts/generate-json-schemas.ts +++ b/packages/types/scripts/generate-json-schemas.ts @@ -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' }, diff --git a/packages/types/scripts/generate-swift-models.ts b/packages/types/scripts/generate-swift-models.ts index 7dde267..76ad559 100644 --- a/packages/types/scripts/generate-swift-models.ts +++ b/packages/types/scripts/generate-swift-models.ts @@ -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 ' + diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 0968f1e..bc06bbf 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -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'; diff --git a/packages/types/src/schemas/methods/get-accessibility-context.ts b/packages/types/src/schemas/methods/get-accessibility-context.ts new file mode 100644 index 0000000..87c22f6 --- /dev/null +++ b/packages/types/src/schemas/methods/get-accessibility-context.ts @@ -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; +export type FocusedElementInfo = z.infer; +export type TextSelectionInfo = z.infer; +export type WindowInfo = z.infer; +export type AccessibilityContext = z.infer; +export type SelectionRange = z.infer; \ No newline at end of file diff --git a/packages/types/src/schemas/rpc/request.ts b/packages/types/src/schemas/rpc/request.ts index dc2e52d..e6a09f7 100644 --- a/packages/types/src/schemas/rpc/request.ts +++ b/packages/types/src/schemas/rpc/request.ts @@ -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 diff --git a/packages/ui/eslint.config.mjs b/packages/ui/eslint.config.mjs index 19170f8..04ae8cb 100644 --- a/packages/ui/eslint.config.mjs +++ b/packages/ui/eslint.config.mjs @@ -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; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eddb19b..d469118 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,12 +20,33 @@ importers: apps/electron: dependencies: + '@ai-sdk/openai': + specifier: ^1.3.22 + version: 1.3.22(zod@3.25.24) + '@amical/eslint-config': + specifier: workspace:* + version: link:../../packages/eslint-config '@amical/types': specifier: workspace:* version: link:../../packages/types + '@dnd-kit/core': + specifier: ^6.3.1 + version: 6.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@dnd-kit/modifiers': + specifier: ^9.0.0 + version: 9.0.0(@dnd-kit/core@6.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0) + '@dnd-kit/sortable': + specifier: ^10.0.0 + version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0) + '@dnd-kit/utilities': + specifier: ^3.2.2 + version: 3.2.2(react@19.1.0) '@hookform/resolvers': specifier: ^5.0.1 version: 5.0.1(react-hook-form@7.56.4(react@19.1.0)) + '@libsql/client': + specifier: ^0.15.9 + version: 0.15.9 '@radix-ui/react-accordion': specifier: ^1.2.10 version: 1.2.11(@types/react-dom@19.1.5(@types/react@19.1.5))(@types/react@19.1.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -36,10 +57,10 @@ importers: specifier: ^1.1.6 version: 1.1.7(@types/react-dom@19.1.5(@types/react@19.1.5))(@types/react@19.1.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-avatar': - specifier: ^1.1.9 + specifier: ^1.1.10 version: 1.1.10(@types/react-dom@19.1.5(@types/react@19.1.5))(@types/react@19.1.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-checkbox': - specifier: ^1.3.1 + specifier: ^1.3.2 version: 1.3.2(@types/react-dom@19.1.5(@types/react@19.1.5))(@types/react@19.1.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-collapsible': specifier: ^1.1.10 @@ -48,16 +69,16 @@ importers: specifier: ^2.2.14 version: 2.2.15(@types/react-dom@19.1.5(@types/react@19.1.5))(@types/react@19.1.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-dialog': - specifier: ^1.1.13 + specifier: ^1.1.14 version: 1.1.14(@types/react-dom@19.1.5(@types/react@19.1.5))(@types/react@19.1.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-dropdown-menu': - specifier: ^2.1.14 + specifier: ^2.1.15 version: 2.1.15(@types/react-dom@19.1.5(@types/react@19.1.5))(@types/react@19.1.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-hover-card': specifier: ^1.1.13 version: 1.1.14(@types/react-dom@19.1.5(@types/react@19.1.5))(@types/react@19.1.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-label': - specifier: ^2.1.6 + specifier: ^2.1.7 version: 2.1.7(@types/react-dom@19.1.5(@types/react@19.1.5))(@types/react@19.1.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-menubar': specifier: ^1.1.14 @@ -78,50 +99,65 @@ importers: specifier: ^1.2.8 version: 1.2.9(@types/react-dom@19.1.5(@types/react@19.1.5))(@types/react@19.1.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-select': - specifier: ^2.2.4 + specifier: ^2.2.5 version: 2.2.5(@types/react-dom@19.1.5(@types/react@19.1.5))(@types/react@19.1.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-separator': - specifier: ^1.1.6 + specifier: ^1.1.7 version: 1.1.7(@types/react-dom@19.1.5(@types/react@19.1.5))(@types/react@19.1.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-slider': specifier: ^1.3.4 version: 1.3.5(@types/react-dom@19.1.5(@types/react@19.1.5))(@types/react@19.1.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-slot': - specifier: ^1.2.2 + specifier: ^1.2.3 version: 1.2.3(@types/react@19.1.5)(react@19.1.0) '@radix-ui/react-switch': specifier: ^1.2.4 version: 1.2.5(@types/react-dom@19.1.5(@types/react@19.1.5))(@types/react@19.1.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-tabs': - specifier: ^1.1.11 + specifier: ^1.1.12 version: 1.1.12(@types/react-dom@19.1.5(@types/react@19.1.5))(@types/react@19.1.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-toggle': - specifier: ^1.1.8 + specifier: ^1.1.9 version: 1.1.9(@types/react-dom@19.1.5(@types/react@19.1.5))(@types/react@19.1.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-toggle-group': - specifier: ^1.1.9 + specifier: ^1.1.10 version: 1.1.10(@types/react-dom@19.1.5(@types/react@19.1.5))(@types/react@19.1.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-tooltip': - specifier: ^1.2.6 + specifier: ^1.2.7 version: 1.2.7(@types/react-dom@19.1.5(@types/react@19.1.5))(@types/react@19.1.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@ricky0123/vad-web': specifier: ^0.0.24 version: 0.0.24 - '@types/better-sqlite3': - specifier: ^7.6.13 - version: 7.6.13 + '@tabler/icons-react': + specifier: ^3.34.0 + version: 3.34.0(react@19.1.0) + '@tanstack/react-query': + specifier: ^5.81.2 + version: 5.81.2(react@19.1.0) + '@tanstack/react-table': + specifier: ^8.21.3 + version: 8.21.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@trpc/client': + specifier: ^11.4.2 + version: 11.4.2(@trpc/server@11.4.2(typescript@5.8.3))(typescript@5.8.3) + '@trpc/react-query': + specifier: ^11.4.2 + version: 11.4.2(@tanstack/react-query@5.81.2(react@19.1.0))(@trpc/client@11.4.2(@trpc/server@11.4.2(typescript@5.8.3))(typescript@5.8.3))(@trpc/server@11.4.2(typescript@5.8.3))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.8.3) + '@trpc/server': + specifier: ^11.4.2 + version: 11.4.2(typescript@5.8.3) '@types/split2': specifier: ^4.2.3 version: 4.2.3 '@types/uuid': specifier: ^10.0.0 version: 10.0.0 + ai: + specifier: ^4.3.16 + version: 4.3.16(react@19.1.0)(zod@3.25.24) async-mutex: specifier: ^0.5.0 version: 0.5.0 - better-sqlite3: - specifier: ^11.10.0 - version: 11.10.0 class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -142,13 +178,16 @@ importers: version: 0.31.1 drizzle-orm: specifier: ^0.43.1 - version: 0.43.1(@types/better-sqlite3@7.6.13)(better-sqlite3@11.10.0) + version: 0.43.1(@libsql/client@0.15.9)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(better-sqlite3@11.10.0) + electron-log: + specifier: ^5.4.0 + version: 5.4.0 electron-squirrel-startup: specifier: ^1.0.1 version: 1.0.1 - electron-store: - specifier: ^10.0.1 - version: 10.0.1 + electron-trpc-experimental: + specifier: 1.0.0-alpha.1 + version: 1.0.0-alpha.1(@trpc/client@11.4.2(@trpc/server@11.4.2(typescript@5.8.3))(typescript@5.8.3))(@trpc/server@11.4.2(typescript@5.8.3))(electron@36.2.0) embla-carousel-react: specifier: ^8.6.0 version: 8.6.0(react@19.1.0) @@ -161,6 +200,9 @@ importers: keytar: specifier: ^7.9.0 version: 7.9.0 + libsql: + specifier: ^0.5.13 + version: 0.5.13 lucide-react: specifier: ^0.510.0 version: 0.510.0(react@19.1.0) @@ -169,7 +211,7 @@ importers: version: 0.4.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0) openai: specifier: ^4.98.0 - version: 4.103.0(encoding@0.1.13)(ws@8.18.0)(zod@3.25.67) + version: 4.103.0(encoding@0.1.13)(ws@8.18.0)(zod@3.25.24) react: specifier: ^19.1.0 version: 19.1.0 @@ -188,12 +230,18 @@ importers: recharts: specifier: ^2.15.3 version: 2.15.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + smart-whisper: + specifier: 0.2.0 + version: 0.2.0 sonner: specifier: ^2.0.3 version: 2.0.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0) split2: specifier: ^4.2.0 version: 4.2.0 + superjson: + specifier: ^2.2.2 + version: 2.2.2 tailwind-merge: specifier: ^3.3.0 version: 3.3.0 @@ -206,6 +254,9 @@ importers: vaul: specifier: ^1.1.2 version: 1.1.2(@types/react-dom@19.1.5(@types/react@19.1.5))(@types/react@19.1.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + zod: + specifier: ^3.25.24 + version: 3.25.24 devDependencies: '@electron-forge/cli': specifier: ^7.8.1 @@ -234,6 +285,9 @@ importers: '@electron/fuses': specifier: ^1.8.0 version: 1.8.0 + '@rollup/plugin-commonjs': + specifier: ^28.0.6 + version: 28.0.6(rollup@4.41.0) '@tailwindcss/vite': specifier: ^4.1.6 version: 4.1.7(vite@5.4.19(@types/node@22.15.12)(lightningcss@1.30.1)) @@ -243,12 +297,6 @@ importers: '@types/react-dom': specifier: ^19.1.3 version: 19.1.5(@types/react@19.1.5) - '@typescript-eslint/eslint-plugin': - specifier: ^5.62.0 - version: 5.62.0(@typescript-eslint/parser@5.62.0(eslint@9.27.0(jiti@2.4.2))(typescript@5.8.3))(eslint@9.27.0(jiti@2.4.2))(typescript@5.8.3) - '@typescript-eslint/parser': - specifier: ^5.62.0 - version: 5.62.0(eslint@9.27.0(jiti@2.4.2))(typescript@5.8.3) electron: specifier: 36.2.0 version: 36.2.0 @@ -260,7 +308,7 @@ importers: version: 9.1.0(eslint@9.27.0(jiti@2.4.2)) eslint-plugin-import: specifier: ^2.31.0 - version: 2.31.0(@typescript-eslint/parser@5.62.0(eslint@9.27.0(jiti@2.4.2))(typescript@5.8.3))(eslint@9.27.0(jiti@2.4.2)) + version: 2.31.0(eslint@9.27.0(jiti@2.4.2)) eslint-plugin-prettier: specifier: ^5.4.0 version: 5.4.0(eslint-config-prettier@9.1.0(eslint@9.27.0(jiti@2.4.2)))(eslint@9.27.0(jiti@2.4.2))(prettier@3.5.3) @@ -284,7 +332,7 @@ importers: dependencies: '@next/third-parties': specifier: ^15.3.2 - version: 15.3.2(next@15.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0) + version: 15.3.2(next@15.3.1(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0) '@radix-ui/react-accordion': specifier: ^1.2.10 version: 1.2.11(@types/react-dom@19.1.5(@types/react@19.1.5))(@types/react@19.1.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -314,22 +362,22 @@ importers: version: 2.1.1 fumadocs-core: specifier: 15.3.0 - version: 15.3.0(@types/react@19.1.5)(next@15.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + version: 15.3.0(@types/react@19.1.5)(next@15.3.1(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0) fumadocs-mdx: specifier: 11.6.3 - version: 11.6.3(@fumadocs/mdx-remote@1.3.3(@types/react@19.1.5)(acorn@8.14.1)(fumadocs-core@15.3.0(@types/react@19.1.5)(next@15.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0))(acorn@8.14.1)(fumadocs-core@15.3.0(@types/react@19.1.5)(next@15.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(next@15.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0)) + version: 11.6.3(@fumadocs/mdx-remote@1.3.3(@types/react@19.1.5)(acorn@8.14.1)(fumadocs-core@15.3.0(@types/react@19.1.5)(next@15.3.1(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0))(acorn@8.14.1)(fumadocs-core@15.3.0(@types/react@19.1.5)(next@15.3.1(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(next@15.3.1(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)) fumadocs-ui: specifier: 15.3.0 - version: 15.3.0(@types/react-dom@19.1.5(@types/react@19.1.5))(@types/react@19.1.5)(next@15.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(tailwindcss@4.1.7) + version: 15.3.0(@types/react-dom@19.1.5(@types/react@19.1.5))(@types/react@19.1.5)(next@15.3.1(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(tailwindcss@4.1.7) lucide-react: specifier: ^0.509.0 version: 0.509.0(react@19.1.0) next: specifier: 15.3.1 - version: 15.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + version: 15.3.1(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) next-plausible: specifier: ^3.12.4 - version: 3.12.4(next@15.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + version: 3.12.4(next@15.3.1(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0) react: specifier: ^19.1.0 version: 19.1.0 @@ -496,6 +544,38 @@ importers: packages: + '@ai-sdk/openai@1.3.22': + resolution: {integrity: sha512-QwA+2EkG0QyjVR+7h6FE7iOu2ivNqAVMm9UJZkVxxTk5OIq5fFJDTEI/zICEMuHImTTXR2JjsL6EirJ28Jc4cw==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.0.0 + + '@ai-sdk/provider-utils@2.2.8': + resolution: {integrity: sha512-fqhG+4sCVv8x7nFzYnFo19ryhAa3w096Kmc3hWxMQfW/TubPOmt3A6tYZhl4mUfQWWQMsuSkLrtjlWuXBVSGQA==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.23.8 + + '@ai-sdk/provider@1.1.3': + resolution: {integrity: sha512-qZMxYJ0qqX/RfnuIaab+zp8UAeJn/ygXXAffR5I4N0n1IrvA6qBsjc8hXLmBiMV2zoXlifkacF7sEFnYnjBcqg==} + engines: {node: '>=18'} + + '@ai-sdk/react@1.2.12': + resolution: {integrity: sha512-jK1IZZ22evPZoQW3vlkZ7wvjYGYF+tRBKXtrcolduIkQ/m/sOAVcVeVDUDvh1T91xCnWCdUGCPZg2avZ90mv3g==} + engines: {node: '>=18'} + peerDependencies: + react: ^18 || ^19 || ^19.0.0-rc + zod: ^3.23.8 + peerDependenciesMeta: + zod: + optional: true + + '@ai-sdk/ui-utils@1.2.11': + resolution: {integrity: sha512-3zcwCc8ezzFlwp3ZD15wAPjf2Au4s3vAbKsXQVyhxODHcmu0iyPO2Eua6D/vicq/AUm/BAo60r97O6HU+EI0+w==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.23.8 + '@alloc/quick-lru@5.2.0': resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} @@ -727,6 +807,34 @@ packages: resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} + '@dnd-kit/accessibility@3.1.1': + resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==} + peerDependencies: + react: '>=16.8.0' + + '@dnd-kit/core@6.3.1': + resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@dnd-kit/modifiers@9.0.0': + resolution: {integrity: sha512-ybiLc66qRGuZoC20wdSSG6pDXFikui/dCNGthxv4Ndy8ylErY0N3KVxY2bgo7AWwIbxDmXDg3ylAFmnrjcbVvw==} + peerDependencies: + '@dnd-kit/core': ^6.3.0 + react: '>=16.8.0' + + '@dnd-kit/sortable@10.0.0': + resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==} + peerDependencies: + '@dnd-kit/core': ^6.3.0 + react: '>=16.8.0' + + '@dnd-kit/utilities@3.2.2': + resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==} + peerDependencies: + react: '>=16.8.0' + '@drizzle-team/brocli@0.10.2': resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==} @@ -1664,6 +1772,67 @@ packages: '@jridgewell/trace-mapping@0.3.9': resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + '@libsql/client@0.15.9': + resolution: {integrity: sha512-VT3do0a0vwYVaNcp/y05ikkKS3OrFR5UeEf5SUuYZVgKVl1Nc1k9ajoYSsOid8AD/vlhLDB5yFQaV4HmT/OB9w==} + + '@libsql/core@0.15.9': + resolution: {integrity: sha512-4OVdeAmuaCUq5hYT8NNn0nxlO9AcA/eTjXfUZ+QK8MT3Dz7Z76m73x7KxjU6I64WyXX98dauVH2b9XM+d84npw==} + + '@libsql/darwin-arm64@0.5.13': + resolution: {integrity: sha512-ASz/EAMLDLx3oq9PVvZ4zBXXHbz2TxtxUwX2xpTRFR4V4uSHAN07+jpLu3aK5HUBLuv58z7+GjaL5w/cyjR28Q==} + cpu: [arm64] + os: [darwin] + + '@libsql/darwin-x64@0.5.13': + resolution: {integrity: sha512-kzglniv1difkq8opusSXM7u9H0WoEPeKxw0ixIfcGfvlCVMJ+t9UNtXmyNHW68ljdllje6a4C6c94iPmIYafYA==} + cpu: [x64] + os: [darwin] + + '@libsql/hrana-client@0.7.0': + resolution: {integrity: sha512-OF8fFQSkbL7vJY9rfuegK1R7sPgQ6kFMkDamiEccNUvieQ+3urzfDFI616oPl8V7T9zRmnTkSjMOImYCAVRVuw==} + + '@libsql/isomorphic-fetch@0.3.1': + resolution: {integrity: sha512-6kK3SUK5Uu56zPq/Las620n5aS9xJq+jMBcNSOmjhNf/MUvdyji4vrMTqD7ptY7/4/CAVEAYDeotUz60LNQHtw==} + engines: {node: '>=18.0.0'} + + '@libsql/isomorphic-ws@0.1.5': + resolution: {integrity: sha512-DtLWIH29onUYR00i0GlQ3UdcTRC6EP4u9w/h9LxpUZJWRMARk6dQwZ6Jkd+QdwVpuAOrdxt18v0K2uIYR3fwFg==} + + '@libsql/linux-arm-gnueabihf@0.5.13': + resolution: {integrity: sha512-UEW+VZN2r0mFkfztKOS7cqfS8IemuekbjUXbXCwULHtusww2QNCXvM5KU9eJCNE419SZCb0qaEWYytcfka8qeA==} + cpu: [arm] + os: [linux] + + '@libsql/linux-arm-musleabihf@0.5.13': + resolution: {integrity: sha512-NMDgLqryYBv4Sr3WoO/m++XDjR5KLlw9r/JK4Ym6A1XBv2bxQQNhH0Lxx3bjLW8qqhBD4+0xfms4d2cOlexPyA==} + cpu: [arm] + os: [linux] + + '@libsql/linux-arm64-gnu@0.5.13': + resolution: {integrity: sha512-/wCxVdrwl1ee6D6LEjwl+w4SxuLm5UL9Kb1LD5n0bBGs0q+49ChdPPh7tp175iRgkcrTgl23emymvt1yj3KxVQ==} + cpu: [arm64] + os: [linux] + + '@libsql/linux-arm64-musl@0.5.13': + resolution: {integrity: sha512-xnVAbZIanUgX57XqeI5sNaDnVilp0Di5syCLSEo+bRyBobe/1IAeehNZpyVbCy91U2N6rH1C/mZU7jicVI9x+A==} + cpu: [arm64] + os: [linux] + + '@libsql/linux-x64-gnu@0.5.13': + resolution: {integrity: sha512-/mfMRxcQAI9f8t7tU3QZyh25lXgXKzgin9B9TOSnchD73PWtsVhlyfA6qOCfjQl5kr4sHscdXD5Yb3KIoUgrpQ==} + cpu: [x64] + os: [linux] + + '@libsql/linux-x64-musl@0.5.13': + resolution: {integrity: sha512-rdefPTpQCVwUjIQYbDLMv3qpd5MdrT0IeD0UZPGqhT9AWU8nJSQoj2lfyIDAWEz7PPOVCY4jHuEn7FS2sw9kRA==} + cpu: [x64] + os: [linux] + + '@libsql/win32-x64-msvc@0.5.13': + resolution: {integrity: sha512-aNcmDrD1Ws+dNZIv9ECbxBQumqB9MlSVEykwfXJpqv/593nABb8Ttg5nAGUPtnADyaGDTrGvPPP81d/KsKho4Q==} + cpu: [x64] + os: [win32] + '@malept/cross-spawn-promise@1.1.1': resolution: {integrity: sha512-RTBGWL5FWQcg9orDOCcp4LvItNzUPcyEU9bwaeJX0rJ1IQxzucC48Y0/sQLp/g6t99IQgAlGIaesJS+gTn7tVQ==} engines: {node: '>= 10'} @@ -1679,6 +1848,9 @@ packages: '@mdx-js/mdx@3.1.0': resolution: {integrity: sha512-/QxEhPAvGwbQmy1Px8F899L5Uc2KZ6JtXwlCgJmjSTBedwOZkByYcBG4GceIGPXRDsmfxhHazuS+hlOShRLeDw==} + '@neon-rs/load@0.0.4': + resolution: {integrity: sha512-kTPhdZyTQxB+2wpiRcFWrDcejc4JI6tkPuS7UZCG4l6Zvc5kU/gGQ/ozvHTh1XR5tS+UlfAfGuPajjzQjCiHCw==} + '@next/env@15.3.1': resolution: {integrity: sha512-cwK27QdzrMblHSn9DZRV+DQscHXRuJv6MydlJRpFSqJWZrTYMLzKDeyueJNN9MGd8NNiUKzDQADAf+dMLXX7YQ==} @@ -1764,6 +1936,10 @@ packages: engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} deprecated: This functionality has been moved to @npmcli/fs + '@opentelemetry/api@1.9.0': + resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} + engines: {node: '>=8.0.0'} + '@orama/orama@3.1.7': resolution: {integrity: sha512-6yB0117ZjsgNevZw3LP+bkrZa9mU/POPVaXgzMPOBbBc35w2P3R+1vMMhEfC06kYCpd5bf0jodBaTkYQW5TVeQ==} engines: {node: '>= 20.0.0'} @@ -2420,6 +2596,24 @@ packages: '@ricky0123/vad-web@0.0.24': resolution: {integrity: sha512-uv6GWW/kq8BkVErMQzXp3uwSyYMT3w/3QJiUerVaaKp7EwhOTIRY+96EoyFdG2WOFU5RkLk/2CVGbI7nDlxhEg==} + '@rollup/plugin-commonjs@28.0.6': + resolution: {integrity: sha512-XSQB1K7FUU5QP+3lOQmVCE3I0FcbbNvmNT4VJSj93iUjayaARrTQeoRdiYQoftAJBLrR9t2agwAd3ekaTgHNlw==} + engines: {node: '>=16.0.0 || 14 >= 14.17'} + peerDependencies: + rollup: ^2.68.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + + '@rollup/pluginutils@5.2.0': + resolution: {integrity: sha512-qWJ2ZTbmumwiLFomfzTyt5Kng4hwPi9rwCYN4SHb6eaRU1KNO4ccxINHr/VhH4GgPlt1XfSTLX2LBTme8ne4Zw==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + '@rollup/rollup-android-arm-eabi@4.41.0': resolution: {integrity: sha512-KxN+zCjOYHGwCl4UCtSfZ6jrq/qi88JDUtiEFk8LELEHq2Egfc/FgW+jItZiOLRuQfb/3xJSgFuNPC9jzggX+A==} cpu: [arm] @@ -2789,6 +2983,14 @@ packages: resolution: {integrity: sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==} engines: {node: '>=10'} + '@tabler/icons-react@3.34.0': + resolution: {integrity: sha512-OpEIR2iZsIXECtAIMbn1zfKfQ3zKJjXyIZlkgOGUL9UkMCFycEiF2Y8AVfEQsyre/3FnBdlWJvGr0NU47n2TbQ==} + peerDependencies: + react: '>= 16' + + '@tabler/icons@3.34.0': + resolution: {integrity: sha512-jtVqv0JC1WU2TTEBN32D9+R6mc1iEBuPwLnBsWaR02SIEciu9aq5806AWkCHuObhQ4ERhhXErLEK7Fs+tEZxiA==} + '@tailwindcss/node@4.1.7': resolution: {integrity: sha512-9rsOpdY9idRI2NH6CL4wORFY0+Q6fnx9XP9Ju+iq/0wJwGD5IByIgFmwVbyy4ymuyprj8Qh4ErxMKTUL4uNh3g==} @@ -2882,6 +3084,25 @@ packages: peerDependencies: vite: ^5.2.0 || ^6 + '@tanstack/query-core@5.81.2': + resolution: {integrity: sha512-QLYkPdrudoMATDFa3MiLEwRhNnAlzHWDf0LKaXUqJd0/+QxN8uTPi7bahRlxoAyH0UbLMBdeDbYzWALj7THOtw==} + + '@tanstack/react-query@5.81.2': + resolution: {integrity: sha512-pe8kFlTrL2zFLlcAj2kZk9UaYYHDk9/1hg9EBaoO3cxDhOZf1FRGJeziSXKrVZyxIfs7b3aoOj/bw7Lie0mDUg==} + peerDependencies: + react: ^18 || ^19 + + '@tanstack/react-table@8.21.3': + resolution: {integrity: sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==} + engines: {node: '>=12'} + peerDependencies: + react: '>=16.8' + react-dom: '>=16.8' + + '@tanstack/table-core@8.21.3': + resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==} + engines: {node: '>=12'} + '@tootallnate/once@2.0.0': resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==} engines: {node: '>= 10'} @@ -2889,6 +3110,27 @@ packages: '@tootallnate/quickjs-emscripten@0.23.0': resolution: {integrity: sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==} + '@trpc/client@11.4.2': + resolution: {integrity: sha512-Eep1rorAsATs9bxgaXf+BV34CRs4lAKQmwumUL4CNdNDkJItyfuWUr3xWx0np1w3EzUDVA0YDMK93iKDBBA0KQ==} + peerDependencies: + '@trpc/server': 11.4.2 + typescript: '>=5.7.2' + + '@trpc/react-query@11.4.2': + resolution: {integrity: sha512-tm2y9asG3PmdyZqgE92hQEauxls/2ZlEMpA6y/hLizdhjLiKCEtYCQ38pU+n4vUYczffZ90aTaSFm7zf+tnz/g==} + peerDependencies: + '@tanstack/react-query': ^5.80.3 + '@trpc/client': 11.4.2 + '@trpc/server': 11.4.2 + react: '>=18.2.0' + react-dom: '>=18.2.0' + typescript: '>=5.7.2' + + '@trpc/server@11.4.2': + resolution: {integrity: sha512-THyq/V5bSFDHeWEAk6LqHF0IVTGk6voGwWsFEipzRRKOWWMIZINCsKZ4cISG6kWO2X9jBfMWv/S2o9hnC0zQ0w==} + peerDependencies: + typescript: '>=5.7.2' + '@tsconfig/node10@1.0.11': resolution: {integrity: sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==} @@ -2948,6 +3190,9 @@ packages: '@types/debug@4.1.12': resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} + '@types/diff-match-patch@1.0.36': + resolution: {integrity: sha512-xFdR6tkm0MWvBfO8xXCSsinYxHcqkQUlcHeSpMC2ukzOb6lwQAfDmW+Qt0AvlGd8HpsS28qKsB+oPeJn9I39jg==} + '@types/estree-jsx@1.0.5': resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} @@ -3024,9 +3269,6 @@ packages: '@types/responselike@1.0.3': resolution: {integrity: sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==} - '@types/semver@7.7.0': - resolution: {integrity: sha512-k107IF4+Xr7UHjwDc7Cfd6PRQfbdkiRabXGRjo07b4WyPahFBZCZ1sE+BNxYIJPPg73UkfOsVOLwqVc/6ETrIA==} - '@types/split2@4.2.3': resolution: {integrity: sha512-59OXIlfUsi2k++H6CHgUQKEb2HKRokUA39HY1i1dS8/AIcqVjtAAFdf8u+HxTWK/4FUHMJQlKSZ4I6irCBJ1Zw==} @@ -3048,20 +3290,12 @@ packages: '@types/uuid@9.0.8': resolution: {integrity: sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==} + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@types/yauzl@2.10.3': resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} - '@typescript-eslint/eslint-plugin@5.62.0': - resolution: {integrity: sha512-TiZzBSJja/LbhNPvk6yc0JrX9XqhQ0hdh6M2svYfsHGejaKFIAGd9MQ+ERIMzLGlN/kZoYIgdxFV0PuljTKXag==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - peerDependencies: - '@typescript-eslint/parser': ^5.0.0 - eslint: ^6.0.0 || ^7.0.0 || ^8.0.0 - typescript: '*' - peerDependenciesMeta: - typescript: - optional: true - '@typescript-eslint/eslint-plugin@8.32.1': resolution: {integrity: sha512-6u6Plg9nP/J1GRpe/vcjjabo6Uc5YQPAMxsgQyGC/I0RuukiG1wIe3+Vtg3IrSCVJDmqK3j8adrtzXSENRtFgg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -3070,16 +3304,6 @@ packages: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <5.9.0' - '@typescript-eslint/parser@5.62.0': - resolution: {integrity: sha512-VlJEV0fOQ7BExOsHYAGrgbEiZoi8D+Bl2+f6V2RrXerRSylnp+ZBHmPvaIa8cz0Ajx7WO7Z5RqfgYg7ED1nRhA==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - peerDependencies: - eslint: ^6.0.0 || ^7.0.0 || ^8.0.0 - typescript: '*' - peerDependenciesMeta: - typescript: - optional: true - '@typescript-eslint/parser@8.32.1': resolution: {integrity: sha512-LKMrmwCPoLhM45Z00O1ulb6jwyVr2kr3XJp+G+tSEZcbauNnScewcQwtJqXDhXeYPDEjZ8C1SjXm015CirEmGg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -3087,24 +3311,10 @@ packages: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <5.9.0' - '@typescript-eslint/scope-manager@5.62.0': - resolution: {integrity: sha512-VXuvVvZeQCQb5Zgf4HAxc04q5j+WrNAtNh9OwCsCgpKqESMTu3tF/jhZ3xG6T4NZwWl65Bg8KuS2uEvhSfLl0w==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - '@typescript-eslint/scope-manager@8.32.1': resolution: {integrity: sha512-7IsIaIDeZn7kffk7qXC3o6Z4UblZJKV3UBpkvRNpr5NSyLji7tvTcvmnMNYuYLyh26mN8W723xpo3i4MlD33vA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/type-utils@5.62.0': - resolution: {integrity: sha512-xsSQreu+VnfbqQpW5vnCJdq1Z3Q0U31qiWmRhr98ONQmcp/yhiPJFPq8MXiJVLiksmOKSjIldZzkebzHuCGzew==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - peerDependencies: - eslint: '*' - typescript: '*' - peerDependenciesMeta: - typescript: - optional: true - '@typescript-eslint/type-utils@8.32.1': resolution: {integrity: sha512-mv9YpQGA8iIsl5KyUPi+FGLm7+bA4fgXaeRcFKRDRwDMu4iwrSHeDPipwueNXhdIIZltwCJv+NkxftECbIZWfA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -3112,35 +3322,16 @@ packages: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <5.9.0' - '@typescript-eslint/types@5.62.0': - resolution: {integrity: sha512-87NVngcbVXUahrRTqIK27gD2t5Cu1yuCXxbLcFtCzZGlfyVWWh8mLHkoxzjsB6DDNnvdL+fW8MiwPEJyGJQDgQ==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - '@typescript-eslint/types@8.32.1': resolution: {integrity: sha512-YmybwXUJcgGqgAp6bEsgpPXEg6dcCyPyCSr0CAAueacR/CCBi25G3V8gGQ2kRzQRBNol7VQknxMs9HvVa9Rvfg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/typescript-estree@5.62.0': - resolution: {integrity: sha512-CmcQ6uY7b9y694lKdRB8FEel7JbU/40iSAPomu++SjLMntB+2Leay2LO6i8VnJk58MtE9/nQSFIH6jpyRWyYzA==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - peerDependencies: - typescript: '*' - peerDependenciesMeta: - typescript: - optional: true - '@typescript-eslint/typescript-estree@8.32.1': resolution: {integrity: sha512-Y3AP9EIfYwBb4kWGb+simvPaqQoT5oJuzzj9m0i6FCY6SPvlomY2Ei4UEMm7+FXtlNJbor80ximyslzaQF6xhg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <5.9.0' - '@typescript-eslint/utils@5.62.0': - resolution: {integrity: sha512-n8oxjeb5aIbPFEtmQxQYOLI0i9n5ySBEY/ZEHHZqKQSFnxio1rv6dthascc9dLuwrL0RC5mPCxB7vnAVGAYWAQ==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - peerDependencies: - eslint: ^6.0.0 || ^7.0.0 || ^8.0.0 - '@typescript-eslint/utils@8.32.1': resolution: {integrity: sha512-DsSFNIgLSrc89gpq1LJB7Hm1YpuhK086DRDJSNrewcGvYloWW1vZLHBTIvarKZDcAORIy/uWNx8Gad+4oMpkSA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -3148,10 +3339,6 @@ packages: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <5.9.0' - '@typescript-eslint/visitor-keys@5.62.0': - resolution: {integrity: sha512-07ny+LHRzQXepkGg6w0mFY41fVUNBrL2Roj/++7V1txKugfjm/Ci/qSND03r2RhlJhJYMcTn9AhhSSqQp0Ysyw==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - '@typescript-eslint/visitor-keys@8.32.1': resolution: {integrity: sha512-ar0tjQfObzhSaW3C3QNmTc5ofj0hDoNQ5XWrCy6zDyabdr0TWhCkClp+rywGNj/odAFBVzzJrK4tEq5M4Hmu4w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -3159,6 +3346,9 @@ packages: '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + '@watchable/unpromise@1.0.2': + resolution: {integrity: sha512-yGCKYzCrAfJQ9yzm76r1bl4WUIWyqmh4vqidXn5LyOfPbgdiZrKOyvW2ivqIvtmsRVb7u3ModEpc4q901VRgXw==} + '@xmldom/xmldom@0.8.10': resolution: {integrity: sha512-2WALfTl4xo2SkGCYRt6rDTFfk9R1czmBvUQy12gK2KuRKIpWEhcbbzy8EZXtz/jkRqHX8bFEc6FC1HjX4TUWYw==} engines: {node: '>=10.0.0'} @@ -3218,20 +3408,19 @@ packages: resolution: {integrity: sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==} engines: {node: '>=8'} - ajv-formats@3.0.1: - resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + ai@4.3.16: + resolution: {integrity: sha512-KUDwlThJ5tr2Vw0A1ZkbDKNME3wzWhuVfAOwIvFUzl1TPVDFAXDFTXio3p+jaKneB+dKNCvFFlolYmmgHttG1g==} + engines: {node: '>=18'} peerDependencies: - ajv: ^8.0.0 + react: ^18 || ^19 || ^19.0.0-rc + zod: ^3.23.8 peerDependenciesMeta: - ajv: + react: optional: true ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} - ajv@8.17.1: - resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} - ansi-escapes@4.3.2: resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} engines: {node: '>=8'} @@ -3365,9 +3554,6 @@ packages: resolution: {integrity: sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==} engines: {node: '>= 4.0.0'} - atomically@2.0.3: - resolution: {integrity: sha512-kU6FmrwZ3Lx7/7y3hPS5QnbJfaohcIul5fGqf7ok+4KklIEk9tJ0C2IQPdacSbVUWv6zVHXEBWoWd6NrVMT7Cw==} - author-regex@1.0.0: resolution: {integrity: sha512-KbWgR8wOYRAPekEmMXrYYdc7BRyhn2Ftk7KWfMUnQ43hFdojWEFRxhhRUm3/OFEdPa1r0KAvTTg9YQK57xTe0g==} engines: {node: '>=0.8'} @@ -3512,6 +3698,10 @@ packages: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} + chalk@5.4.1: + resolution: {integrity: sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + change-case@3.1.0: resolution: {integrity: sha512-2AZp7uJZbYEzRPsFoa+ijKdvp9zsrnnt6+yFokfwEpeJm0xuJDVoxiRCAaTzyJND8GJkofo2IcKWaUZ/OECVzw==} @@ -3674,6 +3864,9 @@ packages: resolution: {integrity: sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==} engines: {node: ^12.20.0 || >=14} + commondir@1.0.1: + resolution: {integrity: sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==} + compare-version@0.1.2: resolution: {integrity: sha512-pJDh5/4wrEnXX/VWRZvruAGHkzKdr46z11OlTPN+VrATlWWhSKewNCJ1futCO5C7eJB3nPMFZA1LeYtcFboZ2A==} engines: {node: '>=0.10.0'} @@ -3692,10 +3885,6 @@ packages: concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} - conf@13.1.0: - resolution: {integrity: sha512-Bi6v586cy1CoTFViVO4lGTtx780lfF96fUmS1lSX6wpZf6330NvHUu6fReVuDP1de8Mg0nkZb01c8tAQdz1o3w==} - engines: {node: '>=18'} - connect-redis@7.1.1: resolution: {integrity: sha512-M+z7alnCJiuzKa8/1qAYdGUXHYfDnLolOGAUjOioB07pP39qxjG+X9ibsud7qUBc4jMV5Mcy3ugGv8eFcgamJQ==} engines: {node: '>=16'} @@ -3741,6 +3930,10 @@ packages: resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} engines: {node: '>= 0.6'} + copy-anything@3.0.5: + resolution: {integrity: sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w==} + engines: {node: '>=12.13'} + core-js-pure@3.42.0: resolution: {integrity: sha512-007bM04u91fF4kMgwom2I5cQxAFIy8jVulgr9eozILl/SZE53QOqnW/+vviC+wQWLv+AunBG+8Q0TLoeSsSxRQ==} @@ -3833,6 +4026,10 @@ packages: data-uri-to-buffer@2.0.2: resolution: {integrity: sha512-ND9qDTLc6diwj+Xe5cdAgVTbLVdXbtxTJRXRhli8Mowuaan+0EJOtdqJ0QCHNSSPyoXGx9HX2/VMnKeC34AChA==} + data-uri-to-buffer@4.0.1: + resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} + engines: {node: '>= 12'} + data-uri-to-buffer@6.0.2: resolution: {integrity: sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==} engines: {node: '>= 14'} @@ -3852,10 +4049,6 @@ packages: date-fns@4.1.0: resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==} - debounce-fn@6.0.0: - resolution: {integrity: sha512-rBMW+F2TXryBwB54Q0d8drNEI+TfoS9JpNTAoVpukbWEhjXQq4rySFYLaqXMFXwdv61Zb2OHtj5bviSoimqxRQ==} - engines: {node: '>=18'} - debug@2.6.9: resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} peerDependencies: @@ -3965,6 +4158,10 @@ packages: resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + detect-libc@2.0.2: + resolution: {integrity: sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw==} + engines: {node: '>=8'} + detect-libc@2.0.4: resolution: {integrity: sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==} engines: {node: '>=8'} @@ -3981,6 +4178,9 @@ packages: dezalgo@1.0.4: resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==} + diff-match-patch@1.0.5: + resolution: {integrity: sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw==} + diff@4.0.2: resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==} engines: {node: '>=0.3.1'} @@ -4005,10 +4205,6 @@ packages: dot-case@2.1.1: resolution: {integrity: sha512-HnM6ZlFqcajLsyudHq7LeeLDr2rFAVYtDv/hV5qchQEidSck8j9OPUsXY9KwJv/lHMtYlX4DjRQqwFYa+0r8Ug==} - dot-prop@9.0.0: - resolution: {integrity: sha512-1gxPBJpI/pcjQhKgIU91II6Wkay+dLcN3M6rf2uwP8hRur3HtQXjVrdAK3sjC0piaEuxzMwjXChcETiJl47lAQ==} - engines: {node: '>=18'} - dotenv@16.0.3: resolution: {integrity: sha512-7GO6HghkA5fYG9TYnNxi14/7K9f5occMlp3zXAuSxn7CKCxt9xbNWG7yF8hTCSUchlfWSe3uLmlPfigevRItzQ==} engines: {node: '>=12'} @@ -4136,12 +4332,19 @@ packages: os: [darwin, linux] hasBin: true + electron-log@5.4.0: + resolution: {integrity: sha512-AXI5OVppskrWxEAmCxuv8ovX+s2Br39CpCAgkGMNHQtjYT3IiVbSQTncEjFVGPgoH35ZygRm/mvUMBDWwhRxgg==} + engines: {node: '>= 14'} + electron-squirrel-startup@1.0.1: resolution: {integrity: sha512-sTfFIHGku+7PsHLJ7v0dRcZNkALrV+YEozINTW8X1nM//e5O3L+rfYuvSW00lmGHnYmUjARZulD8F2V8ISI9RA==} - electron-store@10.0.1: - resolution: {integrity: sha512-Ok0bF13WWdTzZi9rCtPN8wUfwx+yDMmV6PAnCMqjNRKEXHmklW/rV+6DofV/Vf5qoAh+Bl9Bj7dQ+0W+IL2psg==} - engines: {node: '>=20'} + electron-trpc-experimental@1.0.0-alpha.1: + resolution: {integrity: sha512-7UZNnXpgTWoavza5L6FgRdacWSAoFDnzJ2A2sPAKoY1xQpdJ763AFM0gvjRhmO0c64xLLJuiofMTjhDJs3CzdQ==} + peerDependencies: + '@trpc/client': '>11.0.0' + '@trpc/server': '>11.0.0' + electron: '>19.0.0' electron-winstaller@5.4.0: resolution: {integrity: sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==} @@ -4201,10 +4404,6 @@ packages: resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} engines: {node: '>=6'} - env-paths@3.0.0: - resolution: {integrity: sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - err-code@2.0.3: resolution: {integrity: sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==} @@ -4378,10 +4577,6 @@ packages: eslint: '>6.6.0' turbo: '>2.0.0' - eslint-scope@5.1.1: - resolution: {integrity: sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==} - engines: {node: '>=8.0.0'} - eslint-scope@8.3.0: resolution: {integrity: sha512-pUNxi75F8MJ/GdeKtVLSbYg4ZI34J6C0C7sbL4YOp2exGwen7ZsuBqKzUhXd0qMQ362yET3z+uPwKeg/0C2XCQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -4421,10 +4616,6 @@ packages: resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} engines: {node: '>=4.0'} - estraverse@4.3.0: - resolution: {integrity: sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==} - engines: {node: '>=4.0'} - estraverse@5.3.0: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} @@ -4450,6 +4641,9 @@ packages: estree-util-visit@2.0.0: resolution: {integrity: sha512-m5KgiH85xAhhW8Wta0vShLcUvOsh3LLPI2YVwcbio1l7E09NTLL1EyMZFM1OyWowoH0skScNbhOPl4kcBgzTww==} + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} @@ -4551,9 +4745,6 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} - fast-uri@3.0.6: - resolution: {integrity: sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==} - fast-xml-parser@4.4.1: resolution: {integrity: sha512-xkjOecfnKGkSsOwtZ5Pz7Us/T6mrbPQrq0nh+aCO5V9nk5NLWmasAHumTKjiPJPWANe+kAZ84Jc8ooJkzZ88Sw==} hasBin: true @@ -4564,6 +4755,18 @@ packages: fd-slicer@1.1.0: resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==} + fdir@6.4.6: + resolution: {integrity: sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fetch-blob@3.2.0: + resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} + engines: {node: ^12.20 || >= 14.13} + figures@3.2.0: resolution: {integrity: sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==} engines: {node: '>=8'} @@ -4639,6 +4842,10 @@ packages: resolution: {integrity: sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==} engines: {node: '>= 12.20'} + formdata-polyfill@4.0.10: + resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} + engines: {node: '>=12.20.0'} + formidable@2.1.5: resolution: {integrity: sha512-Oz5Hwvwak/DCaXVVUtPn4oLMLLy1CdclLKO1LFgU7XzDpVMUU5UjlSLpGMocyQNNk8F6IJW9M/YdooSn2MRI+Q==} @@ -4869,10 +5076,6 @@ packages: resolution: {integrity: sha512-7dUi7RvCoT/xast/o/dLN53oqND4yk0nsHkhRgn9w65C4PofCLOoJ39iSOg+qVDdWQPIEj+eszMHQ+aLVwwQSg==} engines: {node: '>=8'} - globby@11.1.0: - resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} - engines: {node: '>=10'} - globby@14.1.0: resolution: {integrity: sha512-0Ia46fDOaT7k4og1PDW4YbodWWr3scS2vAr2lTbsplOt2WkKp0vQbkI9wKis/T5LV/dqPjO3bpS/z6GTJB82LA==} engines: {node: '>=18'} @@ -5239,6 +5442,9 @@ packages: is-promise@2.2.2: resolution: {integrity: sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==} + is-reference@1.2.1: + resolution: {integrity: sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==} + is-regex@1.2.1: resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} engines: {node: '>= 0.4'} @@ -5293,6 +5499,10 @@ packages: resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==} engines: {node: '>= 0.4'} + is-what@4.1.16: + resolution: {integrity: sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A==} + engines: {node: '>=12.13'} + isarray@2.0.5: resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} @@ -5344,11 +5554,8 @@ packages: json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} - json-schema-traverse@1.0.0: - resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} - - json-schema-typed@8.0.1: - resolution: {integrity: sha512-XQmWYj2Sm4kn4WeTYvmpKEbyPsL7nBsb647c7pMe6l02/yx2+Jfc4dT6UZkEXnIUb5LhD55r2HPsJ1milQ4rDg==} + json-schema@0.4.0: + resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==} json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} @@ -5360,6 +5567,11 @@ packages: resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==} hasBin: true + jsondiffpatch@0.6.0: + resolution: {integrity: sha512-3QItJOXp2AP1uv7waBkao5nCvhEv+QmJAd38Ybq7wNI74Q+BBmnLn4EDKz6yI9xGAIQoUF87qHt+kc1IVxB4zQ==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + jsonfile@4.0.0: resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==} @@ -5391,6 +5603,11 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} + libsql@0.5.13: + resolution: {integrity: sha512-5Bwoa/CqzgkTwySgqHA5TsaUDRrdLIbdM4egdPcaAnqO3aC+qAgS6BwdzuZwARA5digXwiskogZ8H7Yy4XfdOg==} + cpu: [x64, arm64, wasm32, arm] + os: [darwin, linux, win32] + lightningcss-darwin-arm64@1.30.1: resolution: {integrity: sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==} engines: {node: '>= 12.0.0'} @@ -5781,10 +5998,6 @@ packages: resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} engines: {node: '>=6'} - mimic-function@5.0.1: - resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} - engines: {node: '>=18'} - mimic-response@1.0.1: resolution: {integrity: sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==} engines: {node: '>=4'} @@ -5909,9 +6122,6 @@ packages: napi-build-utils@2.0.0: resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} - natural-compare-lite@1.4.0: - resolution: {integrity: sha512-Tj+HTDSJJKaZnfiuw+iaF9skdPpTo2GtEly5JHnWV/hfv2Qj/9RKsGISQtLh2ox3l5EAGw487hnBee0sIJ6v2g==} - natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} @@ -5981,6 +6191,9 @@ packages: node-addon-api@4.3.0: resolution: {integrity: sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ==} + node-addon-api@7.1.1: + resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} + node-api-version@0.2.1: resolution: {integrity: sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q==} @@ -5998,6 +6211,10 @@ packages: encoding: optional: true + node-fetch@3.3.2: + resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + node-plop@0.26.3: resolution: {integrity: sha512-Cov028YhBZ5aB7MdMWJEmwyBig43aGL5WT4vdoB28Oitau1zZAcHUn8Sgfk9HM33TqhtLJ9PlM/O0Mv+QpV/4Q==} engines: {node: '>=8.9.4'} @@ -6281,6 +6498,10 @@ packages: resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} engines: {node: '>=8.6'} + picomatch@4.0.2: + resolution: {integrity: sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==} + engines: {node: '>=12'} + pify@2.3.0: resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==} engines: {node: '>=0.10.0'} @@ -6358,6 +6579,9 @@ packages: bluebird: optional: true + promise-limit@2.7.0: + resolution: {integrity: sha512-7nJ6v5lnJsXwGprnGXga4wx6d1POjvi5Qmf1ivTRxTjH4Z/9Czja/UCMLVmB9N93GeWOU93XaFaEt6jbuoagNw==} + promise-retry@2.0.1: resolution: {integrity: sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==} engines: {node: '>=10'} @@ -6666,10 +6890,6 @@ packages: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} - require-from-string@2.0.2: - resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} - engines: {node: '>=0.10.0'} - resedit@2.0.3: resolution: {integrity: sha512-oTeemxwoMuxxTYxXUwjkrOPfngTQehlv0/HoYFNkB4uzsP1Un1A9nI8JQKGOFkxpqkC7qkMs0lUsGrvUlbLNUA==} engines: {node: '>=14', npm: '>=7'} @@ -6795,6 +7015,9 @@ packages: resolution: {integrity: sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==} engines: {node: '>=4'} + secure-json-parse@2.7.0: + resolution: {integrity: sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==} + semver-compare@1.0.0: resolution: {integrity: sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==} @@ -6945,6 +7168,9 @@ packages: resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} + smart-whisper@0.2.0: + resolution: {integrity: sha512-OOpBDrD6x7RnoksqrarfXuStMGFzbTClxv/xrosQX5QZ9qhvx6ROQ6jbSGC+gb7TL7q9F6QNrYQNMLfIzHGR/w==} + snake-case@2.1.0: resolution: {integrity: sha512-FMR5YoPFwOLuh4rRz92dywJjyKYZNLpMn1R5ujVpIYkbA9p01fq8RMg0FkO4M+Yobt4MjHeLTJVm5xFFBHSV2Q==} @@ -7124,9 +7350,6 @@ packages: strnum@1.1.2: resolution: {integrity: sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==} - stubborn-fs@1.2.5: - resolution: {integrity: sha512-H2N9c26eXjzL/S/K+i/RHHcFanE74dptvvjM8iwzwbVcWY/zjBbgRqF3K0DY4+OD+uTTASTBvDoxPDaPN02D7g==} - style-to-js@1.1.16: resolution: {integrity: sha512-/Q6ld50hKYPH3d/r6nr117TZkHR0w0kGGIVfpG9N6D8NymRPM9RqCUv4pRpJ62E5DqOYx2AFpbZMyCPnjQCnOw==} @@ -7154,6 +7377,10 @@ packages: resolution: {integrity: sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==} engines: {node: '>= 8.0'} + superjson@2.2.2: + resolution: {integrity: sha512-5JRxVqC8I8NuOUjzBbvVJAKNM8qoVuH0O77h4WInc/qC2q5IreqKxYwgkga3PfA22OayK2ikceb/B26dztPl+Q==} + engines: {node: '>=16'} + supports-color@5.5.0: resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} engines: {node: '>=4'} @@ -7169,6 +7396,11 @@ packages: swap-case@1.1.2: resolution: {integrity: sha512-BAmWG6/bx8syfc6qXPprof3Mn5vQgf5dwdUNJhsNqU9WdPt5P+ES/wQ5bxfijy8zwZgZZHslC3iAsxsuQMCzJQ==} + swr@2.3.3: + resolution: {integrity: sha512-dshNvs3ExOqtZ6kJBaAsabhPdHyeY4P2cKwRCniDVifBMoG/SVI7tfLWqPXriVspf2Rg4tPzXJTnwaihIeFw2A==} + peerDependencies: + react: ^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + synckit@0.11.6: resolution: {integrity: sha512-2pR2ubZSV64f/vqm9eLPz/KOvR9Dm+Co/5ChLgeHl0yEDRc6h5hXHoxEQH8Y5Ljycozd3p1k5TTSVdzYGkPvLw==} engines: {node: ^14.18.0 || >=16.0.0} @@ -7216,6 +7448,10 @@ packages: third-party-capital@1.0.20: resolution: {integrity: sha512-oB7yIimd8SuGptespDAZnNkzIz+NWaJCu2RMsbs4Wmp9zSDUM8Nhi3s2OOcqYuv3mN4hitXc8DVx+LyUmbUDiA==} + throttleit@2.1.0: + resolution: {integrity: sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw==} + engines: {node: '>=18'} + through@2.3.8: resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} @@ -7309,12 +7545,6 @@ packages: resolution: {integrity: sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==} engines: {node: '>=0.6.x'} - tsutils@3.21.0: - resolution: {integrity: sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==} - engines: {node: '>= 6'} - peerDependencies: - typescript: '>=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta' - tsx@4.19.4: resolution: {integrity: sha512-gK5GVzDkJK1SI1zwHf32Mqxf2tSJkNx+eYcNly5+nHvWqXUJYUkWBQtKauoESz3ymezAI++ZwT855x5p5eop+Q==} engines: {node: '>=18.0.0'} @@ -7376,10 +7606,6 @@ packages: resolution: {integrity: sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==} engines: {node: '>=10'} - type-fest@4.41.0: - resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} - engines: {node: '>=16'} - type-is@1.6.18: resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} engines: {node: '>= 0.6'} @@ -7447,10 +7673,6 @@ packages: resolution: {integrity: sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==} engines: {node: '>= 0.8'} - uint8array-extras@1.4.0: - resolution: {integrity: sha512-ZPtzy0hu4cZjv3z5NW9gfKnNLjoz4y6uv4HlelAjDK7sY/xOkKZv9xK/WQpcsBB3jEybChz9DPC2U/+cusjJVQ==} - engines: {node: '>=18'} - unbox-primitive@1.1.0: resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} engines: {node: '>= 0.4'} @@ -7651,6 +7873,10 @@ packages: wcwidth@1.0.1: resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} + web-streams-polyfill@3.3.3: + resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} + engines: {node: '>= 8'} + web-streams-polyfill@4.0.0-beta.3: resolution: {integrity: sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==} engines: {node: '>= 14'} @@ -7661,9 +7887,6 @@ packages: whatwg-url@5.0.0: resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} - when-exit@2.1.4: - resolution: {integrity: sha512-4rnvd3A1t16PWzrBUcSDZqcAmsUIy4minDXT/CZ8F2mVDgd65i4Aalimgz1aQkRGU0iH5eT5+6Rx2TK8o443Pg==} - which-boxed-primitive@1.1.1: resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} engines: {node: '>= 0.4'} @@ -7858,6 +8081,40 @@ packages: snapshots: + '@ai-sdk/openai@1.3.22(zod@3.25.24)': + dependencies: + '@ai-sdk/provider': 1.1.3 + '@ai-sdk/provider-utils': 2.2.8(zod@3.25.24) + zod: 3.25.24 + + '@ai-sdk/provider-utils@2.2.8(zod@3.25.24)': + dependencies: + '@ai-sdk/provider': 1.1.3 + nanoid: 3.3.11 + secure-json-parse: 2.7.0 + zod: 3.25.24 + + '@ai-sdk/provider@1.1.3': + dependencies: + json-schema: 0.4.0 + + '@ai-sdk/react@1.2.12(react@19.1.0)(zod@3.25.24)': + dependencies: + '@ai-sdk/provider-utils': 2.2.8(zod@3.25.24) + '@ai-sdk/ui-utils': 1.2.11(zod@3.25.24) + react: 19.1.0 + swr: 2.3.3(react@19.1.0) + throttleit: 2.1.0 + optionalDependencies: + zod: 3.25.24 + + '@ai-sdk/ui-utils@1.2.11(zod@3.25.24)': + dependencies: + '@ai-sdk/provider': 1.1.3 + '@ai-sdk/provider-utils': 2.2.8(zod@3.25.24) + zod: 3.25.24 + zod-to-json-schema: 3.24.5(zod@3.25.24) + '@alloc/quick-lru@5.2.0': {} '@ampproject/remapping@2.3.0': @@ -8378,6 +8635,38 @@ snapshots: dependencies: '@jridgewell/trace-mapping': 0.3.9 + '@dnd-kit/accessibility@3.1.1(react@19.1.0)': + dependencies: + react: 19.1.0 + tslib: 2.8.1 + + '@dnd-kit/core@6.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@dnd-kit/accessibility': 3.1.1(react@19.1.0) + '@dnd-kit/utilities': 3.2.2(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + tslib: 2.8.1 + + '@dnd-kit/modifiers@9.0.0(@dnd-kit/core@6.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)': + dependencies: + '@dnd-kit/core': 6.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@dnd-kit/utilities': 3.2.2(react@19.1.0) + react: 19.1.0 + tslib: 2.8.1 + + '@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)': + dependencies: + '@dnd-kit/core': 6.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@dnd-kit/utilities': 3.2.2(react@19.1.0) + react: 19.1.0 + tslib: 2.8.1 + + '@dnd-kit/utilities@3.2.2(react@19.1.0)': + dependencies: + react: 19.1.0 + tslib: 2.8.1 + '@drizzle-team/brocli@0.10.2': {} '@electron-forge/cli@7.8.1(encoding@0.1.13)': @@ -9059,10 +9348,10 @@ snapshots: dependencies: tslib: 2.8.1 - '@fumadocs/mdx-remote@1.3.3(@types/react@19.1.5)(acorn@8.14.1)(fumadocs-core@15.3.0(@types/react@19.1.5)(next@15.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)': + '@fumadocs/mdx-remote@1.3.3(@types/react@19.1.5)(acorn@8.14.1)(fumadocs-core@15.3.0(@types/react@19.1.5)(next@15.3.1(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)': dependencies: '@mdx-js/mdx': 3.1.0(acorn@8.14.1) - fumadocs-core: 15.3.0(@types/react@19.1.5)(next@15.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + fumadocs-core: 15.3.0(@types/react@19.1.5)(next@15.3.1(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0) gray-matter: 4.0.3 react: 19.1.0 zod: 3.25.67 @@ -9296,6 +9585,68 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.0 + '@libsql/client@0.15.9': + dependencies: + '@libsql/core': 0.15.9 + '@libsql/hrana-client': 0.7.0 + js-base64: 3.7.7 + libsql: 0.5.13 + promise-limit: 2.7.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@libsql/core@0.15.9': + dependencies: + js-base64: 3.7.7 + + '@libsql/darwin-arm64@0.5.13': + optional: true + + '@libsql/darwin-x64@0.5.13': + optional: true + + '@libsql/hrana-client@0.7.0': + dependencies: + '@libsql/isomorphic-fetch': 0.3.1 + '@libsql/isomorphic-ws': 0.1.5 + js-base64: 3.7.7 + node-fetch: 3.3.2 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@libsql/isomorphic-fetch@0.3.1': {} + + '@libsql/isomorphic-ws@0.1.5': + dependencies: + '@types/ws': 8.18.1 + ws: 8.17.1 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@libsql/linux-arm-gnueabihf@0.5.13': + optional: true + + '@libsql/linux-arm-musleabihf@0.5.13': + optional: true + + '@libsql/linux-arm64-gnu@0.5.13': + optional: true + + '@libsql/linux-arm64-musl@0.5.13': + optional: true + + '@libsql/linux-x64-gnu@0.5.13': + optional: true + + '@libsql/linux-x64-musl@0.5.13': + optional: true + + '@libsql/win32-x64-msvc@0.5.13': + optional: true + '@malept/cross-spawn-promise@1.1.1': dependencies: cross-spawn: 7.0.6 @@ -9349,6 +9700,8 @@ snapshots: - acorn - supports-color + '@neon-rs/load@0.0.4': {} + '@next/env@15.3.1': {} '@next/eslint-plugin-next@15.3.2': @@ -9379,9 +9732,9 @@ snapshots: '@next/swc-win32-x64-msvc@15.3.1': optional: true - '@next/third-parties@15.3.2(next@15.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)': + '@next/third-parties@15.3.2(next@15.3.1(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)': dependencies: - next: 15.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + next: 15.3.1(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) react: 19.1.0 third-party-capital: 1.0.20 @@ -9409,6 +9762,8 @@ snapshots: mkdirp: 1.0.4 rimraf: 3.0.2 + '@opentelemetry/api@1.9.0': {} + '@orama/orama@3.1.7': {} '@paralleldrive/cuid2@2.2.2': @@ -10097,6 +10452,26 @@ snapshots: dependencies: onnxruntime-web: 1.14.0 + '@rollup/plugin-commonjs@28.0.6(rollup@4.41.0)': + dependencies: + '@rollup/pluginutils': 5.2.0(rollup@4.41.0) + commondir: 1.0.1 + estree-walker: 2.0.2 + fdir: 6.4.6(picomatch@4.0.2) + is-reference: 1.2.1 + magic-string: 0.30.17 + picomatch: 4.0.2 + optionalDependencies: + rollup: 4.41.0 + + '@rollup/pluginutils@5.2.0(rollup@4.41.0)': + dependencies: + '@types/estree': 1.0.7 + estree-walker: 2.0.2 + picomatch: 4.0.2 + optionalDependencies: + rollup: 4.41.0 + '@rollup/rollup-android-arm-eabi@4.41.0': optional: true @@ -10559,6 +10934,13 @@ snapshots: dependencies: defer-to-connect: 2.0.1 + '@tabler/icons-react@3.34.0(react@19.1.0)': + dependencies: + '@tabler/icons': 3.34.0 + react: 19.1.0 + + '@tabler/icons@3.34.0': {} + '@tailwindcss/node@4.1.7': dependencies: '@ampproject/remapping': 2.3.0 @@ -10638,10 +11020,43 @@ snapshots: tailwindcss: 4.1.7 vite: 5.4.19(@types/node@22.15.12)(lightningcss@1.30.1) + '@tanstack/query-core@5.81.2': {} + + '@tanstack/react-query@5.81.2(react@19.1.0)': + dependencies: + '@tanstack/query-core': 5.81.2 + react: 19.1.0 + + '@tanstack/react-table@8.21.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@tanstack/table-core': 8.21.3 + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + + '@tanstack/table-core@8.21.3': {} + '@tootallnate/once@2.0.0': {} '@tootallnate/quickjs-emscripten@0.23.0': {} + '@trpc/client@11.4.2(@trpc/server@11.4.2(typescript@5.8.3))(typescript@5.8.3)': + dependencies: + '@trpc/server': 11.4.2(typescript@5.8.3) + typescript: 5.8.3 + + '@trpc/react-query@11.4.2(@tanstack/react-query@5.81.2(react@19.1.0))(@trpc/client@11.4.2(@trpc/server@11.4.2(typescript@5.8.3))(typescript@5.8.3))(@trpc/server@11.4.2(typescript@5.8.3))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.8.3)': + dependencies: + '@tanstack/react-query': 5.81.2(react@19.1.0) + '@trpc/client': 11.4.2(@trpc/server@11.4.2(typescript@5.8.3))(typescript@5.8.3) + '@trpc/server': 11.4.2(typescript@5.8.3) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + typescript: 5.8.3 + + '@trpc/server@11.4.2(typescript@5.8.3)': + dependencies: + typescript: 5.8.3 + '@tsconfig/node10@1.0.11': {} '@tsconfig/node12@1.0.11': {} @@ -10687,6 +11102,7 @@ snapshots: '@types/better-sqlite3@7.6.13': dependencies: '@types/node': 22.15.12 + optional: true '@types/cacheable-request@6.0.3': dependencies: @@ -10727,6 +11143,8 @@ snapshots: dependencies: '@types/ms': 2.1.0 + '@types/diff-match-patch@1.0.36': {} + '@types/estree-jsx@1.0.5': dependencies: '@types/estree': 1.0.7 @@ -10809,8 +11227,6 @@ snapshots: dependencies: '@types/node': 22.15.12 - '@types/semver@7.7.0': {} - '@types/split2@4.2.3': dependencies: '@types/node': 22.15.12 @@ -10829,30 +11245,15 @@ snapshots: '@types/uuid@9.0.8': {} + '@types/ws@8.18.1': + dependencies: + '@types/node': 22.15.12 + '@types/yauzl@2.10.3': dependencies: '@types/node': 22.15.12 optional: true - '@typescript-eslint/eslint-plugin@5.62.0(@typescript-eslint/parser@5.62.0(eslint@9.27.0(jiti@2.4.2))(typescript@5.8.3))(eslint@9.27.0(jiti@2.4.2))(typescript@5.8.3)': - dependencies: - '@eslint-community/regexpp': 4.12.1 - '@typescript-eslint/parser': 5.62.0(eslint@9.27.0(jiti@2.4.2))(typescript@5.8.3) - '@typescript-eslint/scope-manager': 5.62.0 - '@typescript-eslint/type-utils': 5.62.0(eslint@9.27.0(jiti@2.4.2))(typescript@5.8.3) - '@typescript-eslint/utils': 5.62.0(eslint@9.27.0(jiti@2.4.2))(typescript@5.8.3) - debug: 4.4.1 - eslint: 9.27.0(jiti@2.4.2) - graphemer: 1.4.0 - ignore: 5.3.2 - natural-compare-lite: 1.4.0 - semver: 7.7.2 - tsutils: 3.21.0(typescript@5.8.3) - optionalDependencies: - typescript: 5.8.3 - transitivePeerDependencies: - - supports-color - '@typescript-eslint/eslint-plugin@8.32.1(@typescript-eslint/parser@8.32.1(eslint@9.27.0(jiti@2.4.2))(typescript@5.8.3))(eslint@9.27.0(jiti@2.4.2))(typescript@5.8.3)': dependencies: '@eslint-community/regexpp': 4.12.1 @@ -10870,18 +11271,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@5.62.0(eslint@9.27.0(jiti@2.4.2))(typescript@5.8.3)': - dependencies: - '@typescript-eslint/scope-manager': 5.62.0 - '@typescript-eslint/types': 5.62.0 - '@typescript-eslint/typescript-estree': 5.62.0(typescript@5.8.3) - debug: 4.4.1 - eslint: 9.27.0(jiti@2.4.2) - optionalDependencies: - typescript: 5.8.3 - transitivePeerDependencies: - - supports-color - '@typescript-eslint/parser@8.32.1(eslint@9.27.0(jiti@2.4.2))(typescript@5.8.3)': dependencies: '@typescript-eslint/scope-manager': 8.32.1 @@ -10894,28 +11283,11 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/scope-manager@5.62.0': - dependencies: - '@typescript-eslint/types': 5.62.0 - '@typescript-eslint/visitor-keys': 5.62.0 - '@typescript-eslint/scope-manager@8.32.1': dependencies: '@typescript-eslint/types': 8.32.1 '@typescript-eslint/visitor-keys': 8.32.1 - '@typescript-eslint/type-utils@5.62.0(eslint@9.27.0(jiti@2.4.2))(typescript@5.8.3)': - dependencies: - '@typescript-eslint/typescript-estree': 5.62.0(typescript@5.8.3) - '@typescript-eslint/utils': 5.62.0(eslint@9.27.0(jiti@2.4.2))(typescript@5.8.3) - debug: 4.4.1 - eslint: 9.27.0(jiti@2.4.2) - tsutils: 3.21.0(typescript@5.8.3) - optionalDependencies: - typescript: 5.8.3 - transitivePeerDependencies: - - supports-color - '@typescript-eslint/type-utils@8.32.1(eslint@9.27.0(jiti@2.4.2))(typescript@5.8.3)': dependencies: '@typescript-eslint/typescript-estree': 8.32.1(typescript@5.8.3) @@ -10927,24 +11299,8 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/types@5.62.0': {} - '@typescript-eslint/types@8.32.1': {} - '@typescript-eslint/typescript-estree@5.62.0(typescript@5.8.3)': - dependencies: - '@typescript-eslint/types': 5.62.0 - '@typescript-eslint/visitor-keys': 5.62.0 - debug: 4.4.1 - globby: 11.1.0 - is-glob: 4.0.3 - semver: 7.7.2 - tsutils: 3.21.0(typescript@5.8.3) - optionalDependencies: - typescript: 5.8.3 - transitivePeerDependencies: - - supports-color - '@typescript-eslint/typescript-estree@8.32.1(typescript@5.8.3)': dependencies: '@typescript-eslint/types': 8.32.1 @@ -10959,21 +11315,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@5.62.0(eslint@9.27.0(jiti@2.4.2))(typescript@5.8.3)': - dependencies: - '@eslint-community/eslint-utils': 4.7.0(eslint@9.27.0(jiti@2.4.2)) - '@types/json-schema': 7.0.15 - '@types/semver': 7.7.0 - '@typescript-eslint/scope-manager': 5.62.0 - '@typescript-eslint/types': 5.62.0 - '@typescript-eslint/typescript-estree': 5.62.0(typescript@5.8.3) - eslint: 9.27.0(jiti@2.4.2) - eslint-scope: 5.1.1 - semver: 7.7.2 - transitivePeerDependencies: - - supports-color - - typescript - '@typescript-eslint/utils@8.32.1(eslint@9.27.0(jiti@2.4.2))(typescript@5.8.3)': dependencies: '@eslint-community/eslint-utils': 4.7.0(eslint@9.27.0(jiti@2.4.2)) @@ -10985,11 +11326,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/visitor-keys@5.62.0': - dependencies: - '@typescript-eslint/types': 5.62.0 - eslint-visitor-keys: 3.4.3 - '@typescript-eslint/visitor-keys@8.32.1': dependencies: '@typescript-eslint/types': 8.32.1 @@ -10997,6 +11333,8 @@ snapshots: '@ungap/structured-clone@1.3.0': {} + '@watchable/unpromise@1.0.2': {} + '@xmldom/xmldom@0.8.10': {} abbrev@1.1.1: {} @@ -11043,9 +11381,17 @@ snapshots: clean-stack: 2.2.0 indent-string: 4.0.0 - ajv-formats@3.0.1(ajv@8.17.1): + ai@4.3.16(react@19.1.0)(zod@3.25.24): + dependencies: + '@ai-sdk/provider': 1.1.3 + '@ai-sdk/provider-utils': 2.2.8(zod@3.25.24) + '@ai-sdk/react': 1.2.12(react@19.1.0)(zod@3.25.24) + '@ai-sdk/ui-utils': 1.2.11(zod@3.25.24) + '@opentelemetry/api': 1.9.0 + jsondiffpatch: 0.6.0 + zod: 3.25.24 optionalDependencies: - ajv: 8.17.1 + react: 19.1.0 ajv@6.12.6: dependencies: @@ -11054,13 +11400,6 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 - ajv@8.17.1: - dependencies: - fast-deep-equal: 3.1.3 - fast-uri: 3.0.6 - json-schema-traverse: 1.0.0 - require-from-string: 2.0.2 - ansi-escapes@4.3.2: dependencies: type-fest: 0.21.3 @@ -11200,11 +11539,6 @@ snapshots: at-least-node@1.0.0: {} - atomically@2.0.3: - dependencies: - stubborn-fs: 1.2.5 - when-exit: 2.1.4 - author-regex@1.0.0: {} available-typed-arrays@1.0.7: @@ -11231,10 +11565,12 @@ snapshots: dependencies: bindings: 1.5.0 prebuild-install: 7.1.3 + optional: true bindings@1.5.0: dependencies: file-uri-to-path: 1.0.0 + optional: true bl@4.1.0: dependencies: @@ -11386,6 +11722,8 @@ snapshots: ansi-styles: 4.3.0 supports-color: 7.2.0 + chalk@5.4.1: {} + change-case@3.1.0: dependencies: camel-case: 3.0.0 @@ -11551,6 +11889,8 @@ snapshots: commander@9.5.0: {} + commondir@1.0.1: {} + compare-version@0.1.2: {} compressible@2.0.18: @@ -11573,18 +11913,6 @@ snapshots: concat-map@0.0.1: {} - conf@13.1.0: - dependencies: - ajv: 8.17.1 - ajv-formats: 3.0.1(ajv@8.17.1) - atomically: 2.0.3 - debounce-fn: 6.0.0 - dot-prop: 9.0.0 - env-paths: 3.0.0 - json-schema-typed: 8.0.1 - semver: 7.7.2 - uint8array-extras: 1.4.0 - connect-redis@7.1.1(express-session@1.18.1): dependencies: express-session: 1.18.1 @@ -11622,6 +11950,10 @@ snapshots: cookie@0.7.2: {} + copy-anything@3.0.5: + dependencies: + is-what: 4.1.16 + core-js-pure@3.42.0: {} cors@2.8.5: @@ -11712,6 +12044,8 @@ snapshots: data-uri-to-buffer@2.0.2: {} + data-uri-to-buffer@4.0.1: {} + data-uri-to-buffer@6.0.2: {} data-view-buffer@1.0.2: @@ -11734,10 +12068,6 @@ snapshots: date-fns@4.1.0: {} - debounce-fn@6.0.0: - dependencies: - mimic-function: 5.0.1 - debug@2.6.9: dependencies: ms: 2.0.0 @@ -11821,6 +12151,8 @@ snapshots: destroy@1.2.0: {} + detect-libc@2.0.2: {} + detect-libc@2.0.4: {} detect-node-es@1.1.0: {} @@ -11837,6 +12169,8 @@ snapshots: asap: 2.0.6 wrappy: 1.0.2 + diff-match-patch@1.0.5: {} + diff@4.0.2: {} dir-compare@4.2.0: @@ -11863,10 +12197,6 @@ snapshots: dependencies: no-case: 2.3.2 - dot-prop@9.0.0: - dependencies: - type-fest: 4.41.0 - dotenv@16.0.3: {} dotenv@16.5.0: {} @@ -11880,8 +12210,10 @@ snapshots: transitivePeerDependencies: - supports-color - drizzle-orm@0.43.1(@types/better-sqlite3@7.6.13)(better-sqlite3@11.10.0): + drizzle-orm@0.43.1(@libsql/client@0.15.9)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(better-sqlite3@11.10.0): optionalDependencies: + '@libsql/client': 0.15.9 + '@opentelemetry/api': 1.9.0 '@types/better-sqlite3': 7.6.13 better-sqlite3: 11.10.0 @@ -11939,16 +12271,24 @@ snapshots: - supports-color optional: true + electron-log@5.4.0: {} + electron-squirrel-startup@1.0.1: dependencies: debug: 2.6.9 transitivePeerDependencies: - supports-color - electron-store@10.0.1: + electron-trpc-experimental@1.0.0-alpha.1(@trpc/client@11.4.2(@trpc/server@11.4.2(typescript@5.8.3))(typescript@5.8.3))(@trpc/server@11.4.2(typescript@5.8.3))(electron@36.2.0): dependencies: - conf: 13.1.0 - type-fest: 4.41.0 + '@trpc/client': 11.4.2(@trpc/server@11.4.2(typescript@5.8.3))(typescript@5.8.3) + '@trpc/server': 11.4.2(typescript@5.8.3) + '@watchable/unpromise': 1.0.2 + debug: 4.4.1 + electron: 36.2.0 + typescript: 5.8.3 + transitivePeerDependencies: + - supports-color electron-winstaller@5.4.0: dependencies: @@ -12025,8 +12365,6 @@ snapshots: env-paths@2.2.1: {} - env-paths@3.0.0: {} - err-code@2.0.3: {} error-ex@1.3.2: @@ -12268,17 +12606,16 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.0(@typescript-eslint/parser@5.62.0(eslint@9.27.0(jiti@2.4.2))(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint@9.27.0(jiti@2.4.2)): + eslint-module-utils@2.12.0(eslint-import-resolver-node@0.3.9)(eslint@9.27.0(jiti@2.4.2)): dependencies: debug: 3.2.7 optionalDependencies: - '@typescript-eslint/parser': 5.62.0(eslint@9.27.0(jiti@2.4.2))(typescript@5.8.3) eslint: 9.27.0(jiti@2.4.2) eslint-import-resolver-node: 0.3.9 transitivePeerDependencies: - supports-color - eslint-plugin-import@2.31.0(@typescript-eslint/parser@5.62.0(eslint@9.27.0(jiti@2.4.2))(typescript@5.8.3))(eslint@9.27.0(jiti@2.4.2)): + eslint-plugin-import@2.31.0(eslint@9.27.0(jiti@2.4.2)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.8 @@ -12289,7 +12626,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.27.0(jiti@2.4.2) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.0(@typescript-eslint/parser@5.62.0(eslint@9.27.0(jiti@2.4.2))(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint@9.27.0(jiti@2.4.2)) + eslint-module-utils: 2.12.0(eslint-import-resolver-node@0.3.9)(eslint@9.27.0(jiti@2.4.2)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -12300,8 +12637,6 @@ snapshots: semver: 6.3.1 string.prototype.trimend: 1.0.9 tsconfig-paths: 3.15.0 - optionalDependencies: - '@typescript-eslint/parser': 5.62.0(eslint@9.27.0(jiti@2.4.2))(typescript@5.8.3) transitivePeerDependencies: - eslint-import-resolver-typescript - eslint-import-resolver-webpack @@ -12350,11 +12685,6 @@ snapshots: eslint: 9.27.0(jiti@2.4.2) turbo: 2.5.3 - eslint-scope@5.1.1: - dependencies: - esrecurse: 4.3.0 - estraverse: 4.3.0 - eslint-scope@8.3.0: dependencies: esrecurse: 4.3.0 @@ -12422,8 +12752,6 @@ snapshots: dependencies: estraverse: 5.3.0 - estraverse@4.3.0: {} - estraverse@5.3.0: {} estree-util-attach-comments@3.0.0: @@ -12459,6 +12787,8 @@ snapshots: '@types/estree-jsx': 1.0.5 '@types/unist': 3.0.3 + estree-walker@2.0.2: {} + estree-walker@3.0.3: dependencies: '@types/estree': 1.0.7 @@ -12610,8 +12940,6 @@ snapshots: fast-levenshtein@2.0.6: {} - fast-uri@3.0.6: {} - fast-xml-parser@4.4.1: dependencies: strnum: 1.1.2 @@ -12624,6 +12952,15 @@ snapshots: dependencies: pend: 1.2.0 + fdir@6.4.6(picomatch@4.0.2): + optionalDependencies: + picomatch: 4.0.2 + + fetch-blob@3.2.0: + dependencies: + node-domexception: 1.0.0 + web-streams-polyfill: 3.3.3 + figures@3.2.0: dependencies: escape-string-regexp: 1.0.5 @@ -12632,7 +12969,8 @@ snapshots: dependencies: flat-cache: 4.0.1 - file-uri-to-path@1.0.0: {} + file-uri-to-path@1.0.0: + optional: true filename-reserved-regex@2.0.0: {} @@ -12712,6 +13050,10 @@ snapshots: node-domexception: 1.0.0 web-streams-polyfill: 4.0.0-beta.3 + formdata-polyfill@4.0.10: + dependencies: + fetch-blob: 3.2.0 + formidable@2.1.5: dependencies: '@paralleldrive/cuid2': 2.2.2 @@ -12775,7 +13117,7 @@ snapshots: fsevents@2.3.3: optional: true - fumadocs-core@15.3.0(@types/react@19.1.5)(next@15.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + fumadocs-core@15.3.0(@types/react@19.1.5)(next@15.3.1(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0): dependencies: '@formatjs/intl-localematcher': 0.6.1 '@orama/orama': 3.1.7 @@ -12793,14 +13135,14 @@ snapshots: shiki: 3.4.2 unist-util-visit: 5.0.0 optionalDependencies: - next: 15.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + next: 15.3.1(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) react: 19.1.0 react-dom: 19.1.0(react@19.1.0) transitivePeerDependencies: - '@types/react' - supports-color - fumadocs-mdx@11.6.3(@fumadocs/mdx-remote@1.3.3(@types/react@19.1.5)(acorn@8.14.1)(fumadocs-core@15.3.0(@types/react@19.1.5)(next@15.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0))(acorn@8.14.1)(fumadocs-core@15.3.0(@types/react@19.1.5)(next@15.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(next@15.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0)): + fumadocs-mdx@11.6.3(@fumadocs/mdx-remote@1.3.3(@types/react@19.1.5)(acorn@8.14.1)(fumadocs-core@15.3.0(@types/react@19.1.5)(next@15.3.1(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0))(acorn@8.14.1)(fumadocs-core@15.3.0(@types/react@19.1.5)(next@15.3.1(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(next@15.3.1(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)): dependencies: '@mdx-js/mdx': 3.1.0(acorn@8.14.1) '@standard-schema/spec': 1.0.0 @@ -12809,21 +13151,21 @@ snapshots: esbuild: 0.25.4 estree-util-value-to-estree: 3.4.0 fast-glob: 3.3.3 - fumadocs-core: 15.3.0(@types/react@19.1.5)(next@15.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + fumadocs-core: 15.3.0(@types/react@19.1.5)(next@15.3.1(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0) gray-matter: 4.0.3 js-yaml: 4.1.0 lru-cache: 11.1.0 - next: 15.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + next: 15.3.1(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) picocolors: 1.1.1 unist-util-visit: 5.0.0 zod: 3.25.24 optionalDependencies: - '@fumadocs/mdx-remote': 1.3.3(@types/react@19.1.5)(acorn@8.14.1)(fumadocs-core@15.3.0(@types/react@19.1.5)(next@15.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0) + '@fumadocs/mdx-remote': 1.3.3(@types/react@19.1.5)(acorn@8.14.1)(fumadocs-core@15.3.0(@types/react@19.1.5)(next@15.3.1(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0) transitivePeerDependencies: - acorn - supports-color - fumadocs-ui@15.3.0(@types/react-dom@19.1.5(@types/react@19.1.5))(@types/react@19.1.5)(next@15.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(tailwindcss@4.1.7): + fumadocs-ui@15.3.0(@types/react-dom@19.1.5(@types/react@19.1.5))(@types/react@19.1.5)(next@15.3.1(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(tailwindcss@4.1.7): dependencies: '@radix-ui/react-accordion': 1.2.11(@types/react-dom@19.1.5(@types/react@19.1.5))(@types/react@19.1.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-collapsible': 1.1.11(@types/react-dom@19.1.5(@types/react@19.1.5))(@types/react@19.1.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -12836,9 +13178,9 @@ snapshots: '@radix-ui/react-slot': 1.2.3(@types/react@19.1.5)(react@19.1.0) '@radix-ui/react-tabs': 1.1.12(@types/react-dom@19.1.5(@types/react@19.1.5))(@types/react@19.1.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) class-variance-authority: 0.7.1 - fumadocs-core: 15.3.0(@types/react@19.1.5)(next@15.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + fumadocs-core: 15.3.0(@types/react@19.1.5)(next@15.3.1(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0) lodash.merge: 4.6.2 - next: 15.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + next: 15.3.1(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) next-themes: 0.4.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0) postcss-selector-parser: 7.1.0 react: 19.1.0 @@ -13034,15 +13376,6 @@ snapshots: merge2: 1.4.1 slash: 3.0.0 - globby@11.1.0: - dependencies: - array-union: 2.1.0 - dir-glob: 3.0.1 - fast-glob: 3.3.3 - ignore: 5.3.2 - merge2: 1.4.1 - slash: 3.0.0 - globby@14.1.0: dependencies: '@sindresorhus/merge-streams': 2.3.0 @@ -13499,6 +13832,10 @@ snapshots: is-promise@2.2.2: {} + is-reference@1.2.1: + dependencies: + '@types/estree': 1.0.7 + is-regex@1.2.1: dependencies: call-bound: 1.0.4 @@ -13550,6 +13887,8 @@ snapshots: call-bound: 1.0.4 get-intrinsic: 1.3.0 + is-what@4.1.16: {} + isarray@2.0.5: {} isbinaryfile@4.0.10: {} @@ -13594,9 +13933,7 @@ snapshots: json-schema-traverse@0.4.1: {} - json-schema-traverse@1.0.0: {} - - json-schema-typed@8.0.1: {} + json-schema@0.4.0: {} json-stable-stringify-without-jsonify@1.0.1: {} @@ -13607,6 +13944,12 @@ snapshots: dependencies: minimist: 1.2.8 + jsondiffpatch@0.6.0: + dependencies: + '@types/diff-match-patch': 1.0.36 + chalk: 5.4.1 + diff-match-patch: 1.0.5 + jsonfile@4.0.0: optionalDependencies: graceful-fs: 4.2.11 @@ -13647,6 +13990,21 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 + libsql@0.5.13: + dependencies: + '@neon-rs/load': 0.0.4 + detect-libc: 2.0.2 + optionalDependencies: + '@libsql/darwin-arm64': 0.5.13 + '@libsql/darwin-x64': 0.5.13 + '@libsql/linux-arm-gnueabihf': 0.5.13 + '@libsql/linux-arm-musleabihf': 0.5.13 + '@libsql/linux-arm64-gnu': 0.5.13 + '@libsql/linux-arm64-musl': 0.5.13 + '@libsql/linux-x64-gnu': 0.5.13 + '@libsql/linux-x64-musl': 0.5.13 + '@libsql/win32-x64-msvc': 0.5.13 + lightningcss-darwin-arm64@1.30.1: optional: true @@ -14292,8 +14650,6 @@ snapshots: mimic-fn@2.1.0: {} - mimic-function@5.0.1: {} - mimic-response@1.0.1: {} mimic-response@3.1.0: {} @@ -14413,8 +14769,6 @@ snapshots: napi-build-utils@2.0.0: {} - natural-compare-lite@1.4.0: {} - natural-compare@1.4.0: {} negotiator@0.6.3: {} @@ -14427,9 +14781,9 @@ snapshots: netmask@2.0.2: {} - next-plausible@3.12.4(next@15.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + next-plausible@3.12.4(next@15.3.1(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0): dependencies: - next: 15.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + next: 15.3.1(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) react: 19.1.0 react-dom: 19.1.0(react@19.1.0) @@ -14438,7 +14792,7 @@ snapshots: react: 19.1.0 react-dom: 19.1.0(react@19.1.0) - next@15.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + next@15.3.1(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0): dependencies: '@next/env': 15.3.1 '@swc/counter': 0.1.3 @@ -14458,6 +14812,7 @@ snapshots: '@next/swc-linux-x64-musl': 15.3.1 '@next/swc-win32-arm64-msvc': 15.3.1 '@next/swc-win32-x64-msvc': 15.3.1 + '@opentelemetry/api': 1.9.0 sharp: 0.34.2 transitivePeerDependencies: - '@babel/core' @@ -14475,6 +14830,8 @@ snapshots: node-addon-api@4.3.0: {} + node-addon-api@7.1.1: {} + node-api-version@0.2.1: dependencies: semver: 7.7.2 @@ -14487,6 +14844,12 @@ snapshots: optionalDependencies: encoding: 0.1.13 + node-fetch@3.3.2: + dependencies: + data-uri-to-buffer: 4.0.1 + fetch-blob: 3.2.0 + formdata-polyfill: 4.0.10 + node-plop@0.26.3: dependencies: '@babel/runtime-corejs3': 7.27.1 @@ -14610,7 +14973,7 @@ snapshots: onnxruntime-common: 1.14.0 platform: 1.3.6 - openai@4.103.0(encoding@0.1.13)(ws@8.18.0)(zod@3.25.67): + openai@4.103.0(encoding@0.1.13)(ws@8.18.0)(zod@3.25.24): dependencies: '@types/node': 18.19.103 '@types/node-fetch': 2.6.12 @@ -14621,7 +14984,7 @@ snapshots: node-fetch: 2.7.0(encoding@0.1.13) optionalDependencies: ws: 8.18.0 - zod: 3.25.67 + zod: 3.25.24 transitivePeerDependencies: - encoding @@ -14803,6 +15166,8 @@ snapshots: picomatch@2.3.1: {} + picomatch@4.0.2: {} + pify@2.3.0: {} platform@1.3.6: {} @@ -14871,6 +15236,8 @@ snapshots: promise-inflight@1.0.1: {} + promise-limit@2.7.0: {} + promise-retry@2.0.1: dependencies: err-code: 2.0.3 @@ -15359,8 +15726,6 @@ snapshots: require-directory@2.1.1: {} - require-from-string@2.0.2: {} - resedit@2.0.3: dependencies: pe-library: 1.0.1 @@ -15512,6 +15877,8 @@ snapshots: extend-shallow: 2.0.1 kind-of: 6.0.3 + secure-json-parse@2.7.0: {} + semver-compare@1.0.0: optional: true @@ -15775,6 +16142,10 @@ snapshots: smart-buffer@4.2.0: {} + smart-whisper@0.2.0: + dependencies: + node-addon-api: 7.1.1 + snake-case@2.1.0: dependencies: no-case: 2.3.2 @@ -15988,8 +16359,6 @@ snapshots: strnum@1.1.2: {} - stubborn-fs@1.2.5: {} - style-to-js@1.1.16: dependencies: style-to-object: 1.0.8 @@ -16011,6 +16380,10 @@ snapshots: transitivePeerDependencies: - supports-color + superjson@2.2.2: + dependencies: + copy-anything: 3.0.5 + supports-color@5.5.0: dependencies: has-flag: 3.0.0 @@ -16026,6 +16399,12 @@ snapshots: lower-case: 1.1.4 upper-case: 1.1.3 + swr@2.3.3(react@19.1.0): + dependencies: + dequal: 2.0.3 + react: 19.1.0 + use-sync-external-store: 1.5.0(react@19.1.0) + synckit@0.11.6: dependencies: '@pkgr/core': 0.2.4 @@ -16090,6 +16469,8 @@ snapshots: third-party-capital@1.0.20: {} + throttleit@2.1.0: {} + through@2.3.8: {} tiny-each-async@2.0.3: @@ -16196,11 +16577,6 @@ snapshots: tsscmp@1.0.6: {} - tsutils@3.21.0(typescript@5.8.3): - dependencies: - tslib: 1.14.1 - typescript: 5.8.3 - tsx@4.19.4: dependencies: esbuild: 0.25.4 @@ -16252,8 +16628,6 @@ snapshots: type-fest@1.4.0: {} - type-fest@4.41.0: {} - type-is@1.6.18: dependencies: media-typer: 0.3.0 @@ -16323,8 +16697,6 @@ snapshots: dependencies: random-bytes: 1.0.0 - uint8array-extras@1.4.0: {} - unbox-primitive@1.1.0: dependencies: call-bound: 1.0.4 @@ -16531,6 +16903,8 @@ snapshots: dependencies: defaults: 1.0.4 + web-streams-polyfill@3.3.3: {} + web-streams-polyfill@4.0.0-beta.3: {} webidl-conversions@3.0.1: {} @@ -16540,8 +16914,6 @@ snapshots: tr46: 0.0.3 webidl-conversions: 3.0.1 - when-exit@2.1.4: {} - which-boxed-primitive@1.1.1: dependencies: is-bigint: 1.1.0