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,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AIPipelineManager - Orchestrator for the AI analysis pipeline
|
|
3
|
+
*
|
|
4
|
+
* Determines which processing tier to use (free vs BYOK), generates output accordingly,
|
|
5
|
+
* and ensures the free-tier safety net is always available as fallback.
|
|
6
|
+
*
|
|
7
|
+
* Key invariant: session data is NEVER lost. Free-tier output is always generated first,
|
|
8
|
+
* and AI enhancement is layered on top only when it succeeds.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { Session } from '../SessionController';
|
|
12
|
+
import type { MarkdownDocument } from '../output/FileManager';
|
|
13
|
+
import type { ISettingsManager } from '../settings/SettingsManager';
|
|
14
|
+
import { generateDocumentForFileManager } from '../output/sessionAdapter';
|
|
15
|
+
import { ClaudeAnalyzer } from './ClaudeAnalyzer';
|
|
16
|
+
import { structuredMarkdownBuilder } from './StructuredMarkdownBuilder';
|
|
17
|
+
import type {
|
|
18
|
+
AITier,
|
|
19
|
+
AIPipelineOutput,
|
|
20
|
+
} from './types';
|
|
21
|
+
|
|
22
|
+
export interface PipelineProcessOptions {
|
|
23
|
+
settingsManager: ISettingsManager;
|
|
24
|
+
projectName?: string;
|
|
25
|
+
screenshotDir?: string;
|
|
26
|
+
hasRecording?: boolean;
|
|
27
|
+
recordingFilename?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Determine which AI tier is available based on stored API keys.
|
|
32
|
+
*/
|
|
33
|
+
async function determineTier(settingsManager: ISettingsManager): Promise<AITier> {
|
|
34
|
+
const anthropicKey = await settingsManager.getApiKey('anthropic');
|
|
35
|
+
if (anthropicKey && anthropicKey.length > 0) {
|
|
36
|
+
console.log('[AIPipelineManager] Tier decision: BYOK (Anthropic API key found in keychain)');
|
|
37
|
+
return 'byok';
|
|
38
|
+
}
|
|
39
|
+
console.log('[AIPipelineManager] Tier decision: FREE (no Anthropic API key configured)');
|
|
40
|
+
return 'free';
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Generate a free-tier (rule-based) document. This is the safety net that always works.
|
|
45
|
+
*/
|
|
46
|
+
function generateFreeTierDocument(
|
|
47
|
+
session: Session,
|
|
48
|
+
projectName: string,
|
|
49
|
+
screenshotDir: string,
|
|
50
|
+
): MarkdownDocument {
|
|
51
|
+
return generateDocumentForFileManager(session, {
|
|
52
|
+
projectName,
|
|
53
|
+
screenshotDir,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Process a session through the AI pipeline.
|
|
59
|
+
*
|
|
60
|
+
* 1. Always generates free-tier output first (safety net)
|
|
61
|
+
* 2. If BYOK tier is available, attempts AI enhancement
|
|
62
|
+
* 3. On any AI failure, returns the free-tier output
|
|
63
|
+
*
|
|
64
|
+
* @returns A MarkdownDocument compatible with FileManager.saveSession()
|
|
65
|
+
*/
|
|
66
|
+
export async function processSession(
|
|
67
|
+
session: Session,
|
|
68
|
+
options: PipelineProcessOptions,
|
|
69
|
+
): Promise<{ document: MarkdownDocument; pipelineOutput: AIPipelineOutput }> {
|
|
70
|
+
const startTime = Date.now();
|
|
71
|
+
const projectName = options.projectName || session.metadata?.sourceName || 'Feedback Session';
|
|
72
|
+
const screenshotDir = options.screenshotDir || './screenshots';
|
|
73
|
+
|
|
74
|
+
// ALWAYS generate free-tier output first as safety net
|
|
75
|
+
console.log('[AIPipelineManager] Generating free-tier output as safety net...');
|
|
76
|
+
const freeTierDoc = generateFreeTierDocument(session, projectName, screenshotDir);
|
|
77
|
+
console.log('[AIPipelineManager] Free-tier output ready (rule-based markdown generated)');
|
|
78
|
+
|
|
79
|
+
// Determine tier
|
|
80
|
+
const tier = await determineTier(options.settingsManager);
|
|
81
|
+
|
|
82
|
+
if (tier === 'free') {
|
|
83
|
+
console.log(
|
|
84
|
+
`[AIPipelineManager] Using free-tier output (no AI enhancement). ` +
|
|
85
|
+
`Session had ${session.feedbackItems.length} feedback items, ` +
|
|
86
|
+
`${session.transcriptBuffer.length} transcript events. ` +
|
|
87
|
+
`Completed in ${Date.now() - startTime}ms.`
|
|
88
|
+
);
|
|
89
|
+
return {
|
|
90
|
+
document: freeTierDoc,
|
|
91
|
+
pipelineOutput: {
|
|
92
|
+
markdown: freeTierDoc.content,
|
|
93
|
+
aiEnhanced: false,
|
|
94
|
+
processingTimeMs: Date.now() - startTime,
|
|
95
|
+
tier: 'free',
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// BYOK tier: attempt AI enhancement
|
|
101
|
+
console.log('[AIPipelineManager] BYOK tier: attempting Claude AI enhancement...');
|
|
102
|
+
try {
|
|
103
|
+
const apiKey = await options.settingsManager.getApiKey('anthropic');
|
|
104
|
+
if (!apiKey) {
|
|
105
|
+
// Shouldn't happen since determineTier checked, but be defensive
|
|
106
|
+
console.warn('[AIPipelineManager] BYOK -> FREE fallback: API key disappeared between tier check and usage');
|
|
107
|
+
return {
|
|
108
|
+
document: freeTierDoc,
|
|
109
|
+
pipelineOutput: {
|
|
110
|
+
markdown: freeTierDoc.content,
|
|
111
|
+
aiEnhanced: false,
|
|
112
|
+
processingTimeMs: Date.now() - startTime,
|
|
113
|
+
tier: 'free',
|
|
114
|
+
fallbackReason: 'API key not found after tier selection',
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
console.log(
|
|
120
|
+
`[AIPipelineManager] Calling Claude API (BYOK) with ` +
|
|
121
|
+
`${session.feedbackItems.length} feedback items, ` +
|
|
122
|
+
`${session.transcriptBuffer.length} transcript events...`
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
const analyzer = new ClaudeAnalyzer(apiKey);
|
|
126
|
+
const analysis = await analyzer.analyze(session);
|
|
127
|
+
|
|
128
|
+
if (!analysis) {
|
|
129
|
+
console.warn(
|
|
130
|
+
`[AIPipelineManager] BYOK -> FREE fallback: Claude API returned null analysis ` +
|
|
131
|
+
`after ${Date.now() - startTime}ms. Using free-tier rule-based output instead.`
|
|
132
|
+
);
|
|
133
|
+
return {
|
|
134
|
+
document: freeTierDoc,
|
|
135
|
+
pipelineOutput: {
|
|
136
|
+
markdown: freeTierDoc.content,
|
|
137
|
+
aiEnhanced: false,
|
|
138
|
+
processingTimeMs: Date.now() - startTime,
|
|
139
|
+
tier: 'byok',
|
|
140
|
+
fallbackReason: 'Claude analysis returned null',
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Build AI-enhanced markdown
|
|
146
|
+
const aiMarkdown = structuredMarkdownBuilder.buildDocument(session, analysis, {
|
|
147
|
+
projectName,
|
|
148
|
+
screenshotDir,
|
|
149
|
+
hasRecording: options.hasRecording,
|
|
150
|
+
recordingFilename: options.recordingFilename,
|
|
151
|
+
modelId: 'claude-sonnet-4-5-20250929',
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// Build a MarkdownDocument compatible with FileManager
|
|
155
|
+
const aiDocument: MarkdownDocument = {
|
|
156
|
+
content: aiMarkdown,
|
|
157
|
+
metadata: {
|
|
158
|
+
itemCount: analysis.items.length,
|
|
159
|
+
screenshotCount: session.screenshotBuffer.length,
|
|
160
|
+
types: [...new Set(analysis.items.map((item) => item.category))],
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
console.log(
|
|
165
|
+
`[AIPipelineManager] AI analysis complete: ${analysis.items.length} items, ` +
|
|
166
|
+
`${analysis.metadata.criticalCount} critical, ${analysis.metadata.highCount} high ` +
|
|
167
|
+
`(${Date.now() - startTime}ms)`,
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
document: aiDocument,
|
|
172
|
+
pipelineOutput: {
|
|
173
|
+
markdown: aiMarkdown,
|
|
174
|
+
aiEnhanced: true,
|
|
175
|
+
analysis,
|
|
176
|
+
processingTimeMs: Date.now() - startTime,
|
|
177
|
+
tier: 'byok',
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
} catch (error) {
|
|
181
|
+
// ANY error in the AI path falls back to free tier - never lose the session
|
|
182
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
183
|
+
console.error(
|
|
184
|
+
`[AIPipelineManager] BYOK -> FREE fallback: AI pipeline threw after ${Date.now() - startTime}ms. ` +
|
|
185
|
+
`Error: ${errorMessage}. Using free-tier rule-based output instead.`
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
return {
|
|
189
|
+
document: freeTierDoc,
|
|
190
|
+
pipelineOutput: {
|
|
191
|
+
markdown: freeTierDoc.content,
|
|
192
|
+
aiEnhanced: false,
|
|
193
|
+
processingTimeMs: Date.now() - startTime,
|
|
194
|
+
tier: 'byok',
|
|
195
|
+
fallbackReason: error instanceof Error ? error.message : 'Unknown error',
|
|
196
|
+
},
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
}
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ClaudeAnalyzer - Core AI analysis engine for markupr
|
|
3
|
+
*
|
|
4
|
+
* Takes a session's transcript + screenshots, sends to Claude Sonnet 4.5 with vision,
|
|
5
|
+
* and returns structured feedback analysis as AIAnalysisResult.
|
|
6
|
+
*
|
|
7
|
+
* On any error, returns null so the caller can fall back to the free tier.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import Anthropic from '@anthropic-ai/sdk';
|
|
11
|
+
import type { Session } from '../SessionController';
|
|
12
|
+
import type {
|
|
13
|
+
AIAnalysisResult,
|
|
14
|
+
ClaudeAnalyzerOptions,
|
|
15
|
+
OptimizedImage,
|
|
16
|
+
} from './types';
|
|
17
|
+
import {
|
|
18
|
+
DEFAULT_CLAUDE_ANALYZER_OPTIONS,
|
|
19
|
+
AIPipelineError,
|
|
20
|
+
} from './types';
|
|
21
|
+
import { optimizeForAPI } from './ImageOptimizer';
|
|
22
|
+
import type { ImageOptimizeOptions } from './types';
|
|
23
|
+
|
|
24
|
+
// =============================================================================
|
|
25
|
+
// System Prompt (from AI_PIPELINE_DESIGN.md)
|
|
26
|
+
// =============================================================================
|
|
27
|
+
|
|
28
|
+
const SYSTEM_PROMPT = `You are markupr's AI analysis engine. You receive a developer's voice-narrated feedback session: a transcript of everything they said while reviewing software, paired with screenshots captured at natural pause points.
|
|
29
|
+
|
|
30
|
+
Your job is to transform this raw narration into a structured, actionable feedback document.
|
|
31
|
+
|
|
32
|
+
## Rules
|
|
33
|
+
|
|
34
|
+
1. **Preserve the user's voice.** Quote their exact words in blockquotes. Never rephrase their observations.
|
|
35
|
+
2. **Group related feedback.** If the user mentions the same area multiple times, combine those into one item.
|
|
36
|
+
3. **Match screenshots to feedback.** Each screenshot was captured during or after the text segment it accompanies. Reference screenshots by their index (e.g., [Screenshot 1]).
|
|
37
|
+
4. **Extract action items.** For each feedback item, write a concrete 1-sentence action item a developer could act on immediately.
|
|
38
|
+
5. **Assign priority.** Use Critical/High/Medium/Low based on the severity of the issue described.
|
|
39
|
+
6. **Categorize.** Use exactly one of: Bug, UX Issue, Performance, Suggestion, Question, Positive Note.
|
|
40
|
+
7. **Write a summary.** 2-3 sentences capturing the most important findings.
|
|
41
|
+
8. **Be concise.** Developers will paste this into AI coding tools. Every word must earn its place.
|
|
42
|
+
|
|
43
|
+
## Output Format
|
|
44
|
+
|
|
45
|
+
Respond with ONLY valid JSON matching this schema:
|
|
46
|
+
|
|
47
|
+
{
|
|
48
|
+
"summary": "2-3 sentence overview of key findings",
|
|
49
|
+
"items": [
|
|
50
|
+
{
|
|
51
|
+
"title": "Short descriptive title (5-10 words)",
|
|
52
|
+
"category": "Bug|UX Issue|Performance|Suggestion|Question|Positive Note",
|
|
53
|
+
"priority": "Critical|High|Medium|Low",
|
|
54
|
+
"quote": "User's exact words (the relevant excerpt)",
|
|
55
|
+
"screenshotIndices": [0, 1],
|
|
56
|
+
"actionItem": "Concrete 1-sentence action for a developer",
|
|
57
|
+
"area": "Component or area of the app this relates to (e.g., 'Navigation', 'Login Form', 'Dashboard')"
|
|
58
|
+
}
|
|
59
|
+
],
|
|
60
|
+
"themes": ["theme1", "theme2"],
|
|
61
|
+
"positiveNotes": ["Things the user explicitly praised"],
|
|
62
|
+
"metadata": {
|
|
63
|
+
"totalItems": 5,
|
|
64
|
+
"criticalCount": 1,
|
|
65
|
+
"highCount": 2
|
|
66
|
+
}
|
|
67
|
+
}`;
|
|
68
|
+
|
|
69
|
+
// =============================================================================
|
|
70
|
+
// Helpers
|
|
71
|
+
// =============================================================================
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Convert an absolute timestamp to session-relative MM:SS format.
|
|
75
|
+
*/
|
|
76
|
+
function toRelativeTimestamp(timestampMs: number, sessionStartMs: number): string {
|
|
77
|
+
const relSec = Math.max(0, Math.floor((timestampMs - sessionStartMs) / 1000));
|
|
78
|
+
const mm = Math.floor(relSec / 60).toString().padStart(2, '0');
|
|
79
|
+
const ss = (relSec % 60).toString().padStart(2, '0');
|
|
80
|
+
return `${mm}:${ss}`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Build the transcript portion of the user message from session data.
|
|
85
|
+
*
|
|
86
|
+
* Uses final transcripts grouped chronologically. Falls back to all transcripts
|
|
87
|
+
* if no finals exist (e.g., timer-only tier).
|
|
88
|
+
*/
|
|
89
|
+
function buildTranscriptText(session: Session): string {
|
|
90
|
+
const finals = session.transcriptBuffer
|
|
91
|
+
.filter((e) => e.isFinal && e.text.trim().length > 0)
|
|
92
|
+
.sort((a, b) => a.timestamp - b.timestamp);
|
|
93
|
+
|
|
94
|
+
const events = finals.length > 0
|
|
95
|
+
? finals
|
|
96
|
+
: session.transcriptBuffer
|
|
97
|
+
.filter((e) => e.text.trim().length > 0)
|
|
98
|
+
.sort((a, b) => a.timestamp - b.timestamp);
|
|
99
|
+
|
|
100
|
+
if (events.length === 0) {
|
|
101
|
+
return '[No transcript available]';
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return events
|
|
105
|
+
.map((e) => {
|
|
106
|
+
// TranscriptEvent timestamps are in seconds; convert to ms for relative calc
|
|
107
|
+
const tsMs = Math.round(e.timestamp * 1000);
|
|
108
|
+
const rel = toRelativeTimestamp(tsMs, session.startTime);
|
|
109
|
+
return `[${rel}] ${e.text.trim()}`;
|
|
110
|
+
})
|
|
111
|
+
.join('\n');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Build the Claude API message content array with text + image blocks.
|
|
116
|
+
*/
|
|
117
|
+
function buildUserContent(
|
|
118
|
+
session: Session,
|
|
119
|
+
optimizedImages: OptimizedImage[],
|
|
120
|
+
): Anthropic.Messages.ContentBlockParam[] {
|
|
121
|
+
const sourceName = session.metadata?.sourceName || 'Application';
|
|
122
|
+
const transcriptText = buildTranscriptText(session);
|
|
123
|
+
|
|
124
|
+
// Map optimized images back to their original screenshot timestamps
|
|
125
|
+
const screenshotTimestamps = new Map<string, number>();
|
|
126
|
+
for (const s of session.screenshotBuffer) {
|
|
127
|
+
screenshotTimestamps.set(s.id, s.timestamp);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Build the text preamble
|
|
131
|
+
let textContent = `## Transcript\n\nThe user narrated the following while reviewing the application "${sourceName}":\n\n${transcriptText}`;
|
|
132
|
+
|
|
133
|
+
if (optimizedImages.length > 0) {
|
|
134
|
+
textContent += `\n\n---\n\n## Screenshots\n\n${optimizedImages.length} screenshots were captured at natural pause points during narration.\nThey are provided as images below in chronological order.`;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const content: Anthropic.Messages.ContentBlockParam[] = [
|
|
138
|
+
{ type: 'text', text: textContent },
|
|
139
|
+
];
|
|
140
|
+
|
|
141
|
+
// Add image blocks
|
|
142
|
+
for (let i = 0; i < optimizedImages.length; i++) {
|
|
143
|
+
const img = optimizedImages[i];
|
|
144
|
+
const originalTs = screenshotTimestamps.get(img.originalScreenshotId) ?? session.startTime;
|
|
145
|
+
const rel = toRelativeTimestamp(originalTs, session.startTime);
|
|
146
|
+
|
|
147
|
+
content.push({
|
|
148
|
+
type: 'text',
|
|
149
|
+
text: `Screenshot ${i + 1} (captured at ${rel}):`,
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
content.push({
|
|
153
|
+
type: 'image',
|
|
154
|
+
source: {
|
|
155
|
+
type: 'base64',
|
|
156
|
+
media_type: img.mediaType,
|
|
157
|
+
data: img.data.toString('base64'),
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return content;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// =============================================================================
|
|
166
|
+
// ClaudeAnalyzer
|
|
167
|
+
// =============================================================================
|
|
168
|
+
|
|
169
|
+
export class ClaudeAnalyzer {
|
|
170
|
+
private client: Anthropic;
|
|
171
|
+
private options: ClaudeAnalyzerOptions;
|
|
172
|
+
|
|
173
|
+
constructor(apiKey: string, options?: Partial<ClaudeAnalyzerOptions>, baseUrl?: string) {
|
|
174
|
+
this.options = { ...DEFAULT_CLAUDE_ANALYZER_OPTIONS, ...options };
|
|
175
|
+
|
|
176
|
+
const clientOptions: ConstructorParameters<typeof Anthropic>[0] = { apiKey };
|
|
177
|
+
if (baseUrl) {
|
|
178
|
+
clientOptions.baseURL = baseUrl;
|
|
179
|
+
}
|
|
180
|
+
this.client = new Anthropic(clientOptions);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Analyze a session using Claude's vision API.
|
|
185
|
+
*
|
|
186
|
+
* @param session - The completed session with transcript and screenshots
|
|
187
|
+
* @param imageOptions - Optional image optimization settings
|
|
188
|
+
* @returns Structured analysis result, or null on any error
|
|
189
|
+
*/
|
|
190
|
+
async analyze(
|
|
191
|
+
session: Session,
|
|
192
|
+
imageOptions?: Partial<ImageOptimizeOptions>,
|
|
193
|
+
): Promise<AIAnalysisResult | null> {
|
|
194
|
+
try {
|
|
195
|
+
// Optimize screenshots for the API
|
|
196
|
+
const optimizedImages = optimizeForAPI(session.screenshotBuffer, imageOptions);
|
|
197
|
+
|
|
198
|
+
// Build message content
|
|
199
|
+
const userContent = buildUserContent(session, optimizedImages);
|
|
200
|
+
|
|
201
|
+
// Call Claude API
|
|
202
|
+
let timeoutHandle: NodeJS.Timeout | undefined;
|
|
203
|
+
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
204
|
+
timeoutHandle = setTimeout(() => {
|
|
205
|
+
reject(
|
|
206
|
+
new AIPipelineError(
|
|
207
|
+
`Claude API request timed out after ${this.options.timeoutMs}ms`,
|
|
208
|
+
'API_TIMEOUT',
|
|
209
|
+
),
|
|
210
|
+
);
|
|
211
|
+
}, this.options.timeoutMs);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
const response = await Promise.race([
|
|
215
|
+
this.client.messages.create({
|
|
216
|
+
model: this.options.model,
|
|
217
|
+
max_tokens: this.options.maxTokens,
|
|
218
|
+
temperature: this.options.temperature,
|
|
219
|
+
system: SYSTEM_PROMPT,
|
|
220
|
+
messages: [{ role: 'user', content: userContent }],
|
|
221
|
+
}),
|
|
222
|
+
timeoutPromise,
|
|
223
|
+
]).finally(() => {
|
|
224
|
+
if (timeoutHandle) {
|
|
225
|
+
clearTimeout(timeoutHandle);
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
// Extract text from response
|
|
230
|
+
const textBlock = response.content.find((block) => block.type === 'text');
|
|
231
|
+
if (!textBlock || textBlock.type !== 'text') {
|
|
232
|
+
throw new AIPipelineError('No text content in Claude response', 'INVALID_RESPONSE');
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Parse JSON from response
|
|
236
|
+
const result = parseAnalysisResult(textBlock.text);
|
|
237
|
+
return result;
|
|
238
|
+
} catch (error) {
|
|
239
|
+
if (error instanceof AIPipelineError) {
|
|
240
|
+
console.error(`[ClaudeAnalyzer] Pipeline error (${error.code}):`, error.message);
|
|
241
|
+
} else {
|
|
242
|
+
console.error('[ClaudeAnalyzer] Unexpected error:', error instanceof Error ? error.message : error);
|
|
243
|
+
}
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// =============================================================================
|
|
250
|
+
// Response Parsing
|
|
251
|
+
// =============================================================================
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Parse Claude's JSON response into a validated AIAnalysisResult.
|
|
255
|
+
*
|
|
256
|
+
* Handles common edge cases:
|
|
257
|
+
* - JSON wrapped in markdown code fences
|
|
258
|
+
* - Extra whitespace / trailing commas (via lenient extraction)
|
|
259
|
+
*/
|
|
260
|
+
function parseAnalysisResult(text: string): AIAnalysisResult {
|
|
261
|
+
// Strip markdown code fences if present
|
|
262
|
+
let jsonStr = text.trim();
|
|
263
|
+
const fenceMatch = jsonStr.match(/```(?:json)?\s*([\s\S]*?)```/);
|
|
264
|
+
if (fenceMatch) {
|
|
265
|
+
jsonStr = fenceMatch[1].trim();
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
let parsed: unknown;
|
|
269
|
+
try {
|
|
270
|
+
parsed = JSON.parse(jsonStr);
|
|
271
|
+
} catch {
|
|
272
|
+
throw new AIPipelineError(
|
|
273
|
+
`Failed to parse Claude response as JSON: ${jsonStr.slice(0, 200)}...`,
|
|
274
|
+
'INVALID_RESPONSE',
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Validate required fields
|
|
279
|
+
if (
|
|
280
|
+
typeof parsed !== 'object' ||
|
|
281
|
+
parsed === null ||
|
|
282
|
+
!('summary' in parsed) ||
|
|
283
|
+
!('items' in parsed) ||
|
|
284
|
+
!Array.isArray((parsed as Record<string, unknown>).items)
|
|
285
|
+
) {
|
|
286
|
+
throw new AIPipelineError(
|
|
287
|
+
'Claude response JSON missing required fields (summary, items)',
|
|
288
|
+
'INVALID_RESPONSE',
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const obj = parsed as Record<string, unknown>;
|
|
293
|
+
|
|
294
|
+
// Build items first so we can compute accurate metadata
|
|
295
|
+
const items = Array.isArray(obj.items)
|
|
296
|
+
? (obj.items as Record<string, unknown>[]).map(validateFeedbackItem)
|
|
297
|
+
: [];
|
|
298
|
+
|
|
299
|
+
const result: AIAnalysisResult = {
|
|
300
|
+
summary: String(obj.summary ?? ''),
|
|
301
|
+
items,
|
|
302
|
+
themes: Array.isArray(obj.themes)
|
|
303
|
+
? (obj.themes as unknown[]).map(String)
|
|
304
|
+
: [],
|
|
305
|
+
positiveNotes: Array.isArray(obj.positiveNotes)
|
|
306
|
+
? (obj.positiveNotes as unknown[]).map(String)
|
|
307
|
+
: [],
|
|
308
|
+
metadata: {
|
|
309
|
+
totalItems: items.length,
|
|
310
|
+
criticalCount: items.filter((i) => i.priority === 'Critical').length,
|
|
311
|
+
highCount: items.filter((i) => i.priority === 'High').length,
|
|
312
|
+
},
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
return result;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Validate and coerce a single feedback item from Claude's response.
|
|
320
|
+
*/
|
|
321
|
+
function validateFeedbackItem(raw: Record<string, unknown>): AIAnalysisResult['items'][0] {
|
|
322
|
+
const validCategories = ['Bug', 'UX Issue', 'Performance', 'Suggestion', 'Question', 'Positive Note'];
|
|
323
|
+
const validPriorities = ['Critical', 'High', 'Medium', 'Low'];
|
|
324
|
+
|
|
325
|
+
const category = String(raw.category ?? 'Suggestion');
|
|
326
|
+
const priority = String(raw.priority ?? 'Medium');
|
|
327
|
+
|
|
328
|
+
return {
|
|
329
|
+
title: String(raw.title ?? 'Untitled Feedback'),
|
|
330
|
+
category: validCategories.includes(category) ? category as AIAnalysisResult['items'][0]['category'] : 'Suggestion',
|
|
331
|
+
priority: validPriorities.includes(priority) ? priority as AIAnalysisResult['items'][0]['priority'] : 'Medium',
|
|
332
|
+
quote: String(raw.quote ?? ''),
|
|
333
|
+
screenshotIndices: Array.isArray(raw.screenshotIndices)
|
|
334
|
+
? (raw.screenshotIndices as unknown[]).filter((v): v is number => typeof v === 'number')
|
|
335
|
+
: [],
|
|
336
|
+
actionItem: String(raw.actionItem ?? ''),
|
|
337
|
+
area: String(raw.area ?? 'General'),
|
|
338
|
+
};
|
|
339
|
+
}
|