markupr 2.1.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/commands/review-feedback.md +47 -0
- package/.eslintrc.json +35 -0
- package/.github/CODEOWNERS +16 -0
- package/.github/FUNDING.yml +1 -0
- package/.github/ISSUE_TEMPLATE/bug_report.md +56 -0
- package/.github/ISSUE_TEMPLATE/feature_request.md +54 -0
- package/.github/PULL_REQUEST_TEMPLATE.md +89 -0
- package/.github/dependabot.yml +70 -0
- package/.github/workflows/ci.yml +184 -0
- package/.github/workflows/deploy-landing.yml +134 -0
- package/.github/workflows/nightly.yml +288 -0
- package/.github/workflows/release.yml +318 -0
- package/CHANGELOG.md +127 -0
- package/CLAUDE.md +137 -0
- package/CODE_OF_CONDUCT.md +9 -0
- package/CONTRIBUTING.md +390 -0
- package/LICENSE +21 -0
- package/PRODUCT_VISION.md +277 -0
- package/README.md +517 -0
- package/SECURITY.md +51 -0
- package/SIGNING_INSTRUCTIONS.md +284 -0
- package/assets/DMG_BACKGROUND_INSTRUCTIONS.md +130 -0
- package/assets/svg-source/dmg-background.svg +70 -0
- package/assets/svg-source/icon.svg +20 -0
- package/assets/svg-source/tray-icon-processing.svg +7 -0
- package/assets/svg-source/tray-icon-recording.svg +7 -0
- package/assets/svg-source/tray-icon.svg +6 -0
- package/assets/tray-complete.png +0 -0
- package/assets/tray-complete@2x.png +0 -0
- package/assets/tray-completeTemplate.png +0 -0
- package/assets/tray-completeTemplate@2x.png +0 -0
- package/assets/tray-error.png +0 -0
- package/assets/tray-error@2x.png +0 -0
- package/assets/tray-errorTemplate.png +0 -0
- package/assets/tray-errorTemplate@2x.png +0 -0
- package/assets/tray-icon-processing.png +0 -0
- package/assets/tray-icon-processing@2x.png +0 -0
- package/assets/tray-icon-processingTemplate.png +0 -0
- package/assets/tray-icon-processingTemplate@2x.png +0 -0
- package/assets/tray-icon-recording.png +0 -0
- package/assets/tray-icon-recording@2x.png +0 -0
- package/assets/tray-icon-recordingTemplate.png +0 -0
- package/assets/tray-icon-recordingTemplate@2x.png +0 -0
- package/assets/tray-icon.png +0 -0
- package/assets/tray-icon@2x.png +0 -0
- package/assets/tray-iconTemplate.png +0 -0
- package/assets/tray-iconTemplate@2x.png +0 -0
- package/assets/tray-idle.png +0 -0
- package/assets/tray-idle@2x.png +0 -0
- package/assets/tray-idleTemplate.png +0 -0
- package/assets/tray-idleTemplate@2x.png +0 -0
- package/assets/tray-processing-0.png +0 -0
- package/assets/tray-processing-0@2x.png +0 -0
- package/assets/tray-processing-0Template.png +0 -0
- package/assets/tray-processing-0Template@2x.png +0 -0
- package/assets/tray-processing-1.png +0 -0
- package/assets/tray-processing-1@2x.png +0 -0
- package/assets/tray-processing-1Template.png +0 -0
- package/assets/tray-processing-1Template@2x.png +0 -0
- package/assets/tray-processing-2.png +0 -0
- package/assets/tray-processing-2@2x.png +0 -0
- package/assets/tray-processing-2Template.png +0 -0
- package/assets/tray-processing-2Template@2x.png +0 -0
- package/assets/tray-processing-3.png +0 -0
- package/assets/tray-processing-3@2x.png +0 -0
- package/assets/tray-processing-3Template.png +0 -0
- package/assets/tray-processing-3Template@2x.png +0 -0
- package/assets/tray-processing.png +0 -0
- package/assets/tray-processing@2x.png +0 -0
- package/assets/tray-processingTemplate.png +0 -0
- package/assets/tray-processingTemplate@2x.png +0 -0
- package/assets/tray-recording.png +0 -0
- package/assets/tray-recording@2x.png +0 -0
- package/assets/tray-recordingTemplate.png +0 -0
- package/assets/tray-recordingTemplate@2x.png +0 -0
- package/build/DMG_BACKGROUND_SPEC.md +50 -0
- package/build/dmg-background.png +0 -0
- package/build/dmg-background@2x.png +0 -0
- package/build/entitlements.mac.inherit.plist +27 -0
- package/build/entitlements.mac.plist +41 -0
- package/build/favicon-16.png +0 -0
- package/build/favicon-180.png +0 -0
- package/build/favicon-192.png +0 -0
- package/build/favicon-32.png +0 -0
- package/build/favicon-48.png +0 -0
- package/build/favicon-512.png +0 -0
- package/build/favicon-64.png +0 -0
- package/build/icon-128.png +0 -0
- package/build/icon-16.png +0 -0
- package/build/icon-24.png +0 -0
- package/build/icon-256.png +0 -0
- package/build/icon-32.png +0 -0
- package/build/icon-48.png +0 -0
- package/build/icon-64.png +0 -0
- package/build/icon.icns +0 -0
- package/build/icon.ico +0 -0
- package/build/icon.iconset/icon_128x128.png +0 -0
- package/build/icon.iconset/icon_128x128@2x.png +0 -0
- package/build/icon.iconset/icon_16x16.png +0 -0
- package/build/icon.iconset/icon_16x16@2x.png +0 -0
- package/build/icon.iconset/icon_256x256.png +0 -0
- package/build/icon.iconset/icon_256x256@2x.png +0 -0
- package/build/icon.iconset/icon_32x32.png +0 -0
- package/build/icon.iconset/icon_32x32@2x.png +0 -0
- package/build/icon.iconset/icon_512x512.png +0 -0
- package/build/icon.iconset/icon_512x512@2x.png +0 -0
- package/build/icon.png +0 -0
- package/build/installer-header.bmp +0 -0
- package/build/installer-header.png +0 -0
- package/build/installer-sidebar.bmp +0 -0
- package/build/installer-sidebar.png +0 -0
- package/build/installer.nsh +45 -0
- package/build/overlay-processing.png +0 -0
- package/build/overlay-recording.png +0 -0
- package/build/toolbar-record.png +0 -0
- package/build/toolbar-screenshot.png +0 -0
- package/build/toolbar-settings.png +0 -0
- package/build/toolbar-stop.png +0 -0
- package/dist/main/index.mjs +12612 -0
- package/dist/preload/index.mjs +907 -0
- package/dist/renderer/assets/index-CCmUjl9K.js +19495 -0
- package/dist/renderer/assets/index-CUqz_Gs6.css +2270 -0
- package/dist/renderer/index.html +27 -0
- package/docs/AI_AGENT_QUICKSTART.md +42 -0
- package/docs/AI_PIPELINE_DESIGN.md +595 -0
- package/docs/API.md +514 -0
- package/docs/ARCHITECTURE.md +460 -0
- package/docs/CONFIGURATION.md +336 -0
- package/docs/DEVELOPMENT.md +508 -0
- package/docs/EXPORT_FORMATS.md +451 -0
- package/docs/GETTING_STARTED.md +236 -0
- package/docs/KEYBOARD_SHORTCUTS.md +334 -0
- package/docs/TROUBLESHOOTING.md +418 -0
- package/docs/landing/index.html +672 -0
- package/docs/landing/script.js +342 -0
- package/docs/landing/styles.css +1543 -0
- package/electron-builder.yml +140 -0
- package/electron.vite.config.ts +63 -0
- package/package.json +108 -0
- package/railway.json +12 -0
- package/scripts/build.mjs +51 -0
- package/scripts/generate-icons.mjs +314 -0
- package/scripts/generate-installer-images.cjs +253 -0
- package/scripts/generate-tray-icons.mjs +258 -0
- package/scripts/notarize.cjs +180 -0
- package/scripts/one-click-clean-test.sh +147 -0
- package/scripts/postinstall.mjs +36 -0
- package/scripts/setup-markupr.sh +55 -0
- package/setup +17 -0
- package/site/index.html +1835 -0
- package/site/package.json +11 -0
- package/site/railway.json +12 -0
- package/site/server.js +31 -0
- package/src/main/AutoUpdater.ts +392 -0
- package/src/main/CrashRecovery.ts +655 -0
- package/src/main/ErrorHandler.ts +703 -0
- package/src/main/HotkeyManager.ts +399 -0
- package/src/main/MenuManager.ts +529 -0
- package/src/main/PermissionManager.ts +420 -0
- package/src/main/SessionController.ts +1465 -0
- package/src/main/TrayManager.ts +540 -0
- package/src/main/ai/AIPipelineManager.ts +199 -0
- package/src/main/ai/ClaudeAnalyzer.ts +339 -0
- package/src/main/ai/ImageOptimizer.ts +176 -0
- package/src/main/ai/StructuredMarkdownBuilder.ts +379 -0
- package/src/main/ai/index.ts +16 -0
- package/src/main/ai/types.ts +258 -0
- package/src/main/analysis/ClarificationGenerator.ts +385 -0
- package/src/main/analysis/FeedbackAnalyzer.ts +531 -0
- package/src/main/analysis/index.ts +19 -0
- package/src/main/audio/AudioCapture.ts +978 -0
- package/src/main/audio/audioUtils.ts +100 -0
- package/src/main/audio/index.ts +20 -0
- package/src/main/capture/index.ts +1 -0
- package/src/main/index.ts +1693 -0
- package/src/main/ipc/captureHandlers.ts +272 -0
- package/src/main/ipc/index.ts +45 -0
- package/src/main/ipc/outputHandlers.ts +302 -0
- package/src/main/ipc/sessionHandlers.ts +56 -0
- package/src/main/ipc/settingsHandlers.ts +471 -0
- package/src/main/ipc/types.ts +56 -0
- package/src/main/ipc/windowHandlers.ts +277 -0
- package/src/main/output/ClipboardService.ts +369 -0
- package/src/main/output/ExportService.ts +539 -0
- package/src/main/output/FileManager.ts +416 -0
- package/src/main/output/MarkdownGenerator.ts +791 -0
- package/src/main/output/MarkdownPatcher.ts +299 -0
- package/src/main/output/index.ts +186 -0
- package/src/main/output/sessionAdapter.ts +207 -0
- package/src/main/output/templates/html-template.ts +553 -0
- package/src/main/pipeline/FrameExtractor.ts +330 -0
- package/src/main/pipeline/PostProcessor.ts +399 -0
- package/src/main/pipeline/TranscriptAnalyzer.ts +226 -0
- package/src/main/pipeline/index.ts +36 -0
- package/src/main/platform/WindowsTaskbar.ts +600 -0
- package/src/main/platform/index.ts +16 -0
- package/src/main/settings/SettingsManager.ts +730 -0
- package/src/main/settings/index.ts +19 -0
- package/src/main/transcription/ModelDownloadManager.ts +494 -0
- package/src/main/transcription/TierManager.ts +219 -0
- package/src/main/transcription/TranscriptionRecoveryService.ts +340 -0
- package/src/main/transcription/WhisperService.ts +748 -0
- package/src/main/transcription/index.ts +56 -0
- package/src/main/transcription/types.ts +135 -0
- package/src/main/windows/PopoverManager.ts +284 -0
- package/src/main/windows/TaskbarIntegration.ts +452 -0
- package/src/main/windows/index.ts +23 -0
- package/src/preload/index.ts +1047 -0
- package/src/renderer/App.tsx +515 -0
- package/src/renderer/AppWrapper.tsx +28 -0
- package/src/renderer/assets/logo-dark.svg +7 -0
- package/src/renderer/assets/logo.svg +7 -0
- package/src/renderer/audio/AudioCaptureRenderer.ts +454 -0
- package/src/renderer/capture/ScreenRecordingRenderer.ts +492 -0
- package/src/renderer/components/AnnotationOverlay.tsx +836 -0
- package/src/renderer/components/AudioWaveform.tsx +811 -0
- package/src/renderer/components/ClarificationQuestions.tsx +656 -0
- package/src/renderer/components/CountdownTimer.tsx +495 -0
- package/src/renderer/components/CrashRecoveryDialog.tsx +632 -0
- package/src/renderer/components/DonateButton.tsx +127 -0
- package/src/renderer/components/ErrorBoundary.tsx +308 -0
- package/src/renderer/components/ExportDialog.tsx +872 -0
- package/src/renderer/components/HotkeyHint.tsx +261 -0
- package/src/renderer/components/KeyboardShortcuts.tsx +787 -0
- package/src/renderer/components/ModelDownloadDialog.tsx +844 -0
- package/src/renderer/components/Onboarding.tsx +1830 -0
- package/src/renderer/components/ProcessingOverlay.tsx +157 -0
- package/src/renderer/components/RecordingOverlay.tsx +423 -0
- package/src/renderer/components/SessionHistory.tsx +1746 -0
- package/src/renderer/components/SessionReview.tsx +1321 -0
- package/src/renderer/components/SettingsPanel.tsx +217 -0
- package/src/renderer/components/Skeleton.tsx +347 -0
- package/src/renderer/components/StatusIndicator.tsx +86 -0
- package/src/renderer/components/ThemeProvider.tsx +429 -0
- package/src/renderer/components/Tooltip.tsx +370 -0
- package/src/renderer/components/TranscriptionPreview.tsx +183 -0
- package/src/renderer/components/TranscriptionTierSelector.tsx +640 -0
- package/src/renderer/components/UpdateNotification.tsx +377 -0
- package/src/renderer/components/WindowSelector.tsx +947 -0
- package/src/renderer/components/index.ts +99 -0
- package/src/renderer/components/primitives/ApiKeyInput.tsx +98 -0
- package/src/renderer/components/primitives/ColorPicker.tsx +65 -0
- package/src/renderer/components/primitives/DangerButton.tsx +45 -0
- package/src/renderer/components/primitives/DirectoryPicker.tsx +41 -0
- package/src/renderer/components/primitives/Dropdown.tsx +34 -0
- package/src/renderer/components/primitives/KeyRecorder.tsx +117 -0
- package/src/renderer/components/primitives/SettingsSection.tsx +32 -0
- package/src/renderer/components/primitives/Slider.tsx +43 -0
- package/src/renderer/components/primitives/Toggle.tsx +36 -0
- package/src/renderer/components/primitives/index.ts +10 -0
- package/src/renderer/components/settings/AdvancedTab.tsx +174 -0
- package/src/renderer/components/settings/AppearanceTab.tsx +77 -0
- package/src/renderer/components/settings/GeneralTab.tsx +40 -0
- package/src/renderer/components/settings/HotkeysTab.tsx +79 -0
- package/src/renderer/components/settings/RecordingTab.tsx +84 -0
- package/src/renderer/components/settings/index.ts +9 -0
- package/src/renderer/components/settings/settingsStyles.ts +673 -0
- package/src/renderer/components/settings/tabConfig.tsx +85 -0
- package/src/renderer/components/settings/useSettingsPanel.ts +447 -0
- package/src/renderer/contexts/ProcessingContext.tsx +227 -0
- package/src/renderer/contexts/RecordingContext.tsx +683 -0
- package/src/renderer/contexts/UIContext.tsx +326 -0
- package/src/renderer/contexts/index.ts +24 -0
- package/src/renderer/donateMessages.ts +69 -0
- package/src/renderer/hooks/index.ts +75 -0
- package/src/renderer/hooks/useAnimation.tsx +544 -0
- package/src/renderer/hooks/useTheme.ts +313 -0
- package/src/renderer/index.html +26 -0
- package/src/renderer/main.tsx +52 -0
- package/src/renderer/styles/animations.css +1093 -0
- package/src/renderer/styles/app-shell.css +662 -0
- package/src/renderer/styles/globals.css +515 -0
- package/src/renderer/styles/theme.ts +578 -0
- package/src/renderer/types/electron.d.ts +385 -0
- package/src/shared/hotkeys.ts +283 -0
- package/src/shared/types.ts +809 -0
- package/tests/clipboard.test.ts +228 -0
- package/tests/e2e/criticalPaths.test.ts +594 -0
- package/tests/feedbackAnalyzer.test.ts +303 -0
- package/tests/integration/sessionFlow.test.ts +583 -0
- package/tests/markdownGenerator.test.ts +418 -0
- package/tests/output.test.ts +96 -0
- package/tests/setup.ts +486 -0
- package/tests/unit/appIntegration.test.ts +676 -0
- package/tests/unit/appViewState.test.ts +281 -0
- package/tests/unit/audioIpcChannels.test.ts +17 -0
- package/tests/unit/exportService.test.ts +492 -0
- package/tests/unit/hotkeys.test.ts +92 -0
- package/tests/unit/navigationPreload.test.ts +94 -0
- package/tests/unit/onboardingFlow.test.ts +345 -0
- package/tests/unit/permissionManager.test.ts +175 -0
- package/tests/unit/permissionManagerExpanded.test.ts +296 -0
- package/tests/unit/screenRecordingRenderer.test.ts +368 -0
- package/tests/unit/sessionController.test.ts +515 -0
- package/tests/unit/tierManager.test.ts +61 -0
- package/tests/unit/tierManagerExpanded.test.ts +142 -0
- package/tests/unit/transcriptAnalyzer.test.ts +64 -0
- package/tsconfig.json +25 -0
- package/vitest.config.ts +46 -0
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Settings Module
|
|
3
|
+
*
|
|
4
|
+
* Exports the SettingsManager for persistent settings storage
|
|
5
|
+
* and secure API key management via keytar.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export {
|
|
9
|
+
SettingsManager,
|
|
10
|
+
settingsManager,
|
|
11
|
+
getSettingsManager,
|
|
12
|
+
createSettingsManager,
|
|
13
|
+
DEFAULT_SETTINGS,
|
|
14
|
+
SETTINGS_VERSION,
|
|
15
|
+
} from './SettingsManager';
|
|
16
|
+
|
|
17
|
+
export type { AppSettings, ISettingsManager } from './SettingsManager';
|
|
18
|
+
|
|
19
|
+
export { settingsManager as default } from './SettingsManager';
|
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ModelDownloadManager.ts - Whisper Model Download and Management
|
|
3
|
+
*
|
|
4
|
+
* Handles:
|
|
5
|
+
* - Downloading Whisper models from Hugging Face
|
|
6
|
+
* - Progress tracking with events
|
|
7
|
+
* - Resume support for interrupted downloads
|
|
8
|
+
* - Storage management
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { EventEmitter } from 'events';
|
|
12
|
+
import { app } from 'electron';
|
|
13
|
+
import { createWriteStream, existsSync, statSync, unlinkSync, mkdirSync, renameSync } from 'fs';
|
|
14
|
+
import { join } from 'path';
|
|
15
|
+
import * as https from 'https';
|
|
16
|
+
import type {
|
|
17
|
+
WhisperModel,
|
|
18
|
+
ModelInfo,
|
|
19
|
+
DownloadProgress,
|
|
20
|
+
DownloadResult,
|
|
21
|
+
ProgressCallback,
|
|
22
|
+
CompleteCallback,
|
|
23
|
+
} from './types';
|
|
24
|
+
|
|
25
|
+
// ============================================================================
|
|
26
|
+
// Constants
|
|
27
|
+
// ============================================================================
|
|
28
|
+
|
|
29
|
+
const HUGGINGFACE_BASE_URL = 'https://huggingface.co/ggerganov/whisper.cpp/resolve/main';
|
|
30
|
+
|
|
31
|
+
const MODEL_INFO: Record<WhisperModel, ModelInfo> = {
|
|
32
|
+
tiny: {
|
|
33
|
+
name: 'tiny',
|
|
34
|
+
filename: 'ggml-tiny.bin',
|
|
35
|
+
sizeBytes: 75_000_000,
|
|
36
|
+
sizeMB: 75,
|
|
37
|
+
ramRequired: '~400MB',
|
|
38
|
+
quality: 'Low - Fast, less accurate',
|
|
39
|
+
url: `${HUGGINGFACE_BASE_URL}/ggml-tiny.bin`,
|
|
40
|
+
},
|
|
41
|
+
base: {
|
|
42
|
+
name: 'base',
|
|
43
|
+
filename: 'ggml-base.bin',
|
|
44
|
+
sizeBytes: 142_000_000,
|
|
45
|
+
sizeMB: 142,
|
|
46
|
+
ramRequired: '~700MB',
|
|
47
|
+
quality: 'Medium - Balanced',
|
|
48
|
+
url: `${HUGGINGFACE_BASE_URL}/ggml-base.bin`,
|
|
49
|
+
},
|
|
50
|
+
small: {
|
|
51
|
+
name: 'small',
|
|
52
|
+
filename: 'ggml-small.bin',
|
|
53
|
+
sizeBytes: 466_000_000,
|
|
54
|
+
sizeMB: 466,
|
|
55
|
+
ramRequired: '~1.2GB',
|
|
56
|
+
quality: 'Good - Recommended for low RAM',
|
|
57
|
+
url: `${HUGGINGFACE_BASE_URL}/ggml-small.bin`,
|
|
58
|
+
},
|
|
59
|
+
medium: {
|
|
60
|
+
name: 'medium',
|
|
61
|
+
filename: 'ggml-medium.bin',
|
|
62
|
+
sizeBytes: 1_500_000_000,
|
|
63
|
+
sizeMB: 1500,
|
|
64
|
+
ramRequired: '~2.5GB',
|
|
65
|
+
quality: 'High - Recommended default',
|
|
66
|
+
url: `${HUGGINGFACE_BASE_URL}/ggml-medium.bin`,
|
|
67
|
+
},
|
|
68
|
+
large: {
|
|
69
|
+
name: 'large',
|
|
70
|
+
filename: 'ggml-large-v3.bin',
|
|
71
|
+
sizeBytes: 3_100_000_000,
|
|
72
|
+
sizeMB: 3100,
|
|
73
|
+
ramRequired: '~5GB',
|
|
74
|
+
quality: 'Best - Most accurate, slowest',
|
|
75
|
+
url: `${HUGGINGFACE_BASE_URL}/ggml-large-v3.bin`,
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
// ============================================================================
|
|
80
|
+
// ModelDownloadManager Class
|
|
81
|
+
// ============================================================================
|
|
82
|
+
|
|
83
|
+
export class ModelDownloadManager extends EventEmitter {
|
|
84
|
+
private modelsDir: string;
|
|
85
|
+
private activeDownloads: Map<WhisperModel, { abort: () => void; request?: ReturnType<typeof https.get> }> = new Map();
|
|
86
|
+
|
|
87
|
+
// Callbacks
|
|
88
|
+
private progressCallbacks: ProgressCallback[] = [];
|
|
89
|
+
private completeCallbacks: CompleteCallback[] = [];
|
|
90
|
+
private errorCallbacks: Array<(error: Error, model: WhisperModel) => void> = [];
|
|
91
|
+
|
|
92
|
+
constructor() {
|
|
93
|
+
super();
|
|
94
|
+
this.modelsDir = this.getModelsDirectory();
|
|
95
|
+
this.ensureModelsDirectory();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ============================================================================
|
|
99
|
+
// Public API
|
|
100
|
+
// ============================================================================
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Get the directory where models are stored
|
|
104
|
+
*/
|
|
105
|
+
getModelsDirectory(): string {
|
|
106
|
+
// Handle case where app is not ready yet
|
|
107
|
+
try {
|
|
108
|
+
return join(app.getPath('userData'), 'whisper-models');
|
|
109
|
+
} catch {
|
|
110
|
+
// Fallback for testing or early initialization
|
|
111
|
+
const homeDir = process.env.HOME || process.env.USERPROFILE || '/tmp';
|
|
112
|
+
return join(homeDir, '.markupr', 'whisper-models');
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Get information about all available models
|
|
118
|
+
*/
|
|
119
|
+
getAvailableModels(): ModelInfo[] {
|
|
120
|
+
return Object.values(MODEL_INFO);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Get information about a specific model
|
|
125
|
+
*/
|
|
126
|
+
getModelInfo(model: WhisperModel): ModelInfo {
|
|
127
|
+
return MODEL_INFO[model];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Get the path for a model file
|
|
132
|
+
*/
|
|
133
|
+
getModelPath(model: WhisperModel): string {
|
|
134
|
+
return join(this.modelsDir, MODEL_INFO[model].filename);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Check if a model is downloaded and valid
|
|
139
|
+
*/
|
|
140
|
+
isModelDownloaded(model: WhisperModel): boolean {
|
|
141
|
+
const path = this.getModelPath(model);
|
|
142
|
+
if (!existsSync(path)) {
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Check file size matches expected (with 5% variance for compression differences)
|
|
147
|
+
const stats = statSync(path);
|
|
148
|
+
const expectedSize = MODEL_INFO[model].sizeBytes;
|
|
149
|
+
const variance = expectedSize * 0.05;
|
|
150
|
+
return Math.abs(stats.size - expectedSize) < variance;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Get the default (best available) model
|
|
155
|
+
*/
|
|
156
|
+
getDefaultModel(): WhisperModel {
|
|
157
|
+
// Prefer medium, fall back to smaller models
|
|
158
|
+
const preference: WhisperModel[] = ['medium', 'small', 'base', 'tiny'];
|
|
159
|
+
|
|
160
|
+
for (const model of preference) {
|
|
161
|
+
if (this.isModelDownloaded(model)) {
|
|
162
|
+
return model;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return 'medium'; // Default to download medium
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Check if any Whisper model is downloaded
|
|
171
|
+
*/
|
|
172
|
+
hasAnyModel(): boolean {
|
|
173
|
+
const models: WhisperModel[] = ['tiny', 'base', 'small', 'medium', 'large'];
|
|
174
|
+
return models.some((model) => this.isModelDownloaded(model));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Download a model with progress tracking
|
|
179
|
+
*/
|
|
180
|
+
async downloadModel(model: WhisperModel): Promise<DownloadResult> {
|
|
181
|
+
const info = MODEL_INFO[model];
|
|
182
|
+
const targetPath = this.getModelPath(model);
|
|
183
|
+
const tempPath = `${targetPath}.download`;
|
|
184
|
+
|
|
185
|
+
// Check if already downloaded
|
|
186
|
+
if (this.isModelDownloaded(model)) {
|
|
187
|
+
this.log(`Model ${model} already downloaded`);
|
|
188
|
+
return { success: true, model, path: targetPath };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Check if download already in progress
|
|
192
|
+
if (this.activeDownloads.has(model)) {
|
|
193
|
+
throw new Error(`Download already in progress for ${model}`);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
this.log(`Starting download: ${model} (${info.sizeMB}MB)`);
|
|
197
|
+
|
|
198
|
+
return new Promise((resolve, reject) => {
|
|
199
|
+
let downloadedBytes = 0;
|
|
200
|
+
let lastProgressTime = Date.now();
|
|
201
|
+
let lastDownloadedBytes = 0;
|
|
202
|
+
let aborted = false;
|
|
203
|
+
|
|
204
|
+
// Create abort controller
|
|
205
|
+
const abort = (): void => {
|
|
206
|
+
aborted = true;
|
|
207
|
+
const download = this.activeDownloads.get(model);
|
|
208
|
+
if (download?.request) {
|
|
209
|
+
download.request.destroy();
|
|
210
|
+
}
|
|
211
|
+
// Keep partial download for resume
|
|
212
|
+
this.activeDownloads.delete(model);
|
|
213
|
+
this.log(`Download cancelled: ${model}`);
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
this.activeDownloads.set(model, { abort });
|
|
217
|
+
|
|
218
|
+
// Check for partial download (resume support)
|
|
219
|
+
if (existsSync(tempPath)) {
|
|
220
|
+
const stats = statSync(tempPath);
|
|
221
|
+
downloadedBytes = stats.size;
|
|
222
|
+
lastDownloadedBytes = downloadedBytes;
|
|
223
|
+
this.log(`Resuming download from ${Math.round(downloadedBytes / 1024 / 1024)}MB`);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Create write stream (append mode if resuming)
|
|
227
|
+
const writeStream = createWriteStream(tempPath, {
|
|
228
|
+
flags: downloadedBytes > 0 ? 'a' : 'w',
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
const handleResponse = (response: import('http').IncomingMessage, redirectCount: number = 0): void => {
|
|
232
|
+
if (aborted) {
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Handle redirects (Hugging Face uses redirects)
|
|
237
|
+
if (response.statusCode === 301 || response.statusCode === 302 || response.statusCode === 307) {
|
|
238
|
+
if (redirectCount > 5) {
|
|
239
|
+
const error = new Error('Too many redirects');
|
|
240
|
+
this.handleDownloadError(error, model, tempPath);
|
|
241
|
+
reject(error);
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const redirectUrl = response.headers.location;
|
|
246
|
+
if (redirectUrl) {
|
|
247
|
+
this.log(`Following redirect to: ${redirectUrl.substring(0, 50)}...`);
|
|
248
|
+
const redirectRequest = https.get(
|
|
249
|
+
redirectUrl,
|
|
250
|
+
{
|
|
251
|
+
headers: downloadedBytes > 0 ? { Range: `bytes=${downloadedBytes}-` } : {},
|
|
252
|
+
},
|
|
253
|
+
(redirectResponse) => {
|
|
254
|
+
handleResponse(redirectResponse, redirectCount + 1);
|
|
255
|
+
}
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
redirectRequest.on('error', (error) => {
|
|
259
|
+
this.handleDownloadError(error, model, tempPath);
|
|
260
|
+
reject(error);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
// Store request for abort
|
|
264
|
+
const download = this.activeDownloads.get(model);
|
|
265
|
+
if (download) {
|
|
266
|
+
download.request = redirectRequest;
|
|
267
|
+
}
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Handle partial content (resume) or full content
|
|
273
|
+
if (response.statusCode !== 200 && response.statusCode !== 206) {
|
|
274
|
+
const error = new Error(`Download failed: HTTP ${response.statusCode}`);
|
|
275
|
+
this.handleDownloadError(error, model, tempPath);
|
|
276
|
+
reject(error);
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const contentLength = parseInt(response.headers['content-length'] || '0', 10);
|
|
281
|
+
const totalBytes = downloadedBytes + contentLength;
|
|
282
|
+
|
|
283
|
+
response.on('data', (chunk: Buffer) => {
|
|
284
|
+
if (aborted) {
|
|
285
|
+
response.destroy();
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
downloadedBytes += chunk.length;
|
|
290
|
+
|
|
291
|
+
// Calculate progress
|
|
292
|
+
const now = Date.now();
|
|
293
|
+
const timeDelta = (now - lastProgressTime) / 1000;
|
|
294
|
+
|
|
295
|
+
if (timeDelta >= 0.1) {
|
|
296
|
+
// Update every 100ms
|
|
297
|
+
const bytesDelta = downloadedBytes - lastDownloadedBytes;
|
|
298
|
+
const speedBps = timeDelta > 0 ? bytesDelta / timeDelta : 0;
|
|
299
|
+
const remainingBytes = totalBytes - downloadedBytes;
|
|
300
|
+
const estimatedSecondsRemaining = speedBps > 0 ? remainingBytes / speedBps : 0;
|
|
301
|
+
|
|
302
|
+
const progress: DownloadProgress = {
|
|
303
|
+
model,
|
|
304
|
+
downloadedBytes,
|
|
305
|
+
totalBytes,
|
|
306
|
+
percent: totalBytes > 0 ? Math.round((downloadedBytes / totalBytes) * 100) : 0,
|
|
307
|
+
speedBps: Math.round(speedBps),
|
|
308
|
+
estimatedSecondsRemaining: Math.round(estimatedSecondsRemaining),
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
this.progressCallbacks.forEach((cb) => cb(progress));
|
|
312
|
+
this.emit('progress', progress);
|
|
313
|
+
|
|
314
|
+
lastProgressTime = now;
|
|
315
|
+
lastDownloadedBytes = downloadedBytes;
|
|
316
|
+
}
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
response.pipe(writeStream);
|
|
320
|
+
|
|
321
|
+
writeStream.on('finish', () => {
|
|
322
|
+
if (aborted) {
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
this.activeDownloads.delete(model);
|
|
327
|
+
|
|
328
|
+
// Rename temp file to final
|
|
329
|
+
try {
|
|
330
|
+
renameSync(tempPath, targetPath);
|
|
331
|
+
} catch (renameError) {
|
|
332
|
+
this.handleDownloadError(renameError as Error, model, tempPath);
|
|
333
|
+
reject(renameError);
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Verify download
|
|
338
|
+
if (this.isModelDownloaded(model)) {
|
|
339
|
+
const result: DownloadResult = { success: true, model, path: targetPath };
|
|
340
|
+
this.completeCallbacks.forEach((cb) => cb(result));
|
|
341
|
+
this.emit('complete', result);
|
|
342
|
+
this.log(`Download complete: ${model}`);
|
|
343
|
+
resolve(result);
|
|
344
|
+
} else {
|
|
345
|
+
const error = new Error('Downloaded file size mismatch - download may be corrupted');
|
|
346
|
+
this.handleDownloadError(error, model, targetPath);
|
|
347
|
+
reject(error);
|
|
348
|
+
}
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
writeStream.on('error', (error) => {
|
|
352
|
+
this.handleDownloadError(error, model, tempPath);
|
|
353
|
+
reject(error);
|
|
354
|
+
});
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
// Make initial HTTP request
|
|
358
|
+
const request = https.get(
|
|
359
|
+
info.url,
|
|
360
|
+
{
|
|
361
|
+
headers: downloadedBytes > 0 ? { Range: `bytes=${downloadedBytes}-` } : {},
|
|
362
|
+
},
|
|
363
|
+
(response) => {
|
|
364
|
+
handleResponse(response);
|
|
365
|
+
}
|
|
366
|
+
);
|
|
367
|
+
|
|
368
|
+
request.on('error', (error) => {
|
|
369
|
+
this.handleDownloadError(error, model, tempPath);
|
|
370
|
+
reject(error);
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
// Store request for abort
|
|
374
|
+
const download = this.activeDownloads.get(model);
|
|
375
|
+
if (download) {
|
|
376
|
+
download.request = request;
|
|
377
|
+
}
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Cancel an active download
|
|
383
|
+
*/
|
|
384
|
+
cancelDownload(model: WhisperModel): void {
|
|
385
|
+
const download = this.activeDownloads.get(model);
|
|
386
|
+
if (download) {
|
|
387
|
+
download.abort();
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Check if a download is in progress
|
|
393
|
+
*/
|
|
394
|
+
isDownloading(model: WhisperModel): boolean {
|
|
395
|
+
return this.activeDownloads.has(model);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Delete a downloaded model
|
|
400
|
+
*/
|
|
401
|
+
deleteModel(model: WhisperModel): void {
|
|
402
|
+
const path = this.getModelPath(model);
|
|
403
|
+
if (existsSync(path)) {
|
|
404
|
+
unlinkSync(path);
|
|
405
|
+
this.log(`Model deleted: ${model}`);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// Also delete partial download if exists
|
|
409
|
+
const tempPath = `${path}.download`;
|
|
410
|
+
if (existsSync(tempPath)) {
|
|
411
|
+
unlinkSync(tempPath);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Get storage usage information
|
|
417
|
+
*/
|
|
418
|
+
getStorageInfo(): { totalBytes: number; models: Array<{ model: WhisperModel; sizeBytes: number }> } {
|
|
419
|
+
const models: Array<{ model: WhisperModel; sizeBytes: number }> = [];
|
|
420
|
+
let totalBytes = 0;
|
|
421
|
+
|
|
422
|
+
for (const model of Object.keys(MODEL_INFO) as WhisperModel[]) {
|
|
423
|
+
const path = this.getModelPath(model);
|
|
424
|
+
if (existsSync(path)) {
|
|
425
|
+
const stats = statSync(path);
|
|
426
|
+
models.push({ model, sizeBytes: stats.size });
|
|
427
|
+
totalBytes += stats.size;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
return { totalBytes, models };
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// ============================================================================
|
|
435
|
+
// Event Subscriptions
|
|
436
|
+
// ============================================================================
|
|
437
|
+
|
|
438
|
+
onProgress(callback: ProgressCallback): () => void {
|
|
439
|
+
this.progressCallbacks.push(callback);
|
|
440
|
+
return () => {
|
|
441
|
+
this.progressCallbacks = this.progressCallbacks.filter((cb) => cb !== callback);
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
onComplete(callback: CompleteCallback): () => void {
|
|
446
|
+
this.completeCallbacks.push(callback);
|
|
447
|
+
return () => {
|
|
448
|
+
this.completeCallbacks = this.completeCallbacks.filter((cb) => cb !== callback);
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
onError(callback: (error: Error, model: WhisperModel) => void): () => void {
|
|
453
|
+
this.errorCallbacks.push(callback);
|
|
454
|
+
return () => {
|
|
455
|
+
this.errorCallbacks = this.errorCallbacks.filter((cb) => cb !== callback);
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// ============================================================================
|
|
460
|
+
// Private Methods
|
|
461
|
+
// ============================================================================
|
|
462
|
+
|
|
463
|
+
private ensureModelsDirectory(): void {
|
|
464
|
+
if (!existsSync(this.modelsDir)) {
|
|
465
|
+
mkdirSync(this.modelsDir, { recursive: true });
|
|
466
|
+
this.log(`Created models directory: ${this.modelsDir}`);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
private handleDownloadError(error: Error, model: WhisperModel, _tempPath: string): void {
|
|
471
|
+
this.activeDownloads.delete(model);
|
|
472
|
+
|
|
473
|
+
// Keep partial download for resume (don't delete tempPath)
|
|
474
|
+
this.errorCallbacks.forEach((cb) => cb(error, model));
|
|
475
|
+
this.emit('error', error, model);
|
|
476
|
+
this.logError(`Download failed: ${model}`, error);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
private log(message: string): void {
|
|
480
|
+
console.log(`[ModelDownloadManager] ${message}`);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
private logError(message: string, error?: unknown): void {
|
|
484
|
+
const errorStr = error instanceof Error ? error.message : String(error);
|
|
485
|
+
console.error(`[ModelDownloadManager] ERROR: ${message} - ${errorStr}`);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// ============================================================================
|
|
490
|
+
// Singleton Export
|
|
491
|
+
// ============================================================================
|
|
492
|
+
|
|
493
|
+
export const modelDownloadManager = new ModelDownloadManager();
|
|
494
|
+
export default ModelDownloadManager;
|